Go 服务性能排查:用 pprof 从现象定位到代码

性能问题最怕“凭感觉改代码”。可靠的排查顺序应该是:先确认用户侧现象,再用指标缩小范围,最后用 Profile 找到具体调用路径。pprof 的价值不只是生成一张火焰图,而是把 CPU 时间、内存保留、阻塞和锁竞争都关联回函数与源码。

先明确要回答的问题

采样前先记录故障窗口、请求量、P95/P99 延迟、CPU、RSS、GC 和 Goroutine 数量。不同现象对应不同 Profile:

  • CPU 持续高:采集 CPU Profile,先看 flat,再看 cum
  • RSS 上涨:同时看 heapinuse_space 与进程 RSS,判断是不是 Go 堆。
  • Goroutine 持续增加:看 goroutine,重点找大量停在同一栈帧的调用。
  • CPU 不高但延迟高:看 blockmutex,并结合下游调用耗时。
  • 短时尖峰:持续保存多个窗口,不能只在恢复后采样。

安全地开放诊断端点

生产环境不要把 /debug/pprof 暴露到公网。可以让诊断服务只监听回环地址或管理网络:

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func startDebugServer() {
    go func() {
        log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
    }()
}

容器内可通过 docker exec 采集,集群环境可临时端口转发。诊断端口也应有访问审计和超时,采集结束后及时关闭临时入口。

一套可复用的采集命令

CPU Profile 通常采 30 到 60 秒,窗口太短容易被偶发请求误导:

curl -o cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
go tool pprof -http=:0 cpu.pprof

堆 Profile 建议同时保存采集前后两份,用基线比较增长:

curl -o heap-before.pprof http://127.0.0.1:6060/debug/pprof/heap
sleep 300
curl -o heap-after.pprof http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -base heap-before.pprof heap-after.pprof

交互模式中常用:

top          # 当前节点自身消耗
top -cum     # 包含下游调用的累计消耗
list Func    # 定位到函数源码行
peek regexp  # 查看匹配调用节点

分析内存时要区分 alloc_spaceinuse_space。前者回答“累计分配在哪里”,适合降低 GC 压力;后者回答“当前仍被引用的内存在哪里”,更适合排查内存保留和泄漏。

阻塞与锁竞争不是默认全量采集

阻塞和互斥 Profile 会增加运行成本,应按需开启并控制采样率:

runtime.SetBlockProfileRate(10000)
runtime.SetMutexProfileFraction(10)

如果大量时间停在网络读写,不要立刻把问题归因于 Go 调度器。继续检查是否缺少 Deadline、连接池是否耗尽、下游是否限流,以及请求是否在持锁状态下执行 I/O。

从证据到修复

看到热点后先写可复现的 Benchmark,再改代码并用 benchstat 或相同流量回放对比。常见有效修复包括减少临时对象、预分配切片、避免热路径反射、缩小锁粒度、合并小 I/O;但每项都必须用新 Profile 验证。火焰图变“好看”不等于用户延迟一定下降。

排查清单

  1. Profile 是否覆盖真实故障窗口和代表性流量?
  2. 是否把 flatcum、调用图和源码一起看?
  3. 内存问题是否区分 Go 堆、线程栈、mmap 和内核缓存?
  4. 修复前后是否使用相同输入、相同时长重新采样?
  5. 诊断端点是否只在可信网络开放?

参考资料