9.7 配套代码:优化、验证与否决
对应小节:9.7 步骤六:优化与验证
一、优化清单与顺序(先算,再改)
#!/usr/bin/env python3
"""tools/step6a-plan.py —— 按金字塔排序优化项
第 7.1 节的优先级金字塔:
① 去掉不必要的工作(最大收益、最小风险)
② 减少工作量的算法/数据结构改进
③ 减少等待(并发/池/批量化)
④ 让剩下的更快(微优化、JIT 调参)
⑤ 加资源(最后一招)
本 Lab 的排序(算出「预期收益 / 风险」后得出):
"""
import json
import pathlib
PLAN = [
{
"order": 1,
"name": "给 code 加唯一索引",
"layer": "① 去掉不必要的工作",
"why": "Seq Scan 扫描 100 万行只为找 1 行 —— 99.9999% 是纯粹浪费",
"expected": "P99 420.5ms → 45ms(依赖级 380ms → ~2ms)",
"risk": "极低(一条 DDL;CONCURRENTLY 建索引不锁表)",
"cost": "5 分钟",
"single_variable": "只加索引,其他一律不动",
},
{
"order": 2,
"name": "阻塞调用切到 Dispatchers.IO + Semaphore 限流",
"layer": "③ 减少等待(隔离)",
"why": "JDBC 阻塞占满 Default 调度器(并行度=核数),"
"导致不查库的 /health 也慢",
"expected": "/health P99 380ms → 8ms(其他接口也跟着稳)",
"risk": "低(但需限流,否则 IO 池会被打爆)",
"cost": "30 分钟",
"single_variable": "只切调度器 + 限流,不碰索引/日志",
},
{
"order": 3,
"name": "日志异步化 + 降级 + 参数化",
"layer": "① 去掉不必要的工作",
"why": "热路径 INFO 日志(含完整 UA/IP)同步写盘,"
"wall 中占 6.1%",
"expected": "P99 改善约 5%(45ms → 42.7ms)",
"risk": "极低(异步 appender + 去掉 UA/IP)",
"cost": "20 分钟",
"single_variable": "只改日志",
},
{
"order": 4,
"name": "synchronized → Mutex(或去掉锁)",
"layer": "② 减少工作量 / ③ 减少等待",
"why": "线程级锁在协程里会跨越挂起点;写路径偶发 BLOCKED",
"expected": "写接口 P99 改善约 1.2%",
"risk": "中(改锁容易引入并发 bug,需要压测验证)",
"cost": "1 小时",
"single_variable": "只改锁",
},
{
"order": 5,
"name": "缓存 TTL 加抖动(±20%)",
"layer": "③ 减少等待(削峰)",
"why": "固定 TTL 导致周期性缓存雪崩(埋雷 ③)",
"expected": "消除浸泡时的 DB QPS 脉冲(P99 未必改善,"
"但尖刺消失)",
"risk": "极低",
"cost": "10 分钟",
"single_variable": "只改 TTL 策略",
},
]
REJECTED = [
{
"name": "异步批量 UPDATE hits",
"layer": "③ 减少等待(批量化)",
"measured_gain": "2.1%",
"mdd": "11.8%",
"reason": "2.1% < MDD 11.8% —— 统计上无法区分于噪声",
"extra_cost": "引入最终一致性:hits 会滞后;"
"需处理进程崩溃时的计数丢失;需额外的批量队列",
"decision": "❌ 否决",
"revisit_when": "当 hits 写入成为 top1 瓶颈时(当前排第 3)",
},
{
"name": "加 Redis 做二级缓存",
"layer": "① 去掉不必要的工作",
"measured_gain": "未测(估算 5%~10%)",
"mdd": "11.8%",
"reason": "本地 Caffeine 命中率已达 75%,"
"剩余 25% 中大部分是【长尾冷 key】(幂律分布)——"
"Redis 也缓存不住",
"extra_cost": "新增一个依赖(可用性风险);"
"网络往返约 0.5ms;运维成本;"
"缓存一致性复杂度",
"decision": "❌ 否决",
"revisit_when": "当需要多实例共享缓存(水平扩展)时",
},
]
def main(out: str) -> int:
d = pathlib.Path(out)
d.mkdir(parents=True, exist_ok=True)
print("═══ 优化计划(按金字塔排序)═══\n")
for p in PLAN:
print(f"{p['order']}. {p['name']}")
print(f" 层次:{p['layer']}")
print(f" 理由:{p['why']}")
print(f" 预期:{p['expected']}")
print(f" 风险:{p['risk']} 成本:{p['cost']}")
print(f" ⚠️ 单变量:{p['single_variable']}")
print()
print("═══ 被否决的方案 ═══\n")
for r in REJECTED:
print(f"{r['decision']} {r['name']}")
print(f" 层次:{r['layer']}")
print(f" 实测收益:{r['measured_gain']} MDD:{r['mdd']}")
print(f" 否决理由:{r['reason']}")
print(f" 额外代价:{r['extra_cost']}")
print(f" 重新评估条件:{r['revisit_when']}")
print()
# ── 关键纪律检查 ──
print("═══ 纪律检查 ═══")
print()
print("✅ 每一项都写了「单变量」约束")
print("✅ 被否决的方案都记录了理由与重新评估条件")
print("✅ 排序依据是「金字塔层次」,不是「我觉得哪个好改」")
print()
print("⚠️ 没有记录「为什么不做 X」的报告 = 看起来像没考虑过备选")
(d / "optimization-plan.json").write_text(json.dumps(
{"plan": PLAN, "rejected": REJECTED}, indent=2, ensure_ascii=False))
print(f"\n✅ 计划 → {d}/optimization-plan.json")
return 0
if __name__ == "__main__":
import sys
sys.exit(main(sys.argv[1] if len(sys.argv) > 1
else "perf/results/step6-optimize"))
二、优化 1:加唯一索引
-- perf/sql/02-add-code-index.sql
-- ═══ 优化 1:把 Seq Scan 变成 Index Scan ═══
-- 【关键】用 CONCURRENTLY 避免锁表
-- 普通 CREATE INDEX 会持有 SHARE 锁,阻塞所有写操作
-- CONCURRENTLY 的代价:更慢(要扫两遍表),但线上安全
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_links_code
ON links (code);
-- 建完后必须 ANALYZE,否则 Planner 可能仍选 Seq Scan
ANALYZE links;
-- ── 验证 ──
-- 预期:Index Scan / Index Only Scan,Execution Time < 1ms
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, code, url, user_id, created_at, hits
FROM links WHERE code = 'aB3xK9';
预期输出:
Index Scan using idx_links_code on links (cost=0.42..8.44 rows=1 width=48)
(actual time=0.028..0.030 rows=1 loops=1)
Index Cond: ((code)::text = 'aB3xK9'::text)
Buffers: shared hit=4
Planning Time: 0.112 ms
Execution Time: 0.031 ms
对比:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 计划类型 | Seq Scan | Index Scan | — |
| 扫描行数 | 1,000,000 | 1 | -99.9999% |
Rows Removed by Filter |
999,999 | 0 | 消除 |
| 缓冲块读取 | hit=772 read=9902 | hit=4 | -99.96% |
| Execution Time | 385.456 ms | 0.031 ms | -99.99% |
三、优化 2:调度器隔离
// repository/LinkRepository.kt(改动后)
package shortlink.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
class LinkRepository(private val ds: DataSource) {
/**
* ═══ 优化 2:把阻塞的 JDBC 调用搬到 Dispatchers.IO ═══
*
* ⚠️ 关键:光切调度器是不够的!
* Dispatchers.IO 有 64 个线程(默认),如果不限流,
* 高并发时会瞬间起 64 个线程全部阻塞在 DB 上,
* 把数据库打爆(第 7.6 节)。
*
* 所以必须加 Semaphore 限流,让"想查库的协程"排队而不是
* "无限地去占线程"。
*/
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, code, url, user_id, created_at, hits " +
"FROM links WHERE code = ?"
).use { ps ->
ps.setString(1, code)
ps.executeQuery().use { rs ->
if (rs.next()) rs.toLink() else null
}
}
}
}
}
/**
* 为什么 Semaphore(32) 而不是 64?
* 32 是【与数据库协商的结果】——DB max_connections=200,
* 单实例占 32,留足余量给其他实例与运维工具。
*
* 为什么不用 HikariCP 的 maximumPoolSize 来限流?
* Hikari 的等待是【阻塞线程】的(threadsAwaitingConnection),
* 而 Semaphore 让协程【挂起】(不占线程)——
* 后者在高并发下更省资源。
*/
}
预期效果(关键:要测 /health):
| 指标 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
/health P99 |
380 ms | 8 ms | ← 这才是本优化的主效果 |
/health P50 |
12 ms | 0.8 ms | Default 不再被阻塞 |
| RUNNABLE 线程数 | ≈ 核数(8) | 1~2 | Default 空闲了 |
| 跳转 P99 | 45 ms | 43 ms | 小幅改善(已由优化 1 主导) |
为什么跳转 P99 只小幅改善?
因为优化 1 已经把 DB 查询从 380ms 压到 0.03ms ——
此时跳转的耗时大头已经不是"等 DB",而是别的(序列化、日志等)。
所以优化 2 对跳转的边际收益小。
但优化 2 消除了一个【系统性风险】:
不查库的接口会被查库的接口拖垮。
这个风险在"DB 突然变慢时"会放大:
DB 一慢 → Default 被占满 → 整个服务(含健康检查)不可用
→ K8s 健康检查失败 → 实例被重启 → 雪崩
修复它不需要它带来 P99 提升 —— 它带来的是【可用性】。
这批注必须写进报告,否则会被质疑"改了没用"。
四、优化 3:日志异步化
<!-- src/main/resources/logback.xml -->
<configuration>
<!-- ═══ 优化 3:异步 appender ═══ -->
<!-- 关键:neverBlock=true —— 队列满时【丢弃】日志而不是阻塞业务 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE" />
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<includeCallerData>false</includeCallerData>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<encoder>
<!-- 简化格式:去掉了线程名、logger 名(热路径不需要) -->
<pattern>%d{HH:mm:ss.SSS} %-5level %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>7</maxHistory>
</rollingPolicy>
<immediateFlush>false</immediateFlush> <!-- 关键:不要每条 flush -->
</appender>
<!-- 关键:把热路径的 logger 降到 WARN -->
<logger name="shortlink.http" level="WARN" />
<logger name="io.ktor" level="WARN" />
<root level="INFO">
<appender-ref ref="ASYNC_FILE" />
</root>
</configuration>
// http/Routes.kt(改动后)—— 热路径日志的正确写法
get("/{code}") {
val code = call.parameters["code"]!!
// ═══ 优化 3:热路径日志的正确形态 ═══
//
// 原则:
// ① 热路径默认不打日志(用指标代替 —— 指标是内存计数,几乎免费)
// ② 必须打时用【参数化】(不拼接字符串,级别关掉时零成本)
// ③ 不打大字段(完整 URL / UA / IP 对排障价值低但开销高)
// ④ 需要时用 debug,采样打
//
// 注意 ② 的含义:
// log.info("code={}", code) ← 级别为 WARN 时,不构造字符串,零成本
// log.info("code=" + code) ← 无论如何都拼接字符串,有成本
log.debug("redirect code={}", code)
val link = service.resolve(code)
...
}
为什么 log.info("a={}", x) 比 log.info("a=" + x) 便宜?
后者在【调用 log.info 之前】就已经拼好了字符串 ——
即使日志级别是 WARN(不会输出),字符串也已经分配了。
前者把 code 作为参数传给 logger,logger 先判断级别:
WARN 级别 → 直接 return,【不构造字符串】
DEBUG 级别 → 才做格式化
在高频调用下,这个差别就是"每秒分配几十万个临时字符串"
vs "什么都不分配"。
五、优化 4:TTL 抖动(消除缓存雪崩)
// repository/LinkCache.kt(改动后)
package shortlink.repository
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Expiry
import shortlink.model.Link
import java.time.Duration
import java.util.concurrent.ThreadLocalRandom
class LinkCache {
/**
* ═══ 优化 4:TTL 加抖动 ═══
*
* 问题:所有 key 固定 10 分钟 TTL
* → 同一批写入的 key 在【同一时刻】集体失效
* → DB QPS 出现周期性脉冲
*
* 修复:每个 key 的 TTL 在 [8min, 12min] 之间随机
* → 失效时刻被打散到 4 分钟窗口内
* → 脉冲变成平缓的波浪
*
* 注意:抖动只在【写入时】算一次,同一个 key 的 TTL 是固定的。
* 不要在每次读取时重新随机 —— 那会导致 TTL 无限延长。
*/
private val baseTtl = Duration.ofMinutes(10)
private val jitterRatio = 0.2
private val cache: Cache<String, Link> = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfter(Expiry { _, link, _ ->
val base = baseTtl.toNanos()
val jitter = (base * jitterRatio).toLong()
val ttl = base + ThreadLocalRandom.current().nextLong(-jitter, jitter)
// ── 返回值单位是纳秒 ──
ttl
})
.recordStats()
.build()
fun get(code: String): Link? = cache.getIfPresent(code)
fun put(code: String, link: Link) {
cache.put(code, link)
}
fun invalidate(code: String) {
cache.invalidate(code)
}
fun hitRate(): Double = cache.stats().hitRate()
fun stats() = cache.stats()
}
验证需求:这个优化的效果在短时压测里完全看不出来(10 分钟 TTL 需要至少 20 分钟才能看到两轮脉冲)——必须在步骤七的浸泡测试里验证。
六、三问验证脚本
#!/usr/bin/env bash
# tools/step6b-verify.sh —— 对每个优化执行「A/B/A'」验证
#
# ═══ 三问纪律(第 7.8 节)═══
# ① 提升多少?(必须有对照组,且 >= MDD)
# ② 代价是什么?(复杂度、资源、新的风险)
# ③ 回归验证了吗?(A' 回滚验证 + 回归测试)
set -euo pipefail
OPT="${1:?用法: $0 <优化编号 1-5>}"
OUT="perf/results/step6-optimize/opt-$OPT"
mkdir -p "$OUT"
echo "═══ 优化 $OPT 的 A/B 验证 ═══"
echo
run_phase() {
local phase="$1" # A / B / Aprime
echo "─── 阶段 $phase ───"
echo "静置 30s..."
sleep 30
curl -sf http://localhost:8080/metrics > "$OUT/metrics-$phase.txt" || true
(cd perf/k6 && k6 run \
--summary-export="$OLDPWD/$OUT/summary-$phase.json" \
--quiet baseline-single.js 2>&1 | tail -20) \
|| echo " (k6 退出码非 0,检查是否有阈值)"
python3 - "$OUT/summary-$phase.json" "$phase" <<'PY'
import json, sys
p, phase = sys.argv[1], sys.argv[2]
s = json.loads(open(p).read())["metrics"]
m = s["http_req_duration"]
print(f" {phase}: P50={m['p(50)']:.1f} P95={m['p(95)']:.1f} "
f"P99={m['p(99)']:.1f} RPS={s['http_reqs']['rate']:.1f}")
PY
echo
}
echo "① 阶段 A:优化前(基线)"
run_phase A
echo " → 请现在【应用】优化 $OPT,然后按回车"
read -r
echo "② 阶段 B:优化后"
run_phase B
echo "③ 阶段 A':回滚验证"
echo " → 请现在【回滚】优化 $OPT,然后按回车"
read -r
run_phase Aprime
# ── 汇总判定 ──
python3 - "$OUT" <<'PY'
import json, pathlib, sys, statistics
d = pathlib.Path(sys.argv[1])
def p99(phase):
s = json.loads((d / f"summary-{phase}.json").read_text())["metrics"]
return s["http_req_duration"]["p(99)"]
a, b, ap = p99("A"), p99("B"), p99("Aprime")
# 读噪声底线
nf = pathlib.Path("perf/results/step3-baseline/noise-floor.json")
if nf.exists():
mdd = json.loads(nf.read_text())["mdd_pct"]
else:
mdd = 11.8
print("⚠️ 未找到噪声底线,用默认 MDD=11.8%")
delta = (b - a) / a * 100
print()
print("═══ 三问判定 ═══")
print()
print(f"① 提升多少?")
print(f" A (优化前) {a:.1f} ms")
print(f" B (优化后) {b:.1f} ms")
print(f" 变化 {delta:+.1f}%")
print(f" MDD ±{mdd:.1f}%")
if abs(delta) >= mdd:
verdict = "显著改善 ✅" if delta < 0 else "显著变差 ❌"
print(f" 判定 {verdict}")
else:
print(f" 判定 在噪声内 —— 【无法确认效果】⚠️")
print(f" 含义 不是「没效果」,是「这次测不出来」")
print(f" 要确认需增加轮次或改用更敏感的指标")
print()
print(f"② 代价是什么?")
print(f" → 需人工填写:复杂度 / 资源 / 新风险")
print(f" (脚本无法判断,这是评审时的必答项)")
print()
print(f"③ 回归验证?")
print(f" A' (回滚后) {ap:.1f} ms")
drift = abs(ap - a) / a * 100
print(f" 与 A 的偏差 {drift:.1f}%")
if drift <= mdd:
print(f" 判定 回到基线 ✅(证明是优化本身的效果,不是环境漂移)")
else:
print(f" 判定 未回到基线 ❌")
print(f" 含义 环境在漂移,或优化有【残留副作用】")
print(f" 这次对比结论【不可信】,需重做")
PY
优化 1 的预期输出:
═══ 三问判定 ═══
① 提升多少?
A (优化前) 420.5 ms
B (优化后) 45.2 ms
变化 -89.3%
MDD ±11.8%
判定 显著改善 ✅
② 代价是什么?
→ 需人工填写:复杂度 / 资源 / 新风险
③ 回归验证?
A' (回滚后) 418.9 ms
与 A 的偏差 0.4%
判定 回到基线 ✅(证明是优化本身的效果,不是环境漂移)
否决 1 的预期输出:
═══ 三问判定 ═══
① 提升多少?
A (优化前) 45.2 ms
B (优化后) 44.3 ms
变化 -2.1%
MDD ±11.8%
判定 在噪声内 —— 【无法确认效果】⚠️
含义 不是「没效果」,是「这次测不出来」
要确认需增加轮次或改用更敏感的指标
② 代价是什么?
→ 引入最终一致性;hits 会滞后
→ 需处理进程崩溃时的计数丢失
→ 需要额外的批量队列与定时刷写
③ 回归验证?
A' (回滚后) 45.5 ms
与 A 的偏差 0.7%
判定 回到基线 ✅
结论:
收益 2.1%(测不出来)+ 明显代价(一致性 + 复杂度)
→ **否决**
这个否决算【结论】,不是【失败】——
它证明了你评估过并且有数据支撑。
七、动手改造
| 改动 | 观察什么 |
|---|---|
| 不做阶段 A’,只测 A/B | 无法排除"环境漂移"——A’ 是可信度的来源 |
| 跳过静置直接跑 B | 上一轮的热度污染结果——静置是必要的 |
| 一次应用所有 5 个优化 | 无法归因(是哪个起的作用?)——违反单变量纪律 |
把优化 1 用普通 CREATE INDEX |
会锁表,压测中的写请求全部阻塞——CONCURRENTLY 是线上必须 |
把 Semaphore(32) 改成 Semaphore(1000) |
DB 被打爆(连接数超限)——限流不能省 |
给异步 appender 设 neverBlock=false |
队列满时业务线程会阻塞——日志反噬业务 |
八、这段代码的局限
step6b-verify.sh需要人工介入(应用/回滚优化):无法全自动,因为改代码本身是人工的。- 只做 1 轮 A/B/A’:严格说应该每个阶段跑 5~10 轮取中位数——本 Lab 简化,代价是结论的置信度较低。
Expiry的抖动只在写入时计算:Caffeine 的Expiry接口会多次调用(读/写/更新),实现不当可能产生意外行为,这里简化为始终返回同一个抖动值是不准确的——生产应缓存抖动值。- 优化 2 的
Semaphore(32)是拍的:正确做法是按 Little’s Law 算(第 7.6 节):并发 = 到达率 × 处理时长。修复后 DB 查询约 0.03ms,600 RPS 下只需 0.02 个连接——32 是为了应对突发的保守值。 - 没有做"回归测试"的自动化:三问里的第三问只有 A’,没有单测/集成测试——生产应该同时跑功能回归。
- 否决的结论依赖 MDD:如果 MDD 更小(比如 ±2%),结论可能反转——所以 MDD 必须准确,这是第 5.7 节强调它的原因。