9.7 步骤六:优化与验证
上一节:9.6 步骤五:剖析定位 | 下一节:9.8 步骤七:长稳与回归 用到:第 7 章(优先级金字塔、三问纪律、否决记录) 配套代码:09-capstone/07-step6-optimize 产出:
OPTIMIZATION.md(优化收益报告)
一句话结论
按优先级金字塔从下往上改,一次只改一处,每处都回答三个问题。 而且——你至少要主动否决一项优化,否则说明你没有真的评估过备选方案。
一、优化的优先级排序
用第 7.1 节的金字塔对六个瓶颈排序:
| 瓶颈 | 金字塔层级 | 预期收益 | 成本 | 排序 |
|---|---|---|---|---|
| ① 缺索引 | ② 算法与数据访问 | 90% | 极低(一条 SQL) | 1 |
| ④ Default 上 JDBC | ③ 架构与并发 | 6%(但对全局卡顿 100%) | 低(几行代码) | 2 |
| ② UPDATE hits | ② 算法与数据访问 | 2.4% | 中(要改数据模型) | 3 |
| ⑤ 日志同步 IO | ⑤ 微观常数 | 1.2% | 极低(降级别) | 4 |
⑥ synchronized |
③ 架构与并发 | 写路径为主 | 低 | 5 |
| ③ 缓存 TTL 无抖动 | ④ 缓存 | 周期性尖刺 | 低 | 6 |
注意排序结果:前两名来自 ② 和 ③ 层(算法/架构),后四名收益都在个位数——这正是金字塔的体现。
二、优化 1:加索引(贡献 90%)
改动
-- 优化:给 code 加唯一索引
CREATE UNIQUE INDEX CONCURRENTLY idx_links_code ON links(code);
ANALYZE links;
为什么用 CONCURRENTLY:不阻塞写入(生产环境的常规做法)。
为什么用 UNIQUE:短码本来就该唯一——顺便把「唯一性」这个业务约束交给数据库保证。
三问验证
# 优化前的数据在 E09-baseline(P99 = 420ms)
# 优化后重新测量
tools/verify-optimization.sh E12-optimization opt1-index 3
### 第一问:提升多少
| 指标 | before | after | 变化 | 显著性 |
| --- | --- | --- | --- | --- |
| P50 | 12.3 ms | 3.1 ms | -74.8% | p=0.008 ✅ |
| P95 | 68.4 ms | 12.2 ms | -82.2% | p=0.008 ✅ |
| **P99** | **420.5 ms** | **45.2 ms** | **-89.2%** | **p=0.008 ✅** |
| QPS | 498.8 | 499.1 | +0.1% | 不显著 |
| 错误率 | 0.00% | 0.00% | — | — |
- 环境噪声底线:P99 ±5.9%
- 最小可检测差异(MDD):P99 ±11.8%
- **结论**:改善 89.2%,**超过 MDD 的 7.6 倍**,优化有效且可信
### 第二问:代价是什么
1. **磁盘**:索引占用约 45 MB(100 万行 × 约 45 字节)
2. **写入开销**:INSERT 时需要维护索引(实测写路径 P99 +3%,在噪声范围内)
3. **回滚方式**:`DROP INDEX idx_links_code;`
### 第三问:回归验证
- [x] **功能正确性**:单元测试全通过;手工验证了 5 个短码的跳转
- [x] **性能回归**:写路径 P99 变化 +3%(在噪声范围内);其他接口无退化
- [x] **长稳**:30 分钟浸泡,内存与连接数稳定
注意「QPS +0.1% 不显著」这一行:这是正常且正确的——因为压测用的是恒定到达率模型(QPS 由我们设定,不是系统能力),所以 QPS 不会变化。真正的改善体现在延迟上。
三、优化 2:把 JDBC 切到 Dispatchers.IO
改动
// ❌ before:在 Default 上执行 JDBC
suspend fun findByCode(code: String): Link? =
ds.connection.use { c ->
c.prepareStatement("SELECT id, url FROM links WHERE code = ?").use { ps ->
ps.setString(1, code)
ps.executeQuery().use { rs -> if (rs.next()) rs.toLink() else null }
}
}
// ✅ after:切到 IO + 限制并发
private val dbLimiter = Semaphore(permits = 32)
suspend fun findByCode(code: String): Link? = dbLimiter.withPermit {
withContext(Dispatchers.IO) {
ds.connection.use { c ->
c.prepareStatement("SELECT id, url FROM links WHERE code = ?").use { ps ->
ps.setString(1, code)
ps.executeQuery().use { rs -> if (rs.next()) rs.toLink() else null }
}
}
}
}
为什么加 Semaphore:它是"对下游的保护"——即使 IO 池有 64 个线程,我们也不想把 64 个并发查询全打到数据库(第 7.3 节)。
三问验证(关键:测"受影响的接口")
# ⚠️ 关键:这个优化的效果要测【其他接口】,不是它自己
AFFECTED_ENDPOINT=health \
tools/verify-optimization.sh E12-optimization opt2-dispatcher 3
### 第一问:提升多少
**读接口自己的延迟**(变化不大):
| 指标 | before | after |
| --- | --- | --- |
| P99 | 45.2 ms | 42.1 ms |
**受影响的接口**(这才是主要改善 ⭐):
| 接口 | before | after | 变化 |
| --- | --- | --- | --- |
| `GET /health`(不查数据库) | P99 = 380 ms | P99 = 8 ms | **-97.9%** |
- **结论**:这个优化的价值**不在被改动的接口上**,而在"其他接口不再被拖累"
### 第二问:代价是什么
1. **多了一个参数**(`Semaphore` 的 permits)需要调优
2. **极端情况下限流**:并发超过 32 时会等待(但比占满 Default 好)
### 第三问:回归验证
- [x] 功能:查询结果一致
- [x] 性能:读接口无退化;**其他接口大幅改善**
- [x] 长稳:30 分钟浸泡,协程数稳定
这个优化的教学价值:它演示了「测错了指标会以为优化无效」——如果只测读接口自己,你会看到 45.2 → 42.1 ms(改善 7%,接近噪声),可能误判为"没什么用"。
四、优化 3–6:简要记录
| # | 优化 | 收益 | 代价 | 是否采纳 |
|---|---|---|---|---|
| 3 | 把 UPDATE hits 改成批量/异步累加 |
P99 -2.1% | 需要引入一个计数队列(复杂度) | ❌ 否决(见下) |
| 4 | 日志降级 + 异步 appender | P99 -1.2% | 无(配置改动) | ✅ 采纳 |
| 5 | synchronized → Mutex |
写路径 BLOCKED 消失 | 无 | ✅ 采纳 |
| 6 | 缓存 TTL 加抖动 | 周期性尖刺消失 | 无 | ✅ 采纳 |
五、被否决的优化(必须至少一项)
## 被否决的方案
### 否决 1:把 `UPDATE hits` 改成异步批量累加
**预期收益**:P99 -2.1%(消除行锁竞争)
**否决理由**:
1. **收益太小**:2.1% 略高于噪声底线(5.9%)的 1/3,**低于 MDD(11.8%)**——
即使用它做优化,也无法用当前环境的实验证实
2. **代价大**:需要引入一个计数队列 + 批量刷库逻辑 + 进程退出时的 flush 保证
(否则会丢计数),**复杂度远超收益**
3. **掩盖问题**:`hits` 计数不是核心业务(它只用于统计展示),
为了它引入队列不值得
4. **替代方案**:如果确实需要计数,可以用 Redis 的 `INCR`(更简单),
或者把 `hits` 改成定期从日志聚合(完全不占在线路径)
**重新评估的条件**:
- 如果 `hits` 变成核心业务(比如"按热度排序"的实时推荐)
- 或者写 QPS 增长 10 倍导致锁竞争成为主瓶颈
**记录位置**:`rejections.json` → R1
否决 2:加 Redis 缓存(预期收益看似很大但实际不需要)
预期收益:如果缓存命中率能到 95%,P99 可能再降 20%
否决理由:
- 查询已经优化:加索引后单次查询 3ms,缓存能省的有限(Redis 也要 1–2ms)
- 命中率不确定:幂律分布下命中率能到 75%,但未命中的 25% 仍需查库
- 引入一致性风险:短链创建后需要立即失效缓存
- 收益 < 成本:省下的 2ms 换来的是一致性复杂度
重新评估的条件:
- 如果数据库 QPS 成为瓶颈(当前 500 QPS,数据库能扛 5000+)
- 或者 P99 需要进一步降到 20ms 以下
六、优化后的整体效果
## 优化前后对比(500 RPS,3 轮中位数)
| 指标 | 优化前 | 优化后 | 变化 |
| --- | --- | --- | --- |
| P50 | 12.3 ms | 2.1 ms | -82.9% |
| P95 | 68.4 ms | 8.3 ms | -87.9% |
| **P99** | **420.5 ms** | **19.2 ms** | **-95.4%** |
| 错误率 | 0.00% | 0.00% | — |
| 数据库查询 P99 | 380 ms | 3.1 ms | -99.2% |
| 连接池 pending | 0(在 700 RPS 时上升) | 0(在 1500 RPS 时仍为 0) | — |
**SLO 达标情况**:
- 优化前:P99 = 420.5 ms ❌(SLO 100ms)
- 优化后:P99 = 19.2 ms ✅
**容量变化**(需要步骤四复测确认):
- 优化前拐点:约 400–700 RPS
- 优化后拐点:约 1500+ RPS(待复测)
七、这一步的验收
## 步骤六验收清单
### 优先级
- [ ] 按**金字塔层级**对瓶颈排序(不是按发现的顺序)
- [ ] 说明了为什么这个顺序
### 每项优化
- [ ] **一次只改一处**(能单独归因)
- [ ] 第一问:before/after(≥3 轮)+ 显著性 + 与噪声底线/MDD 对比
- [ ] 第二问:显式列出**代价**(磁盘/复杂度/风险)+ **回滚方式**
- [ ] 第三问:三种回归(功能、性能、长稳)
### 关键细节
- [ ] 对"调度器污染"这类优化,**测的是受影响的接口**(不是被改动的接口)
- [ ] 解释了"为什么 QPS 没变化"(因为用的是恒定到达率模型)
### 否决记录 ⭐
- [ ] **至少一项**被主动否决的优化
- [ ] 含否决理由与**重新评估的条件**
- [ ] 记录进 `rejections.json`
### 整体
- [ ] 优化前后的总对比(含 SLO 达标情况)
- [ ] 明确说明「容量需要步骤四复测确认」
八、本节小结
- 按金字塔层级排序:前两名来自 ②(算法/数据)和 ③(架构/并发),后四名收益都在个位数。
- 一次只改一处,每处都回答三问(提升多少、代价、回归验证)。
- 优化 2 的教学价值最大:它的效果体现在其他接口上——如果只测被改动的接口,会误判为"没什么用"。
- QPS 没变化是正常的——恒定到达率模型下 QPS 由我们设定,改善体现在延迟上。
- 必须主动否决至少一项:否决 1(异步批量更新)收益 2.1% < MDD,代价却很大;否决 2(加 Redis 缓存)在查询已优化后不再必要。
- 否决要写"重新评估的条件"——因为否决不是永恒的。
- 优化后要说明"容量需要复测确认"——延迟达标不等于容量恢复。
九、自测
- 优化 2(调度器隔离)的效果为什么要测「其他接口」而不是「被改动的接口」?如果只测被改动的接口会得出什么错误结论?
- 优化 1 的结果里显示「QPS +0.1%,不显著」。这是不是说明优化没有提升吞吐?请解释。
- 为什么必须「主动否决至少一项优化」?如果所有候选优化都采纳了,会有什么问题?
- 因为调度器污染的危害不是"让被改动的接口变慢",而是"让所有使用同一调度器的接口一起变慢"。具体来说:① 被改动的接口(
GET /{code})本来就在等数据库,它自己的延迟主要由查询耗时决定(加索引后已经是 3ms),把 JDBC 从 Default 移到 IO 不会显著改变它自己的延迟(45.2 → 42.1 ms);② 真正受影响的是不查数据库但共用 Default 的接口(比如/health)——它们原来要排队等 Default 的 worker 空闲,优化后能立即执行(P99 380ms → 8ms,-97.9%)。如果只测被改动的接口:你会看到 7% 的改善(45.2 → 42.1ms),接近噪声底线(5.9%),很可能得出"这个优化没什么用,可以回滚"的错误结论——而实际上它解决了最严重的全局卡顿问题。教训:优化的效果要测"受影响的指标",而不是"被改动的接口"(第 7.9 节)。 - 不能说明吞吐没有提升。原因:本次压测用的是恒定到达率模型(
constant-arrival-rate,RATE=500)——QPS 是我们设定的,不是系统能力的体现。在到达率模型下:① 压测工具会按固定速率发请求,不管服务端处理得快还是慢;② 所以 QPS 恒等于设定值(500),不会因为系统变快而上升;③ 改善体现在延迟上(P99 420ms → 19.2ms)——因为同样的 500 RPS,现在处理得更快了。要看吞吐提升,应该:① 用容量曲线(阶梯加压)测拐点变化——优化前后的拐点从 400–700 RPS 提升到 1500+ RPS,这才是吞吐的提升;② 或者在同样的延迟约束下看能扛多少 QPS(比如"P99 < 100ms 的最大 QPS"从 150 提升到 1500+)。这就是为什么步骤四(容量曲线)和步骤三(基线)用的是不同的负载模型。 - 必须有否决,因为「所有优化都采纳」通常意味着「没有认真评估代价」。理由:① 任何优化都有代价——复杂度、一致性风险、维护成本、可读性下降;② 如果不否决任何东西,说明你只看了收益没看代价——那会导致技术债累积(引入了一堆"收益 1% 但需要长期维护"的机制);③ 否决记录本身就是知识资产——它告诉后来者"这条路试过了,当时因为 X 不值得"(避免重复试探),并且记录了"重新评估的条件"(当条件变化时可以翻案);④ 从评分角度(第 9 章 README 的评分标准):“优化验证"维度里,「有否决记录」是"优秀"的必要条件——它证明你不仅会做优化,还知道什么不该做。实践中的判断标准:如果某项优化的收益低于 MDD(无法被实验证实)且代价明显,就应该否决——比如本节的否决 1(收益 2.1% < MDD 11.8%,代价却要引入计数队列)。