编程 容器 OOMKilled,pprof heap 却只有 14.5MB:先把 Go 进程的内存账拆开

2026-11-10 00:04:35

容器 OOMKilled,pprof heap 却只有 14.5MB:先把 Go 进程的内存账拆开

容器 Last State: OOMKilled,但接上 pprof 后 /debug/pprof/heap 的 inuse_space 只有 14.5MB,top 里 RES 却是 120MB+。这两组数是两个不同的统计对象,先分清再查。

参考资料:Go 官方 GC 指南(诊断 / 内存限制章节)、pkg.go.dev/runtime/pprof,以及线上 OOM 排查记录(qingwave.github.io golang_memory_stats、gocampus 内存与 GC 实战)。

内存不只一个统计对象:RSS 与 Go heap

RSS(top 的 RES、cgroup 的 /sys/fs/cgroup/memory.current、和 k8s limit 比较的那个值)包含:

  • Go 堆:存活对象 + span + 已死未清扫对象
  • goroutine 栈
  • runtime 元数据:mspan / mcache / GCSys / other
  • 已 Free 但尚未归还 OS 的内存
  • cgo 分配、mmap 文件、C 库分配

最后一项完全不进 Go heap 统计。pprof 看的是其中一小块。

runtime.MemStats 的层级:

Sys = HeapSys + StackSys + MSpanSys + MCacheSys + BuckHashSys + GCSys + OtherSys
HeapSys = HeapInuse(含已分配 span)+ HeapIdle(空闲 span,其中 HeapReleased 已还给 OS)

常用关系式:

关系式含义
Sys - HeapReleasedGo runtime 视角的实际占用,也是 GOMEMLIMIT 的计量对象
HeapIdle - HeapReleased预留未归还的内存(span 缓存,供下次快速分配)
HeapInuse - HeapAlloc已分配但未使用的内存,可评估碎片上限
NextGC - HeapAlloc距离下次 GC 的余量

GC 指南的原文表述:memory limit 针对的是 Sys - HeapReleased,等价于 runtime/metrics 的 /memory/classes/total:bytes - /memory/classes/heap/released:bytes。

三分支判定表

观测结论第一动作
RSS 高,heap inuse 稳定不是泄漏。差额来自 heap/free、heap/unused、stacks、metadata/*、other,或 cgo / mmap / 大页确认 GOMEMLIMIT 与容器 limit 的水位,不要改代码
heap inuse 持续上涨不回落泄漏打两次带 ?gc=1 的快照做 -base 差,与 goroutine profile 交叉验证;增长点一般挂在 mapassign / growslice / append / newobject 的调用方(无上限 map、只 append 不清理的 slice、未 Close 的 buffer)
heap 存活稳定,但 GC CPU 占比高、GC 频率高churn(分配太猛)看 /debug/pprof/allocs 的 alloc_space 排序,削分配:sync.Pool、预分配、去接口装箱

pprof 的正确用法

heap 与 allocs 是同一份采样数据的两个入口,只差默认 sample_index:

  • inuse_space(heap 默认):仍存活对象字节,查泄漏
  • alloc_space:启动以来累计分配,查 GC 压力,与泄漏无关
  • inuse_objects / alloc_objects:对象数 / 次数,小对象采样偏差最大,只看量级

采样率 runtime.MemProfileRate 默认 512KB,平均每分配 512KB 采一次,概率与对象大小成正比;小对象高频分配点可能压根不出现在 top 里。需要精确时用 GODEBUG=memprofilerate=1(开销巨大,只在本地或预发短时使用)。

curl -s 'http://127.0.0.1:6060/debug/pprof/heap?gc=1' -o heap1.pb.gz
# 灌流量或等 10 分钟
curl -s 'http://127.0.0.1:6060/debug/pprof/heap?gc=1' -o heap2.pb.gz
go tool pprof -http=:8081 -sample_index=inuse_space -base heap1.pb.gz heap2.pb.gz

?gc=1 是关键:不带它时 inuse 里混着已死未清扫对象,容易把正常波动当泄漏。profile 反映的是最近一次完成的 GC 的状态,天生滞后。

pprof 只给分配点,不给引用路径。查“谁还持有它”要读存储路径(map / slice / chan buffer / goroutine 栈),或用 viewcore 看 core。

GOMEMLIMIT 怎么定

GOGC 决定 GC CPU 与内存的取舍:Target heap = Live heap + (Live heap + GC roots) × GOGC/100,GOGC 翻倍,堆开销翻倍、GC CPU 约减半。GC 有 4MiB 最小堆限制;GOGC 自 1.18 起把根集合也算进目标。

GOMEMLIMIT(Go 1.19+)是 runtime 内存软上限,作用在 Sys - HeapReleased 上,逼近时提高 GC 频率并更积极 scavenge。容器里常见做法是设成 memory limit 的 70%~80%(1Gi → 800MiB,留 20% 给栈、元数据、other、二进制只读段、cgo / mmap)。

三个边界:

  • GOMEMLIMIT 不读 cgroup,必须显式设;Go 1.25 起 runtime 才在 GOMEMLIMIT 未显式设置时默认按 cgroup 设定,旧版本仍要自己算。
  • 它不治泄漏,只是把 OOM 推后。
  • 设到低于真实存活堆,GC 会空转(thrashing):CPU 打满、吞吐塌,但不报 OOM;runtime 把 GC 的 CPU 占用钳在约 50%,进程活着而 P99 爆炸。

该盯的指标(runtime/metrics,稳定接口)

指标用途
/memory/classes/heap/objects:bytesheap inuse,泄漏主指标
/memory/classes/total:bytes、/memory/classes/heap/released:bytes两者相减才是真正占物理内存的量
/memory/classes/heap/free:bytes、/memory/classes/heap/unused:bytesfree/unused 高且不降 = span 缓存
/memory/classes/heap/stacks:bytes、/sched/goroutines:goroutinesgoroutine 泄漏的两个信号
/gc/heap/live:bytes、/gc/heap/goal:bytes存活堆与目标堆
/gc/cycles/total:gc-cycles求导得 GC 频率
/gc/heap/allocs:bytes求导得分配速率,除以 QPS 得每请求分配量
/cpu/classes/gc/mark/assist:cpu-seconds被征用做 GC 的 CPU,churn 的确凿证据
/gc/gogc:percent、/gc/gomemlimit:bytes确认参数真的生效
/sched/pauses/total/gc:seconds(1.20 前叫 /gc/pauses:seconds)STW 分布

告警不要只看 RSS,改成存活堆的持续增长速率、heap/limit 水位比、GC CPU 占比。

不适用 / 别踩

  • 程序用 cgo、syscall.Mmap、大页时,GOMEMLIMIT 管不到那部分,RSS 可以无上限。
  • 单请求读大 body(io.ReadAll)造成的内存高地是容量问题而不是泄漏,靠 http.MaxBytesReader 和流式处理解决。
  • 排查前先落一份 heap 剖面再重启,否则重启后原始现场就没了。
复制全文 生成海报 内存排查 Go pprof GOMEMLIMIT 运维

推荐文章

程序员茄子在线接单