文档目录

6.1 分析流程:五步法

上一节:无 | 下一节:6.2 延迟分解 配套代码:06-analysis-and-profiling/01-analysis-workflow


一句话结论

分析就是「提出假设 → 用一条命令证伪 → 更新认知」的循环。 关键纪律是:每个假设都必须能被一条命令证伪——说不出「看什么数据能推翻它」的假设,不是假设,是感想。


一、用「侦探破案」理解分析流程

一个合格的侦探不会说「我觉得是张三是凶手」就结案。流程是:

侦探 性能分析
现场勘查:什么时候、在哪、死了谁、怎么死的 现象量化:哪个接口、多慢、从何时开始、影响范围
尸检与物证:死亡时间、凶器、痕迹 分层拆解:延迟花在哪几段,CPU 花在哪
提出嫌疑人:谁有动机、有机会 提出假设:可能是连接池排队 / GC / 慢查询
审讯验证:逐个排查不在场证明 设计验证:用一条命令确认或排除
结案:证据链完整,能回答「为什么不是别人」 结论:含贡献占比与被排除的假设

注意最后一项:合格的结案报告不只要说「是张三」,还要说「为什么不是李四」。对应到性能分析:你不仅要证明「是连接池排队」,还要说明「为什么不是 GC」。


二、五步详解

第一步:现象量化(不是「慢」,是「谁在多慢的时候慢」)

必须回答的四个问题:

问题 为什么
谁慢? 是所有接口还是某个接口?是全部实例还是某一台?
多慢? P50 还是 P99?错对率如何?是否达标?
从何时开始? 是突变的(部署引起)还是渐变的(数据量增长/泄漏)?
影响范围? 只影响某类请求?某个地域?某种数据?

用一张表把现象量化:

## 现象量化

| 维度 | 观测值 |
| --- | --- |
| 接口 | GET /orders/{id} |
| 指标 | P99 从 80ms → 400ms(P50 从 20ms → 25ms) |
| 时间 | 2025-01-15 14:30 开始,持续至今 |
| 范围 | 所有实例;只影响带 user_id 的查询;无 user_id 的正常 |
| 错误率 | 0.1%(未变) |
| QPS | 850(未变) |
| CPU | 45%(未变) |
| 变更 | 14:00 有一次部署(版本 v2.3.1) |

这张表本身就排除了很多可能:

  • QPS 没变 + CPU 没变 → 不是流量增长导致的过载。
  • 只在某类查询上慢 → 指向数据访问路径,而不是全局资源。
  • 时间与部署吻合 → 高度怀疑那次变更。

没有这张表,你的所有假设都是无根据的。 有了它,假设空间会立刻缩小。

第二步:分层拆解(把「总账」拆成「明细」)

方法:按测量点做减法(第 1 章 1.3 节):

客户端延迟 − 网关延迟   = 网络 + 客户端排队
网关延迟   − 应用内延迟 = 网关处理 + 服务排队
应用内延迟 − Σ依赖延迟  = 应用自身开销

产出:一个分解表(第 2 节详述):

环节 P99
网络 + 客户端排队 70 ms
网关处理 + 服务排队 90 ms
应用自身 7 ms
PostgreSQL 25 ms
Redis 3 ms
下游 HTTP 5 ms

这一步的价值:它把「400 ms 慢」变成了「90 ms 花在排队上」——假设空间立刻从「所有可能」缩小到「排队相关的几件事」。

第三步:提出假设(必须可证伪)

假设的格式:

现象 X 是由 Y 造成的,如果 Y 成立,那么执行 Z 命令应该看到 W。

✅ 好的假设:

「P99 的尖刺由 GC 停顿造成。
  验证:如果成立,GC 日志里的停顿时间戳应与 P99 尖刺时间戳对齐。
  命令:对比 gc.log 的 Pause 时间与 Prometheus 的 P99 尖刺时间。
  否证条件:如果两者不对齐,则排除 GC。」

❌ 坏的假设:

「可能是数据库慢。」
(问:怎么验证?答:看看慢查询吧。——这不是验证,是继续猜。)

判断标准:能不能写出一条命令,它的输出会明确地支持或推翻这个假设?

第四步:设计验证(一条命令,明确结论)

验证的设计原则:

原则 说明
一条命令 不要"看看这些日志",要给出确切命令与预期输出
可证伪 必须明确「看到什么就是排除」
先验最可能的 按可能性排序,先验证最可能、最容易验证的
排除比确认更重要 「排除 GC」和「确认 GC」同样有价值

常见的验证手段:

假设 验证命令 判据
GC 造成尖刺 对比 gc.log 时间戳与 P99 尖刺 对齐 → 成立;不对齐 → 排除
连接池排队 db_pool_pending > 0 → 成立
慢查询 pg_stat_statements 的 total_exec_time 有查询占大头 → 成立
锁竞争 lock 火焰图 + 线程 dump 里的 BLOCKED 有大量 BLOCKED → 成立
调度器饥饿 wall 火焰图 + 协程 dump 大量协程挂在阻塞调用 → 成立
等下游 JFR 的 jdk.SocketRead 某下游阻塞时间长 → 成立

第五步:得出结论(含贡献占比与被排除的假设)

结论的完整结构(第 8 节详述):

## 根因结论

### 现象
(第一步的量化表)

### 根因
(具体到代码位置或配置项)

### 证据链
(每条证据对应的命令与输出)

### 贡献占比
(该根因解释了总延迟的多少 —— 用"逐个关闭瓶颈看 P99 下降幅度"测量)

### 被排除的假设
(以及排除依据)

### 复现方式
(别人照着这些命令能重现同样的证据)

三、一个完整的排查实例

现象:订单查询接口 P99 从 80ms 涨到 400ms,QPS 与 CPU 未变

① 量化
   范围:只影响"按 user_id 查询"的请求,"按 id 查询"正常
   时间:与 14:00 的部署吻合
   → 假设空间:数据访问路径,且与那次部署有关

② 分层拆解
   应用内 P99 = 40ms(没变!)
   客户端 P99 = 400ms
   → 360ms 花在应用之外:网络、网关、排队
   → 假设空间:转向"排队"或"网关"

③ 假设 H1:服务在排队(等线程/连接)
   验证:看饱和度指标
   → db_pool_pending = 0(排除)
   → executor_queue_depth = 0(排除)
   → 线程 dump 里没有大量 WAITING
   → 排除 H1

④ 假设 H2:网关到应用的网络或网关自身慢
   验证:看网关侧指标
   → 网关处理时间 P99 = 12ms(正常,排除网关自身)
   → 网关到应用的连接等待时间 P99 = 340ms ⭐
   → 成立!

⑤ 假设 H3(为什么连接等待变长?):应用侧连接数被占满
   验证:看应用的最大连接数与实际连接数
   → 部署后新增了一个"长轮询"接口,占用了大量连接
   → 成立!根因找到

⑥ 结论
   根因:v2.3.1 新增的长轮询接口占用了连接池,导致网关到应用的连接排队
   贡献占比:约 340/400 = 85%
   被排除:GC(停顿与尖刺不对齐)、慢查询(SQL 耗时未变)、
           连接池 pending(内部池为 0,问题在网关到应用的连接层)
   复现:`curl -w '%{time_connect}'` 对比部署前后

注意这个实例的三个特点:

  1. 第二步的拆解立刻改变了方向——从「应用内」转到「应用外」。
  2. H1 被排除了,但没有浪费——排除它让方向更明确。
  3. H3 是在 H2 成立后才提出的——分析是层层递进的,不是一次问到底。

四、四个常见的方法错误

错误 后果 改法
跳过量化,直接猜 假设空间太大,试错成本高 先填量化表
假设不可证伪 永远无法确认或排除,陷入循环 每条假设写清"看什么数据能推翻它"
只确认不排除 找到"一个"原因就停手,可能不是主因 主动列出并排除其他可能
过早优化 还没写清根因就开始改代码 结论写完再动手

最后一条最重要:

❌ 「先加个缓存试试,看看有没有改善。」
✅ 「根因是 X(有证据),修复方案是 Y,预期改善 Z%。改完验证。」

「试试看」式的优化,会污染你的基线——因为你不知道到底是哪一次改动起了作用(第 5 章 5.1 节的单变量原则)。


五、本节小结

  1. 分析五步:现象量化 → 分层拆解 → 提出假设 → 设计验证 → 得出结论。
  2. 每个假设都必须能被一条命令证伪——这是本章最核心的纪律。
  3. 现象量化表本身就能排除大量可能(QPS 未变 + CPU 未变 = 不是过载)。
  4. 分层拆解把「总账」变成「明细」,是缩小假设空间最有效的一步。
  5. 结论必须包含贡献占比与被排除的假设——「为什么不是别人」和「是谁」同样重要。
  6. 不要"试试看":先写清根因再动手,否则会污染基线。

六、自测

  1. 一个同事说:「接口慢了,我怀疑是数据库问题。」请把这句话改写成一条可证伪的假设(包含验证命令与否证条件)。
  2. 你做现象量化时发现:P50 从 20 ms 涨到 25 ms,P99 从 80 ms 涨到 400 ms,QPS 和 CPU 都没变。这三条信息分别排除了哪些可能?
  3. 为什么「被排除的假设」和「找到的根因」同样重要?请举一个具体的例子说明。
  1. 可证伪的假设:「P99 升高由数据库查询变慢造成。 如果成立,pg_stat_statements 里应该有某条查询的 mean_exec_time 或 total_exec_time 显著上升,且 dep.postgres 依赖指标(第 1 章 1.3 节)的 P99 会占总延迟的大头。验证命令:SELECT calls, mean_exec_time, total_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; 并对比 dep_postgres_seconds 与 app_handler_duration_seconds 的 P99。否证条件:如果所有查询的耗时与调用量与基线一致(无变化),且 dep.postgres 的 P99 只占总延迟的一小部分,则排除数据库原因,转向网络/排队/GC 等其他假设。」
  2. ① P50 只从 20 → 25 ms(微涨)而 P99 从 80 → 400 ms(暴涨) → 说明不是全局性变慢(不是 CPU/内存/网络整体退化),而是少数请求踩到了慢路径——可能是缓存击穿、锁等待、偶发的慢依赖、或某个特定数据分布触发的慢查询。② QPS 未变 → 排除「流量增长导致的过载」和「上游重试风暴」。③ CPU 未变(45%) → 排除「计算密集型瓶颈」(如序列化变慢、正则编译、算法退化);也提示这可能是等待类问题(等 IO/锁/池)。综合:这些信息把假设空间从"任何可能"缩小到「某条慢路径」,且更可能是等待类而非计算类。下一步:采 wall 火焰图(不是 CPU 图)+ 看饱和度指标。
  3. 因为没有排除,就无法确认——找到的"根因"可能只是众多现象之一,甚至可能是结果而非原因。具体例子:你发现「连接池 pending = 18」,于是得出结论「连接池太小」。但如果不排除其他假设,你可能忽略了这个事实链:慢查询 → 连接被占用更久 → pending 上升。此时 pending 是症状,慢查询才是根因;按"连接池太小"去加大池子,会把压力转移到数据库,问题反而恶化。排除的价值:① 防止把症状当根因;② 缩小搜索范围(排除了 5 个可能,就只剩 2 个要查);③ 在报告里让读者相信你的结论不是"碰巧找到一个看起来像的原因";④ 如果将来问题复发,被排除的清单能帮你快速判断"是不是同一个原因"。