跳转至

03 单机吞吐优化(一):无需硬件升级也能提升吞吐

你好,我是徐逸。

通过前面的学习,我们了解了性能优化的流程和Golang性能优化工具。

这节课开始我将带你掌握工作中常见的性能优化技巧,这也是服务性能优化流程第四步——性能调优的基础。只有掌握了一定的技巧,当我们定位到瓶颈原因时,才能更快地想出性能优化方案,轻松应对常见的性能问题。

在介绍具体的优化技巧之前,先让我们想一想,当你需要提升单机吞吐时,你会怎么办呢?

我们通常的思路是定位到单机瓶颈资源。对于瓶颈资源有两种处理方法,一是增加资源,比如提升单机CPU、内存等资源的规格;二是减少单个请求对瓶颈资源的消耗,让相同的资源可以处理更多的请求。

在服务器数目比较多时,需要增加很多机器成本,所以我们今天就来看看不提升单机CPU和内存规格的前提下,有哪些常用的高性能技巧。因为在Golang里容器类型比较常用,所以后面课程里我们就把它作为研究对象。

实验设计

为了模拟线上的性能优化过程。我们先构造一个http服务。这个服务包含一个请求处理方法Handler。方法里有两个逻辑,一个是循环往切片append数据,另一个是用map类型构造一个集合。

接下来,我们就基于这个接口的性能优化过程,带你学习一些实用的高性能技巧。

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
)

func Handler(w http.ResponseWriter, r *http.Request) {
    slices := getRawSlices()
    getRawSet(slices)
    // 设置响应头,这里设置Content-Type为text/plain,表示返回纯文本内容
    w.Header().Set("Content-Type", "text/plain")
    // 向客户端写入响应内容
    fmt.Fprintln(w, "hh")
}
func main() {
    // 注册路由,当客户端访问根路径"/"时,会调用Handler函数进行处理
    http.HandleFunc("/", Handler)
    err := http.ListenAndServe(":8888", nil)
    if err != nil {
        panic(err)
    }
}

// getRawSlices 循环往切片append数据
func getRawSlices() []int {
    n := 10000
    slices := make([]int, 0)
    for i := 0; i < n; i++ {
        slices = append(slices, i)
    }
    return slices
}

// 构造集合
func getRawSet(slices []int) map[int]bool {
    set := make(map[int]bool, 0)
    for _, item := range slices {
        set[item] = true
    }
    return set
}

现在让我们用ab工具(Apache Bench)模拟线上用户,对这个http服务发起请求压测。

-n 请求数量 
-c 并发数量
ab -n 30000 -c 2 http://127.0.0.1:8888/

假如在ab工具压测的过程中,我们发现内存资源占用比较高,可以用上节课学习到的pprof工具,采集单机内存性能报告并生成下面的内存火焰图。

curl "http://127.0.0.1:8888/debug/pprof/heap?seconds=30" > heap.pprof

go tool pprof -http :8889 heap.pprof

从火焰图中我们可以看出来,在应用内部的处理逻辑,也就是Handler方法里,getRawSet函数是内存消耗最大的函数。接着,我们点击火焰图的最后一行进入getRawSet函数内部查看,可以看到下面的图,消耗内存资源的热点代码是map赋值这行代码。

结合这行代码的上下文,可知这段代码逻辑的目的是用map构造一个集合。那这里面有没有什么优化空间呢?

我们可以从两个方向寻找答案:

  • 先考虑存入map的数据能不能尽量少。
  • 除了我们存入的数据之外,map赋值操作是如何实现的,里面是否存在耗内存的动作,有的话能否减少甚至规避掉?

先让我们来看看第一个方向。

空结构体:集合表示如何省内存?

从集合的使用场景来看,我们要么判断一个key是不是在map中,要么获取map里面的所有key,而map里面的value值,在集合的使用场景中并不需要。那有没有办法消除map里面的value所占的内存空间呢?

在 Go 语言里,提供了空结构体类型struct{},而空结构体对象所占的空间大小为0。咱们可以写个小测试来看看:

func TestSize(t *testing.T) {
    fmt.Printf("struct {} size: %d byte\n", unsafe.Sizeof(struct{}{}))
}

运行这个测试函数,可以看到空结构体类型的确不占内存空间。

=== RUN   TestSize
struct {} size: 0 byte

因此咱们可以把map的value类型,从bool类型改为空结构体类型,就像下面的代码一样。

// value用空结构体类型
func getEmptyStructSet(slices []int) map[int]struct{} {
    set := make(map[int]struct{}, 0)
    for _, item := range slices {
        set[item] = struct{}{}
    }
    return set
}

那么使用空结构体类型,能给我们带来多大的性能提升呢?我们用 Benchmark 来测一测。Benchmark脚本如下所示:

var slices []int

func init() {
    slices = getRawSlices()
}

func BenchmarkGetRawSet(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getRawSet(slices)
    }
}

func BenchmarkGetEmptyStructSet(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getEmptyStructSet(slices)
    }
}

测试结果出来了,我们可以看到value用空结构体类型,性能有了一定程度的提升。从内存消耗来看,value用bool类型,单次函数调用要427596字节内存,而用空结构体类型,每次函数调用只要389449字节内存,节约了 9% 左右的内存资源。

killianxu@KILLIANXU-MB0 3 % go test -bench='.' -benchmem -benchtime=10s
warning: GOPATH set to GOROOT (/usr/local/go) has no effect
goos: darwin
goarch: amd64
pkg: server-go/3
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGetRawSet-4               17214            608437 ns/op          427596 B/op        319 allocs/op
BenchmarkGetEmptyStructSet-4       19430            563814 ns/op          389449 B/op        255 allocs/op

现在让我们再看一下内存火焰图,看看经过空结构体类型的优化后,我们的代码是否还有优化空间。

从火焰图中我们可以看到,用空结构体优化后,构造集合的getEmptyStructSet函数,依然是最消耗内存的函数。

前面我提到可以从两个方向找答案,并且通过将value的类型,从bool改为空结构体,减少了存入map的数据大小。为了继续降低map的内存资源消耗,现在让我们从第二个方向——map赋值操作的方向找答案。

指定容量:容器操作如何避免扩容迁移?

在搞明白map赋值操作的逻辑之前,我们需要先了解map的数据结构。就像下面的图展示的一样,map底层其实是以数组+链表的数据结构存储key-value对的。为了描述方便,数组的每一个位置,通常我们称它为桶。

正常情况下,当往map中写入ket-value对时,Go底层会根据key做hash,定位到需要写入key-value对的桶,并插入桶对应的链表中。

但是当map里面写入的数据过多时,数组里面的桶链表会越来越长。而map的写入和读取,都需要遍历桶链表,因此过长的桶链表会影响map的读写性能。

为了缓解这种情况,在往map写入数据时,如果map中的数据量已经比较多了,Go底层还有个扩容迁徙的分支逻辑,这个扩容迁移的分支逻辑是怎么样的呢?

扩容迁移的逻辑分成2步,我们结合后面这张图来理解更直观一些。

首先申请更大的数组空间。

然后将旧数组空间数据迁移到新数组空间。Go采取了渐进式hash的思想,每次往map写入数据时,会触发从旧数组空间往新数组空间迁移两个桶链表数据,从而避免一次迁移全量数据导致map写请求延时抖动,直到旧数组空间的所有桶链表迁移完为止。

虽然通过扩容迁移,将原先在同一个桶链表里的数据,重新hash到新数组不同的桶链表中,可以减少桶链表长度,避免map读写性能大幅度裂化。但是扩容迁移属于消耗内存和CPU资源的操作,如果循环往map写入,可能会触发多次扩容迁移,消耗大量内存和CPU资源。

那有没有办法避免写入过程触发扩容迁移呢?

实际上,创建map的make函数提供了一个size参数。Go底层在创建map对象时,会根据传入的size参数申请数组空间。因此只要我们能提前知道map要存储的数据量,就能传入这个size参数,从而避免写入过程频繁扩容。

就像下面的代码一样,由于我们的切片里没有重复数据,因此当我们用make函数构造map时,可以将切片长度作为size参数传入。

// 提前指定容量
func getCapacitySet(slices []int) map[int]struct{} {
    set := make(map[int]struct{}, len(slices))
    for _, item := range slices {
        set[item] = struct{}{}
    }
    return set
}

那么构造map时,指定size的方式,又能给咱们带来多大的性能提升呢?咱们继续用Benchmark来测一测。

下面是Benchmark脚本:

func BenchmarkGetEmptyStructSet(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getEmptyStructSet(slices)
    }
}

func BenchmarkGetCapacitySet(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getCapacitySet(slices)
    }
}

然后你会发现,指定map容量的方式,性能提升很明显。我们依然用数据说话。

killianxu@KILLIANXU-MB0 3 % go test -bench='.' -benchmem -benchtime=10s   
warning: GOPATH set to GOROOT (/usr/local/go) has no effect
goos: darwin
goarch: amd64
pkg: server-go/3
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGetEmptyStructSet-4       21218            563814 ns/op          389461 B/op        255 allocs/op
BenchmarkGetCapacitySet-4          41742            287038 ns/op          182480 B/op         11 allocs/op
  • 从内存消耗来看,不指定map容量的方式,单次函数调用要389461字节内存,而指定容量的方式,每次函数调用只要182480字节内存,节约了 53% 左右的内存资源。
  • 从 CPU 资源消耗来看,不指定map容量的方式,单次函数调用要563814ns,而指定容量的方式只要287038ns,节约了 49% 左右的 CPU 资源。

现在让我们再看一下内存火焰图,看看我们的代码是否还有进一步优化空间。

从内存火焰图中我们可以看出,除了已经优化后的集合构造函数getCapacitySet,现在最消耗内存的是切片生成函数getRawSlice。

现在我们从火焰图进入getRawSlice函数内部,就会看到下面的图,消耗内存资源的热点代码是切片append这行代码。

结合这行代码的上下文,可以知道这段代码逻辑是在循环地往切片里面做append操作。那么我们自然会想到,可以去看看切片的append操作是如何实现的,这里面有没有啥优化空间呢?

切片底层数据结构是用一个数组存储数据的,通常进行append操作时,会往数组末尾插入数据。

但是当数组空间不够时,append操作会触发扩容迁移,申请更大的数组空间,并将旧数组数据拷贝到新数组,以便存放更多的数据

我们从扩容迁移的过程可以看出来,扩容迁移存在内存分配和数据拷贝操作,需要消耗内存和CPU资源。当循环做切片append操作时,可能会触发频繁扩容迁移,消耗大量的内存和CPU资源。

那切片append操作有没有办法避免扩容迁移呢?

实际上,和map一样,我们在创建切片对象时,也可以传入容量字段。就像下面的代码一样,这样切片就会一次性申请足够数组空间,从而避免后续写入过程发生扩容迁移。

// getCapacitySlices 提前指定容量
func getCapacitySlices() []int {
    n := 10000
    slices := make([]int, 0, n)
    for i := 0; i < n; i++ {
        slices = append(slices, i)
    }
    return slices
}

切片创建时指定容量的方式,又能给咱们带来多大的性能提升呢?Benchmark脚本如下,我们再来测试一下。

func BenchmarkGetRawSlices(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getRawSlices()
    }
}

func BenchmarkGetCapacitySlices(b *testing.B) {
    for n := 0; n < b.N; n++ {
        getCapacitySlices()
    }
}

果然,指定切片容量的方式,性能提升非常明显。

killianxu@KILLIANXU-MB0 3 % go test -bench='.' -benchmem -benchtime=10s
warning: GOPATH set to GOROOT (/usr/local/go) has no effect
goos: darwin
goarch: amd64
pkg: server-go/3
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGetRawSlices-4           209412             55297 ns/op          357626 B/op         19 allocs/op
BenchmarkGetCapacitySlices-4      844266             13318 ns/op           81920 B/op          1 allocs/op
  • 从内存消耗来看,不指定切片容量的方式,单次函数调用要357626字节内存,而指定容量的方式,每次函数调用只要81920字节内存,节约了 77% 左右的内存资源。
  • 从 CPU 资源消耗来看,不指定切片容量的方式,单次函数调用要55297ns,而指定容量的方式,只要13318ns,节约了 76% 左右的 CPU 资源。

让我们用火焰图再看看我们的函数是否还有优化空间。

从火焰图上我们可以看出来,经过前面几次的优化,应用内部最耗时的函数就只剩下已经优化过之后的getCapacitySet和getCapacitySlices函数,没有进一步优化的空间了。

小结

今天这节课,我以一段待优化的http服务接口为例,在逐步优化其对内存资源的消耗过程中,向你展示了能降低内存和CPU资源消耗的2个高性能容器类型的使用技巧。

  • 当用map构造集合时,我们可以将value类型设置为空结构体类型,空结构体类型不占用内存空间,这样就能帮我们降低内存资源消耗。
  • 当创建map和切片对象时,如果我们可以提前确定容器容量,就可以传入make函数中,从而避免往集合中添加数据时触发扩容迁移,达到降低内存和CPU资源消耗的目的

希望你好好体会这个从线上服务获取内存火焰图寻找瓶颈,再结合Go语言底层实现分析寻找更优方案的过程。在遇到内存和CPU瓶颈时,别忘了尝试运用这两个容器类型的使用技巧,帮你节约更多内存和CPU资源,提升单机吞吐。

思考题

对于容器类型,除了这节课讲到的2种高性能使用技巧,你还知道哪些高性能的使用技巧呢?

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

精选留言(5)
  • 陈卧虫 👍(3) 💬(1)

    听完老师的课查了一下资料,总结一下,不足之处请老师指正,容器类型优化的其它技巧: 1. 尽量避免频繁对 map 和 slice 的增删,这会触发底层结构重新分配 2. 并发场景下 减少锁争用,比如使用并发安全的 sync.map 或者 通过分片技术将大 map 分为小 map 3. 需要大量短生命周期容器对象时,通过 sync.pool 复用对象,减少频繁的内存重新分配 4. 在多线程场景下,使用 sync.once 优化初始化操作,保证只执行一次 5. 容器元素为 大对象 或者 复杂结构体,传递指针而不是值,这样可以减少拷贝的开销,特别是在作为函数参数时 6. 对于结构体来说,按照字段的大小降序排列,可以降低内存对齐时的填充浪费

    2024-12-15

  • Geek_e73ba0 👍(0) 💬(1)

    // getCapacitySlices 提前指定容量 func getCapacitySlices() []int { n := 10000 slices := make([]int, n) for i := 0; i < n; i++ { slices[i] = i } return slices } 不如写成这样,根据下标赋值,性能更好,因为append的方式会有额外的开销。

    2025-02-14

  • Geek_b9db3a 👍(0) 💬(2)

    curl "http://127.0.0.1:8888/debug/pprof/heap?seconds=30" > heap.pprof go tool pprof -http :8889 heap.pprof heap.pprof: parsing profile: unrecognized profile format failed to fetch any source profiles

    2024-12-16

  • 快叫我小白 👍(0) 💬(1)

    我不确定我的理解是否正确,benchmark上内存消耗情况指的是从程序运行到结束分配过的内存总字节数,而不是程序运行过程中某一时刻的最大使用内存字节数。所以我们在做内存性能优化时追求的、并且衡量一个程序内存使用效率的好坏的指标都是前者?跟我一直的理解有点出入,我以为释放掉的内存会被垃圾回收,所以只要确保内存使用峰值不要过高就好了,😂如果我的理解有误希望老师能指正一下~

    2024-12-15

  • lJ 👍(0) 💬(2)

    对于slice,如果无法预估所有数据大小,可以通过每次扩展时成倍增加容量,减少频繁扩容带来的开销。 在高并发场景下,如果需要对 map 进行频繁的多次读一次写操作,推荐使用 sync.Map。如果是多次读写情况,推荐使用concurrent-map。 map 的性能高度依赖于键的哈希分布,尽量选择能避免哈希冲突的键。 对于任何容器类型,如果需要频繁创建和销毁对象,都会导致 GC 压力增加,可以使用 sync.Pool 实现对象的重用。

    2024-12-14