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}'` 对比部署前后
注意这个实例的三个特点:
- 第二步的拆解立刻改变了方向——从「应用内」转到「应用外」。
- H1 被排除了,但没有浪费——排除它让方向更明确。
- H3 是在 H2 成立后才提出的——分析是层层递进的,不是一次问到底。
四、四个常见的方法错误
| 错误 | 后果 | 改法 |
|---|---|---|
| 跳过量化,直接猜 | 假设空间太大,试错成本高 | 先填量化表 |
| 假设不可证伪 | 永远无法确认或排除,陷入循环 | 每条假设写清"看什么数据能推翻它" |
| 只确认不排除 | 找到"一个"原因就停手,可能不是主因 | 主动列出并排除其他可能 |
| 过早优化 | 还没写清根因就开始改代码 | 结论写完再动手 |
最后一条最重要:
❌ 「先加个缓存试试,看看有没有改善。」
✅ 「根因是 X(有证据),修复方案是 Y,预期改善 Z%。改完验证。」
「试试看」式的优化,会污染你的基线——因为你不知道到底是哪一次改动起了作用(第 5 章 5.1 节的单变量原则)。
五、本节小结
- 分析五步:现象量化 → 分层拆解 → 提出假设 → 设计验证 → 得出结论。
- 每个假设都必须能被一条命令证伪——这是本章最核心的纪律。
- 现象量化表本身就能排除大量可能(QPS 未变 + CPU 未变 = 不是过载)。
- 分层拆解把「总账」变成「明细」,是缩小假设空间最有效的一步。
- 结论必须包含贡献占比与被排除的假设——「为什么不是别人」和「是谁」同样重要。
- 不要"试试看":先写清根因再动手,否则会污染基线。
六、自测
- 一个同事说:「接口慢了,我怀疑是数据库问题。」请把这句话改写成一条可证伪的假设(包含验证命令与否证条件)。
- 你做现象量化时发现:P50 从 20 ms 涨到 25 ms,P99 从 80 ms 涨到 400 ms,QPS 和 CPU 都没变。这三条信息分别排除了哪些可能?
- 为什么「被排除的假设」和「找到的根因」同样重要?请举一个具体的例子说明。
- 可证伪的假设:「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 等其他假设。」 - ① P50 只从 20 → 25 ms(微涨)而 P99 从 80 → 400 ms(暴涨) → 说明不是全局性变慢(不是 CPU/内存/网络整体退化),而是少数请求踩到了慢路径——可能是缓存击穿、锁等待、偶发的慢依赖、或某个特定数据分布触发的慢查询。② QPS 未变 → 排除「流量增长导致的过载」和「上游重试风暴」。③ CPU 未变(45%) → 排除「计算密集型瓶颈」(如序列化变慢、正则编译、算法退化);也提示这可能是等待类问题(等 IO/锁/池)。综合:这些信息把假设空间从"任何可能"缩小到「某条慢路径」,且更可能是等待类而非计算类。下一步:采 wall 火焰图(不是 CPU 图)+ 看饱和度指标。
- 因为没有排除,就无法确认——找到的"根因"可能只是众多现象之一,甚至可能是结果而非原因。具体例子:你发现「连接池 pending = 18」,于是得出结论「连接池太小」。但如果不排除其他假设,你可能忽略了这个事实链:慢查询 → 连接被占用更久 → pending 上升。此时 pending 是症状,慢查询才是根因;按"连接池太小"去加大池子,会把压力转移到数据库,问题反而恶化。排除的价值:① 防止把症状当根因;② 缩小搜索范围(排除了 5 个可能,就只剩 2 个要查);③ 在报告里让读者相信你的结论不是"碰巧找到一个看起来像的原因";④ 如果将来问题复发,被排除的清单能帮你快速判断"是不是同一个原因"。