Go 为什么调高 GOGC 会换来 OOM:GOMEMLIMIT 不是替你付账的人

GOGC 调高后 GC 变少了、CPU 好看了,但堆峰值也高了。GOMEMLIMIT 不是另一个版本的 GOGC,它约束的是 Go runtime 管理的内存边界,不是整个 RSS。

服务 p99 每隔几十秒抖一下。

监控里 GC CPU 大概 5%,不算离谱,但曲线很准:GC 一来,延迟就往上冒。群里很快有人提议,把 GOGC 从 100 调到 200,先让 GC 少跑几次。

这个动作看起来很合理。

CPU 下来了,GC 日志没那么密了,p99 也安静了几天。然后另一个问题来了:容器内存开始贴近 limit,某个高峰期直接 OOMKilled。大家又把 GOGC 调回去,延迟抖动也跟着回来。

这就是很多 Go GC 调优的真实状态:不是不会调参数,而是没想清楚自己到底在买什么。

GOGC 调的是账期,不是魔法。

你把它调高,本质上是允许堆多长一段时间,用内存换 CPU;你把它调低,本质上是让 GC 更勤快,用 CPU 换内存。至于 GOMEMLIMIT,它也不是另一个版本的 GOGC,而是在告诉 runtime:Go 运行时能看见的那部分内存,最好别越过这条线。

所以真正的问题不是“GOGC 到底设多少”。

真正的问题是:这笔账,现在谁付得起?

GOGC、GOMEMLIMIT 与容器 limit 的关系

GOGC 调的是账期,不是回收能力

Go 官方 GC Guide 里有一个很关键的目标堆公式,可以先这样理解:

1
Target heap memory = Live heap + (Live heap + GC roots) × GOGC / 100

不用背公式,抓住账本就行。

上一轮 GC 结束后还活着的对象,叫 live heap。GOGC 决定的是:在这些活对象和 roots 成本的基础上,还允许新分配再增长多少,才启动下一轮 GC。

先把 roots 影响放一边,假设上一轮 GC 后 live heap 是 512MiB:

1
2
3
GOGC=50   下一轮目标大约 768MiB
GOGC=100  下一轮目标大约 1024MiB
GOGC=200  下一轮目标大约 1536MiB

这就是为什么 GOGC=200 经常让服务“看起来轻了”。不是 GC 变聪明了,而是你允许它晚点来。

这个“晚点”在线上会变成很具体的东西。一次批量 JSON 解析、一段消息批处理窗口、一个临时 []byte 缓冲、一批短生命周期结构体,本来下一轮 GC 来了就能被扫掉;现在 GC 启动时间往后挪,它们就会多占一会儿堆。

如果这段时间刚好赶上流量峰值,或者 Pod 里还有 sidecar、日志 buffer、TLS buffer、压缩库自己的内存,容器看到的不是“GC 更省 CPU 了”,而是“这个进程怎么又长了一截”。

晚点来,GC 次数可能少一点,GC CPU 可能好看一点;代价是 heap peak 更高,更多已经没用、但还没等到下一轮 GC 的对象,会继续待在堆里。

内存不是省下来的,是被你延期支付的。

如果 CPU 是瓶颈,内存很宽裕,调高 GOGC 有机会换来吞吐。如果内存已经贴着容器上限跑,调高 GOGC 就是在把风险往 OOM 那边推。如果 live heap 本来就在涨,调 GOGC 更像把账单推迟几分钟,不是把问题解决了。

这也是为什么“调完好了几天”最容易骗人。

几天内流量没冲高,临时对象规模没碰到峰值,RSS 还没撞上 limit,你会以为调参成功了。一到大促、批量任务、消息堆积、图片压缩、JSON 大包或者缓存热身期,堆目标被抬高后的代价就会回来找你。

GC 调优不是消灭 GC,而是决定谁来付账。

先看三条线,再决定要不要调 GOGC

线上不要只盯着一个 GCCPUFraction 或一行 gctrace。

我更建议先看三条线:

1
2
3
GC CPU 是否真的高
live heap 是否稳定
RSS / container memory 是否逼近 limit

如果 GC CPU 高,但 live heap 稳定、RSS 离容器 limit 还远,GOGC 可以谨慎往上试。比如从 100 到 150,再到 200,每次只改一档,看 p99、吞吐、GC 次数、RSS 和 heap goal。

这里最怕的是“一把梭”。GOGC=100 直接改成 400,图上 GC CPU 的确可能立刻好看,但你不知道代价来自哪里。更稳的做法是:先灰度一小部分流量,至少跨过一个业务峰谷周期,再决定要不要继续加。

调参不是写配置,是做实验。实验就必须有停止条件:RSS 逼近 80% limit 停;p99 没改善停;GC 后 live heap 继续涨停;错误率或重启次数有异常停。没有停止条件的调优,本质上就是赌博。

如果 RSS 已经紧张,就别急着把 GOGC 调大。你以为自己是在降低 GC CPU,实际上可能是在拿容器内存做抵押。

如果 live heap 每轮 GC 后都在涨,先别谈调参。那更像是对象真的活下来了:全局 map 只增不减、缓存没有上限、goroutine 泄漏带着引用、请求上下文被长期持有。这个时候把 GOGC 调高,只会让问题晚一点爆。

可以临时开 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                     # 下一轮目标堆

你不是为了读懂每个字段才开 gctrace。你是为了回答一个更朴素的问题:每一轮 GC 后,活对象到底有没有下来?目标堆是不是一路被抬高?p99 抖动是不是贴着 GC 周期出现?

调参前,先把这三个问题问完。

我通常会把一次安全的 GOGC 试验写成这样:

1
2
3
4
调整范围:100 -> 150,不直接跳到 200
观察窗口:至少覆盖一个业务高峰
核心指标:p99、吞吐、GC CPU、RSS、heap live、重启次数
回滚条件:RSS 逼近 limit、p99 无改善、live heap 持续上涨

这看起来麻烦,但它能挡住最危险的一类事故:参数改了,指标短时间变好,团队以为问题解决了;真正的流量峰值一来,才发现只是把成本藏到了内存峰值里。

还有一点很重要:不要只看平均值。GC 问题经常不把平均延迟拉得很难看,但会把尾延迟顶起来。你要看的不是“服务整体还行”,而是最倒霉的那批请求是不是刚好撞上了 GC 周期、CPU 争抢或内存尖峰。

如果只看均值,很多 GC 问题都会假装不存在。

GOMEMLIMIT 是边界,不是免死金牌

Go 1.19 以后,GOMEMLIMIT 让容器里的 GC 调优舒服了很多。

以前只有 Kubernetes 的 memory limit,Go runtime 并不知道外面那条线在哪里。它按自己的 heap goal 和 pacer 工作,容器只在外面记账。现在你可以告诉 runtime:这部分由 Go 管的内存,最好控制在一个软上限内。

1
GOMEMLIMIT=800MiB GOGC=100 ./your-service

代码里也能动态设:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import "runtime/debug"

func tuneGC() {
    oldPercent := debug.SetGCPercent(100)
    _ = oldPercent

    oldLimit := debug.SetMemoryLimit(800 << 20)
    _ = oldLimit
}

这里有两个坑,必须说清楚。

第一,GOMEMLIMIT 是 soft limit,不是硬墙。Go runtime 会通过更频繁的 GC、更积极地把内存还给系统等方式,尽量尊重这条线。但它不是内核 cgroup limit,也不是 OOM 保险箱。

第二,它管的不是进程世界里的所有内存。官方文档给出的口径接近:

1
runtime.MemStats.Sys - runtime.MemStats.HeapReleased

Go binary、内核代管的内存、C 代码分配、syscall.Mmap、某些外部库开出来的内存,都不在这条账里。

所以不要把容器 1GiB limit 直接写成:

1
GOMEMLIMIT=1GiB

这等于不给 Go 之外的内存留活路。

更麻烦的是,很多线上 Pod 不是只有一个 Go 进程。你可能还有 sidecar,可能把日志先缓存在本地,可能有 tmpfs,可能启用了 cgo,可能某个库在 Go runtime 看不见的地方申请了一块内存。容器 limit 统计的是总体结果,不会因为你这块内存“不归 Go 管”就手下留情。

所以 GOMEMLIMIT 的思路不是“贴着 limit 写满”,而是“先给 runtime 一条提前刹车线”。这条线越贴近外部 limit,越像把刹车点放到了悬崖边。

更稳的起点通常是先留出余量。比如 1GiB 容器,从 700MiB 到 800MiB 起步,再根据 RSS、heap、cgo、mmap、流量峰值和 sidecar 情况微调。

1
2
3
4
5
6
7
8
9
resources:
  limits:
    memory: "1Gi"

env:
  - name: GOMEMLIMIT
    value: "800MiB"
  - name: GOGC
    value: "100"

这个 800MiB 不是官方定律,只是一个工程起点。如果服务没有 cgo、没有大 mmap、没有复杂外部库,余量可以少一点。反过来,如果你用了图像库、压缩库、向量索引、共享 Pod limit 的 sidecar,或者有 memory-backed emptyDir,就别把 GOMEMLIMIT 贴得太满。

GOMEMLIMIT 是边界,不是免死金牌。

活对象本身太大时,它救不了你。它只会让 GC 更努力、更频繁地工作。再极端一点,程序可能从 OOM 风险滑向严重 slowdown:没被杀,但请求慢到不可用。

这个现象在排障时很容易被误读。有人看到进程没再 OOM,就说 GOMEMLIMIT 生效了;但如果同一时间 GC CPU 飙高、吞吐掉下去、p99 拉长,只能说明 runtime 正在拼命守线。守住边界不等于系统健康。

你真正要看的不是“有没有 OOM”,而是这三件事:第一,GC 后 live heap 有没有回落;第二,RSS 和 container working set 有没有稳定在安全区;第三,业务延迟有没有因为 GC 频率上升而变差。

容器里更稳的顺序是:先设边界,再谈吞吐。

落到配置上,可以用一个很粗但有效的判断:

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

这三行不是标准答案,但比“容器多大就写多大”安全得多。

尤其是最后一种,最容易被误判。GOMEMLIMIT 面对的是“可回收空间还有多少”的问题;如果大部分对象都还活着,它没有东西可回收。你让 runtime 更早开始 GC,它也只能一遍遍确认:这些对象确实还活着。

这时候继续压 GOMEMLIMIT,像是在催一个没钱的人还债。催得再急,也变不出钱。

STW 很短,不等于 GC 没影响

很多人看 gctrace,第一反应是找 STW。

这没错,但容易查偏。

Go GC 不是整轮 stop-the-world。它主要并发执行,通常只有阶段切换时需要短暂停顿。粗略看,可以分成几段:

1
2
3
4
Mark setup       短暂停顿,开启写屏障
Concurrent mark  并发标记,业务 goroutine 继续跑
Mark termination 短暂停顿,完成收尾
Sweep            清扫,通常并发推进

有些服务里,两端 STW 可能只是几十微秒。于是有人看完日志就说:pause 这么短,p99 抖动肯定不是 GC。

这个结论太快了。

STW 很短,不等于 GC 没影响。

p99 抖动不只来自 pause。并发标记会吃 CPU,mutator assist 会让业务 goroutine 帮 GC 干活,写屏障会让标记期的指针写入多一点成本。高分配速率下,应用自己把 GC 喂得太饱,即使 STW 很短,业务线程也可能在标记期感到“变重”。

举个更贴近线上排查的例子:日志里 pause 只有 0.1ms,看起来很漂亮;但标记期持续几十毫秒,CPU 已经被业务和 GC 一起打满。这个时候新请求进来,goroutine 不是被 STW 暂停,而是在争 CPU、做 assist、等调度。你从单行 pause 看不出这个压力,p99 却会老老实实把它暴露出来。

所以排查延迟时,不要只问:

1
pause 有多长?

更应该问:

1
2
3
4
5
GC 周期是否和 p99 抖动重合?
并发标记期 CPU 是否把业务线程挤掉?
是否出现明显 mutator assist?
GC 后 live heap 是否真的下降?
分配速率是否突然变高?

如果 pause 很短,但 GC 期间 CPU 明显上升,p99 仍然贴着 GC 周期抖,你就不能把 GC 排除掉。

很多调优误判,都是从“我只看了最容易看的指标”开始的。

更好的做法,是把 GC 事件和业务曲线叠在一起看。GC 开始前后,p99 有没有同步抬头?标记期 CPU 有没有顶到上限?同一时间请求量是不是并没有变化?如果业务流量平稳,只有 GC 周期附近出现尾延迟尖刺,那就算 STW 很短,GC 也仍然在嫌疑名单里。

反过来,如果 p99 抖动和 GC 周期完全不重合,就别把所有锅都甩给 GC。去看锁竞争、下游 I/O、数据库慢查询、调度、网络和队列堆积。调优最怕的不是没有工具,而是拿着一个工具解释所有问题。

GC 是线索,不是替罪羊。

如果这句话能记住,排查顺序就会稳很多:先确认相关性,再判断成本来源,最后才动参数。否则你调的不是 runtime,而是团队的运气。线上系统不怕你慢一点下判断,怕的是你用一个漂亮的指标,遮住了真正付账的地方,也遮住了下一次故障。

下次别先问参数,先问这五件事

如果你现在手上就有一个 Go 服务,GC CPU 变高、p99 周期性抖、容器内存又不太宽裕,我建议按这个顺序走。

先别开会争 GOGC=150 还是 GOGC=200,把下面这张小账单填完:

1
2
3
4
5
1. GC 周期是否和业务 p99 / 吞吐波动重合?
2. 每轮 GC 后 live heap 是稳定,还是一路上涨?
3. RSS / container memory 离 limit 还有多少余量?
4. 分配速率是不是突然变高,alloc_space 热点在哪里?
5. 服务有没有 cgo、mmap、sidecar、tmpfs 这类 Go 看不全的内存?

答案出来之前,不要急着把 GOGC 拧到 200。

如果 CPU 真的买不起、内存还有余量,可以小步调高 GOGC。如果内存先买不起,先设合理的 GOMEMLIMIT,再查 live heap 和非 Go 内存。如果两个都买不起,别再靠参数找安慰,去减分配、控缓存、修泄漏。

GC 参数不是止痛药。它只是把成本从一边挪到另一边。

这篇先把 GOGCGOMEMLIMIT 和 STW 这三笔账摊开。下一篇会继续往下走:当你已经拿到 pprof、gctrace、heap profile 和 goroutine profile,怎么判断到底是真泄漏、短时分配尖峰,还是容器内存账没算清。

如果你正在排查 Go 服务的内存和延迟问题,可以先关注这个系列。后面不讲玄学调参,只讲线上怎么把证据一层一层钉死。