GC 调优到底先看什么:Go 为什么要从 live heap 开始,而不是先改 GOGC

线上 GC 调优最怕先有答案再找证据。正确的顺序是:先确认 GC 是不是问题→看 live heap→看分配速率→再决定参数→最后设回滚线。没有基线的调优,只是玄学。

上篇讲完 GOGCGOMEMLIMIT 和 STW,很多人真正卡住的地方才刚开始。

不是“不知道这些参数是什么”,而是线上真的出问题时,不知道下一步该干什么。

服务 p99 每隔几十秒抖一下,GC CPU 看起来有 5%。有人说把 GOGC 从 100 调到 200,有人说先上 GOMEMLIMIT,有人说 Go GC 已经很强,肯定不是它的问题。最后开会半小时,结论往往是:先改一个数试试。

这才是 GC 调优最危险的地方。

不是参数难,而是决策路径太松。你不知道自己是在省 CPU,还是在借内存;不知道问题是活对象太多,还是短命对象太多;也不知道什么时候应该停手。

没有路径的调优,就是拿线上流量做抽签。

这篇不再展开概念。我们只解决一个问题:当你已经知道 GOGCGOMEMLIMIT 是什么,线上到底怎么用。

第一步:先证明 GC 真的在场

很多排查一开始就走偏,是因为团队先认定“这就是 GC 问题”。

GC CPU 有 5%,不等于它一定是罪魁祸首;GC CPU 只有 1%,也不等于它一定无关。线上要看的不是某个数字高不高,而是它和业务曲线有没有关系。

先把三条线叠起来看:

1
2
3
p99 / p999 抖动时间点
GC 周期和标记阶段时间点
CPU、RSS、heap live 的同步变化

临时排查可以先开 gctrace

1
GODEBUG=gctrace=1 ./your-service

你可能会看到类似这样的行:

1
gc 12 @8.7s 2%: 0.021+4.3+0.065 ms clock, 64->72->38 MB, 76 MB goal

这行不用每个字段都背下来。先抓三件事:

1
2
3
0.021 + 4.3 + 0.065 ms clock   # 两端短暂停顿,中间并发阶段
64 -> 72 -> 38 MB              # GC 前、GC 后、存活堆
76 MB goal                     # 下一轮目标堆

如果 p99 抖动刚好贴着 GC 周期出现,标记期 CPU 也在往上顶,GC 才进入嫌疑名单。

gctrace 更适合临时排查,不适合长期靠人肉翻日志。线上服务最好把 runtime 指标接进监控,至少分三组看:

1
2
3
存活规模:/gc/heap/live、HeapAlloc
分配速度:/gc/heap/allocs:bytes、allocs:objects
GC 代价:/cpu/classes/gc/total、NumGC、pause 分布

这三组指标不要混在一起解释。存活规模告诉你 GC 之后还剩多少东西;分配速度告诉你业务正在以多快的速度制造新对象;GC 代价告诉你 runtime 为这些对象付了多少 CPU 和停顿成本。

很多团队查 GC 会犯一个很隐蔽的错:看到 NumGC 增长很快,就说 GC 太频繁;看到 HeapAlloc 变大,就说内存泄漏;看到 pause 不长,就说 GC 没影响。单个指标很容易骗人。你要把它们放到同一条时间线上,看它们有没有一起变化。

如果 p99 抖动和 GC 周期没什么关系,别硬往 GC 上靠。去看锁竞争、数据库慢查询、下游 I/O、队列堆积、系统调用和调度。调优最怕的不是没有工具,而是拿一个工具解释所有问题。

GC 是线索,不是替罪羊。

这一步的产出不是“要不要调参数”,而是一个更基础的判断:GC 有没有资格继续被调查。

第二步:看 live heap,别先看参数

如果 GC 确实在场,下一步不要急着调 GOGC

先看每轮 GC 之后,live heap 有没有下来。

1
go tool pprof http://localhost:6060/debug/pprof/heap

如果 GC 后 live heap 一路上涨,优先查对象为什么还活着。常见原因并不神秘:

1
2
3
4
5
全局 map 只增不减
缓存没有上限或淘汰失效
goroutine 泄漏拉住请求上下文
channel 阻塞导致对象释放不了
长生命周期结构体挂住大 slice

这种情况下,调高 GOGC 多半是在拖延爆炸。因为 GOGC 只决定下一轮 GC 什么时候来,它不会替业务判断哪些对象“不该再活着”。

对象还被引用,GC 就不能回收。你把 GOGC=100 改成 GOGC=200,只是让这些对象和新分配一起在堆里待得更久。

这也是很多“调完好了几天”的根源。

前几天流量没到峰值,RSS 还没贴近 limit,监控看起来像优化成功。等批处理、缓存热身、大请求、消息堆积一起来,活对象规模继续涨,容器直接把账单拍到你脸上。

GC 只回收没人要的对象。业务还攥着,它不会抢。

如果 live heap 持续上涨,正确动作不是调参,而是查持有关系:谁还在引用这些对象,为什么它们没有被释放,缓存和队列有没有边界,goroutine 是否泄漏。

第三步:再看分配速率

另一类问题刚好相反:live heap 不高,但分配速率很难看。

也就是说,对象大多能被回收,只是你制造垃圾的速度太快。网关、日志清洗、协议编解码、JSON 重度服务、模板渲染、批量字符串拼接,都容易掉进这个坑。

这时要看 alloc profile:

1
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap

如果 inuse_space 不吓人,但 alloc_space 的热点集中在几个热路径函数上,说明 GC 被喂得太饱了。

这个时候调高 GOGC 确实可能有短期收益。GC 少跑几轮,CPU 好看一点,p99 可能也安静一点。

但你要知道这笔账怎么来的。

1
2
3
4
// 热路径里反复拼字符串,通常会制造很多临时对象
msg := fmt.Sprintf("user=%s action=%s cost=%d", user, action, cost)

// 如果这里只是写日志或协议字段,优先考虑结构化字段或复用 buffer

调大 GOGC 不是把这些临时对象消灭了,只是让它们晚点被处理。真正的解法通常在代码里:减少热路径临时对象,复用 buffer,避免不必要的 []bytestring 来回转换,少在高频路径里做反射、装箱和格式化。

参数能延期还债,代码才会减少欠账。

所以第三步要回答的是:问题来自“活得太久”,还是“生得太快”。这两个问题看起来都能让 GC 忙起来,但调法完全不同。

第四步:按三种场景选动作

走到这里,你才有资格谈参数。

不是因为参数不重要,而是因为参数只有放在场景里才有意义。

Go GC 调优决策路径

场景一:CPU 紧,内存宽,live heap 稳定

这是比较适合试探性提高 GOGC 的场景。

比如服务 GC CPU 确实偏高,p99 抖动和 GC 周期相关;同时 live heap 稳定,RSS 离容器 limit 还远,非 Go 内存也没明显风险。

这时可以小步走:

1
GOGC=150 ./api-server

如果是 Kubernetes 里的服务,不要直接全量改 Deployment。更稳的是先挑一组低风险 Pod,或者给一小部分实例加环境变量,单独观察它们和基线组的差异。

你要比较的不是“调参组看起来有没有变好”,而是同一段时间里,调参组相对基线组有没有稳定收益。流量、下游状态、缓存命中率都会影响延迟。如果没有基线组,你很容易把一次流量回落误判成 GC 参数生效。

观察时至少把这些图放在一起:

1
2
3
4
5
6
p99 / p999
QPS 和错误率
GC CPU 与总 CPU
RSS / container memory
heap live 与 heap goal
Pod 重启和 OOMKilled

不要从 100 直接跳到 300,更不要一上来关 GC。先从 150 开始,灰度一小部分流量,至少覆盖一个业务高峰。观察 p99、吞吐、GC CPU、RSS、heap goal、重启次数。

如果 GC CPU 下来了,p99 改善,RSS 仍然安全,再评估要不要继续到 200。

这里的关键不是“150 比 200 更正确”,而是你要保留判断空间。一步调太大,出了问题你很难知道是阈值过猛、流量变化,还是原本判断就错了。

场景二:内存紧,CPU 还够

如果容器 RSS 已经贴近 limit,或者出现过 OOMKilled,就别急着提高 GOGC

你以为自己是在降低 GC CPU,实际上可能是在拿容器内存做抵押。

更稳的顺序是先设边界:

1
GOMEMLIMIT=1500MiB GOGC=100 ./api-server

假设容器 limit 是 2GiB,这个 1500MiB 不是官方定律,只是一个工程起点。具体留多少余量,要看 Pod 里还有没有 sidecar、cgo、mmap、tmpfs、文件缓存、压缩库或图像库这类 Go runtime 看不全的内存。

一个粗略但实用的起点是:

1
2
3
Go 进程基本独占 Pod,非 Go 内存少:GOMEMLIMIT 可从 75%-80% limit 试
有 cgo / mmap / sidecar / tmpfs:先从 60%-70% 试
live heap 已经接近 limit:别幻想 GOMEMLIMIT 救场,先降 live heap

GOMEMLIMIT 是 soft limit,不是硬墙。它会让 runtime 更积极地工作,但不会让活对象凭空消失,也管不到所有 Go 外内存。

所以调完之后,如果 OOM 少了,但 GC CPU 飙高、吞吐掉下去、p99 拉长,也不能算成功。那只是 runtime 正在拼命守线。

守住边界,不等于系统健康。

场景三:live heap 涨,或者少分配程序硬往 GC 上靠

如果每轮 GC 后 live heap 都在涨,先查泄漏、缓存和持有关系。不要把 GOGC 当止痛药。

如果程序本身分配很少,allocs/op 接近 0,gctrace 稀疏,GC CPU 也不高,那就别在 GC 参数上消耗时间。去看锁、网络、数据库、调度、系统调用、算法和队列。

这一类问题最容易被“调优热情”带偏。团队已经决定今天要调 GC,于是所有证据都被解释成 GC。最后参数改了一圈,真正的慢查询还在那里,锁竞争还在那里,下游超时还在那里。

调优最怕先有答案,再找证据。

第五步:写停手条件和回滚线

GC 调优最容易上瘾。

从 100 到 150,有一点收益;从 150 到 200,好像还有一点收益;再往上,GC CPU 继续变好看。问题是每一步都可能在扩大内存峰值、延后回收、增加尾部风险。

所以调参前要写停手条件。

比如这次准备把 GOGC 从 100 调到 150,可以这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
观察窗口:至少覆盖一个完整业务高峰,最好 24 小时
成功条件:
- GC CPU 明显下降
- p99 / p999 同步抖动减少
- 吞吐和错误率无负向变化
- RSS 峰值仍低于容器 limit 的安全线
- OOMKilled 和重启次数为 0

回滚条件:
- RSS 快速逼近 limit
- p99 没改善或变差
- GC 后 live heap 持续上涨
- 错误率、重启次数或 throttling 异常

如果只满足“GC CPU 下降”,不算成功。

很多线上事故,就是只看一张 CPU 图做出的。GC CPU 从 5% 到 3%,看起来很漂亮;但 RSS 峰值从 1.2GiB 到 1.8GiB,离 2GiB limit 只剩一点点。这个优化不是成功,是把风险换了个地方藏起来。

反过来,调完 GOMEMLIMIT 后 GC CPU 上升,也不一定是坏事。它可能是在用更多 CPU 换更稳的内存边界。只要 p99、吞吐、错误率和 RSS 都能接受,这笔交易就可能是划算的。

真正的问题不是 GC CPU 从 2% 变成 4%。

真正的问题是:你知不知道这 2% 买来了什么。

一份可以直接带走的现场清单

下次遇到“GC 可能导致延迟抖动”,不要先问 GOGC 设多少。

先按这个顺序问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
1. p99 / p999 抖动时间点,是否和 GC 周期重合?
2. STW pause 是长,还是并发标记阶段 CPU 抢占明显?
3. GC 后 live heap 是否下降?
4. 如果不下降,谁还持有这些对象?
5. 如果能下降,分配速率是不是太高?
6. alloc_space 的 top 函数是不是热路径?
7. 容器 RSS 离 limit 还有多少余量?
8. 有没有 cgo、mmap、sidecar、tmpfs、文件缓存这类 Go 外内存?
9. 这次优化目标是降低 CPU、降低 RSS,还是降低 p99?
10. 成功条件和回滚线是什么?

这十个问题问完,通常就不会再出现“先把 GOGC 调大试试”的冲动。

如果团队里有人还是想直接改参数,你可以把这十个问题当成上线前检查单:没有回答完,就不要动生产配置;回答完了,再把调整范围、观察窗口、成功条件和回滚条件写进变更单。GC 调优不是一次命令,而是一次有证据、有边界、有撤退路线的线上实验。

因为你已经知道,自己到底在调什么。

GC 调优不需要神奇数字。它需要的是证据顺序:先确认相关性,再拆 live heap 和分配速率,再按 CPU / 内存 / 容器边界做交易,最后写清楚什么时候停手。

参数只是按钮。

路径才是保险丝。它不能保证永远不出事,但能保证出事前你知道自己越过了哪条红线。

如果你正在排查 Go 服务的内存、尾延迟和容器 OOM 问题,可以关注这个系列。后面会继续写 Go 线上排查里那些最容易被误判的地方:pprof 怎么看、goroutine 泄漏怎么钉、以及一个指标到底什么时候才算证据。