Go 有垃圾回收,并不代表不会发生内存泄漏。只要仍有可达引用,GC 就不能释放对象;无界缓存、未清理的订阅、长期存活的 goroutine、不断增长的 map,以及被小切片引用的大底层数组,都可能让存活堆持续上升。
本文以一个订单服务为例:发布两小时后容器内存从 420 MiB 增长到 1.6 GiB,QPS 已回落但内存不降,最终被 OOMKilled。我们不靠重启掩盖问题,而是用运行时指标和两个时点的 heap profile,把“内存上涨”收敛为可验证的代码假设。
图:先判断增长来自 Go 存活堆还是进程其他区域,再对同一实例采集双时点快照;点击查看原图。
一、先证明它真的是“泄漏”
线上看到的通常是容器 working set 或进程 RSS,而 pprof 的 heap profile 只描述 Go 堆中的抽样分配。两者不相等:线程栈、runtime 元数据、尚未归还操作系统的空闲页、cgo 分配、mmap 文件和内核缓冲都可能进入进程内存,却不会完整出现在 Go heap 中。
先把现象分成三类:
| 现象 | 更可能的原因 | 下一步 |
|---|---|---|
每次 GC 后 HeapAlloc 基线仍持续上升 |
存活对象被长期引用 | 查看 heap 的 inuse_space |
HeapAlloc 能回落,但累计分配和 GC 频率很高 |
分配抖动,不一定是泄漏 | 查看 alloc_space,优化热路径分配 |
| RSS 上升,但 Go 存活堆稳定 | 空闲页未归还、cgo、mmap 或其他原生内存 |
对照 HeapIdle、HeapReleased,再检查原生分配 |
如果服务暴露了 Go runtime 的 Prometheus 指标,至少把以下曲线放到同一个时间窗口:
- 进程 RSS 或容器 working set;
HeapAlloc、HeapInuse、HeapIdle、HeapReleased;- GC 次数、暂停时间和内存限制;
- goroutine 数量、QPS、并发数、队列长度和错误率;
- 发布版本、实例重启和流量切换事件。
关注“完成 GC 后的低水位”比盯着锯齿峰值更可靠。业务高峰产生临时对象时,堆先增长再被 GC 回收是正常现象;只有低水位跨多个周期持续抬高,才值得按对象保留问题调查。
二、以受控方式开放 pprof
最省事的做法是副作用导入 net/http/pprof,但它会把处理器注册到 http.DefaultServeMux。生产服务更适合使用独立端口和独立 ServeMux,避免调试端点意外混入业务路由:
1 | package debugserver |
这里的 addr 应来自受控配置。在虚拟机上可以监听 127.0.0.1:6060,通过 SSH 隧道访问;在 Kubernetes 中可以不创建 Service 和 Ingress,只对目标 Pod 执行 kubectl port-forward,并用 NetworkPolicy 阻断其他入口。如果平台必须通过管理网络访问,还应在代理层增加双向 TLS、短期凭证和审计日志。
cmdline、goroutine 栈和 profile 都可能暴露包名、路径、参数甚至业务结构,因此绝不能把 /debug/pprof/ 直接暴露到公网。Go 1.22 起这些端点只接受 GET,请求脚本也应显式使用 GET。
三、选择实例并保全现场
不要一看到告警就轮流抓所有 Pod。先选出真正增长的单个实例,并记录:
- Pod 名称、启动时间、节点和当前流量;
- Git commit、Go 版本、构建参数和镜像 digest;
- 与该实例完全匹配、未被 strip 掉必要符号的可执行文件;
- 告警前后的 RSS、Go 堆、GC 和 goroutine 曲线;
- 当时是否发生全量同步、大查询、缓存预热或流量倾斜。
保留匹配版本的二进制很重要。profile 可以携带部分符号信息,但要可靠使用 list、weblist 和源码行视图,最好让分析工具拿到生成该 profile 的同一构建产物。
在本机建立端口转发:
1 | kubectl -n order port-forward pod/order-api-7d9f6c8d8f-k2m7p 6060:6060 |
另开终端确认端点可用,并把采集时间、实例和版本写入文件名或旁边的事件记录:
1 | curl -fsS http://127.0.0.1:6060/debug/pprof/ |
gc=1 会在生成 heap profile 前主动执行一次 GC,让两个快照都基于新完成的 GC,适合确认存活对象;它也会带来额外工作,不应被高频监控轮询。对延迟敏感的服务,可以先采集不带 gc=1 的快照,或只在一个代表性副本上低频执行。关键不是强制 GC 本身,而是两次采集条件一致、时间窗口内确实出现了持续增长。
四、正确理解四种内存视角
同一份 Go heap profile 通常包含四个 sample index:
| sample index | 含义 | 适合回答的问题 |
|---|---|---|
inuse_space |
当前仍存活对象的抽样字节数 | 哪些分配点正在占住最多堆内存? |
inuse_objects |
当前仍存活对象的抽样数量 | 是否有海量小对象未释放? |
alloc_space |
进程启动以来累计分配的抽样字节数 | 哪些路径制造了最大的分配吞吐? |
alloc_objects |
进程启动以来累计分配的抽样对象数 | 哪些路径频繁创建对象? |
排查“内存为什么不下来”时先看 inuse_space,再用 inuse_objects 判断是少量大对象还是大量小对象。alloc_space 很高只说明历史上分配得多:对象可能早已回收,它更适合排查 GC 压力和分配抖动,不能单独证明泄漏。
Go heap profile 是抽样数据,不是逐对象清单。小对象、短时间窗口和低样本数量都会带来噪声,因此应该看数量级、趋势和调用路径,不要对几十 KiB 的差异做过度解释。
五、先读单个快照
先用增长后的快照确认主要存活对象:
1 | go tool pprof \ |
flat 表示直接归因到该函数分配点的存活量,cum 表示该函数及其下游调用累计贡献。默认按 flat 排序;如果真正分配发生在通用解码器或标准库中,按 cum 排序更容易找到业务入口:
1 | go tool pprof \ |
也可以启动只监听本机的 Web 界面,在 Graph、Flame Graph、Peek 和 Source 之间切换:
1 | go tool pprof \ |
在交互模式中,常用命令是:
1 | (pprof) top 20 |
list 能把样本标到源码行上,但 pprof 展示的是存活对象的分配栈,不是 GC 意义上的完整引用链。看到 json.Unmarshal 占用高,不代表标准库泄漏;还要沿 cum 调用栈回到业务入口,检查谁把解码结果放进了长生命周期容器。
六、用双快照差分消除存量噪声
服务启动后会长期保留路由表、连接池、模板、字典和正常缓存。单个快照常被这些合理存量占据,真正泄漏的增量反而不显眼。
对两个 live heap 快照使用 -diff_base,可以直接观察后一个时点相对前一个时点的增减:
1 | go tool pprof \ |
差分报告中的正值表示增长,负值表示减少。关注同时满足以下条件的路径:
- 增长量明显高于抽样噪声;
- 与告警时间窗口和业务操作吻合;
- 第二、第三个窗口中仍以相近斜率增长;
- 能映射到一个长生命周期的 map、slice、队列、订阅或 goroutine。
如果比较的是同一进程中随时间累计的 alloc_space,则可以使用 -base 做累计值相减,得到窗口内的分配吞吐:
1 | go tool pprof \ |
不要比较不同版本、不同 Pod 或明显不同流量下的快照;函数地址、代码路径和对象规模都可能不同,差分结论会失真。
七、案例:无界的租户报表缓存
差分结果显示,20 分钟内约 620 MiB 的新增存活堆都来自 report.(*Buffer).Add 下游。示意输出如下:
1 | Showing nodes accounting for 619.8MB, 96.4% of 642.7MB total |
对应代码把每次导出的完整报表都追加到按租户分组的 slice,却没有容量和过期边界:
1 | type Buffer struct { |
map 本身只占很小一部分,真正占内存的是 slice 中每个 Report 引用的 payload。由于 Buffer 与进程同生命周期,所有历史报表都保持可达,GC 的行为完全正确。
修复方案不是在告警时调用 runtime.GC(),而是为数据生命周期建立业务边界。假设产品只需要每个租户最近 100 条,可先做有界保留:
1 | type Buffer struct { |
实际系统还应考虑 TTL、租户数量上限、单条 payload 大小限制和淘汰指标。如果数据必须完整保留,应把它写入有生命周期策略的外部存储,而不是把进程堆当数据库。
八、修复后怎样证明问题消失
“部署后没再 OOM”不够,因为流量可能恰好下降。用与故障时相近的数据量和操作序列做灰度验证,并预先写出成功条件:
- 预热结束后,连续多个 GC 周期的
HeapAlloc低水位进入平台期; - 相同时间间隔的
inuse_space差分不再持续指向同一业务路径; - 缓存条目数、租户数和 payload 字节数都受显式上限约束;
alloc_space速率、GC CPU、P95/P99 延迟和错误率没有明显恶化;- 灰度实例与旧版本承接相近流量,观察时间覆盖原故障窗口。
Go 堆下降后,RSS 不一定立即等比例回落。runtime 可能暂时保留空闲页以便复用,应结合 HeapIdle 与 HeapReleased 判断。不要把 debug.FreeOSMemory() 当作常规定时任务;它可能制造额外 GC 和延迟抖动,而且不会修复仍然可达的对象。
可以把这次故障转成回归测试:持续写入超过上限的数据,强制完成一个测试内 GC 周期后,验证缓存条目数和业务暴露的 payload 字节指标保持稳定。测试重点是业务不变量,不要依赖精确堆字节数,因为 GC 和抽样会随 Go 版本变化。
九、pprof 看不到时往哪里查
如果 RSS 快速上涨而 inuse_space 稳定,继续重复抓 heap profile 通常不会得到新结论。按证据分流:
- goroutine 数同步增长:采集 goroutine profile,检查未退出的 worker、阻塞 channel、未关闭响应体和定时器;
HeapIdle - HeapReleased很高:说明 Go runtime 持有较多尚未归还的空闲堆页,结合分配峰值和后续复用观察;- 使用 cgo 或原生 SDK:Go heap 无法完整解释其分配,需使用库自身统计或原生内存分析工具;
- 大量
mmap、文件缓存或共享内存:检查进程内存映射和操作系统指标; - 只在某类请求后增长:给业务操作增加计数与字节指标,建立可复现负载,再采集对应窗口。
goroutine profile 可以这样保存并分析:
1 | curl -fsS -o goroutine.pb.gz \ |
如需人工快速阅读完整栈,也可以临时请求 ?debug=2 的文本格式,但要把结果当作敏感诊断数据妥善保存。
十、线上排障清单
采集前
- 确认增长的是目标实例,并保存版本、流量和时间窗口;
- 区分 RSS、Go 存活堆、空闲堆页和 goroutine 趋势;
- 只通过本机或受控管理通道访问 pprof;
- 准备与实例完全匹配的二进制;
- 一次只采集一种 profile,避免诊断工具互相干扰。
分析时
- 先看
inuse_space,再按需要切到对象数和累计分配; - 同一实例采集两个以上时点,用差分识别持续增量;
- 同时检查 flat 分配点和 cum 业务入口;
- 回到代码确认对象由谁持有、何时删除、是否有容量边界;
- 对 cgo、
mmap和 goroutine 保留保持独立假设。
修复后
- 在相近负载和足够长窗口中复测;
- 同时验证堆、RSS、GC、延迟和错误率;
- 为缓存大小、队列深度和 payload 字节数增加可观测上限;
- 把故障场景转成容量测试、回归测试和运行手册;
- profile 按敏感数据管理,到期删除。
小结
pprof 最有价值的地方不是生成一张漂亮的火焰图,而是把模糊的“进程内存很高”转换成一条证据链:先证明 Go 存活堆确实增长,再在同一实例、同一版本、相近负载下采集双快照,用正确的 sample index 做差分,最后回到对象生命周期修复并复测。
线上内存问题经常跨越 Go 堆、runtime、goroutine 和原生内存。守住指标分层、采集边界和对照实验,就能避免把正常缓存当泄漏,也不会在真正的无界引用面前只会重启和强制 GC。
评论