文档目录

9.6 步骤五:剖析定位

上一节:9.5 步骤四:容量曲线与拐点 | 下一节:9.7 步骤六:优化与验证 用到:第 6 章(五步法、延迟分解、火焰图、瓶颈模式库) 配套代码:09-capstone/06-step5-diagnose 产出:bottlenecks.md(瓶颈清单:证据链 + 贡献占比 + 被排除的假设)


一句话结论

在拐点负载下剖析(不是在低负载下)——因为低负载时一切都很健康,看不出问题。这一步的产出是一份能被人追问的瓶颈清单。


一、五步法在埋雷服务上的应用

① 现象量化 → 已有(步骤三、四的数据)
② 分层拆解 → 找出哪一段占大头
③ 提出假设 → 六颗埋雷对应六个假设
④ 设计验证 → 每条假设一条可证伪的命令
⑤ 得出结论 → 证据链 + 贡献占比 + 被排除的假设

第 ① 步已经在步骤三、四做完了——这正是前面步骤的价值:你不用再从头量化。


二、② 分层拆解:找出大头

# 在拐点负载(约 700 RPS)下测量
tools/decompose-at-knee.sh E11-diagnosis
#!/usr/bin/env bash
# tools/decompose-at-knee.sh <EXP_ID>
set -uo pipefail

EXP_ID="${1:?usage: decompose-at-knee.sh <exp_id>}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results"

# ⚠️ 关键:在【拐点负载】下剖析,不是在低负载下
KNEE_QPS="${KNEE_QPS:-700}"

scripts/restart-app.sh "$DIR/results/gc.log"
sleep 5
BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 60s loadtest/read-path.js > /dev/null

# 后台施压(持续整个剖析过程)
BASE_URL=http://127.0.0.1:8080 RATE="$KNEE_QPS" DURATION=5m \
k6 run --summary-export="$DIR/results/k6-knee.json" loadtest/read-path.js > /dev/null 2>&1 &
K6_PID=$!
sleep 40    # 等进入稳态

PID=$(jcmd | grep app.jar | awk '{print $1}')

echo "═══ 在 ${KNEE_QPS} RPS 下采集证据 ═══"
echo

# ① 四类火焰图
echo "① 火焰图"
for E in cpu wall alloc lock; do
  asprof -d 20 -e "$E" -f "$DIR/results/$E.html" "$PID" > /dev/null 2>&1
  asprof -d 20 -e "$E" -o collapsed -f "$DIR/results/$E.collapsed" "$PID" > /dev/null 2>&1
  echo "   $E.html / $E.collapsed"
done
echo

# ② 线程快照(连续 3 份)
echo "② 线程快照"
mkdir -p "$DIR/results/threads"
for i in 1 2 3; do
  jcmd "$PID" Thread.print > "$DIR/results/threads/threads-$i.txt"
  sleep 3
done
python3 tools/analyze-threads.py "$DIR/results/threads"/threads-*.txt \
  > "$DIR/results/threads/analysis.txt"
echo "   分析已生成"
echo

# ③ 指标快照
curl -s localhost:8080/metrics > "$DIR/results/metrics.txt"

# ④ 数据库侧
echo "③ 数据库诊断"
psql "${PG_CONN:-postgresql://app:app@localhost:5432/shortlink}" > "$DIR/results/pg-diagnostics.txt" 2>&1 <<'SQL'
-- 最贵的语句(看 calls 与 mean)
SELECT calls, round(mean_exec_time::numeric,2) AS mean_ms,
       round(total_exec_time::numeric,0) AS total_ms,
       left(regexp_replace(query,'\s+',' ','g'), 50) AS query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC LIMIT 10;

-- 当前活跃查询与等待
SELECT pid, round(extract(epoch from (now()-query_start))::numeric,1) AS dur_s,
       state, wait_event_type, wait_event, left(query,50)
FROM pg_stat_activity WHERE state <> 'idle' AND pid <> pg_backend_pid()
ORDER BY dur_s DESC NULLS LAST LIMIT 10;
SQL
echo "   已保存到 pg-diagnostics.txt"

wait $K6_PID 2>/dev/null || true

echo
echo "═══ 关键证据摘要 ═══"
echo
echo "── 数据库最贵的语句 ──"
head -8 "$DIR/results/pg-diagnostics.txt" | tail -6

echo
echo "── 火焰图热点(自身耗时 Top 5)──"
for E in cpu wall; do
  echo "[$E]"
  python3 tools/analyze-flamegraph.py "$DIR/results/$E.collapsed" 2>/dev/null \
    | grep -A6 "自身耗时 Top" | tail -5 | sed 's/^/  /'
done

echo
echo "── 线程分析关键结论 ──"
grep -E "BLOCKED|持续|⚠️|❌" "$DIR/results/threads/analysis.txt" 2>/dev/null | head -6 | sed 's/^/  /'

echo
echo "✅ 证据已采集 → $DIR/results"

三、预期发现:六颗埋雷的证据

埋雷 预期证据 在哪份数据里
① 无索引 pg_stat_statements 里 SELECT ... WHERE code = ? 的 mean 高;EXPLAIN 显示 Seq Scan pg-diagnostics.txt
② 每次 UPDATE lock 火焰图有内容;pg_stat_activity 有 wait_event_type=Lock lock.collapsed
③ TTL 无抖动 缓存命中率曲线有周期性下凹 需要额外采集
④ Default 上 JDBC cpu 火焰图很空,wall 火焰图全是阻塞栈 cpu.collapsed vs wall.collapsed
⑤ 日志同步 IO sys CPU 偏高;火焰图有文件写栈 cpu.collapsed
⑥ synchronized 线程 dump 有 BLOCKED threads/analysis.txt

埋雷 ④ 的证据(最重要的教学点)

cpu.collapsed  → 很空(几乎没有采样)
wall.collapsed → 大量栈停在 socketRead0 / JDBC 调用

这个对比就是第 6.3 节的核心:
「只采 CPU 火焰图会得出『没有瓶颈』的错误结论」

验证:

# 对比两张图的「自身耗时 Top」
python3 tools/analyze-flamegraph.py results/cpu.collapsed | head -20
python3 tools/analyze-flamegraph.py results/wall.collapsed | head -20

四、③④ 提出假设并逐个验证

对每颗埋雷,写一条可证伪的假设 + 验证命令 + 否证条件。

<!-- 假设台账:hypotheses.json 的可读版本 -->

## 假设台账

| ID | 假设 | 验证命令 | 否证条件 | 状态 |
| --- | --- | --- | --- | --- |
| H1 | 跳转接口慢由 `code` 无索引(全表扫描)造成 | `EXPLAIN (ANALYZE, BUFFERS) SELECT ... WHERE code = 'c123'` | 若显示 Index Scan 或 mean < 10ms 则排除 | 待验证 |
| H2 | 每次跳转的 `UPDATE hits` 造成行锁竞争 | `pg_stat_activity` 查 `wait_event_type=Lock`;`lock` 火焰图 | 若无 Lock 等待则排除 | 待验证 |
| H3 | 缓存 TTL 无抖动导致周期性雪崩 | 对齐 DB QPS 脉冲与 TTL(10min) | 若 QPS 曲线平滑则排除 | 待验证 |
| H4 | Default 调度器被 JDBC 阻塞调用占满 | `wall` 火焰图;`RUNNABLE 线程数 == 核数` | 若 RUNNABLE ≠ 核数则排除 | 待验证 |
| H5 | 热路径 INFO 日志造成同步 IO 开销 | JFR `jdk.FileWrite`;降级日志级别的对照实验 | 若降级后 P99 无变化则排除 | 待验证 |
| H6 | `synchronized` 生成 code 造成写路径锁竞争 | 线程 dump 的 `BLOCKED`;`lock` 火焰图 | 若无 BLOCKED 且写路径正常则排除 | 待验证 |
| H7 | GC 停顿造成 P99 尖刺 | GC 日志时间戳与 P99 尖刺对齐 | **不对齐则排除**(预期:排除) | 待验证 |
| H8 | 连接池太小导致排队 | 用 Little's Law 算所需连接数 vs 当前池大小 | 若查询优化后 pending 消失则排除 | 待验证 |

注意 H7 和 H8:

H7(GC):预期被【排除】——因为基线显示 GC 停顿只有 18ms,远小于 P99 的 420ms
H8(连接池):预期被【排除】——因为 pending 上升是慢查询的结果,不是原因

“被排除的假设"和"找到的根因"同样重要(第 6.8 节)——它们证明你不是"碰巧找到一个看起来像的原因”。

验证 H1(最关键的一条)

psql -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, url FROM links WHERE code = 'c123456';"

预期输出:

Seq Scan on links  (cost=0.00..22906.00 rows=1 width=40) (actual time=0.015..385.234 rows=1 loops=1)
  Filter: (code = 'c123456'::text)
  Rows Removed by Filter: 999999          ← ⭐ 扫了 100 万行只留 1 行
  Buffers: shared hit=10890
Planning Time: 0.123 ms
Execution Time: 385.456 ms                ← 与观测的数据库 P99 = 380ms 吻合 ✅

这就是 H1 的证据链:

① 现象:数据库查询 P99 = 380ms
② 证据:EXPLAIN 显示 Seq Scan,Rows Removed by Filter: 999999
③ 吻合:Execution Time 385ms ≈ 观测的 380ms
④ 结论:H1 成立

验证 H4(第二关键)

# ① wall 火焰图 vs cpu 火焰图
python3 tools/analyze-flamegraph.py results/wall.collapsed | grep -A8 "关键模式检测"
python3 tools/analyze-flamegraph.py results/cpu.collapsed | grep -A8 "关键模式检测"

# ② RUNNABLE 线程数 vs CPU 核数
CORES=$(nproc)
RUNNABLE=$(grep -c "java.lang.Thread.State: RUNNABLE" results/threads/threads-1.txt)
echo "CPU 核数: $CORES, RUNNABLE 线程数: $RUNNABLE"
# 预期:两者相等(8 == 8)⭐

这是调度器污染的强信号(第 6.5 节):

CPU 核数 == RUNNABLE 线程数,且 CPU 利用率不高
→ Dispatchers.Default 的 worker 全被阻塞调用占住

五、⑤ 得出结论:瓶颈清单

<!-- bottlenecks.md -->

# 瓶颈清单

> 分析负载:700 RPS(拐点附近) | 测量点:客户端
> 关联实验:E10-capacity(曲线)、E11-diagnosis(剖析)

## 总览:按对 P99 的贡献排序

| 排名 | 瓶颈 | 对 P99 的贡献 | 证据 | 修复优先级 |
| --- | --- | --- | --- | --- |
| 1 | **`code` 无索引(全表扫描)** | **~380 ms(90%)** | `EXPLAIN` Seq Scan | P0 |
| 2 | Default 上的 JDBC 阻塞调用 | ~25 ms(6%) | wall 火焰图 + RUNNABLE==核数 | P0 |
| 3 | 每次跳转 UPDATE hits(行锁) | ~10 ms(2.4%) | lock 火焰图 + Lock 等待 | P1 |
| 4 | 热路径 INFO 日志 | ~5 ms(1.2%) | JFR FileWrite | P1 |
| 5 | `synchronized` 生成 code(写路径) | 写路径为主,读路径影响小 | BLOCKED 线程 | P1 |
| 6 | 缓存 TTL 无抖动 | 周期性尖刺,均值影响小 | 需要额外采集 | P2 |

**测量方法**:逐个关闭(方法一,最可信)。

---

## 瓶颈 1:`code` 无索引导致全表扫描

### 现象
数据库查询 P99 = 380ms(占总 P99 420ms 的 90%)。

### 根因
`links` 表的 `code` 列没有索引,`SELECT ... WHERE code = ?` 走全表扫描。
具体位置:`init.sql` 里缺少 `CREATE UNIQUE INDEX ON links(code)`。

### 证据链
1. **`EXPLAIN (ANALYZE, BUFFERS)`**:

Seq Scan on links (actual time=0.015..385.234 rows=1 loops=1) Rows Removed by Filter: 999999 Execution Time: 385.456 ms

2. **`pg_stat_statements`**:

calls=210000 mean_ms=382.1 total_ms=80241000

(calls 与 QPS 同阶,说明每次跳转都执行一次)
3. **吻合验证**:Execution Time 385ms ≈ 依赖指标 `dep.postgres` 的 P99 380ms ✅

### 贡献占比
**约 90%**(逐个关闭:加索引后 P99 从 420ms 降到 45ms,差值 375ms / 420ms = 89%)

### 被排除的假设
- **连接池太小**:`pending` 上升是慢查询的**结果**;查询优化后 pending 自然消失
- **GC**:停顿仅 18ms,与 P99 尖刺不对齐
- **CPU 不足**:CPU 42%,且 wall 火焰图显示时间花在等待

### 复现
```bash
psql -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, url FROM links WHERE code = 'c123456';"

瓶颈 2:Default 调度器上的 JDBC 阻塞调用

现象

CPU 只有 42%,但所有接口(包括不查数据库的)一起变慢。

根因

LinkRepository.findByCode 在 Dispatchers.Default 上执行 JDBC。 Default 的并行度 = CPU 核数(8),8 个 worker 被阻塞调用占满后, 所有使用 Default 的协程排队。

证据链

  1. wall 火焰图:8 个 Default worker 全停在 SocketInputStream.read
  2. cpu 火焰图:几乎为空(这是关键对比——CPU 图看不出这个问题)
  3. 线程 dump(连续 3 份):持续有 8 个 RUNNABLE 停在 JDBC 调用栈
  4. 关键巧合:RUNNABLE 线程数(8) == CPU 核数(8)

贡献占比

约 6%(对读路径的 P99 贡献),但对"全局卡顿"的贡献接近 100% ——因为它会让所有使用 Default 的接口一起变慢。

被排除的假设

  • 线程池太小:线程数正常,且问题是"线程被阻塞"而不是"线程不够"
  • 锁竞争:无 BLOCKED 线程,lock 火焰图为空

复现

asprof -d 30 -e wall -f wall.html <pid>    # 看 Default worker 的栈
jcmd <pid> Thread.print | grep -c RUNNABLE # 应该等于 CPU 核数

(瓶颈 3–6 同理,略)


未能确认的问题

问题 为什么没确认 下一步
缓存 TTL 无抖动的实际影响 需要长时间观测(> 10 分钟)才能看到周期性脉冲 放到步骤七的浸泡测试里
写路径的锁竞争程度 本次主要是读路径压测 单独对 POST /links 施压

---

## 六、这一步的验收

```markdown
## 步骤五验收清单

### 剖析条件
- [ ] **在拐点负载下剖析**(不是低负载)
- [ ] 采集了**四类火焰图**(cpu / wall / alloc / lock)
- [ ] 采了**连续多份线程快照**(≥3)
- [ ] 采了数据库侧诊断(`pg_stat_statements` + `pg_stat_activity`)

### 假设台账
- [ ] 每条假设都有**验证命令**与**否证条件**
- [ ] 每条假设都有**状态**(成立 / 排除 / 待验证)
- [ ] **至少有一条被明确排除**(并给出依据)

### 结论
- [ ] 瓶颈清单**按贡献排序**
- [ ] 每条瓶颈含:现象、根因、**证据链**、**贡献占比**、**被排除的假设**、复现命令
- [ ] 明确说明了**测量贡献占比的方法**(逐个关闭 / 延迟分解 / 相关性)
- [ ] 列出了**未能确认的问题**与下一步

七、本节小结

  1. 在拐点负载下剖析——低负载时一切健康,看不出问题。
  2. 第①步(现象量化)已在步骤三、四完成——这是前面步骤的价值。
  3. 六颗埋雷对应六条假设,每条都要有验证命令与否证条件。
  4. cpu 火焰图为空 + wall 火焰图有阻塞栈 是调度器污染的关键对比——这也是「只采 CPU 图会误判」的实证。
  5. RUNNABLE 线程数 == CPU 核数 且 CPU 不高 是调度器污染的强信号。
  6. pending 上升是结果不是原因——它会被"被排除的假设"明确记下来。
  7. 结论必须含贡献占比与被排除的假设——后者证明你不是"碰巧找到一个"。

八、自测

  1. 为什么要在拐点负载下剖析,而不是在低负载下?请举一个具体的后果。
  2. cpu 火焰图几乎为空,wall 火焰图有大量阻塞栈。请解释这个对比的含义,以及如果只看 cpu 图会得出什么错误结论。
  3. 你的瓶颈清单里,瓶颈 1 贡献 90%,瓶颈 2 贡献 6%。请说明你会先修哪个,以及"贡献占比"这个数字是怎么测出来的。
  1. 因为在低负载下系统没有排队:① 延迟主要由单次操作耗时构成,看不出排队与饱和;② CPU 利用率低,看不出热点;③ 连接池 pending 为 0,看不出池是否够用;④ 火焰图上的热点不明显(因为每个操作都是独立执行,没有竞争)。具体后果举例:如果你在 200 RPS 下剖析,会看到 P99 = 180ms、CPU 20%、pending = 0,你可能会得出"系统健康,问题只是某个查询稍慢"的结论;但在 700 RPS(拐点)下,你会看到 P99 = 620ms、pending 开始上升、wall 火焰图显示大量等待——这时才能看到"排队"这个真实的瓶颈。更本质的原因:性能问题的形态随负载变化——低负载下的问题和拐点处的问题可能完全不同(第 0 章 0.5 节的排队非线性)。
  2. 含义:① cpu 火焰图统计的是「CPU 时间」——线程在等待(阻塞在 socket/IO)时不消耗 CPU,所以采不到栈;② wall 火焰图统计的是「墙上时间」——包括等待时间,所以能看到线程阻塞在哪里。这个对比说明:时间不是花在"计算"上,而是花在"等待"上(等 IO / 等锁 / 等下游)。如果只看 cpu 图会得出什么错误结论:你会看到一张"几乎空白"的图,然后得出**「没有热点,可能不是应用的问题」**这个错误结论——接着可能去查网络、查网关、怀疑工具坏了,浪费几个小时(第 6.3 节提到的常见错误)。正确做法:两张图对照——cpu 空但 wall 有内容 → 明确的"等待类瓶颈",然后去 wall 图里找最宽的等待栈(这里是 SocketInputStream.read,指向数据库)。
  3. 先修瓶颈 1(缺索引)。理由:① 贡献占比 90% vs 6%——修瓶颈 1 的收益上限是 90%,修瓶颈 2 是 6%;② 修复成本:加索引是一条 SQL(几秒钟),而改调度器需要改代码(几分钟);③ 收益/成本比相差悬殊(第 7.1 节的优先级金字塔:算法与数据访问属于 ② 级,架构与并发属于 ③ 级,前者优先)。“贡献占比"的测量方法(三种,本步骤用的是第一种):① 逐个关闭(最可信)——记录当前 P99 = 420ms,然后只加索引(其他不动),复测得到 P99 = 45ms,差值 375ms 就是瓶颈 1 的贡献(375/420 = 89%);② 延迟分解(最常用)——如果依赖指标显示数据库查询占 P99 的 90%,直接用这个比例;③ 相关性分析(最弱)——看"数据库查询耗时"与"P99"的时间序列相关性。报告里必须说明用的是哪种方法(第 6.8 节)——因为三种方法的可信度不同。