文档目录

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)。如果只是"把正则提到顶层"这类纯代码改动,可以只做功能与性能回归。