简单整理一下 Golang 中比较常见的性能优化思路。
性能优化其实很容易走偏,比如程序一慢就开始研究:
sync.Pool
GOGC
goroutine
各种零拷贝
最后代码复杂了不少,性能可能没什么变化。
我觉得性能优化最重要的一点还是:
先找到慢在哪里,再考虑怎么优化。
基本流程就是:
发现问题 -> 测量 -> 找到瓶颈 -> 修改 -> 再测量
不要凭感觉优化。
1. 先确定是什么问题
首先要知道自己到底想优化什么。
常见的基本就是:
CPU 太高
内存太高
GC 太频繁
接口延迟高
吞吐量低
goroutine 太多
比如接口需要 1 秒:
Go 代码 5ms -> MySQL 600ms -> HTTP 请求 300ms
这时候把 Go 代码从:
5ms -> 2ms
其实没有太大意义。
应该先看 MySQL 和 HTTP 请求为什么慢。
所以优化的时候,先找最大的瓶颈。
2. Benchmark
如果是优化一个具体函数,可以直接写 Benchmark。
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
Foo()
}
}
执行:
go test -bench=. -benchmem
主要看:
ns/op
B/op
allocs/op
分别对应:
执行时间
内存分配
分配次数
优化代码之前和之后都跑一下。
before -> 修改代码 -> after
如果数据没变好,那基本不能叫优化。
3. pprof
实际服务性能有问题,我觉得 pprof 是最应该先看的东西。
常见的几个 Profile:
CPU -> CPU 消耗
heap -> 内存分配
goroutine -> goroutine
mutex -> 锁竞争
block -> 阻塞
HTTP 服务可以直接引入:
import _ "net/http/pprof"
然后启动:
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
抓 CPU Profile:
go tool pprof \
"http://localhost:6060/debug/pprof/profile?seconds=30"
看内存:
go tool pprof \
http://localhost:6060/debug/pprof/heap
核心就是先找到:
CPU -> 到底消耗在哪个函数
Memory -> 到底是谁在大量申请内存
一个函数只占 0.1% CPU,就算优化十倍,对整体也没什么影响。
4. 少做没必要的内存分配
Go 有 GC,不代表内存分配没有成本。
大致可以理解为:
大量 Allocation -> Heap 变大 -> GC 工作增加 -> CPU 消耗增加
比较常见的是 Slice。
如果已经知道大概有多少数据:
var result []string
for _, v := range values {
result = append(result, v.Name)
}
可以提前分配:
result := make([]string, 0, len(values))
for _, v := range values {
result = append(result, v.Name)
}
Map 也是一样:
m := make(map[string]string, len(values))
这种优化比较简单,而且在大量数据处理的时候比较常见。
5. 字符串不要在大循环里面一直 +
比如:
var result string
for _, v := range values {
result += v
}
数据多的时候会产生不少中间字符串。
可以用:
var builder strings.Builder
for _, v := range values {
builder.WriteString(v)
}
result := builder.String()
如果大概知道大小,还可以:
builder.Grow(size)
不过数据就几条的时候没必要折腾。
优化热点代码就行。
6. goroutine 不是越多越好
Go 创建 goroutine 很简单:
go work()
所以有时候很容易变成:
程序慢 -> 多开 goroutine
但 goroutine 最后还是需要 CPU 执行。
比如:
4 Core CPU -> 10000 个 CPU 密集型 goroutine
不会因为 goroutine 多就变成 10000 核。
反而还可能增加调度、内存和锁竞争。
并发比较大的地方最好有一个限制,比如:
请求 -> Worker Pool -> 执行任务
或者 Semaphore。
goroutine 主要解决的是并发问题,不是无限增加性能。
7. 锁的范围尽量小
例如:
mu.Lock()
defer mu.Unlock()
value := cache[key]
process(value)
如果 process 很慢,相当于锁一直没有释放。
如果业务允许,可以改成:
mu.Lock()
value := cache[key]
mu.Unlock()
process(value)
简单来说:
拿锁 -> 操作共享数据 -> 释放锁 -> 做其他事情
而不是:
拿锁 -> 做一堆事情 -> 释放锁
如果怀疑锁竞争,可以直接看 mutex profile,而不是先猜哪把锁有问题。
8. IO 往往比 Go 代码本身更值得优化
实际业务里面很常见:
Request -> Go -> MySQL -> Redis -> HTTP API -> Response
真正慢的地方经常不是 Go。
比如:
Go 3ms
MySQL 50ms
HTTP 100ms
这种情况首先应该考虑:
能不能减少数据库查询次数
能不能批量查询
能不能减少 RPC
能不能并行 IO
能不能缓存
一次少掉一个 50ms 的数据库请求,可能比优化半天 Go 代码收益都大。
9. GC 不要一上来就调参数
常见的:
GOGC
GOMEMLIMIT
确实可以影响 Go GC。
但如果程序本身一直在:
大量创建对象 -> 大量 Heap Allocation -> GC
只调整 GOGC 很可能只是改变 GC 什么时候发生。
第一件事还是应该看:
为什么分配这么多内存?
也就是:
pprof heap -> 找 Allocation 热点 -> 优化代码 -> 再考虑 GC 参数
GC 参数更多是调节 CPU 和内存之间的平衡,不是什么性能开关。
10. 算法比语法优化重要
例如:
for _, a := range users {
for _, b := range orders {
if a.ID == b.UserID {
// ...
}
}
}
如果数据量很大,与其研究 range 能不能快一点,不如先想:
orders -> map[userID][]Order
把数据结构改掉。
很多时候:
算法优化 -> IO 优化 -> 并发优化 -> 内存优化 -> 语法微优化
越靠前收益通常越明显。
当然也不是绝对的,最终还是要看 Profile。
总结
Golang 性能优化我觉得先掌握这些就够用了。
首先:
不要猜 -> 先测
常用工具:
Benchmark
pprof
trace
然后从真正的瓶颈开始看:
CPU
Memory
GC
Lock
IO
代码层面比较高频的也就是:
Slice / Map 提前分配容量
减少没必要的 Allocation
大量字符串拼接使用 strings.Builder
控制 goroutine 数量
缩小锁的范围
减少数据库和网络 IO
优先优化算法和数据结构
最后还是要回到:
修改前数据 -> 修改代码 -> 修改后数据
数据确实变好了,才说明这个优化是有效的。
不然很多所谓的“优化”,最后只是让代码变得更复杂。
参考
Go Diagnostics:
https://go.dev/doc/diagnostics
Profiling Go Programs:
Go GC Guide:
runtime/pprof: