4.2 两类现象,两条路径
上一节:4.1 负载生成器 | 下一节:4.3 async-profiler
一句话结论
剖析工具选错的代价,是得到「没有瓶颈」这个错误结论。 记住一条分叉:CPU 高 → 用 on-CPU 采样;CPU 不高但慢 → 用 wall-clock 采样。 这两条路径的差别,比工具之间的差别重要得多。
一、用厨房理解两种「慢」
一家餐厅出菜慢,有两种完全不同的原因:
| 现象 | 厨房状态 | 类比后端 |
|---|---|---|
| 灶台全开着,厨师忙不过来 | CPU 时间被占满 | CPU 高:真在算 |
| 厨师都闲着,在等食材送到 | CPU 空闲,时间花在等待 | CPU 不高:在等 IO / 锁 / 下游 |
关键洞察:这两种情况的排查手段完全不同。
- 第一种:看「谁在占用灶台」→ on-CPU 采样,找最宽的函数。
- 第二种:看「厨师在等什么」→ wall-clock 采样,找阻塞点。
如果对第二种情况用第一种工具:你会看到一张几乎空白的火焰图(因为 CPU 没在干活),然后得出「没有瓶颈」的错误结论。
这是新手最常见的分析错误,也是最浪费时间的一种——因为你会反复采集、反复看空图,怀疑是工具坏了。
二、分叉决策表
| 现象 | 首选工具 | 事件类型 | 你会看到 |
|---|---|---|---|
| CPU 接近饱和(> 70%) | async-profiler / JFR | cpu / jdk.ExecutionSample |
火焰图顶部有明确的宽块 |
| CPU 低(< 40%)但延迟高 | async-profiler | wall |
线程大面积停在等待栈上 |
CPU 的 sys 占比高 |
系统工具 + 火焰图 | cpu(看内核栈) |
系统调用、锁、页错误 |
| GC 停顿与延迟尖刺对齐 | GC 日志 / JFR | jdk.GCPhasePause |
周期性停顿 |
| 分配速率高 | async-profiler | alloc |
谁在制造垃圾 |
| 吞吐上不去但 CPU 不高 | async-profiler | lock |
锁争用 |
| 所有指标都正常但用户说慢 | 看测量点(第 1 章 1.3) | 客户端/网关/tracing | 问题可能在网络或上游 |
三、四种事件的完整分工
| 事件 | 采样依据 | 回答 | 典型发现 |
|---|---|---|---|
cpu |
CPU 时间 | 谁在消耗 CPU? | 热点函数、算法问题、序列化开销 |
wall |
墙上时间 | 时间都花在哪(含等待)? | 阻塞 IO、锁等待、下游慢 |
alloc |
分配的对象 | 谁在制造 GC 压力? | 装箱、字符串拼接、正则编译 |
lock |
锁等待时间 | 谁在抢锁? | 锁竞争、临界区过大 |
记忆方法:
cpu → 忙的人在干什么
wall → 所有人(含闲着的)都在干什么 ← 最容易被忽略,也最有用
alloc → 谁在制造垃圾
lock → 谁在互相等
四、一个常见的错误分析流程
❌ 错误流程:
① 发现 P99 高
② 采 CPU 火焰图
③ 图很空
④ 结论:「没有热点,可能是网络问题」
⑤ 开始查网络、查网关、查压测机……
花了几个小时,最后发现是数据库慢查询
✅ 正确流程:
① 发现 P99 高,先看 CPU 利用率
② CPU 只有 30% → 时间是花在等待上的
③ 采 wall-clock 火焰图
④ 看到大量线程停在 socketRead0 / JDBC 调用
⑤ 结论:在等数据库 → 去查 SQL(第 7 节)
第 ② 步是关键分叉。这一个判断就能省下几个小时。
五、还要注意「两者的结合」
有些问题只在两张图对照时才看得出来:
| 对照 | 结论 |
|---|---|
| cpu 图有热点,wall 图也有同样热点 | 纯 CPU 瓶颈,优化该函数 |
| cpu 图空,wall 图有大量等待栈 | 等待类瓶颈(IO/锁/下游) |
| cpu 图有热点,但 wall 图里这个热点占比很小 | 该热点不是瓶颈——它很忙但不影响整体延迟(可能是并行的) |
| 两张图都「很空」 | 可能是:① 采样没生效(权限问题);② 请求量太小;③ 瓶颈在这个进程之外(网关/网络/压测机) |
最后一行特别重要:「两张图都空」往往不是「没问题」,而是「问题不在这个进程里」。
六、本节小结
- 选错剖析路径的代价,是得到「没有瓶颈」的错误结论。
- 分叉点:CPU 高 →
cpu事件;CPU 不高但慢 →wall事件。 - 四种事件的分工:
cpu看谁在忙、wall看谁在等、alloc看谁在制造垃圾、lock看谁在抢锁。 - 只采 CPU 火焰图是新手最常见的错误,而「CPU 不高但很慢」在后端极其常见。
- 两张图对照看,比单看一张信息量大得多。两张都空,通常意味着问题不在这个进程里。
七、自测
- 一个服务的 CPU 利用率是 22%,P99 是 800 ms。你会先采哪种火焰图?为什么另一种会误导你?
- CPU 火焰图上有个函数占了 40% 的宽度,但接口的 P99 只有 50 ms(SLO 是 500 ms)。你该优化这个函数吗?
- 你采了 cpu 和 wall 两张图,发现两张都「很空」,几乎没有采样到任何业务代码。请列出至少三种可能的解释。
- 先采 wall-clock 火焰图(
asprof -e wall)。因为 CPU 只有 22%,说明时间花在等待上(等数据库、等锁、等下游、等网络);这种情况下 CPU 火焰图会显示「空闲」或「很空」,你会错误地得出「没有瓶颈」的结论。wall-clock 采样基于墙上时间,能看到线程阻塞在哪个调用栈上,直接指向等待源。 - 不该盲目优化。理由:① 接口 P99 只有 50 ms,远低于 SLO 500 ms——说明当前不是问题(第 3.7 节的「什么时候不该测」);② 火焰图上的宽度代表「CPU 时间占比」,如果这个函数在并行线程上运行(比如后台任务、定时任务、预热代码),它占 40% CPU 但不影响请求延迟;③ 要判断它是否是瓶颈,应该看「wall 火焰图里它占多少」以及「优化它能降低多少 P99」。正确的判断顺序:先确认 SLO 是否有问题 → 有问题才优化;优化前先算「上限收益」。
- 可能解释:① 采样没生效:perf 权限不足(
perf_event_paranoid太严),async-profiler 退化为精度很低的模式或采不到用户态栈;② 请求量太小或采样时长太短:采样次数不够,栈没有累积起来(可以延长-d时长);③ 瓶颈在这个进程之外:比如网关、网络、压测机、或者上游服务——此时进程内确实「无事可做」,两张图都空是真实反映;④ 另一种可能:采样的是错误的进程(比如采到了 shell 或启动脚本的 PID);⑤ 或者是 JIT 把代码内联/优化后,栈上只剩框架帧,看起来"不像业务代码"。