内存一直涨,不一定是泄漏:Go GC 真正在做什么
从一个线上内存持续增长的场景讲起,拆解 Go GC 的三色标记、写屏障、STW、GOGC 与 GOMEMLIMIT,给出一套能落地的排查和调优顺序。
服务内存从 800MB 慢慢涨到 2.6GB。
曲线不陡,不像雪崩。它只是每天往上爬一点,重启以后掉下来,过几天又回到老地方。告警响了,群里第一句话通常是:是不是内存泄漏?
这个判断不算错,但太急。
在 Go 服务里,内存持续上涨可能是真泄漏,也可能是 live heap 变大了,可能是 GOGC 目标让下一轮回收来得更晚,也可能是短命对象太多,把 GC 拖进了频繁工作。还有一种更容易误判:heap 已经回收了,但 RSS 没有马上按你想象的方式降下来。
内存涨,不等于内存泄漏。
要把这件事查清楚,不能只盯 RSS。你得知道 Go GC 什么时候启动、怎么判断对象还活着、为什么需要写屏障、哪里会停顿,以及哪些参数是真的能调。
GC 不是保洁员,是成本调度器
很多人对 GC 的直觉是“内存不用了,运行时帮我扫掉”。这个说法只对了一半。
真正重要的是另一半:GC 每扫一次,都要花 CPU;扫得太勤,CPU 被回收器吃掉,服务吞吐和延迟会受影响。扫得太晚,内存峰值又会涨上去,容器可能先把你杀掉。
所以 Go GC 不是等地上脏了才来的保洁员。它更像一个拿着预算表的人:上一轮还有多少对象活着?再往后拖一点,内存能不能接受?现在开始扫,CPU 能不能接受?
Go 官方 GC Guide 讲得很清楚:GC 主要消耗 CPU 和物理内存。想省 CPU,就得允许堆多长一会儿;想省内存,就得让 GC 更频繁地工作。
这就是 GOGC 的本质。
GOGC=100 不是“内存达到 100MB 触发 GC”。它的意思更接近:在上一轮存活堆的基础上,允许新分配对象按约 100% 的比例增长,再触发下一轮 GC。Go 1.19 之后,目标堆计算还会把 GC roots 的成本纳入进来,可以简化理解为:
| |
GOGC 调的是成本,不是情绪。
从 100 调到 200,GC 频率可能降低,CPU 压力可能变小,但内存峰值会更高。从 100 调到 50,内存可能收得更紧,但 GC 更勤。
所以线上排查时,不要一上来就问“GOGC 该设多少”。先问:现在到底是内存买不起,还是 CPU 买不起?
Go GC 一轮到底怎么走
把细节先压住,Go GC 可以先看成三种状态轮转:清扫、空闲、标记。清扫阶段处理上一轮死亡对象,空闲阶段应用继续分配。等分配增长接近下一轮目标,运行时启动标记,从根对象出发,找出哪些对象还活着。
真正决定对象命运的是“可达性”。
所谓根对象,可以先理解成运行时确定仍然有效的入口:全局变量、goroutine 栈上的指针、寄存器里的引用等。从这些入口一路沿着指针往下走,能走到的对象就是活的;走不到的对象,才有机会被回收。
这就是 mark-sweep:先标记,再清扫。
Go 的 GC 是 non-moving 的,不会把对象搬来搬去压缩堆。好处是指针关系和运行时实现更直接,代价是 RSS 表现不能简单等同于“Go 已经把所有不用的内存都还给操作系统”。
所以 heap profile 降了,RSS 未必马上掉到同样低。只看 RSS,很容易冤枉 GC。
三色标记解决的不是颜色问题
三色标记的名字容易把人带偏,好像重点是白、灰、黑三种颜色。其实颜色只是一个状态机。
白色:还没确认可达。标记结束后仍然是白色,才会被当成垃圾。
灰色:已经确认可达,但它指向的对象还没有全部扫描完。灰色对象更像一个待办队列。
黑色:已经确认可达,并且它指向的对象也扫过了。
一轮标记刚开始,大多数堆对象都是白色。GC 从 roots 出发,把直接能碰到的对象变灰。接着不断拿出灰色对象,扫描它的指针,把它指向的白色对象也变灰。扫完后,它就变黑。
如果世界是静止的,这套算法很简单。问题是 Go 的世界不是静止的。
应用 goroutine 还在跑,指针还在改,新的对象还在分配。GC 一边标记,业务代码一边写指针。于是会出现一个危险场景:某个黑色对象已经被 GC 认为“扫完了”,结果应用又把它改成指向一个白色对象。如果这个白色对象再没有其他灰色路径能到达,GC 就可能漏标。
漏标的后果很严重:对象明明还活着,却被当成垃圾清掉。
这不是性能问题,这是正确性问题。
所以并发标记必须有保险。这个保险就是写屏障。
写屏障是在业务写指针时插一脚
写屏障不用想得太玄。它做的事很具体:业务代码在 GC 标记期间修改指针时,运行时插入额外逻辑,确保标记正确性。
早期并发三色标记常讲一个强约束:黑色对象不能直接指向白色对象。只要这个约束被保持住,GC 就不会漏掉还活着的对象。
但现实里,维持这个强约束并不便宜。Go 1.5 引入并发 GC,大幅降低长时间 STW;Go 1.8 使用混合写屏障,核心目标之一就是消除 STW 的 stack re-scan,让停顿时间进一步降下来。
混合写屏障可以粗略理解为两件事的组合:
| |
背后的设计更复杂,但工程排查先抓住这个结论:写屏障不是为了“让 GC 更高级”,而是为了让应用并发写指针时,GC 不会误删活对象。
当然,保险不是免费的。
在标记期间,指针写入会有额外成本。大多数业务不会因为这个成本单独出问题,但如果高峰期疯狂分配、疯狂改指针,GC 的存在感就会变强:CPU 上去,吞吐掉一点,日志里 GC 变密。
STW 可怕的不是存在,而是你没量过
Go GC 不是整轮都 stop-the-world。它主要并发执行,但仍然需要短暂停顿来完成阶段切换。
典型可以看成这几段:
- Mark setup:短暂停顿,打开写屏障,准备进入并发标记。
- Concurrent mark:应用继续运行,GC 并发扫描可达对象。
- Mark termination:短暂停顿,完成标记收尾,关闭写屏障。
- Sweep:清扫未标记对象,通常与应用并发推进。
Go 1.8 的 release notes 明确提到,GC pause 显著缩短,通常低于 100 微秒,并消除了 stop-the-world stack re-scanning。这是 Go GC 演进里很关键的一步。
但写文章可以这么说,线上排查不能只背这句话。
你的服务里到底停了多久,要看自己的数据。goroutine 数量、堆大小、对象图形态、Go 版本、CPU 状态都会影响观察结果。
最小观察方式是打开 gctrace:
| |
你会看到类似这样的输出:
| |
先别急着把每个字段背下来。排查时先抓几件事:
64->72->38 MB:GC 前、GC 后、存活堆大概是什么变化。76 MB goal:下一轮目标大概是多少。0.021+4.3+0.065 ms:两端短暂停顿和中间并发标记的大致耗时。- 百分比:GC 占用 CPU 的大致比例。
如果 GC 后 live heap 一直上升,说明活对象真的越来越多。它可能是业务缓存、全局 map、未释放引用,也可能是 goroutine 泄漏间接拉住对象。
如果 live heap 能下来,但 GC 非常频繁,通常要看分配速率和短命对象。这个问题不靠调大 GOGC 根治,调大只是让回收少来几次;该减少分配的地方还是要减少。
如果 STW 很短,但 p99 仍然抖,就别只盯 pause。标记期 CPU、assist、锁竞争、调度延迟都可能参与了这次抖动。
GOGC 和 GOMEMLIMIT,不是一个方向的旋钮
GOGC 控制的是相对增长目标,GOMEMLIMIT 控制的是运行时内存软上限。它们经常一起出现,但解决的问题不一样。
GOGC 更像是在问:为了少花 CPU,我愿意让堆在上一轮 live heap 基础上多长多少?
GOMEMLIMIT 更像是在问:这个进程整体由 Go runtime 管理的内存,最好别超过多少?
Go 1.19 引入 GOMEMLIMIT 后,容器场景好用了很多。以前只设容器 memory limit,Go runtime 并不知道外面那条线在哪里。现在你可以给 Go 一个软上限。
例如容器限制 1GiB,不要把 GOMEMLIMIT 也设成 1GiB。Go 官方建议给非 Go runtime 管理的内存留余量,工程上常见做法是留出 5%-10% 甚至更多。
| |
代码里也能动态改:
| |
但这里有个坑:内存限制不是硬墙。Go 为了避免程序陷入“只 GC 不干活”的 thrashing,会限制 GC 消耗 CPU 的程度。极端情况下,内存可能短暂超过限制。
所以别把 GOMEMLIMIT 当成 OOM 保险箱。它是运行时信号,不是操作系统豁免权。
真正该优化的,往往不是 GC 参数
线上看到 GC 压力,最常见的错误动作是先调参数。
参数当然能调,但它应该排在后面。更稳的顺序是:先确认对象为什么活着,再确认为什么分配这么多,最后再调 GC 策略。
第一步,看 live heap。
| |
进入 pprof 后先看:
| |
如果某些对象在 inuse 视角下长期占用,说明它们真的还活着。继续查引用链、全局结构、缓存淘汰、goroutine 生命周期。这个时候怪 GC 没意义,GC 不会回收仍然可达的对象。
第二步,看分配速率。
短命对象很多时,heap profile 的 inuse 未必吓人,但 alloc_space 会很难看。比如循环里反复 fmt.Sprintf、JSON encode/decode、临时 slice/map、日志字段拼装,都可能把 GC 喂得很饱。
| |
这类问题的解法通常是减少分配:复用 buffer,预估 slice 容量,避免热路径反射,减少临时对象,必要时用 sync.Pool。但 sync.Pool 不是业务缓存,它适合生命周期短、可复用、创建成本明显的对象。
第三步,看 GC 频率和延迟。
如果 live heap 稳定、分配也合理,只是容器内存太紧,可以考虑降低 GOGC 或设置 GOMEMLIMIT。如果 CPU 更贵、内存还有空间,可以适当提高 GOGC。
这背后没有神奇数字。
你调的是一条业务曲线:内存峰值、GC CPU、p99、吞吐、OOM 风险。只看其中一个指标,都会调歪。
一套能落地的排查顺序
下次再遇到开头那种“内存一直涨”的问题,我建议按这个顺序查。
先看运行时指标:
| |
HeapAlloc 代表仍在使用的堆对象规模,HeapSys 是从系统拿到的堆内存,HeapReleased 是已经还给操作系统的部分。RSS 涨的时候,把这些值放在一起看。
再开 gctrace 看每轮 GC 后 live heap 是否下降。如果 GC 后仍然一路涨,优先查对象持有。如果 GC 后能下来,但 GC 次数太密,优先查分配热点。
接着抓 heap profile。不要只看一次,隔几分钟抓两次,对比趋势。很多问题不是一个瞬间的 top 能看出来的。
如果怀疑 goroutine 泄漏,再看 goroutine profile。goroutine 泄漏经常会把请求对象、channel、buffer、上下文引用一起拉住,让 GC 认为这些对象都还活着。
最后才动参数。
- 内存紧,CPU 还有余量:考虑降低
GOGC,或设置更合理的GOMEMLIMIT。 - CPU 紧,内存有余量:考虑提高
GOGC,减少 GC 频率。 - 容器环境:优先给
GOMEMLIMIT留足 headroom,不要贴着 cgroup 上限设。 - 分配过猛:先改代码,别指望参数替你还债。
GC 能帮你回收不可达对象,不能帮你判断业务对象该不该活着。
结尾:别再把 GC 当黑箱
回到开头那条内存曲线。
服务从 800MB 涨到 2.6GB,第一反应当然可以怀疑泄漏。但有效排查不是在群里喊“Go GC 有问题”,而是把问题拆开:对象是不是还可达?分配是不是太快?GC 目标是不是太宽?容器内存是不是太紧?
三色标记、写屏障、STW 这些词,单独背下来没什么用。它们真正的价值,是让你知道一件事:GC 没有魔法,它只是在保证正确性的前提下,在 CPU 和内存之间做一笔账。
账算错了,线上就会还。
内存涨,不先怪 GC。
先把证据拿出来。