Go 服务 RSS 8G 但 heap 只有 1.2G:GOGC、GOMEMLIMIT、madvise 我挨个试了一遍

🔑 关键词:Go内存不释放, GOMEMLIMIT, GOGC, Go OOMKilled, pprof

📖 摘要:一次线上 OOMKilled 的排查记录。heap profile 干净得不像话(inuse 才 1.2G),RSS 却稳稳爬到 7.9G。文中给了完整的 MemStats 数据、gctrace 输出、GOGC=50 / GOMEMLIMIT / GOGC=off 三组配置的 CPU 与 RSS 实测对比,以及 bigcache 改造后的数字。

先说现象:8Gi 的 limit,12 小时被 OOM 一次

图片

我们组那个订单查询服务,Go 1.21 编译,跑在 K8s 上,memory limit 给到 8Gi。现象规律得让人绝望:pod 起来之后 RSS 一路往上爬,爬到 7.9Gi 左右被 OOMKilled,重启,然后重来一遍。一个完整周期大概 12 小时,凌晨 3 点和下午 3 点各挂一次,值班的同学都能掐着表等。

第一反应当然是 goroutine 泄漏或者某个 map 只增不减。挂上 pprof,go tool pprof -sample_index=inuse_space 打出来,inuse 才 1.2Gi,flat 排序前几名全是正常的业务结构体,没有任何异常。goroutine 数稳定在 300 上下,跑一天也不涨。

这就很反直觉了——堆里就 1.2G 的对象,RSS 凭什么到 7.9G?中间这 6G 去哪了?

HeapIdle 5.98G,HeapReleased 只有 200M

别急着猜,先把 MemStats 拉出来看。

$ curl -s localhost:6060/debug/pprof/heap?debug=1 | head -20
# runtime.MemStats
# Alloc = 1187734528
# Sys = 8432897120
# HeapAlloc = 1187734528
# HeapSys = 7214968832
# HeapIdle = 5987643392
# HeapInuse = 1227325440
# HeapReleased = 209715200
# HeapObjects = 4821993

几个关键值先理一下:HeapSys = HeapIdle + HeapInuse,这个等式永远成立。HeapReleased 是其中已经真正还给操作系统的部分。

我们这里 HeapIdle 有 5.98G,HeapReleased 只有 200M。也就是说 runtime 从 OS 那里拿了 7.2G 的内存,用不上的 5.98G 全挂在自己的 free span 链表上,一个字节都没还。heap profile 根本不统计这块,所以你 profile 看着干干净净,RSS 却在涨。

图片

再看 gctrace。加环境变量 GODEBUG=gctrace=1 重启,输出长这样:

gc 412 @3600.123s 0%: 0.048+2.1+0.011 ms clock, 0.38+0.9/3.2/0.078+0.088 ms cpu, 1180->1210->1120 MB, 2240 MB goal, 8 P

最后那串 1180->1210->1120 MB, 2240 MB goal,第三个数字 1120MB 是这次 GC 之后的 live heap,一直稳稳不动,波动不超过 200MB。goal 是 2240MB,意思是 runtime 认为这块堆涨到 2.2G 才该触发下一次 GC。

live heap 是平的,GC 目标是 2.2G,那 5.98G 的 idle 到底为什么留着?

madvise 这段历史,Go 1.12 到 1.16

先排除一个老坑。Go 1.12 在 Linux 上把归还内存用的 syscall 从 MADV_DONTNEED 换成了 MADV_FREE。区别在于:DONTNEED 是立马释放物理页,下次要用得重新 fault、重新写零;FREE 是惰性的,只给页打个「可以回收」的标记,真正回收得等系统内存吃紧。

好处是重新分配这块内存时快得多,坏处就是 RSS 不降。监控一看就是内存泄漏,其实只是延迟归还。当年多少人被这个坑掉头发。

Go 1.16 改回了 MADV_DONTNEED 作为默认。如果你还在跑 1.12 到 1.15 之间的版本,GODEBUG=madvdontneed=1 是第一件该试的事,加完 RSS 曲线立刻变样。

但我们这个服务是 1.21,默认就是 DONTNEED,HeapReleased 还是只有 200M,所以问题不在这。

图片

真正的原因在 background scavenger。Go runtime 里有个后台协程专门负责把 idle span 还给 OS,但它的策略非常克制。它不会主动去「把 RSS 压到最低」,它的目标只是「别让堆无限膨胀」。每次 GC 之后它就还个几十 MB,而业务每秒要分配几百 MB,一边还一边涨,永远追不上。

用 GODEBUG=scavtrace=1 能看到它每次的动作,配合 gctrace 一起看很直观。

三组配置的实测对比

方案一:GOGC=50

这是网上搜「Go 内存不释放」出来最多的答案。我们试了,结果不太行。

CPU 平均占用从 30% 涨到 55%(GC 次数翻了将近一倍),RSS 峰值从 7.9G 降到 6.2G,但还是会 OOM,只是从 12 小时变成 20 小时挂一次。更糟的是 P99 延迟从 18ms 涨到 34ms,业务那边直接来问是不是在发版。

为什么效果这么差?因为 GOGC 是个相对值,公式是「下次 GC 触发点 = live heap × (1 + GOGC/100)」。live heap 1.12G 的时候,GOGC=100 目标是 2.24G,GOGC=50 是 1.68G。它只控制 GC 频率,完全不管 idle 的内存还不还。你把 GC 调快,只是让 free span 产生得更快而已。

方案二:GOMEMLIMIT

图片

Go 1.19 引入的软内存限制。注意是软限制,不是硬限制——它不会拦着你不让分配,只是让 runtime 在堆逼近这个值时变得更激进地 GC 和 scavenge。

GOMEMLIMIT=6400MiB GOGC=100

单位别写错,Go 支持 B/KiB/MiB/GiB 这些二进制单位,也可以写 6400MiB 或者 6710886400。写成 6400M 是 10 进制,实际是 6.4GB 不是 6.25GiB,差一点。

结果:RSS 稳定在 6.4~6.9G 之间来回波动,不再单调上升,OOM 消失。代价是 CPU 涨了大概 8%,从 30% 到 32.5%。

这里有个大坑必须说:GOMEMLIMIT 设太低会引发 GC thrashing。如果你把 limit 设到接近 live set,比如 live 1.2G 你把 limit 设 1.5G,runtime 会几乎一直在跑 GC,CPU 打满,吞吐掉一半,而且因为 GC 期间还要标记分配,堆反而降不下去,形成死亡螺旋。经验值是 GOMEMLIMIT 至少是 live heap 的 3 倍,同时取容器 limit 的 70%~80%。我们是 8Gi limit,所以取 6400MiB。

方案三:GOGC=off + GOMEMLIMIT

GOMEMLIMIT=6400MiB GOGC=off

这是我现在生产在用的配置。GOGC=off 之后,GC 唯一的触发条件就是堆逼近 memory limit。

图片

效果:CPU 从 GOGC=50 时候的 55% 掉到 26%,比最初默认的 30% 还低。GC 次数从每分钟 40 多次降到每分钟 3~5 次,GC pause 的 P99 从 800μs 降到 150μs。

风险也很直白:如果 live set 突然暴涨(缓存穿透、某个查询返回巨量对象),堆会在没有 GC 的情况下直接冲顶,然后开始疯狂 GC。所以这个组合一定要配 live heap 的监控告警,我们设的阈值是 2.5GiB,超了就报警。

方案四:把大对象挪出 GC 视野

这个和上面三个不冲突,可以叠加。如果你的服务里有那种「几十万条、每条几百字节」的常驻缓存,每次 GC 都要沿着指针扫一遍,纯属浪费。

我们另一个服务把 80 万条 session 从 map[string]*Session 换成了 bigcache,heap inuse 从 1.8G 降到 340M,GC pause P99 从 900μs 降到 120μs。bigcache 的原理是把数据序列化进一块大的 []byte 环形缓冲里,对象本身不带指针,GC 扫描时直接跳过整块内存。

代价是读的时候多一次反序列化,单次读大概多 2μs。写少读多的场景非常划算,写多的场景要慎重,反序列化开销会累积。

我现在的配置和监控

env:
  - name: GOMEMLIMIT
    value: 6400MiB
  - name: GOGC
    value: off
resources:
  requests:
    memory: 6Gi
  limits:
    memory: 8Gi

现在 RSS 长期稳定在 6.5G 上下,live heap 1.1~1.4G,跑了两周没重启过。request 和 limit 别设得太离谱(比如 request 2Gi、limit 8Gi),调度器会算不准,节点上容易出现雪崩。

图片

监控这块提醒一句:别用 runtime.ReadMemStats 做采集。它会 STW,虽然新版本已经很快了,但高频调用还是有影响。用 runtime/metrics 包,官方推荐的几个指标:

  • /memory/classes/heap/objects:bytes —— 对应 live heap
  • /memory/classes/heap/released:bytes —— 归还给 OS 的量
  • /gc/heap/goal:bytes —— 下次 GC 的目标

采集是非阻塞的,也不需要额外依赖。

几个跟主流说法不太一样的观点

第一,Go 服务里真正算「泄漏」的(有对象一直被引用、GC 回收不掉)其实很少见。绝大部分所谓的内存泄漏,是 runtime 行为导致的——idle span 不还、scavenger 太保守、profiler 只看 inuse 看不到 HeapIdle。你拿着一份干净的 heap profile 去跟人说「内存没问题」,是要被打脸的。

第二,别一上来就调 GOGC。GOGC 是给「live heap 会持续增长」的应用准备的旋钮。如果你的 live heap 是平的(gctrace 里第三个数字一直不变),调它只是拿 CPU 换一条好看点的曲线。先确认 live heap 的形状,再决定动不动它。这个顺序反了,白折腾。

第三,GOMEMLIMIT 确实好用,但它解决的是「Go runtime 不知道自己在容器里」这个问题。Go 1.19 之前 runtime 完全不读 cgroup 的 memory limit,你在 K8s 里设 8Gi,Go 一无所知,它还按宿主机几百 G 的内存去规划 GC 目标。如果你的服务不是容器部署,或者 limit 和实际用量差很远,它的效果会打不少折扣。

最后,runtime/debug.FreeOSMemory() 不是不能用,但别放进定时任务里每 5 分钟跑一次。它会同步触发一次 GC 然后强制归还,等于把异步的 scavenger 换成了一个阻塞的同步调用。高频调用会让 P99 直接爆掉。真要手动触发,放在流量低谷期的一次性任务里,比如每天凌晨跑一次,可以接受。

🏷️ 标签: