9.1 项目设定与埋雷清单
上一节:无 | 下一节:9.2 步骤一:定义目标 配套代码:09-capstone/01-project-setup
一句话结论
为了让这个实战有东西可查,你必须故意保留几个已知问题。 一个"什么都对"的服务,练不出定位能力——六颗埋雷是本章最重要的准备。
一、服务设计:短链服务
两个接口
POST /links 创建短链
body: { "url": "https://example.com/very/long/url" }
→ { "code": "aB3xK9" }
GET /{code} 跳转
→ 302 Location: https://example.com/very/long/url
为什么这两个接口够用:
| 特征 | 对应知识点 |
|---|---|
| 读多写少(读:写 ≈ 50:1) | 缓存策略(第 7.4 节) |
| 热门短链被反复访问 | 幂律分布(第 5.5 节) |
| 读路径有缓存命中/未命中 | 缓存对 P99 的影响(第 7.4 节) |
| 写路径有唯一索引冲突 | 数据库错误处理(第 6.6 节) |
| 代码短(6 位 base62) | 可以自然地做"加索引/不加索引"的对比 |
数据表
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL, -- 短码
url TEXT NOT NULL, -- 原始 URL
user_id BIGINT NOT NULL, -- 创建者(用于热点分析)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
hits BIGINT NOT NULL DEFAULT 0 -- 访问计数(用于写路径演示)
);
-- ⚠️ 注意:故意先不建 code 的唯一索引(见埋雷清单)
二、六颗埋雷(本章的核心准备)
这六个问题对应第 6 章的六个瓶颈模式。请在实现时刻意保留它们,不要提前修。
| # | 埋雷 | 对应瓶颈模式 | 症状 | 定位工具 |
|---|---|---|---|---|
| ① | code 列没有唯一索引 |
缺索引 / 全表扫描(6.6-②) | 跳转接口随数据量线性变慢 | EXPLAIN 看到 Seq Scan |
| ② | 跳转接口每次都 UPDATE hits = hits + 1 |
写放大 + 行锁竞争(6.6-③) | 高并发下吞吐上不去 | lock 火焰图 + sys CPU |
| ③ | 缓存 TTL 没有抖动,且用同一个值 | 缓存雪崩(6.6-⑤) | 周期性 DB QPS 脉冲 | 对齐缓存过期时间 |
| ④ | Dispatchers.Default 上执行 JDBC |
调度器污染(6.5-③) | CPU 低但所有接口慢 | wall 火焰图 + RUNNABLE == 核数 |
| ⑤ | 每个请求都打 INFO 日志(含完整 URL) | 日志同步 IO(6.6-⑦) | sys CPU 偏高 |
JFR 的 jdk.FileWrite |
| ⑥ | 创建短链时用 synchronized 生成 code |
锁竞争 + 挂起持锁(6.7-❷) | 写路径偶发卡顿 | 线程 dump 的 BLOCKED |
每颗埋雷的代码示意
// ═══ 埋雷 ①:code 没有唯一索引 ═══
// 建表时不加:CREATE UNIQUE INDEX ON links(code);
// 只在 id 上有主键索引 → 按 code 查询走全表扫描
// ═══ 埋雷 ②:每次跳转都 UPDATE ═══
get("/{code}") {
val link = repo.findByCode(code) // 埋雷 ① 让这里很慢
repo.incrementHits(link.id) // 埋雷 ② 写放大 + 行锁
call.respondRedirect(link.url)
}
// ═══ 埋雷 ③:缓存 TTL 无抖动 ═══
val cache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofMinutes(10)) // ❌ 所有 key 同时失效
.build<String, Link>()
// ═══ 埋雷 ④:Default 上执行 JDBC ═══
suspend fun findByCode(code: String): Link? =
ds.connection.use { c -> // ❌ 阻塞调用,没切到 IO
c.prepareStatement("SELECT ... WHERE code = ?").use { /* ... */ }
}
// ═══ 埋雷 ⑤:热路径 INFO 日志 ═══
get("/{code}") {
log.info("redirect request: code={}, url={}, ua={}", code, link.url, userAgent) // ❌
}
// ═══ 埋雷 ⑥:synchronized 生成 code ═══
private val lock = Any()
private fun generateCode(): String = synchronized(lock) { // ❌ 锁 + 可能挂起
val random = SecureRandom()
base62(random.nextInt())
}
三、为什么必须「故意留雷」
如果服务是"完美的",你会:
❌ 压测 → 各项指标正常 → 「看来没问题」→ 学不到任何东西
有了埋雷,你会:
✅ 压测 → P99 超标 → 延迟分解 → 发现是数据库
→ 剖析 → EXPLAIN 显示全表扫描
→ 加索引 → 复测 → P99 降 60%
→ 写进报告:证据链 + 贡献占比 + 被排除的假设
关键:埋雷让每一步都有"非做不可"的理由——而不是"走个流程"。
四、环境要求
| 项 | 最低要求 | 推荐 |
|---|---|---|
| 机器 | 4 核 8G(本地开发机可行) | 独立测试环境 |
| Docker | 用于 Testcontainers 与观测栈 | — |
| 压测客户端 | 与服务不同进程 | 不同机器 |
| 数据量 | 10 万行(能看出问题) | 100 万行(更接近真实) |
| 观测 | /metrics 端点 |
+ Prometheus + Grafana |
⚠️ 关于「压测客户端与服务同机」:
如果只有一台机器:
→ 用 Docker 的资源限制把两边隔开(--cpus)
→ 并在报告里【明确声明】这个局限
→ 数据的绝对值不可信,但【相对比较】仍有效
不要假装同机压测的数据是可信的——这是第 5 章强调的诚实原则。
五、目录结构
docs/
├── slo.md # 步骤一产出
├── capacity.md # 步骤八产出
├── PERF-REPORT.md # 最终报告
├── bottlenecks.md # 步骤五产出
├── OPTIMIZATION.md # 步骤六产出
└── experiments/
├── E09-baseline/ # 步骤三
├── E10-capacity/ # 步骤四
├── E11-diagnosis/ # 步骤五
├── E12-optimization/ # 步骤六
└── E13-soak/ # 步骤七
六、本节小结
- 短链服务是理想的教学载体:读写悬殊、有天然热点、有缓存两条路径、代码量可控。
- 六颗埋雷对应第 6 章的六个瓶颈模式——必须故意保留它们,否则学不到定位能力。
- 埋雷让每一步都有"非做不可"的理由,而不是走流程。
- 环境要求:Docker、独立压测客户端、100 万行数据、
/metrics端点。 - 同机压测时必须声明局限——数据的绝对值不可信,只有相对比较有效。
七、自测
- 六颗埋雷分别对应第 6 章的哪些瓶颈模式?请说出每一颗的「症状」和「验证命令」。
- 为什么「故意留雷」比「一开始就写好」更有教学价值?请说明理由。
- 如果你和压测客户端只能在同一台机器上,你的报告里必须写什么?为什么?
- 对应关系与验证:① 缺索引(6.6-②)——症状:跳转接口随数据量线性变慢;验证:
EXPLAIN (ANALYZE, BUFFERS) SELECT ... WHERE code = ?看是否Seq Scan,以及Rows Removed by Filter是否很大。② 写放大 + 行锁竞争(6.6-③)——症状:高并发下吞吐上不去、sysCPU 升高;验证:asprof -e lock锁火焰图、pg_stat_activity的wait_event_type=Lock。③ 缓存雪崩(6.6-⑤)——症状:数据库 QPS 出现周期性脉冲;验证:把 DB QPS 曲线与缓存 TTL 时间对齐。④ 调度器污染(6.5-③)——症状:CPU 低但所有接口慢、RUNNABLE 线程数 == CPU 核数;验证:asprof -e wall(CPU 火焰图看不出)。⑤ 日志同步 IO(6.6-⑦)——症状:sysCPU 偏高;验证:jfr print --events jdk.FileWrite,或做"降日志级别"的对照实验。⑥ 锁竞争 + 挂起持锁(6.7-❷)——症状:写路径偶发卡顿;验证:连续多份jcmd Thread.print看BLOCKED,代码搜索synchronized是否出现在suspend函数里。 - 因为「定位能力」只能通过"有问题可查"来训练。如果一个服务所有指标都正常,你走完压测流程后得到的结论是"没问题"——你没有练习到:延迟分解、火焰图判读、瓶颈模式识别、贡献占比测量、排除假设。而埋雷让每一步都产生可验证的中间结论:① 压测发现 P99 超标(现象量化);② 延迟分解指出是数据库(分层拆解);③
EXPLAIN显示全表扫描(假设验证);④ 加索引后复测(优化验证);⑤ 用"逐个关闭"测贡献占比。每一步都有明确的产出。而且埋雷还有一个隐性价值:它模拟了真实世界的状况——真实系统从来不是"干净的",总是有各种历史遗留问题。 - 必须写的内容:①「压测客户端与被测服务运行在同一台机器上」这个事实;② 它导致的两个方向的失真——低估(本地回环网络延迟接近 0)与高估/恶化(客户端与服务抢 CPU,导致服务端表现变差);③ 结论的适用范围:本次数据不能用于容量规划(绝对 QPS 不可信),但可以用于相对比较(优化前后的对比,因为两边受同样的干扰);④ 如果有条件,说明"已用 Docker 的 CPU 限制把两边隔开"(这能部分缓解,但仍不理想)。为什么必须写:因为不写就等于默许读者把它当可信数据使用——如果别人据此做容量规划,会得出错误结论(第 1 章 1.7 节的「实验元数据」与第 8 章的「诚实原则」)。这也呼应第 5.8 节的「未覆盖的场景」——明确边界是专业性的体现。