用pprof定位Go服务内存飙高的实战记录

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-09 09:47 ·4 浏览 ·0 回复

线上告警再一次响起:Go 服务的内存占用在半小时内从 300MB 一路爬升到 2GB,最后被 OOM Killer 直接杀掉。重启后恢复,但隔几小时又来一次。这已经是本周第三次了,靠重启续命不是办法,只能硬着头皮把 pprof 请出来,展开一场与内存飙高的正面对决。

从现象到工具:先把现场保护起来

接到告警的第一步不是立刻分析,而是保证服务“活着”方便采集证据。Go 项目里一般会提前在代码中引入 `net/http/pprof`,如果没有,生产环境强烈建议开启一个独立监听端口,避免和业务路由混在一起:

import _ "net/http/pprof"

go func() {
    http.ListenAndServe("localhost:6060", nil)
}()

在服务内存到达危险水位但还没有 OOM 时,立刻抓下内存快照:

curl http://localhost:6060/debug/pprof/heap > heap.out

也可以顺手抓 goroutine:

curl http://localhost:6060/debug/pprof/goroutine > goroutine.out

这一步很关键,内存飙高往往伴随着 goroutine 泄漏,两个文件互为印证。

用 go tool pprof 找到内存大头

拿到 heap 文件后,进入交互式命令行分析:

go tool pprof heap.out

输入 `top`,结果可能长这样:

Showing nodes accounting for 1520MB, 96.3% of 1582MB total
      flat  flat%   sum%        cum   cum%
     724MB 45.8%  45.8%      724MB 45.8%  service/internal/model.parseMessage
     488MB 30.8%  76.6%      488MB 30.8%  service/internal/cache.(*Manager).Set

第一眼看上去,`parseMessage` 占了 724MB,似乎是罪魁祸首。但如果只看 `inuse_space`,很容易被误导——有些对象可能只是当时恰好在堆上,并不是持续增长的主因。我切换成 `-inuse_objects` 再比较,有时候数量暴涨比空间更说明问题,尤其是大量小对象导致的 GC 压力。

更直观的方式还是可视化。在不方便打开浏览器的情况下,可以用 `go tool pprof` 直接生成 SVG:

go tool pprof -svg heap.out > heap.svg

图上每个节点的大小代表内存占用,粗箭头则标识引用关系。我习惯先从最大的节点反推调用栈,重点关注数据到底被谁长期持有,而不是只看分配点的函数名字。

抽丝剥茧:从片段分析到整体反思

继续追查时发现,问题并不只是 `parseMessage` 本身,而是在它内部调用了 `json.Unmarshal` 到结构体,然后根据业务逻辑将其中一部分内容写入一个全局缓存 map:

var msgCache = make(map[string][]byte)

func parseMessage(raw []byte) {
    var msg Message
    json.Unmarshal(raw, &msg)
    key := msg.ID
    // 问题就出在这一行
    msgCache[key] = raw
}

缓存 key 的集合在持续增长,而且没有过期清理机制。每一次消息解析都会把原始报文永久保存在内存中,消息量大了自然就是内存失控。定位到具体业务代码之后,修复方案也清晰——给缓存加上上限或者 TTL,改成基于 `sync.Map` 加 LRU 策略,或者干脆交给 Redis 这类外部存储,不把数据根植在进程里。

别忘了 goroutine 的暗坑

内存飙高往往不只是堆对象多,也可能是 goroutine 泄漏导致栈内存以及引用对象持续堆积。继续看 goroutine 的抽样:

go tool pprof goroutine.out
top

如果发现大量 goroutine 停留在同一个 channel 操作上,比如:

goroutine 12345 [chan receive 100 minutes]:
service/internal/consumer.processMessage
    consumer.go:42

那说明消息消费者在等待一个永远不会到达的数据。最典型的场景是往 channel 发送的时候上游超时直接 return,但下游的接收 goroutine 并没有设置超时控制,白白挂起一辈子。修复方式不外乎 `channel close` 统一管理,或者给阻塞操作加 `context.WithTimeout`——原则就是不要写没有任何兜底的阻塞等待代码。

预防和总结

最终这次实战的收尾很简单:改掉缓存没有淘汰机制的问题,加全量监控和内存采样。之后同一服务运行了一周,内存稳定在 500MB 以内。

回头看这次排查,真正帮助到我的不是命令用得多熟,而是思维习惯:先看现象,再抓 heap 和 goroutine 两个 profile,然后区分是“对象本身占用大”还是“引用导致无法回收”,切忌看到一个大节点就急着下手改代码。尤其当 pprof 输出显示内存来自业务缓存时,多问一句:这些缓存需要全量保留吗?谁持有引用?过期了怎么办?

pprof 是 Go 工程师排查问题最称手的兵器,但前提是生产环境要提前开启端口、备好抓取脚本。在线上稳定运行的服务,最好每隔一段时间自动保存一次 profile,等到出问题时才有历史数据可以做前后对比。异常内存往往不是一秒钟变大的,而是一点点积累出来的,早看到数据就早一天睡好觉。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-167.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~