GC 调优到底先看什么:Go 为什么要从 live heap 开始,而不是先改 GOGC
线上 GC 调优最怕先有答案再找证据。正确的顺序是:先确认 GC 是不是问题→看 live heap→看分配速率→再决定参数→最后设回滚线。没有基线的调优,只是玄学。
上篇讲完 GOGC、GOMEMLIMIT 和 STW,很多人真正卡住的地方才刚开始。
不是“不知道这些参数是什么”,而是线上真的出问题时,不知道下一步该干什么。
服务 p99 每隔几十秒抖一下,GC CPU 看起来有 5%。有人说把 GOGC 从 100 调到 200,有人说先上 GOMEMLIMIT,有人说 Go GC 已经很强,肯定不是它的问题。最后开会半小时,结论往往是:先改一个数试试。
这才是 GC 调优最危险的地方。
不是参数难,而是决策路径太松。你不知道自己是在省 CPU,还是在借内存;不知道问题是活对象太多,还是短命对象太多;也不知道什么时候应该停手。
没有路径的调优,就是拿线上流量做抽签。
这篇不再展开概念。我们只解决一个问题:当你已经知道 GOGC 和 GOMEMLIMIT 是什么,线上到底怎么用。
第一步:先证明 GC 真的在场
很多排查一开始就走偏,是因为团队先认定“这就是 GC 问题”。
GC CPU 有 5%,不等于它一定是罪魁祸首;GC CPU 只有 1%,也不等于它一定无关。线上要看的不是某个数字高不高,而是它和业务曲线有没有关系。
先把三条线叠起来看:
| |
临时排查可以先开 gctrace:
| |
你可能会看到类似这样的行:
| |
这行不用每个字段都背下来。先抓三件事:
| |
如果 p99 抖动刚好贴着 GC 周期出现,标记期 CPU 也在往上顶,GC 才进入嫌疑名单。
但 gctrace 更适合临时排查,不适合长期靠人肉翻日志。线上服务最好把 runtime 指标接进监控,至少分三组看:
| |
这三组指标不要混在一起解释。存活规模告诉你 GC 之后还剩多少东西;分配速度告诉你业务正在以多快的速度制造新对象;GC 代价告诉你 runtime 为这些对象付了多少 CPU 和停顿成本。
很多团队查 GC 会犯一个很隐蔽的错:看到 NumGC 增长很快,就说 GC 太频繁;看到 HeapAlloc 变大,就说内存泄漏;看到 pause 不长,就说 GC 没影响。单个指标很容易骗人。你要把它们放到同一条时间线上,看它们有没有一起变化。
如果 p99 抖动和 GC 周期没什么关系,别硬往 GC 上靠。去看锁竞争、数据库慢查询、下游 I/O、队列堆积、系统调用和调度。调优最怕的不是没有工具,而是拿一个工具解释所有问题。
GC 是线索,不是替罪羊。
这一步的产出不是“要不要调参数”,而是一个更基础的判断:GC 有没有资格继续被调查。
第二步:看 live heap,别先看参数
如果 GC 确实在场,下一步不要急着调 GOGC。
先看每轮 GC 之后,live heap 有没有下来。
| |
如果 GC 后 live heap 一路上涨,优先查对象为什么还活着。常见原因并不神秘:
| |
这种情况下,调高 GOGC 多半是在拖延爆炸。因为 GOGC 只决定下一轮 GC 什么时候来,它不会替业务判断哪些对象“不该再活着”。
对象还被引用,GC 就不能回收。你把 GOGC=100 改成 GOGC=200,只是让这些对象和新分配一起在堆里待得更久。
这也是很多“调完好了几天”的根源。
前几天流量没到峰值,RSS 还没贴近 limit,监控看起来像优化成功。等批处理、缓存热身、大请求、消息堆积一起来,活对象规模继续涨,容器直接把账单拍到你脸上。
GC 只回收没人要的对象。业务还攥着,它不会抢。
如果 live heap 持续上涨,正确动作不是调参,而是查持有关系:谁还在引用这些对象,为什么它们没有被释放,缓存和队列有没有边界,goroutine 是否泄漏。
第三步:再看分配速率
另一类问题刚好相反:live heap 不高,但分配速率很难看。
也就是说,对象大多能被回收,只是你制造垃圾的速度太快。网关、日志清洗、协议编解码、JSON 重度服务、模板渲染、批量字符串拼接,都容易掉进这个坑。
这时要看 alloc profile:
| |
如果 inuse_space 不吓人,但 alloc_space 的热点集中在几个热路径函数上,说明 GC 被喂得太饱了。
这个时候调高 GOGC 确实可能有短期收益。GC 少跑几轮,CPU 好看一点,p99 可能也安静一点。
但你要知道这笔账怎么来的。
| |
调大 GOGC 不是把这些临时对象消灭了,只是让它们晚点被处理。真正的解法通常在代码里:减少热路径临时对象,复用 buffer,避免不必要的 []byte 和 string 来回转换,少在高频路径里做反射、装箱和格式化。
参数能延期还债,代码才会减少欠账。
所以第三步要回答的是:问题来自“活得太久”,还是“生得太快”。这两个问题看起来都能让 GC 忙起来,但调法完全不同。
第四步:按三种场景选动作
走到这里,你才有资格谈参数。
不是因为参数不重要,而是因为参数只有放在场景里才有意义。

场景一:CPU 紧,内存宽,live heap 稳定
这是比较适合试探性提高 GOGC 的场景。
比如服务 GC CPU 确实偏高,p99 抖动和 GC 周期相关;同时 live heap 稳定,RSS 离容器 limit 还远,非 Go 内存也没明显风险。
这时可以小步走:
| |
如果是 Kubernetes 里的服务,不要直接全量改 Deployment。更稳的是先挑一组低风险 Pod,或者给一小部分实例加环境变量,单独观察它们和基线组的差异。
你要比较的不是“调参组看起来有没有变好”,而是同一段时间里,调参组相对基线组有没有稳定收益。流量、下游状态、缓存命中率都会影响延迟。如果没有基线组,你很容易把一次流量回落误判成 GC 参数生效。
观察时至少把这些图放在一起:
| |
不要从 100 直接跳到 300,更不要一上来关 GC。先从 150 开始,灰度一小部分流量,至少覆盖一个业务高峰。观察 p99、吞吐、GC CPU、RSS、heap goal、重启次数。
如果 GC CPU 下来了,p99 改善,RSS 仍然安全,再评估要不要继续到 200。
这里的关键不是“150 比 200 更正确”,而是你要保留判断空间。一步调太大,出了问题你很难知道是阈值过猛、流量变化,还是原本判断就错了。
场景二:内存紧,CPU 还够
如果容器 RSS 已经贴近 limit,或者出现过 OOMKilled,就别急着提高 GOGC。
你以为自己是在降低 GC CPU,实际上可能是在拿容器内存做抵押。
更稳的顺序是先设边界:
| |
假设容器 limit 是 2GiB,这个 1500MiB 不是官方定律,只是一个工程起点。具体留多少余量,要看 Pod 里还有没有 sidecar、cgo、mmap、tmpfs、文件缓存、压缩库或图像库这类 Go runtime 看不全的内存。
一个粗略但实用的起点是:
| |
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,可以这样写:
| |
如果只满足“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 设多少。
先按这个顺序问:
| |
这十个问题问完,通常就不会再出现“先把 GOGC 调大试试”的冲动。
如果团队里有人还是想直接改参数,你可以把这十个问题当成上线前检查单:没有回答完,就不要动生产配置;回答完了,再把调整范围、观察窗口、成功条件和回滚条件写进变更单。GC 调优不是一次命令,而是一次有证据、有边界、有撤退路线的线上实验。
因为你已经知道,自己到底在调什么。
GC 调优不需要神奇数字。它需要的是证据顺序:先确认相关性,再拆 live heap 和分配速率,再按 CPU / 内存 / 容器边界做交易,最后写清楚什么时候停手。
参数只是按钮。
路径才是保险丝。它不能保证永远不出事,但能保证出事前你知道自己越过了哪条红线。
如果你正在排查 Go 服务的内存、尾延迟和容器 OOM 问题,可以关注这个系列。后面会继续写 Go 线上排查里那些最容易被误判的地方:pprof 怎么看、goroutine 泄漏怎么钉、以及一个指标到底什么时候才算证据。