后端开发

Go 使用 pprof 排查线上内存泄漏

2026-08-24 #Go#性能优化#pprof#内存泄漏#线上排障

Go 有垃圾回收,并不代表不会发生内存泄漏。只要仍有可达引用,GC 就不能释放对象;无界缓存、未清理的订阅、长期存活的 goroutine、不断增长的 map,以及被小切片引用的大底层数组,都可能让存活堆持续上升。

本文以一个订单服务为例:发布两小时后容器内存从 420 MiB 增长到 1.6 GiB,QPS 已回落但内存不降,最终被 OOMKilled。我们不靠重启掩盖问题,而是用运行时指标和两个时点的 heap profile,把“内存上涨”收敛为可验证的代码假设。

Go 服务从指标分层、同实例双快照、pprof 差分定位到修复复测的线上内存泄漏排查闭环图

图:先判断增长来自 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package debugserver

import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/pprof"
"time"
)

func Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
return mux
}

func Serve(ctx context.Context, addr string, logger *slog.Logger) error {
srv := &http.Server{
Addr: addr,
Handler: Handler(),
ReadHeaderTimeout: 3 * time.Second,
IdleTimeout: 30 * time.Second,
}

errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()

select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
logger.Error("pprof server stopped", "error", err)
return err
}
}

这里的 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。先选出真正增长的单个实例,并记录:

  1. Pod 名称、启动时间、节点和当前流量;
  2. Git commit、Go 版本、构建参数和镜像 digest;
  3. 与该实例完全匹配、未被 strip 掉必要符号的可执行文件;
  4. 告警前后的 RSS、Go 堆、GC 和 goroutine 曲线;
  5. 当时是否发生全量同步、大查询、缓存预热或流量倾斜。

保留匹配版本的二进制很重要。profile 可以携带部分符号信息,但要可靠使用 list、weblist 和源码行视图,最好让分析工具拿到生成该 profile 的同一构建产物。

在本机建立端口转发:

1
kubectl -n order port-forward pod/order-api-7d9f6c8d8f-k2m7p 6060:6060

另开终端确认端点可用,并把采集时间、实例和版本写入文件名或旁边的事件记录:

1
2
3
4
5
6
7
8
9
curl -fsS http://127.0.0.1:6060/debug/pprof/

curl -fsS -o heap-20260823-1030.pb.gz \
'http://127.0.0.1:6060/debug/pprof/heap?gc=1'

# 在相近流量下等待内存再次增长,例如 10~20 分钟。

curl -fsS -o heap-20260823-1050.pb.gz \
'http://127.0.0.1:6060/debug/pprof/heap?gc=1'

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
2
3
4
5
go tool pprof \
-top \
-sample_index=inuse_space \
./order-api \
heap-20260823-1050.pb.gz

flat 表示直接归因到该函数分配点的存活量,cum 表示该函数及其下游调用累计贡献。默认按 flat 排序;如果真正分配发生在通用解码器或标准库中,按 cum 排序更容易找到业务入口:

1
2
3
4
5
go tool pprof \
-top -cum \
-sample_index=inuse_space \
./order-api \
heap-20260823-1050.pb.gz

也可以启动只监听本机的 Web 界面,在 Graph、Flame Graph、Peek 和 Source 之间切换:

1
2
3
4
5
go tool pprof \
-http=127.0.0.1:0 \
-sample_index=inuse_space \
./order-api \
heap-20260823-1050.pb.gz

在交互模式中,常用命令是:

1
2
3
4
(pprof) top 20
(pprof) top -cum
(pprof) list 'example.com/order/internal/report.*Add'
(pprof) peek 'example.com/order/internal/report'

list 能把样本标到源码行上,但 pprof 展示的是存活对象的分配栈,不是 GC 意义上的完整引用链。看到 json.Unmarshal 占用高,不代表标准库泄漏;还要沿 cum 调用栈回到业务入口,检查谁把解码结果放进了长生命周期容器。

六、用双快照差分消除存量噪声

服务启动后会长期保留路由表、连接池、模板、字典和正常缓存。单个快照常被这些合理存量占据,真正泄漏的增量反而不显眼。

对两个 live heap 快照使用 -diff_base,可以直接观察后一个时点相对前一个时点的增减:

1
2
3
4
5
6
go tool pprof \
-top \
-diff_base heap-20260823-1030.pb.gz \
-sample_index=inuse_space \
./order-api \
heap-20260823-1050.pb.gz

差分报告中的正值表示增长,负值表示减少。关注同时满足以下条件的路径:

  • 增长量明显高于抽样噪声;
  • 与告警时间窗口和业务操作吻合;
  • 第二、第三个窗口中仍以相近斜率增长;
  • 能映射到一个长生命周期的 map、slice、队列、订阅或 goroutine。

如果比较的是同一进程中随时间累计的 alloc_space,则可以使用 -base 做累计值相减,得到窗口内的分配吞吐:

1
2
3
4
5
6
go tool pprof \
-top \
-base heap-20260823-1030.pb.gz \
-sample_index=alloc_space \
./order-api \
heap-20260823-1050.pb.gz

不要比较不同版本、不同 Pod 或明显不同流量下的快照;函数地址、代码路径和对象规模都可能不同,差分结论会失真。

七、案例:无界的租户报表缓存

差分结果显示,20 分钟内约 620 MiB 的新增存活堆都来自 report.(*Buffer).Add 下游。示意输出如下:

1
2
3
4
5
Showing nodes accounting for 619.8MB, 96.4% of 642.7MB total
flat flat% sum% cum cum%
487.2MB 75.8% 75.8% 487.2MB 75.8% report.clonePayload
101.4MB 15.8% 91.6% 588.6MB 91.6% report.(*Buffer).Add
31.2MB 4.9% 96.4% 619.8MB 96.4% api.(*Handler).finishExport

对应代码把每次导出的完整报表都追加到按租户分组的 slice,却没有容量和过期边界:

1
2
3
4
5
6
7
8
9
10
11
type Buffer struct {
mu sync.Mutex
byTenant map[string][]Report
}

func (b *Buffer) Add(tenantID string, report Report) {
b.mu.Lock()
defer b.mu.Unlock()

b.byTenant[tenantID] = append(b.byTenant[tenantID], report)
}

map 本身只占很小一部分,真正占内存的是 slice 中每个 Report 引用的 payload。由于 Buffer 与进程同生命周期,所有历史报表都保持可达,GC 的行为完全正确。

修复方案不是在告警时调用 runtime.GC(),而是为数据生命周期建立业务边界。假设产品只需要每个租户最近 100 条,可先做有界保留:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Buffer struct {
mu sync.Mutex
byTenant map[string][]Report
maxPerTenant int
}

func (b *Buffer) Add(tenantID string, report Report) {
b.mu.Lock()
defer b.mu.Unlock()

items := append(b.byTenant[tenantID], report)
if len(items) > b.maxPerTenant {
// 复制到新底层数组,避免小切片继续引用曾经膨胀的大数组。
kept := make([]Report, b.maxPerTenant)
copy(kept, items[len(items)-b.maxPerTenant:])
items = kept
}
b.byTenant[tenantID] = items
}

实际系统还应考虑 TTL、租户数量上限、单条 payload 大小限制和淘汰指标。如果数据必须完整保留,应把它写入有生命周期策略的外部存储,而不是把进程堆当数据库。

八、修复后怎样证明问题消失

“部署后没再 OOM”不够,因为流量可能恰好下降。用与故障时相近的数据量和操作序列做灰度验证,并预先写出成功条件:

  1. 预热结束后,连续多个 GC 周期的 HeapAlloc 低水位进入平台期;
  2. 相同时间间隔的 inuse_space 差分不再持续指向同一业务路径;
  3. 缓存条目数、租户数和 payload 字节数都受显式上限约束;
  4. alloc_space 速率、GC CPU、P95/P99 延迟和错误率没有明显恶化;
  5. 灰度实例与旧版本承接相近流量,观察时间覆盖原故障窗口。

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
2
3
4
curl -fsS -o goroutine.pb.gz \
'http://127.0.0.1:6060/debug/pprof/goroutine'

go tool pprof -top ./order-api 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。

参考资料

评论
分享

评论