文档目录

6.8 结论的写法

上一节:6.7 陷阱清单:Kotlin 与 C++ 直觉 | 下一节:6.9 Lab 6 配套代码:06-analysis-and-profiling/08-writing-conclusions


一句话结论

一份合格的根因结论,必须能让读者回答三个问题:「你怎么知道是这个?」「它占多少?」「为什么不是别的?」——分别对应证据链、贡献占比、被排除的假设。


一、用「结案报告」理解结论

一个合格的结案报告不会只写「凶手是张三」。它会包含:

结案报告 性能结论
案件经过(时间、地点、受害人) 现象量化(哪个接口、多慢、何时开始)
证据链(物证、口供、监控) 证据链(指标、火焰图、日志,含命令与输出)
作案动机与手法 根因(具体到代码位置/配置项)
在整体中的位置(是主犯还是从犯) 贡献占比(占总延迟的百分比)
为什么不是李四(不在场证明) 被排除的假设(含排除依据)
如何重现 复现方式(命令与预期输出)

最容易被忽略的是「为什么不是李四」——但它往往是报告中最有专业性的部分。


二、结论的标准结构

# 根因结论:<标题>

## 1. 现象
| 维度 | 观测值 |
| --- | --- |
| 接口 | GET /orders/{id} |
| 指标 | P99 从 80ms → 400ms(P50 20→25ms) |
| 时间 | 2025-01-15 14:30 开始 |
| 范围 | 所有实例;只影响带 user_id 的查询 |
| 变更 | 14:00 部署 v2.3.1 |

## 2. 延迟分解
| 环节 | P99 | 占比 |
| --- | --- | --- |
| 网络 + 客户端排队 | 20 ms | 5% |
| **服务排队** | **290 ms** | **72%** ⭐ |
| 应用自身 | 13 ms | 3% |
| PostgreSQL | 65 ms | 16% |
| Redis + 下游 | 12 ms | 3% |

## 3. 根因
**v2.3.1 新增的 `/orders/stream` 长轮询接口,占用了网关到应用的连接池,
导致普通请求在网关侧排队等待可用连接。**

具体位置:`OrderStreamHandler.kt:42`(长轮询默认 60 秒超时)

## 4. 证据链

### 证据 1:网关侧连接等待时间

命令:curl -s gateway:9090/metrics | grep gateway_upstream_wait 输出:P99 = 340ms(基线 8ms)


### 证据 2:应用侧连接数

命令:ss -tn state established ‘( sport = :8080 )’ | wc -l 输出:部署后 4800(基线 400),且持续不降


### 证据 3:长轮询接口的连接持有时间

命令:jfr print –events jdk.SocketWrite recording.jfr | grep -A5 stream 输出:连接平均持有 58 秒


### 证据 4:相关性

命令:对比 14:00 部署前后的网关等待时间曲线 输出:部署后立刻从 8ms 跳到 340ms(时间完全吻合)


## 5. 贡献占比
**约 85%**(340ms / 400ms)

测量方法:临时关闭长轮询接口(或限流到 1/10),P99 立刻回落到 95ms。
- 关闭前:400 ms
- 关闭后:95 ms
- 差值:305 ms,占总延迟的 76%
(与网关等待时间的 340ms 基本吻合,交叉验证)

## 6. 被排除的假设

| 假设 | 排除依据 |
| --- | --- |
| GC 停顿导致 | GC 日志的停顿时间戳与 P99 尖刺**不对齐**,且停顿幅度只有 15ms |
| 慢查询 | `pg_stat_statements` 的 mean/total 与基线一致(无变化) |
| 连接池 pending | 应用**内部**池 `db_pool_pending = 0`(问题在网关到应用的连接层) |
| CPU 饱和 | CPU 45%,且关闭长轮询后 CPU 未变 |
| 锁竞争 | 线程 dump 无 BLOCKED,`lock` 火焰图为空 |

## 7. 复现方式
```bash
# 1. 启动长轮询接口
curl -N http://app:8080/orders/stream &
# 2. 观察网关等待时间
watch -n1 'curl -s gateway:9090/metrics | grep upstream_wait'
# 预期:等待时间从 8ms 涨到 340ms

8. 修复建议

  1. 短期:给长轮询单独的连接池/路由(隔离),或缩短超时到 5 秒
  2. 长期:改用 SSE/WebSocket(不占 HTTP 连接),或把长轮询移到独立服务
  3. 验证:修复后重跑同样的压测,确认 P99 回落且长轮询仍可用

---

## 三、贡献占比怎么测(这是最难的一步)

### 方法一:逐个关闭(最可信)

```text
① 记录当前 P99 = 400 ms
② 关闭瓶颈 A → P99 = 95 ms(A 贡献 305 ms)
③ 恢复 A,关闭瓶颈 B → P99 = 380 ms(B 贡献 20 ms)
④ 结论:A 贡献 76%,B 贡献 5%

优点:直接测量,不依赖推断。 缺点:需要能"关闭"瓶颈(生产环境可能做不到)。

方法二:延迟分解(最常用)

瓶颈 A 的那一段在分解表里占 290 ms / 400 ms = 72%
→ 直接作为贡献占比

优点:不用改环境。 缺点:分位数相减是近似(第 6.2 节)。

方法三:相关性分析(最弱)

瓶颈 A 的指标(如 pending)与 P99 的相关性 r = 0.92
→ 推断 A 是主因

优点:不用关闭任何东西。 缺点:相关性不等于因果——两个指标可能同时被第三个因素驱动。

优先级:方法一 > 方法二 > 方法三。报告里应该说明你用的是哪种。


四、五个提升结论可信度的写法

❶ 给出「命令 + 输出」,而不是「我看了下」

❌ 「查看了线程 dump,发现很多线程在等连接。」
✅ 「`jcmd 12345 Thread.print | grep -c HikariPool.getConnection` → 47
    (连续 5 份快照都是 47,说明持续阻塞而非瞬时)」

❷ 说明「为什么排除」,而不只是「找到什么」

❌ 「根因是连接池排队。」
✅ 「根因是连接池排队。排除 GC 的依据:GC 停顿时间戳与 P99 尖刺不对齐
    (见附件 gc-timeline.png)。」

❸ 明确「未验证的部分」

✅ 「本次分析覆盖:常规请求路径。
    未覆盖:冷启动路径、数据量增长 10 倍后的表现、多副本场景。」

这一条最能体现专业性——它告诉读者你的结论的适用范围。

❹ 量化不确定性

❌ 「优化后 P99 降到 95 ms。」
✅ 「优化后 P99 中位数 95 ms(5 轮,CV 3.1%,95% 置信区间 [93.6, 96.1])。
    与环境噪声底线(±13%)和 MDD(±26%)对比:改善幅度 76%,远超 MDD,可信。」

❺ 给出可复现的最小命令集

✅ 「复现只需三条命令:
    ① curl -N http://app:8080/orders/stream &
    ② watch -n1 'curl -s gateway:9090/metrics | grep upstream_wait'
    ③ 预期:等待时间从 8ms 涨到 340ms」

五、三个反面写法

❶ 只写「可能」和「建议排查」

❌ 「P99 高可能是数据库慢,建议排查慢查询、锁、连接池、GC、网络……」

问题:这不是结论,是待办清单。它把分析工作推给了读者。

❷ 只写根因,不写贡献占比

❌ 「根因是 N+1 查询。」

问题:如果 N+1 只占总延迟 3%,那它不是主要矛盾。没有占比,读者无法判断该不该优先修。

❸ 把「相关性」当「因果」

❌ 「我们发现 CPU 使用率与 P99 高度相关,所以 CPU 是瓶颈。」

问题:CPU 高可能是结果(比如 GC 频繁导致 CPU 高,而 GC 才是根因)。必须做单变量实验或分层拆解来确认因果方向。


六、本节小结

  1. 结论要能回答三个问题:「你怎么知道?」「占多少?」「为什么不是别的?」
  2. 标准结构:现象 → 延迟分解 → 根因 → 证据链 → 贡献占比 → 被排除的假设 → 复现方式 → 修复建议。
  3. 贡献占比的三种测法:逐个关闭(最可信)> 延迟分解(最常用)> 相关性(最弱)。报告里要说明用了哪种。
  4. 提升可信度的五个写法:命令+输出、说明为什么排除、明确未验证部分、量化不确定性、给出最小复现命令集。
  5. 三个反面写法:只写"可能"、不写占比、把相关性当因果。
  6. 「被排除的假设」和「未覆盖的场景」最能体现专业性——它们告诉读者结论的边界。

七、自测

  1. 一份结论写:「P99 高的原因可能是数据库慢,建议排查慢查询、锁、连接池。」请指出至少三个问题,并给出改进方向。
  2. 你要证明「N+1 查询是 P99 高的主因」。请说出三种测量贡献占比的方法,以及各自的优缺点。
  3. 为什么「明确未验证的部分」能提升结论的可信度,而不是削弱它?
  1. 三个问题:① 不是结论而是待办清单——「可能是……建议排查……」把分析工作推给了读者,没有任何验证;② 缺少证据——没有给出任何命令、数据或观测结果;③ 缺少贡献占比——即使确认了是数据库慢,也要说明它占总延迟多少(否则无法判断优先级);④ 还缺少被排除的假设(为什么不是 GC、不是网络、不是排队)和现象量化(哪个接口、多慢、何时开始)。改进方向:按第 8 节的标准结构重写——先量化现象,做延迟分解,然后对每个假设设计一条可证伪的验证命令,最后给出结论 + 证据链 + 贡献占比 + 被排除的假设 + 复现方式。
  2. 三种方法:① 逐个关闭(最可信)——记录当前 P99,关闭 N+1 相关代码路径(或改成批量查询)后再测,差值就是贡献。优点:直接测量,因果清晰。缺点:需要能修改/关闭路径,生产环境可能做不到;成本高。② 延迟分解(最常用)——用依赖级指标看 dep.postgres 占总延迟的比例。优点:不用改环境,随时可做。缺点:分位数相减是近似(不是同一批请求);且依赖计时包含"等连接",可能高估。③ 相关性分析(最弱)——对比「数据库查询次数」与「P99」的时间序列相关性。优点:不用改任何东西。缺点:相关性不等于因果;两个指标可能同时被第三个因素(如某个特定请求类型)驱动。实践建议:日常排查用 ②,关键决策用 ①,③ 只作为线索。
  3. 因为它界定了结论的适用范围,防止读者过度推广。具体价值:① 防止误用——如果你写「未覆盖冷启动路径」,读者就不会拿这个结论去论证"滚动发布安全";② 暴露风险——未验证的部分往往正是下次可能出问题的地方,写出来相当于提前告知风险;③ 体现严谨——一个不写边界的结论,读者会怀疑"是不是还有别的情况没考虑到";④ 指导下一步——它是后续实验的输入(第 5 章的「遗留问题」)。类比:药品说明书必须写「未在孕妇中验证」——这不是削弱药品的可信度,而是让医生能正确使用它。同理,性能结论写清边界,才能被安全地用于决策。