文档目录

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/                       # 步骤七

六、本节小结

  1. 短链服务是理想的教学载体:读写悬殊、有天然热点、有缓存两条路径、代码量可控。
  2. 六颗埋雷对应第 6 章的六个瓶颈模式——必须故意保留它们,否则学不到定位能力。
  3. 埋雷让每一步都有"非做不可"的理由,而不是走流程。
  4. 环境要求:Docker、独立压测客户端、100 万行数据、/metrics 端点。
  5. 同机压测时必须声明局限——数据的绝对值不可信,只有相对比较有效。

七、自测

  1. 六颗埋雷分别对应第 6 章的哪些瓶颈模式?请说出每一颗的「症状」和「验证命令」。
  2. 为什么「故意留雷」比「一开始就写好」更有教学价值?请说明理由。
  3. 如果你和压测客户端只能在同一台机器上,你的报告里必须写什么?为什么?
  1. 对应关系与验证:① 缺索引(6.6-②)——症状:跳转接口随数据量线性变慢;验证:EXPLAIN (ANALYZE, BUFFERS) SELECT ... WHERE code = ? 看是否 Seq Scan,以及 Rows Removed by Filter 是否很大。② 写放大 + 行锁竞争(6.6-③)——症状:高并发下吞吐上不去、sys CPU 升高;验证: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-⑦)——症状:sys CPU 偏高;验证:jfr print --events jdk.FileWrite,或做"降日志级别"的对照实验。⑥ 锁竞争 + 挂起持锁(6.7-❷)——症状:写路径偶发卡顿;验证:连续多份 jcmd Thread.print 看 BLOCKED,代码搜索 synchronized 是否出现在 suspend 函数里。
  2. 因为「定位能力」只能通过"有问题可查"来训练。如果一个服务所有指标都正常,你走完压测流程后得到的结论是"没问题"——你没有练习到:延迟分解、火焰图判读、瓶颈模式识别、贡献占比测量、排除假设。而埋雷让每一步都产生可验证的中间结论:① 压测发现 P99 超标(现象量化);② 延迟分解指出是数据库(分层拆解);③ EXPLAIN 显示全表扫描(假设验证);④ 加索引后复测(优化验证);⑤ 用"逐个关闭"测贡献占比。每一步都有明确的产出。而且埋雷还有一个隐性价值:它模拟了真实世界的状况——真实系统从来不是"干净的",总是有各种历史遗留问题。
  3. 必须写的内容:①「压测客户端与被测服务运行在同一台机器上」这个事实;② 它导致的两个方向的失真——低估(本地回环网络延迟接近 0)与高估/恶化(客户端与服务抢 CPU,导致服务端表现变差);③ 结论的适用范围:本次数据不能用于容量规划(绝对 QPS 不可信),但可以用于相对比较(优化前后的对比,因为两边受同样的干扰);④ 如果有条件,说明"已用 Docker 的 CPU 限制把两边隔开"(这能部分缓解,但仍不理想)。为什么必须写:因为不写就等于默许读者把它当可信数据使用——如果别人据此做容量规划,会得出错误结论(第 1 章 1.7 节的「实验元数据」与第 8 章的「诚实原则」)。这也呼应第 5.8 节的「未覆盖的场景」——明确边界是专业性的体现。