Go pprof 性能剖析实战:从火焰图到根因

线上 Go 服务出问题,无非三类:CPU 打满、内存暴涨、goroutine 泄漏。pprof 是 Go 内置的剖析工具,但很多人只会 go tool pprof 然后对着输出发呆。这篇文章讲一套完整的方法论:怎么采样、怎么读火焰图、怎么从图定位到代码行,最后给三个真实案例。

先把采样端配好

生产服务直接挂 HTTP 端点,一行导入:

import _ "net/http/pprof"

func main() {
    go func() {
        // 单独端口,不要暴露公网
        log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
    }()
    // ... 业务逻辑
}

这会注册一组路由到 /debug/pprof/ 下。最常用的四个:

# CPU:采样 30 秒内的 CPU 时间分布
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30

# 堆内存:当前存活对象的分配情况
go tool pprof http://127.0.0.1:6060/debug/pprof/heap

# goroutine:所有协程的调用栈
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

# 阻塞与锁竞争(需要先 SetBlockProfileRate / SetMutexProfileFraction)
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex

两个容易忽略的点:CPU profile 是「采样期间」的快照,问题没复现时采了也白采,要在故障发生时采;heap profile 默认展示 inuse_space(存活内存),排查「分配太频繁导致 GC 压力大」要切到 alloc_space 视角:go tool pprof -sample_index=alloc_space

火焰图怎么读

go tool pprof -http=:8080 profile.pb.gz 打开 Web UI,切到 Flame Graph 视图。读图只需要记三条规则:

横轴是占比,不是时间顺序。 一个函数的宽度代表它(含子调用)占用的 CPU 采样比例。找最宽的塔。

纵轴是调用栈,从下往上。 底部是入口(如 main.main 或某个 goroutine 起点),越往上越深。

找「平顶」。 顶部又宽又平的帧意味着 CPU 真正消耗在这个函数自身(self time),而不是它的子调用——这就是优化目标。

火焰图判读示意

图形看不清时回到命令行,top 看 self 时间排序,list 函数名 直接看到每一行代码的耗时标注——这是 pprof 最被低估的命令。

案例一:CPU 打满,凶手是 JSON

某服务 QPS 没变,CPU 却从 40% 涨到 90%。采 CPU profile,火焰图上 encoding/json.Marshal 一族占了 58%,平顶集中在 reflect.Value 相关帧。

list 定位到一个监控上报函数:每次请求都把完整上下文结构体(嵌套七层)序列化成 JSON 打日志。一周前有人往结构体里加了个大 map。

修复分三层。短期:日志降采样,1% 抽样上报。中期:这个路径换成手写的 MarshalJSON 只输出关键字段。长期:在 CI 里加 benchmark 守门:

func BenchmarkReportMarshal(b *testing.B) {
    ctx := buildFullContext()
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _, _ = json.Marshal(ctx)
    }
}
// 配合 benchstat 对比 PR 前后,劣化超 20% 报警

CPU 回到 45%。教训:反射型序列化在热路径上就是定时炸弹,json.Marshal 出现在火焰图前排时先怀疑它。

案例二:内存缓慢上涨,不是泄漏是「逃逸」

另一个服务内存每天涨 300MB,重启后归零。heap profile 的 inuse_spacebytes.growSlice 占大头,调用方是一个缓冲池的 Get

看代码发现这是个经典错误:sync.Pool 里放 *bytes.Buffer,但有个慢路径会把 buffer 扩到几十 MB,用完照样放回池里。池子越养越肥。

func putBuffer(b *bytes.Buffer) {
    if b.Cap() > 1<<20 { // 超过 1MB 的不回池,让 GC 收走
        return
    }
    b.Reset()
    bufPool.Put(b)
}

顺手用逃逸分析确认了另一个热点函数的分配来源:

go build -gcflags='-m -m' ./pkg/codec/ 2>&1 | grep escape
# ./codec.go:42:13: buf escapes to heap: 传给了 interface 参数

interface{} 参数、闭包捕获、返回局部 slice 指针,是逃逸三大常客。内存问题不全是泄漏,「该死的对象死不掉」和「不该堆分配的上了堆」同样常见,前者看 inuse,后者看 alloc。

案例三:goroutine 泄漏,每小时多一千个

监控显示 goroutine 数量线性增长。直接看文本版快照,按栈聚合:

curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -30
# 1024 @ 0x43a1c5 0x44f9e7 ...
# #  chan send, 47 minutes
# #  github.com/xx/svc/worker.process+0x1a4

一千多个 goroutine 卡在同一个 chan send,已经卡了 47 分钟。读 worker.process:它往一个结果 channel 发送数据,而接收方在超时后直接 return 了,没人收,发送方永久阻塞。

修复原则就一条——发送方永远要有退路:

select {
case resultCh <- res:
case <-ctx.Done(): // 接收方超时离场后,发送方从这里退出
    return ctx.Err()
}

更系统的防御是给所有 worker 类 goroutine 强制传 context,并在集成测试里用 goleak 检查:

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

持续剖析:从救火到巡检

以上都是「出了事再采」的被动模式。更进一步是持续剖析(Continuous Profiling):agent 常驻采样(开销约 1~3% CPU),profile 数据带时间戳入库,出问题时直接对比「故障时段 vs 正常时段」的差分火焰图,连复现都省了。Pyroscope、Parca 都是现成方案,Go 的原生支持也很好。我把它当作 APM 三件套(metrics、trace、log)之后的第四件,中型以上的服务建议默认开启——它把「能不能采到现场」这个最大的不确定性直接消灭了。

方法论总结

三个案例背后是同一套流程:先用监控确定问题类别(CPU/内存/goroutine),选对 profile 类型和采样视角;火焰图找最宽的塔和平顶帧,list 下钻到代码行;修复后用 benchmark + benchstat 量化收益,并把检查固化进 CI。

pprof 还有不少本文没碰的能力,比如配合 runtime/trace 分析调度延迟和 GC 停顿,不过那是另一个话题了。日常的线上问题,上面这套流程够用八成。剩下两成,通常卡在「采的时候问题没发生」——所以再啰嗦一遍:持续剖析早点上。