从 pprof 到持续 profiling:Go 性能工具链的三次升级

把 Go 性能工具链拆成三个主问题:现在慢在哪(pprof)、为什么慢(trace)、什么时候慢(持续 profiling)。8 组实测告诉你什么时候该升级。

封面

“pprof + 火焰图就够了。”

我以前也这么想。直到我连续跑了几组实验,想证明“锁争用在 pprof 里看不见”。第一版实验直接翻车,第二版重做后,数据才站住。

性能工具链最常见的误解,不是不会用 pprof,而是把它当成唯一入口。CPU pprof 首先回答的是:现在 CPU 花在哪。pprof 家族当然还能看 heap、block、mutex、goroutine,但它不擅长还原等待结构,也不给你长期时间维度。

生产上的问题通常还会再追问两句:为什么慢?什么时候慢?

所以我更愿意把 Go 的性能工具链记成三类主问题:现在慢在哪、为什么慢、什么时候慢。对应的不是三个互斥工具,而是三条首选路径:pprof 看热点,trace 还原事件流,持续 profiling 负责自动采集、历史存储、diff 和多实例聚合。

本文只聚焦最常见的三类问题:CPU、等待、时段。heap、goroutine、alloc、GC 也都在这条工具链里,但不在这篇展开。

也别把这三层理解成某种“从低到高”的鄙视链。很多服务一辈子只用 pprof,也完全够用;真正重要的不是工具层级,而是你问的问题有没有换维度。

换句话说,你缺热点分布,就先上 pprof;你缺等待结构和因果,就别指望一张火焰图替你讲完;你缺历史和时段证据,就别拿一次性快照去猜长期趋势。

下面我按这三个问题拆开讲。每一层都先讲它能解决什么,再讲它会在哪个边界上逼你升级。

所有数据都来自我在 M 系列 MacBook 上的实测,Go 1.26.2。实验代码和原始 profile 都在文末附录里,可复现。


一、第一个盲点:最宽 ≠ 瓶颈

先说一个容易被说满的事实——Go runtime/pprof 这条 CPU profiling 路径生成的火焰图,默认是 100Hz 采样。

我一开始想比较 100Hz / 1000Hz / 10000Hz 的分辨率,代码先写成了这样:

1
2
runtime.SetCPUProfileRate(1000)
pprof.StartCPUProfile(f)

跑起来 Go 直接报错:

1
runtime: cannot set cpu profile rate until previous profile has finished.

查 Go 1.26.2 的 runtime/pprof/pprof.go,能看到这段注释:

1
2
3
4
5
6
7
// in practice operating systems cannot trigger signals at more than
// about 500 Hz, and our processing of the signal is not cheap
// ...
// 100 Hz is a reasonable choice ...
// Instead of requiring each client to specify the frequency,
// we hard code it.
const hz = 100

这里真正被写死的,是 runtime/pprof.StartCPUProfile 这条高层路径的 CPU profile 频率。runtime.SetCPUProfileRate 这个 API 不是废了;问题在于它和 pprof.StartCPUProfile 互斥。你先把 rate 设成 1000,再走 StartCPUProfile,后者会因为 profiler 已启用/冲突而失败。注释里的 500Hz 也只是 runtime 对操作系统信号能力的经验值,不该写成普遍硬上限。

100Hz 会错过什么?我构造了一个程序,让四个函数都累计跑约 10 秒 CPU 时间:

  • heavyFn:120ms / call
  • mediumFn:5ms / call
  • shortFn:50μs / call
  • microFn:5μs / call

如果采样能精确还原现实,它们在火焰图里都该接近 25%。实测不是:

1
2
3
4
5
6
      flat  flat%   sum%        cum   cum%
     9.11s 25.87% 25.87%      9.33s 26.49%  main.shortFn   (~50μs/call)
     8.91s 25.30% 51.16%      9.22s 26.18%  main.microFn   (~5μs/call)
     8.82s 25.04% 76.21%      9.23s 26.21%  main.mediumFn  (~5ms/call)
     7.19s 20.41% 96.62%      7.41s 21.04%  main.heavyFn   (~120ms/call)
     1.16s  3.29% 99.91%      1.16s  3.29%  runtime.asyncPreempt

反直觉的地方在于:最短的 microFn 几乎贴着理论值,最长的 heavyFn 反而在这组 workload 里被低估了约 15%。

go tool pprof -peek heavyFn 给了原因:

1
2
3
main.heavyFn:
  flat: 7.19s (20.41%)    cum: 7.41s (21.04%)
  被调用方 runtime.asyncPreempt: 0.22s (2.97%)

更准确的说法不是“每 10ms 都会发一次信号抢占”,而是:调度器大致按 10ms 时间片请求抢占,异步抢占还受 safe point 约束。长函数更容易被打断,采样点有一部分就落到了 runtime.asyncPreempt 上,所以在这组 workload 里被系统性低估。

所以 pprof 告诉你的,不是“函数 X 精确占了 20% CPU 时间”,而是“在 100Hz 采样瞬间,栈顶落在函数 X 的概率大约是 20%”。

中间至少隔着四件事:

  1. runtime/pprof 这条 CPU profile 路径默认就是 100Hz
  2. 异步抢占会把长函数的一部分时间分摊给 runtime
  3. inline 归因可能飘到调用方
  4. 信号到真正记录栈之间还有微小延迟

火焰图是统计近似,不是测量仪。它最适合回答的是:CPU 大概花在哪。

这也是很多线上争论会拧巴的原因。有人拿 pprof 说“这个函数只占 20%”,有人拿 benchmark 说“它明明更重”。两边都不一定错,只是在回答不同问题:前者回答采样分布,后者回答隔离环境下的执行成本。把这两个答案混着用,结论就会飘。

真想精确测某个函数的 CPU 时间,别盯着火焰图,直接写 benchmark。

小医给 CPU 大块头做 CT,火焰图最宽那块标着问号


二、第二个盲点:pprof 给你一个数,trace 给你一个故事

我原本想证明“pprof 看不见锁等待”。第一版实验先翻车了。

20 个 goroutine 抢 1 把 mutex,锁内做少量内存写,锁外接一个故意变慢的 channel。结果 mutex profile 是空的。原因后来算明白了:锁临界区只有 300-500ns,channel 等待却有 200μs。后者是前者的 400-600 倍,流量先被 channel 稀释,锁根本没真正争起来。

第二版我把 channel 砍掉,50 个 worker 抢一把锁,临界区拉长到 6-10μs,跑 1.38 秒。这次数据才站住。

CPU profile

1
2
3
4
5
6
Duration: 1.50s, Total samples = 1980ms (131.84%)
   1300ms 65.66%  runtime.pthread_cond_signal
    480ms 24.24%  runtime.pthread_cond_wait
    200ms 10.10%  runtime.usleep
     20ms  1.01%  runtime.lock2
         (main.incrementHeavy 完全不出现)

50 个 worker 激烈争锁,但 CPU profile 里业务代码几乎是 0%。热区全在 runtime 调度器。CPU profile 能看到“CPU 在忙”,看不到“CPU 忙在等待本身”。

Mutex profile

1
2
3
4
Showing nodes accounting for 64.05s, 100% of 64.05s total
    64.05s   100%   sync.(*Mutex).Unlock
         0     0%   main.(*shared).incrementHeavy
         0     0%   main.runWorkload.func1

Block profile

1
2
    66.75s 92.94%  sync.(*Mutex).Lock (inline)
     2.30s  3.20%  runtime.chanrecv1

这两张图一起看,意思很清楚:锁等待是真实存在的,只是 CPU profile 不是看它的入口。

但这两张图也很容易看反。mutex profile 里的等待时间会归因到 Unlock;block profile 里的阻塞会落到真正卡住的位置,比如 Lock

这两个 profile 默认都不开。最少复现时,先开:

  • runtime.SetMutexProfileFraction(n):按事件采样锁竞争
  • runtime.SetBlockProfileRate(n):按阻塞时间采样 block 事件
  • mutex profile 主要归因到 Unlock
  • block profile 主要归因到真正的阻塞点

光有累计统计还不够。我继续抓了 trace,并用 go tool trace trace.out 去看事件流。先看两件事就够了:

  1. 阻塞发生得非常多:1.4 秒里有 11.9 万次 Running → Waiting
  2. 有明显的 Runnable 但拿不到 CPU 时间:这 0.99 秒是 pprof 给不出来的

把关键数字压缩一下是这样:

  • 总事件数:803,875,goroutine 数:75
  • Runnable → Running:176,355 次
  • Running → Waiting:119,271 次
  • Waiting → Runnable:119,267 次
  • Running → Runnable:57,033 次(被抢占)
  • 总阻塞时间:83,673ms;总运行时间:1,441ms;总可运行但未运行时间:994ms

如果你只拿到 block profile 的“锁等待 67 秒”,其实还不知道:这是 1 次长阻塞,还是 11 万次短阻塞;等待是不是集中在少数 goroutine;那 994ms Runnable 时间到底是锁后排队,还是 CPU 已经饱和。

pprof 的 block/mutex profile 给你的是“累计等了多久”。trace 给你的是:等了多少次、谁在等谁、什么时候等、Runnable 阶段有没有排队。

这就是“一个数”和“一个故事”的区别。pprof 家族当然也能覆盖 block / mutex / goroutine,但它们本质上还是累计统计;时间顺序、调度结构、因果链——是 trace 擅长的。

所以我更愿意把它们看成一前一后两步:先用 block / mutex profile 确认“等待确实存在”,再用 trace 还原“等待到底是怎么发生的”。前者帮你定性,后者帮你拆结构。

当你怀疑是锁、channel、调度或者 GC 在拖慢系统时,正确顺序通常不是“一上来先开 trace”,而是先用便宜的 profile 确认方向,再在短窗口里用 trace 拿结构。这样你既不会把 trace 当常规监控,也不会在没证据时一头扎进事件海。

代价也很现实:trace 开销比 pprof 大,文件也大,只适合短窗口诊断,不适合常开。

小电用放大镜指着时间轴上的等待段,小医在一旁疑惑


三、第三个盲点:pprof 把毛刺变成底噪

再往前走一步,你会遇到第三类问题:不是“现在慢在哪”,也不是“为什么慢”,而是什么时候慢

我写了一个 Go HTTP 服务。/fast 正常只跑约 15μs CPU;/slow 大多数时候也很快,但在 15-20 秒这个 5 秒窗口里会切到慢路径,单次约 200ms CPU。

压测 30 秒后,我用了两种方式采样:

  • Group A:一次性 30 秒 CPU profile
  • Group B:每 5 秒一个窗口,共 6 个窗口

本来想两种方式同时跑,结果直接撞上:

1
cpu profiling already in use

这句话需要说准确:同一进程里,不能同时跑两路基于 Go runtime CPU profiler / runtime/pprof 的 CPU 采样。 也就是说,手动 /debug/pprof/profile 和 Pyroscope 这类 Go SDK 路线会互相抢;Parca 那种 eBPF 路线不受这条单实例约束。

两组结果差得很明显。

Group A:30 秒单窗口

1
2
3
4
5
6
7
8
Duration: 30.11s, Total samples = 96.48s (320.41%)
   56.06s 58.11%  syscall.rawsyscalln
   12.26s 12.71%  runtime.usleep
    7.15s  7.41%  runtime.kevent
    6.30s  6.53%  runtime.pthread_cond_wait
    4.63s  4.80%  main.processSlowPath
    3.70s  3.83%  main.processFastPath
    3.54s  3.67%  runtime.pthread_cond_signal

processSlowPath 当然“看得见”,但只占 4.80%。如果你只拿这张火焰图,很难判断这是不是毛刺,还是正常波动的一部分。

你知道 slow path 存在,却不知道它是整段都在发生,还是只在某个 5 秒窗口突然冒头。没有时间轴,也没有基线,这 4.80% 很难被判断成“需要处理的时段异常”。

Group B:5 秒时间窗口

1
2
3
4
5
6
窗口 1(0-5s):   processSlowPath 不出现
窗口 2(5-10s):  processSlowPath 不出现
窗口 3(10-15s): processSlowPath 1.40%
窗口 4(15-20s): processSlowPath 16.47%
窗口 5(20-25s): processSlowPath 不出现
窗口 6(25-30s): processSlowPath 不出现

同一个慢路径,在 30 秒大窗口里只有 4.80%;拉到 5 秒窗口,15-20 秒这段直接飙到 16.47%。4.80% vs 16.47%,差了 3.43 倍。

毛刺没有消失——只是被大窗口平均成了底噪。

这也是持续 profiling 真正解决的问题:不是“把 pprof 定时跑一遍”,而是把 profile 变成自动采集、历史存储、时间序列 diff 和多实例聚合。你终于能定位异常发生在什么时候、哪一版、哪一台。

这也是 Pyroscope 和 Parca 分成两条技术路线的原因:

  • Pyroscope 的 Go SDK 路线:会占用同一进程里的 Go runtime CPU profiler,所以会和手动 pprof 冲突
  • Parca 的 eBPF 路线:从内核侧采样,不走这条 Go runtime 单实例路径

这不是谁一定更先进,而是两种约束下的取舍。前者更贴 Go 生态,后者更贴基础设施能力;你要看的不是名字,而是你到底受不受那条单实例限制。

那生产上值不值得开?我本地搭了一套 Pyroscope 1.21.0,直接测了一次激进配置:10 种 profile 全开,每 15 秒上传一次。基线服务和挂 agent 的服务业务逻辑完全一致,压测 60 秒、30 并发,结果如下:

指标 基线(无 agent) 带 agent(全量 profile) 差距
QPS 45,388 44,657 -1.61%
P50 延迟 0.7 ms 0.7 ms 持平
P95 延迟 0.7 ms 0.8 ms +14%
P99 延迟 0.9 ms 0.8 ms -11%(误差内)
进程 CPU 283.9% 294.4% +3.70%
进程 RSS 23.1 MB 30.9 MB +7.8 MB

公开口径里的 “<1%” 不能脱离配置读。在我这组“10 种 profile 全开”的本地实验里,代价是 QPS -1.61%、CPU +3.70%、RSS +7.8MB。这个数不夸张,但也绝不是“零成本”。

对大多数业务服务,这笔交易通常划算;对网关、代理、CDN 边缘节点这类把每 0.5% 吞吐都当成本的系统,就未必。

这也是我更愿意把持续 profiling 看成平台能力,而不是个人调试技巧。个人调试靠的是你此刻有没有想起去抓 profile;平台能力靠的是问题发生后,你还能不能回头拿到那段时间的证据。

所以持续 profiling 的价值,不是“终于能看见 profile”。30 秒 pprof 也能看见 processSlowPath。真正不可替代的是:它把 profile 运营化了:回看历史、做版本 diff、看多实例分布。

小检翻年度性能报告,圈出长时间序列折线上的异常毛刺


四、把三次实验收回来,才是这条工具链

前面几组实验,其实已经把这篇的 8 条自造论据铺出来了。先收成一张很短的证据卡:

  • 采样盲点runtime/pprof 这条 CPU 路径默认 100Hz,长函数在这组 workload 里会被异步抢占低估
  • 等待结构:block / mutex profile 能告诉你累计等了多久,trace 才能把等待次数、Runnable 排队和时序关系拉出来
  • 时段毛刺:30 秒单窗口会把 5 秒毛刺稀释成底噪
  • 持续 profiling 的代价与价值:它确实有开销,但换来的是自动采集、历史存储、diff 和聚合

这时候再看三层框架,边界就没那么容易说满了。

主问题 首选工具 额外维度
现在 CPU 花在哪? pprof CPU 热点、heap / block / mutex / goroutine 等 profile 快照
为什么会慢? trace 事件流、调度时序、等待结构、短窗口里的“什么时候”
什么时候慢? 持续 profiling 自动采集、历史存储、diff、多实例聚合

这个表是“首选工具”,不是“唯一工具”。trace 也能回答短窗口里的“什么时候”;pprof 家族也不只有 CPU。类比只是帮你记主问题,不是说三类工具完全没有重叠。

三个问题之所以不能互相代替,不是因为工具名不同,而是因为信息形式不同。你知道函数 X 占了 20% CPU,推不出它是在等锁;你知道它在等锁,也推不出它只在每天下午 3 点出现。采样统计、事件流、时间序列,本来就是三种不同证据。

所以这三层不是替代关系,更像同一条诊断链上的三个视角:空间分布、事件结构、时间演化。你什么时候觉得上一层“不够”,本质上就是你开始缺下一种证据。

如果你更喜欢记场景,可以记成三个比喻:

  • pprof 像 CT:先看阴影在哪
  • trace 像心电图:看节律什么时候乱、怎么乱
  • 持续 profiling 像年度体检:看指标怎么沿时间变化

历史上它们大致也是这么长出来的:

  • 2012 年:Go 1.0 发布,pprof 成为第一层常用工具
  • 2015 年起:execution tracer 出现,后续版本逐步补齐可用性
  • 2020 年前后:Pyroscope、Parca 让持续 profiling 进入日常工具箱

这不是先验设计出来的一套完美分层,而是社区被三类问题逼出来的三次升级。

三个角色的分诊台——三类问题分别被指向对应的工具


五、你该停在哪一层,什么时候再升级

这一章不是让你背工具大全,而是帮你少走错路。先看症状,再选首选工具;只有首选工具回答不了问题,才往下一层升级。

最后直接给一张能用的表。

高频症状表

症状 首选工具 理由
CPU 使用率高 pprof CPU 先看热点函数
QPS 上不去但 CPU 不满 trace 大概率是等待或调度问题
P99 高但 P50 正常 trace 需要还原单次请求路径
GC STW 可疑 trace 最直接 gctrace / runtime metrics / pprof 也常是入口
内存持续增长 pprof heap + 持续 profiling 一个给快照,一个给增长曲线
偶发延迟毛刺 持续 profiling 大窗口会稀释时段问题
发版后性能下降 持续 profiling diff 需要跨版本对比
某个 pod 慢、其他正常 持续 profiling 聚合 需要跨实例比较
goroutine 泄漏 pprof goroutine 直接看 goroutine 栈

这张表的用法,不是按关键词机械匹配,而是先判断你缺的到底是哪种证据。你缺热点分布,就先上 pprof;你缺调度和因果,就上 trace;你缺历史、diff 和实例间对比,才轮到持续 profiling。

真正容易踩坑的,不是不会选工具,而是明明缺的是因果,却还在追加热点采样;明明缺的是时间轴,却还在重复抓一次性 profile。很多“工具没用”的抱怨,最后都不是工具错了,而是问题问错了。

升级信号

从 pprof 升到 trace:

  • CPU profile 里主要是 runtime,业务热点解释不了问题
  • 你要知道的是“等了多少次、谁在等谁”,不是“累计等了多久”
  • 你需要 GC、调度、锁等待的时序结构

从 trace 升到持续 profiling:

  • 问题是偶发的
  • 需要回看历史
  • 需要版本间 diff
  • 需要多实例聚合

先别升级:

  • 服务规模小,问题稳定可复现
  • 团队还在学习 pprof 基础
  • 流量本身就是瓶颈,持续 profiling 的固定开销不划算

常见错配提醒

错配 问题 正确做法
拿 CPU profile 查锁等待 只能看到 CPU 忙,不一定看到等待结构 直接上 trace 或至少补 block / mutex profile
长期开 trace 开销和数据量都偏大 只在短窗口诊断时打开
装了 Pyroscope 还频繁手动拉 /debug/pprof/profile 同一进程里的 Go runtime CPU profiler 会冲突 用平台统一采样,或错开手工抓取
只看最宽的柱子 容易把统计近似当成精确测量 先确认瓶颈是 CPU 还是等待,再选工具

工具链升级不是加法,更像分诊:先用最便宜的工具回答当前问题,跨到下一个维度,再付下一个成本。

三级升级阶梯:pprof 基线 → trace 窗口诊断 → 持续 profiling


六、别把工具链升级成工具崇拜

最容易犯的错,不是不会用工具,而是刚学会一个新工具,就想把所有服务都装上。

我见过太多团队刚学会持续 profiling,就先往 100 QPS 的内部服务上挂 agent,然后花几周调 dashboard、告警和存储。最后回头看,真正该做的只是一次 pprof 加一轮 benchmark。

小服务、问题稳定、能复现,pprof 往往就够了。先把火焰图、heap、block、mutex 这些基本功练熟。到了偶发毛刺、时段波动、版本回退这种问题,再把 trace 和持续 profiling 请上来。那时候你付出的开销,才换得回真实判断增量。

如果非要粗分阶段,我会这样看:入门阶段先把 pprof 用明白;成长阶段把 trace 当成诊断刀;成熟阶段、且业务真的开始受版本回归和跨实例异常折磨时,再考虑把持续 profiling 当常备设施。顺序错了,工具越多,噪音越大。

真要上第三层前,最好先问自己三个问题:我现在靠 pprof 能解决多少问题?加 trace 之后还剩多少?剩下的问题,是不是确实和时段、历史或多实例比较有关?三个答案都偏“是”,再上。

工具告诉你能看到什么。要不要为这份可见性付成本,是另一件事。判断力——工具给不了。

三个角色站在半开的门前,门外是模糊的性能问题森林


附录:实验代码和原始数据

本文 4 组实验的代码和 profile 原始文件已开源:

GitHub:zhiyulab-evidence/go-profiling-toolchain

  • E1+E4 采样盲点:四个不同调用频率的函数在 CPU profile 中的占比差异。包含 100Hz / 1000Hz / 10000Hz 三组实测;1000Hz / 10000Hz 因 SetCPUProfileRateStartCPUProfile 路径互斥没生效,翻车过程本身就是素材
  • E2 等待结构:Run1 channel 节流阀失败(锁根本没争起来)和 E2a 50 worker 锁争用成功复现都保留了,含 CPU / mutex / block profile 和 trace.out
  • E3 时段毛刺:Group A(30 秒单窗口)vs Group B(6 个 5 秒窗口)对比,HTTP 服务源码和完整压测输出齐备
  • E5 Pyroscope 开销:基线 vs 全量 profile 开销实测,Pyroscope 1.21.0 本地部署脚本 + hey 压测原始数据

每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build