第3章:pprof进阶与火焰图
说实话,pprof 的基本用法很多人都会。但真正到了线上问题排查,你会发现——光会看 CPU 和内存还不够。goroutine 泄漏、锁竞争、阻塞调用,这些才是生产环境的「隐形杀手」。
这一章,我带你深入 pprof 的四个核心剖析能力:heap、goroutine、mutex、block。再配上火焰图,你就能像看 X 光片一样,把 Go 程序的性能问题看得清清楚楚。
3.1 heap 剖析:不只是看内存大小
heap 剖析,说白了就是看「谁在分配内存」。但很多人只盯着 alloc_space 看,其实不够。
我个人习惯,先看 alloc_objects。为什么?因为内存泄漏往往不是单次分配太大,而是对象数量在持续增长。我在项目中遇到过,一个缓存组件每次请求都 new 一个对象,但忘了释放——内存总量不大,但对象数暴涨,GC 压力直接拉满。
alloc_objects:累计分配对象数,看增长趋势inuse_objects:当前存活对象数,看泄漏alloc_space/inuse_space:辅助判断,别只看这个
怎么用?
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
打开后,切换到 「inuse_objects」 视图。如果某个函数占用的对象数持续增长,且没有下降趋势——嗯,基本可以断定是泄漏了。
-base 参数对比两个时间点的快照,能更精确地看到增量。比如:go tool pprof -base heap1.pprof heap2.pprof
3.2 goroutine 剖析:协程泄漏的照妖镜
goroutine 泄漏比内存泄漏更隐蔽。内存泄漏你还能看到 OOM,goroutine 泄漏可能只是服务越来越慢,直到彻底卡死。
我曾经排查过一个案例:一个消息推送服务,每来一个请求就启动一个 goroutine 去处理,但处理完后 goroutine 没有正常退出。结果呢?goroutine 数量从几百涨到几十万,调度器直接瘫痪。
怎么查?
go tool pprof http://localhost:6060/debug/pprof/goroutine
进去后,用 top 看哪个函数创建的 goroutine 最多。然后 list 展开,看看是不是有 channel 阻塞、死锁、或者 select 没有 default 分支。
?debug=2 导出完整栈信息,然后用脚本分析。
3.3 mutex 剖析:锁竞争的真相
很多人觉得 Go 的锁很轻量,其实不然。当锁竞争激烈时,性能下降可能比你想的严重得多。
mutex 剖析,看的是「等待锁的时间」。不是看谁持有锁,而是看谁在等锁。
go tool pprof http://localhost:6060/debug/pprof/mutex
这里有个坑:默认 mutex 剖析是关闭的,需要先开启:
runtime.SetMutexProfileFraction(1) // 开启,采样率 100%
我建议在测试环境或者灰度环境开启,线上全量开启会有性能开销。开启后,pprof 会记录每次锁等待的时长和调用栈。
看结果时,重点关注 「Delay」 列。如果某个锁的等待时间占比超过 20%,就得考虑优化了——比如用读写锁替换互斥锁,或者用原子操作替代。
3.4 block 剖析:阻塞的隐形杀手
block 剖析比 mutex 更广,它记录的是所有导致 goroutine 阻塞的操作:channel 收发、锁等待、time.Sleep、网络 IO 等。
开启方式类似:
runtime.SetBlockProfileRate(1) // 开启阻塞剖析
我记得有一次,一个日志库在每次写日志时都做同步 IO,导致大量 goroutine 阻塞在 os.File.Write 上。用 block 剖析一看,阻塞时间占比超过 60%。换成异步写入后,吞吐量直接翻倍。
3.5 火焰图:一眼看穿性能瓶颈
火焰图,说白了就是把 pprof 的调用栈可视化。横轴是时间占比,纵轴是调用栈深度。越宽的「火焰」说明越耗时。
生成火焰图有两种方式:
- pprof 自带火焰图:
go tool pprof -http=:8080 profile.pprof,点击「Flame Graph」 - 使用 Brendan Gregg 的 FlameGraph 工具:更灵活,支持交互
我个人习惯用 pprof 自带的,方便。但如果你需要对比两个 profile,推荐用 pprof -diff_base 生成差异火焰图。
怎么看火焰图?记住三点:
- 看最宽的顶部函数:那是真正的热点
- 看颜色:红色通常表示内核态,橙色是用户态
- 看「平顶」:如果某个函数顶部很宽,说明它自身消耗大,不是子函数的问题
fmt.Sprintf 导致的。所以一定要点进去看子调用,别只看表面。
3.6 实战案例:定位内存泄漏
好了,理论讲完了。咱们来一个真实的案例。
场景:一个 API 网关服务,运行 24 小时后内存从 200MB 涨到 2GB,然后 OOM 重启。
第一步,抓 heap profile:
curl http://localhost:6060/debug/pprof/heap > heap1.pprof
# 等 10 分钟
curl http://localhost:6060/debug/pprof/heap > heap2.pprof
第二步,对比分析:
go tool pprof -base heap1.pprof heap2.pprof
(pprof) top20
结果发现 handleRequest 函数的 inuse_objects 增长了 50 万。用 list handleRequest 展开,看到每次请求都创建了一个 map[string]interface{},但从未被释放。
第三步,看 goroutine 确认:
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
果然,handleRequest 对应的 goroutine 数量也在增长。说明请求处理完后,goroutine 没有退出,map 自然也不会被 GC 回收。
第四步,修复:
// 原来是这样的
func handleRequest(w http.ResponseWriter, r *http.Request) {
data := make(map[string]interface{})
// ... 处理逻辑
// 没有 return 或 close
}
// 修复后
func handleRequest(w http.ResponseWriter, r *http.Request) {
data := make(map[string]interface{})
defer func() {
data = nil // 显式释放
}()
// ... 处理逻辑
}
修复后,内存稳定在 300MB 左右,再也没有 OOM 了。
嗯,这一章的内容就到这。下一章我们聊聊「Go 内存分配器与 GC 调优」,到时候我会分享一个让 GC 停顿从 50ms 降到 2ms 的实战案例。