容器 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 - HeapReleased | Go 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:bytes | heap inuse,泄漏主指标 |
/memory/classes/total:bytes、/memory/classes/heap/released:bytes | 两者相减才是真正占物理内存的量 |
/memory/classes/heap/free:bytes、/memory/classes/heap/unused:bytes | free/unused 高且不降 = span 缓存 |
/memory/classes/heap/stacks:bytes、/sched/goroutines:goroutines | goroutine 泄漏的两个信号 |
/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 剖面再重启,否则重启后原始现场就没了。