6.9 Lab 6:在埋雷服务上定位三个瓶颈
上一节:6.8 结论的写法 | 下一节:第 7 章 优化与验证 配套代码:06-analysis-and-profiling/09-lab6 预计时长:120 分钟
一、这个 Lab 的目标
在一个「埋了三个不同性质瓶颈」的服务上,走完整套分析流程,产出一份合格的根因结论。
三个瓶颈的设计原则是:分别只在不同类型的工具上可见。这样你会被迫走完所有分析路径。
二、三个埋雷(先不要看这一节的细节,做完再看)
请在服务里实现下面三个接口(或自己设计等价的三个瓶颈):
| 接口 | 瓶颈类型 | 只在哪种工具上可见 |
|---|---|---|
GET /slow-cpu |
CPU 密集:热路径上重复编译正则 | cpu 火焰图 |
GET /slow-io |
阻塞 IO:在 Dispatchers.Default 上执行 JDBC |
wall 火焰图 + 协程 dump |
GET /slow-db |
N+1:循环里单条查询 | DB 指标(calls) |
关键设计
// ① CPU 密集:每次请求都重新编译正则
get("/slow-cpu") {
val text = "x".repeat(20_000)
var hits = 0
repeat(200) {
val re = Regex("""\d{3}-\d{4}""") // ← 每次都编译!
hits += re.findAll(text).count()
}
call.respondText("hits=$hits")
}
// ② 阻塞 IO:在 Default 上做阻塞调用
get("/slow-io") {
val n = ds.connection.use { c -> // ← JDBC 是阻塞的
c.createStatement().use { s ->
s.executeQuery("select pg_sleep(0.02), 1").use { rs -> rs.next(); rs.getInt(2) }
}
}
call.respondText("n=$n")
}
// ③ N+1:循环里单条查询
get("/slow-db") {
val ids = (1L..100L).map { (it * 7919) % 100_000 + 1 }
val urls = ids.map { id -> // ← 100 次独立查询
ds.connection.use { c ->
c.prepareStatement("select url from orders where id = ?").use { ps ->
ps.setLong(1, id)
ps.executeQuery().use { rs -> if (rs.next()) rs.getString(1) else null }
}
}
}
call.respondText(urls.filterNotNull().joinToString("\n"))
}
为什么要分开三个接口:如果三个瓶颈混在同一个接口里,你只能看到「又慢又 CPU 高」,无法分别验证。分开之后,每个瓶颈的症状是干净的。
三、任务清单
| # | 任务 | 产出 | 时长 |
|---|---|---|---|
| 1 | 实现三个埋雷接口并确认各自生效 | 三个接口的基线数据 | 30 min |
| 2 | 对三个接口分别施压 | 三份压测数据 | 20 min |
| 3 | 现象量化(三个接口各填一张表) | 量化表 | 15 min |
| 4 | 延迟分解(至少对一个接口) | 分解表 | 20 min |
| 5 | 采四类火焰图 + 线程/协程 dump + DB 指标 | 证据文件 | 20 min |
| 6 | 写三份根因结论 | conclusions.md |
15 min |
四、任务 3:现象量化(三个接口各一张)
## 现象量化
| 维度 | /slow-cpu | /slow-io | /slow-db |
| --- | --- | --- | --- |
| P50 | | | |
| P99 | | | |
| 错误率 | | | |
| CPU 利用率(压测期间) | | | |
| 线程数 | | | |
| 连接池 pending | | | |
| 数据库 QPS | | | |
| 应用内 P99 | | | |
关键观察:三个接口的 P50/P99 量级应该明显不同,且 CPU 利用率应该呈现不同形态:
| 接口 | 预期 CPU |
|---|---|
| /slow-cpu | 高(真的在算) |
| /slow-io | 低(在等) |
| /slow-db | 中等(等 DB + 少量计算) |
如果三个接口的 CPU 利用率差不多,说明你的埋雷设计有问题(或者压测强度不够)。
五、任务 5:证据采集(本 Lab 的核心)
对每个接口分别施压,并在施压期间采集证据:
#!/usr/bin/env bash
# 对单个接口施压 + 采证据
TARGET="${1:-slow-cpu}"
DIR="docs/experiments/E06-bottlenecks/results/$TARGET"
mkdir -p "$DIR"
# ① 后台施压(只压一个接口,隔离变量)
BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 3m \
-e ENDPOINT="$TARGET" loadtest/single-endpoint.js > "$DIR/k6.txt" 2>&1 &
K6_PID=$!
sleep 30 # 等进入稳态
# ② 采四类火焰图
PID=$(jcmd | grep app.jar | awk '{print $1}')
for E in cpu wall alloc lock; do
asprof -d 20 -e "$E" -f "$DIR/$E.html" "$PID"
done
# ③ 线程 dump(连续 3 份)
for i in 1 2 3; do
jcmd "$PID" Thread.print > "$DIR/threads-$i.txt"
sleep 3
done
# ④ 指标快照
curl -s localhost:8080/metrics > "$DIR/metrics.txt"
# ⑤ 数据库侧(如果是 DB 相关)
psql -c "SELECT calls, mean_exec_time, total_exec_time, left(query,50)
FROM pg_stat_statements ORDER BY calls DESC LIMIT 10;" > "$DIR/pg-stats.txt" 2>/dev/null
wait $K6_PID
echo "✅ $TARGET 证据采集完成 → $DIR"
填出这张「证据矩阵」
这是本 Lab 最有价值的产出——它会清楚地显示「哪个瓶颈只在哪种工具上可见」:
| /slow-cpu | /slow-io | /slow-db | |
|---|---|---|---|
| cpu 火焰图 最宽的栈 | |||
| wall 火焰图 最宽的栈 | |||
| alloc 火焰图 最大项 | |||
| lock 火焰图 有无内容 | |||
| 线程 dump 主要状态 | |||
| DB calls 是否异常 |
预期结果(做完后对照):
| /slow-cpu | /slow-io | /slow-db | |
|---|---|---|---|
| cpu 图 | ✅ 正则编译栈很宽 | ❌ 很空 | ❌ 很空 |
| wall 图 | 与 cpu 类似 | ✅ JDBC/socket 栈很宽 | 有 DB 等待栈 |
| alloc 图 | 有 Pattern/Matcher | 一般 | 有 ResultSet 相关 |
| 线程 dump | RUNNABLE | Default worker 全阻塞 | RUNNABLE 等 DB |
| DB calls | 正常 | 正常 | 异常高 ⭐ |
这张表就是本章的核心教学点:同一个「慢」,三种不同的可见性。 如果你只采 CPU 火焰图,你只能发现第一个瓶颈。
六、任务 6:写三份根因结论
按第 8 节的标准结构,为每个接口写一份结论。每份都必须包含被排除的假设。
示例(针对 /slow-io):
## 根因结论:/slow-io
### 现象
P50 25ms / P99 480ms(正常接口是 P50 8ms / P99 25ms);CPU 30%(异常低)
### 根因
`SlowIoHandler.kt:18` 在 `Dispatchers.Default` 上执行 JDBC 查询。
Default 的并行度 = CPU 核数(8),8 个 worker 被阻塞调用占满后,
所有使用 Default 的协程排队。
### 证据链
1. wall 火焰图:8 个 Default worker 全停在 `SocketInputStream.read`
2. 线程 dump(3 份):持续有 8 个 RUNNABLE 停在 JDBC 调用栈
3. 协程 dump(DebugProbes):240 个协程挂在同一 JDBC 调用点
4. 关键巧合:RUNNABLE 线程数(8) == CPU 核数(8)
### 贡献占比
约 90%:把查询切到 Dispatchers.IO 后,P99 从 480ms 降到 45ms。
### 被排除的假设
| 假设 | 排除依据 |
| --- | --- |
| GC | 停顿时间戳与尖刺不对齐,停顿幅度 < 20ms |
| 连接池 | db_pool_pending = 0(全程) |
| 慢查询 | pg_stat_statements 无异常(pg_sleep 是故意的) |
| CPU 饱和 | CPU 仅 30% |
### 复现
(命令)
### 未覆盖
- 多副本场景;连接池上限被真正打满的情况
七、验收标准
- 三个埋雷接口都实现并确认生效(各自的症状干净)。
- 三个接口各有一张现象量化表,且 CPU 利用率呈现不同形态。
- 至少一个接口做了完整的延迟分解。
- 采齐了四类火焰图 + 连续多份线程 dump + DB 指标。
- 填出了证据矩阵(哪个瓶颈在哪种工具上可见)。
- 三份根因结论,每份都含:现象、根因、证据链、贡献占比、被排除的假设、复现方式。
- 每份结论都写了未覆盖的场景。
- 实验档案含「预期 vs 实际」表与假设台账条目。
八、常见问题
Q:三个接口都会用 Default 调度器,会不会互相影响? A:会——这正是要避免的。所以任务 5 要求分别施压(一次只压一个接口),这样每个瓶颈的症状才是干净的。如果同时压三个,你会看到「又 CPU 高又阻塞又慢查询」,无法归因(违反第 5 章的单变量原则)。
Q:/slow-db 的瓶颈在 DB 指标上,但火焰图上看不到什么,这正常吗?
A:正常。N+1 的特征是单次查询很快(每次 1–2 ms),所以在火焰图上占比不大——但调用次数极多(100 次/请求)。这就是为什么必须看 calls 而不是只看火焰图。如果你只看火焰图,会漏掉这个瓶颈。
Q:为什么 /slow-io 的 CPU 火焰图是空的? A:因为线程在等待(阻塞在 JDBC/网络 IO),不消耗 CPU。CPU 火焰图采样的是「CPU 正在执行什么」,等待时线程不在 CPU 上,所以采不到。这是第 4.2、6.3 节的核心教学点——「CPU 不高但慢」的场景必须用 wall 火焰图。
Q:我采的 alloc 火焰图没什么内容,是不是没采到?
A:两种可能:① 这个接口本身分配不多(比如 /slow-io 主要是等待);② 采样时长太短或流量不够。检查方法:看 HTML 文件大小(太小说明没采到栈),或延长 -d 时长。
Q:DebugProbes 怎么启用? A:需要在代码里临时加(见 6.4 节):
DebugProbes.install() // 应用启动时
// 排障时调用 DebugProbes.dumpCoroutines()
注意它有 10%–30% 的开销,只在排障时用,且要确保基线组和实验组配置一致(第 5 章 5.1 节)。
Q:我找不到三个瓶颈都合适的数据量,怎么办?
A:数据量只要能体现瓶颈特征即可,不必很大。关键是三个接口的症状要"干净":/slow-cpu 的 CPU 要明显高、/slow-io 的 CPU 要明显低、/slow-db 的 calls 要明显异常。如果症状不干净,先调整埋雷的实现(比如增加循环次数、增加 sleep 时间)。