
“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 的分辨率,代码先写成了这样:
|
|
跑起来 Go 直接报错:
|
|
查 Go 1.26.2 的 runtime/pprof/pprof.go,能看到这段注释:
|
|
这里真正被写死的,是 runtime/pprof.StartCPUProfile 这条高层路径的 CPU profile 频率。runtime.SetCPUProfileRate 这个 API 不是废了;问题在于它和 pprof.StartCPUProfile 互斥。你先把 rate 设成 1000,再走 StartCPUProfile,后者会因为 profiler 已启用/冲突而失败。注释里的 500Hz 也只是 runtime 对操作系统信号能力的经验值,不该写成普遍硬上限。
100Hz 会错过什么?我构造了一个程序,让四个函数都累计跑约 10 秒 CPU 时间:
heavyFn:120ms / callmediumFn:5ms / callshortFn:50μs / callmicroFn:5μs / call
如果采样能精确还原现实,它们在火焰图里都该接近 25%。实测不是:
|
|
反直觉的地方在于:最短的 microFn 几乎贴着理论值,最长的 heavyFn 反而在这组 workload 里被低估了约 15%。
go tool pprof -peek heavyFn 给了原因:
|
|
更准确的说法不是“每 10ms 都会发一次信号抢占”,而是:调度器大致按 10ms 时间片请求抢占,异步抢占还受 safe point 约束。长函数更容易被打断,采样点有一部分就落到了 runtime.asyncPreempt 上,所以在这组 workload 里被系统性低估。
所以 pprof 告诉你的,不是“函数 X 精确占了 20% CPU 时间”,而是“在 100Hz 采样瞬间,栈顶落在函数 X 的概率大约是 20%”。
中间至少隔着四件事:
runtime/pprof这条 CPU profile 路径默认就是 100Hz- 异步抢占会把长函数的一部分时间分摊给 runtime
- inline 归因可能飘到调用方
- 信号到真正记录栈之间还有微小延迟
火焰图是统计近似,不是测量仪。它最适合回答的是:CPU 大概花在哪。
这也是很多线上争论会拧巴的原因。有人拿 pprof 说“这个函数只占 20%”,有人拿 benchmark 说“它明明更重”。两边都不一定错,只是在回答不同问题:前者回答采样分布,后者回答隔离环境下的执行成本。把这两个答案混着用,结论就会飘。
真想精确测某个函数的 CPU 时间,别盯着火焰图,直接写 benchmark。

二、第二个盲点: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:
|
|
50 个 worker 激烈争锁,但 CPU profile 里业务代码几乎是 0%。热区全在 runtime 调度器。CPU profile 能看到“CPU 在忙”,看不到“CPU 忙在等待本身”。
Mutex profile:
|
|
Block profile:
|
|
这两张图一起看,意思很清楚:锁等待是真实存在的,只是 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.4 秒里有 11.9 万次
Running → Waiting - 有明显的 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 个窗口
本来想两种方式同时跑,结果直接撞上:
|
|
这句话需要说准确:同一进程里,不能同时跑两路基于 Go runtime CPU profiler / runtime/pprof 的 CPU 采样。 也就是说,手动 /debug/pprof/profile 和 Pyroscope 这类 Go SDK 路线会互相抢;Parca 那种 eBPF 路线不受这条单实例约束。
两组结果差得很明显。
Group A:30 秒单窗口
|
|
processSlowPath 当然“看得见”,但只占 4.80%。如果你只拿这张火焰图,很难判断这是不是毛刺,还是正常波动的一部分。
你知道 slow path 存在,却不知道它是整段都在发生,还是只在某个 5 秒窗口突然冒头。没有时间轴,也没有基线,这 4.80% 很难被判断成“需要处理的时段异常”。
Group B:5 秒时间窗口
|
|
同一个慢路径,在 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 还是等待,再选工具 |
工具链升级不是加法,更像分诊:先用最便宜的工具回答当前问题,跨到下一个维度,再付下一个成本。

六、别把工具链升级成工具崇拜
最容易犯的错,不是不会用工具,而是刚学会一个新工具,就想把所有服务都装上。
我见过太多团队刚学会持续 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 因
SetCPUProfileRate与StartCPUProfile路径互斥没生效,翻车过程本身就是素材 - 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。