文档目录

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 时间)。