7.9 Lab 7:优化 Lab 6 找到的瓶颈
上一节:7.8 优化的三问纪律 | 下一节:第 8 章 持续化 配套代码:07-optimization-and-validation/09-lab7 预计时长:90 分钟
一、这个 Lab 的目标
对 Lab 6 找到的三个瓶颈,实施优化并完成三问验证。
关键的训练点不是「会改代码」,而是:
- 按优先级排序(先改哪个?)
- 一次只改一处(否则无法归因)
- 每处都做三问验证(提升多少、代价、回归)
- 至少否决一项(比如"加缓存")
二、任务清单
| # | 任务 | 产出 | 时长 |
|---|---|---|---|
| 1 | 列出候选优化并排序 | 优先级清单 | 15 min |
| 2 | 实施优化 1(减少工作类) | before/after 数据 | 25 min |
| 3 | 实施优化 2(架构/并发类) | before/after 数据 | 25 min |
| 4 | 评估并否决一项优化 | 否决记录 | 10 min |
| 5 | 写优化收益报告 | OPTIMIZATION.md |
15 min |
三、任务 1:候选优化与排序
针对 Lab 6 的三个瓶颈,列出候选优化:
| 瓶颈 | 候选优化 | 金字塔层级 | 预期收益 | 代价 |
|---|---|---|---|---|
/slow-cpu(重复编译正则) |
① 把 Regex 提到顶层(编译一次) | ② 算法/IO | 高 | 极低 |
| ② 缓存 findAll 的结果 | ④ 缓存 | 中 | 一致性(数据变化) | |
| ③ 减少正则匹配次数(改算法) | ② 算法 | 高 | 中(需理解业务) | |
/slow-io(Default 上阻塞) |
① 切到 Dispatchers.IO |
③ 架构/并发 | 高 | 低 |
| ② 改用异步驱动 | ③ 架构/并发 | 高 | 高(改驱动) | |
| ③ 加信号量限制并发 | ③ 架构/并发 | 中 | 低 | |
/slow-db(N+1) |
① 改成批量查询 | ② 算法/IO | 极高 | 低 |
| ② 加缓存 | ④ 缓存 | 中 | 一致性风险 | |
| ③ 加索引 | ② 算法/IO | 中 | 低 |
排序原则(第 7.1 节):先做「减少工作」和「架构」层,缓存和常数最后。
预期顺序:
① /slow-db → 批量查询(② 级,收益极高,代价极低)
② /slow-cpu → Regex 提到顶层(② 级,收益高,代价极低)
③ /slow-io → 切到 Dispatchers.IO(③ 级,收益高,代价低)
④ (评估)给 /slow-db 加缓存 → 可能被否决
四、任务 2–3:实施优化并验证三问
优化 1:消除 N+1
// before
get("/slow-db") {
val ids = (1L..100L).map { (it * 7919) % 100_000 + 1 }
val urls = ids.map { id -> // 100 次查询
ds.connection.use { c -> /* 单条查询 */ }
}
call.respondText(urls.filterNotNull().joinToString("\n"))
}
// after
get("/slow-db") {
val ids = (1L..100L).map { (it * 7919) % 100_000 + 1 }
val urls = ds.connection.use { c -> // 1 次查询
c.prepareStatement("select id, url from orders where id = any(?)").use { ps ->
ps.setArray(1, c.createArrayOf("bigint", ids.toTypedArray()))
ps.executeQuery().use { rs ->
buildMap { while (rs.next()) put(rs.getLong(1), rs.getString(2)) }
}
}
}
call.respondText(ids.mapNotNull { urls[it] }.joinToString("\n"))
}
验证三问:
### 优化 1:消除 N+1
#### 提升多少
| 指标 | before | after | 变化 |
| --- | --- | --- | --- |
| P50 | | | |
| P99 | | | |
| DB 查询次数/请求 | 100 | 1 | -99% |
(跑 3 轮,取中位数;与噪声底线对比)
#### 代价
- (几乎没有代价——这是"减少工作"类优化的特点)
#### 回归验证
- [ ] 功能:返回的 url 列表与 before 一致(逐条比对)
- [ ] 性能:其他接口无退化
- [ ] 长稳:30 分钟浸泡无异常
优化 2:消除重复正则编译
// before
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")
}
// after
private val PHONE_RE = Regex("""\d{3}-\d{4}""") // 只编译一次
get("/slow-cpu") {
val text = "x".repeat(20_000)
var hits = 0
repeat(200) {
hits += PHONE_RE.findAll(text).count()
}
call.respondText("hits=$hits")
}
优化 3:把阻塞调用切到 IO
// before
get("/slow-io") {
val n = ds.connection.use { /* JDBC — 阻塞 */ }
call.respondText("n=$n")
}
// after
get("/slow-io") {
val n = withContext(Dispatchers.IO) { // ✅ 切到 IO
ds.connection.use { /* JDBC */ }
}
call.respondText("n=$n")
}
// 更好:再加信号量限制并发
private val dbLimiter = Semaphore(64)
get("/slow-io") {
val n = dbLimiter.withPermit {
withContext(Dispatchers.IO) { ds.connection.use { /* JDBC */ } }
}
call.respondText("n=$n")
}
关键验证:优化 3 的效果要用「其他接口是否受影响」来衡量——因为它的症状是"全局卡顿"。
# 优化前后:在压 /slow-io 的同时,测 /normal 的延迟
# before: /normal 的 P99 会被拖到几百毫秒
# after: /normal 的 P99 应该保持稳定(几十毫秒)
这是一个非常好的验证设计——因为 /slow-io 本身可能变化不大,真正的改善体现在其他接口上。
五、任务 4:评估并否决一项优化
选择一项"看起来合理但实际不值得"的优化,给出否决理由。建议的候选:
| 候选 | 预期收益 | 可能的否决理由 |
|---|---|---|
给 /slow-db 加 Redis 缓存 |
P50 -70% | 数据是静态的(缓存意义小);如果是真实业务,key 分散导致命中率低;引入一致性风险 |
给 /slow-cpu 缓存匹配结果 |
中 | 输入是变化的(缓存命中率低);正则已优化,收益已足够 |
把 /slow-io 换成异步驱动(R2DBC) |
高? | 改动大、需要引入新依赖;当前用 withContext(IO) 已解决主要问题 |
| 调 JVM 参数(换 ZGC) | 个位数 % | 收益小于噪声底线;三个瓶颈的根因都不是 GC |
否决记录的写法:
### 被否决的方案:给 /slow-db 加 Redis 缓存
**预期收益**:P50 可能下降 50%–70%
**否决理由**:
1. **收益不确定**:批量查询已经消除了 N+1(P99 从 400ms 降到 45ms),
缓存的边际收益有限;
2. **命中率预估低**:如果 key 分散(每个订单只被查一次),缓存命中率会很低,
而 P99 不会改善(第 7.4 节的数学原理:要让 P99 改善,命中率需 > 99%);
3. **引入一致性与运维成本**:新增 Redis 依赖、失效逻辑、故障降级路径;
4. **掩盖问题**:这类优化会让人以为"查询已经很快了",
而实际上批量查询才是根本解法。
**重新评估的条件**:
- 如果这个接口变成读多写少且热点集中(命中率可 > 90%)
- 或者数据量增长导致即使批量查询也变慢
六、任务 5:优化收益报告
用第 7.8 节的模板写 OPTIMIZATION.md,必须包含:
- 3 个优化措施,每个都有三问验证
- 至少 1 个被否决的方案
- 未覆盖的场景
- 每个优化对应的金字塔层级
七、验收标准
- 列出了候选优化并按金字塔层级排序。
- 一次只改一处(每次改动都能单独归因)。
- 每项优化都有 before/after 数据(≥3 轮,报中位数)。
- 每项优化都与噪声底线/MDD 对比,并给出显著性判断。
- 每项优化都显式写了代价。
- 每项优化都做了三种回归(功能、性能、长稳)。
- 优化 3 的效果是通过"其他接口是否受影响"来衡量的(而不是只看
/slow-io自己)。 - 至少 1 个被否决的方案,含否决理由与「重新评估的条件」。
- 报告里写了未覆盖的场景。
八、常见问题
Q:优化 3(切到 IO)的 /slow-io 自身延迟没怎么变,是不是没效果?
A:不是没效果,是测错了指标。 /slow-io 的瓶颈是"占满 Default 的 worker 导致其他接口排队"——所以正确的验证是:在压 /slow-io 的同时,测 /normal 接口的延迟。优化前 /normal 会被拖到几百毫秒,优化后应该保持稳定。这是一个很重要的教训:优化的效果要测「受影响的指标」,而不是「被改动的接口」。
Q:三个优化都做完后,整体 P99 还是没到目标怎么办? A:① 先确认每一项的收益都真实(单项验证过);② 重新做延迟分解,看剩余的大头在哪;③ 如果剩余的是排队,回到第 6.5 节(池化类瓶颈);④ 如果剩余的是下游,那是外部因素;⑤ 如果已经接近噪声底线,就该停下来(第 7.8 节的边际效应)——继续投入的性价比很低。
Q:我怎么知道"噪声底线"是多少?
A:这是 Lab 5 的产出。如果你还没做 Lab 5,至少要跑 3–5 轮相同配置的实验,算出 CV 与波动范围(用 docs/code/05-experiment-design/07-noise-floor-and-regression.md 的脚本)。没有它,你就无法判断"提升 8%“是真的还是噪声。
Q:被否决的方案,将来怎么知道该不该翻案? A:记录「重新评估的条件」。例如"如果命中率能 > 90%“或"如果数据量增长 10 倍”——当这些条件出现时,重新评估。没有条件的否决,将来无法判断是否还成立。
Q:必须做长稳(浸泡)验证吗? A:如果你的优化引入了新组件(缓存、队列、连接池)或改了并发模型,就必须做——因为这些改动最容易引入缓慢泄漏(第 3 章 3.5)。如果只是"把正则提到顶层"这类纯代码改动,可以只做功能与性能回归。