文档目录

9.3 步骤二:搭服务与数据

上一节:9.2 步骤一:定义目标 | 下一节:9.4 步骤三:建立基线与噪声底线 用到:第 3 章(组件基准、真实依赖)、第 5.5 节(数据集设计) 配套代码:09-capstone/03-step2-build-service 产出:可运行的服务 + 一键起环境脚本 + 100 万行数据


一句话结论

这一步的目标不是"写出好代码",而是"写出一个能被测量的系统"——它必须有真实的依赖、固定的数据、可观测的指标,以及故意保留的埋雷。


一、服务的四层结构

HTTP 层(Ktor 路由)
  ↓
Service 层(业务逻辑:生成短码、缓存编排)
  ↓
Repository 层(数据访问:PostgreSQL + Redis)
  ↓
基础设施(HikariCP 连接池、Redis 客户端)

每一层对应的埋雷:

层 埋雷 说明
HTTP 层 ⑤ 日志同步 IO 每个请求打 INFO 日志
Service 层 ⑥ synchronized 生成 code 锁 + 可能在协程里
Repository 层 ① 无唯一索引、② 每次 UPDATE、④ Default 上 JDBC 数据访问的三个问题
缓存 ③ TTL 无抖动 服务层与缓存层交界处

二、一键起环境

# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: shortlink
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    ports: ["5432:5432"]
    command:
      - postgres
      - -c max_connections=100
      - -c shared_buffers=256MB
      - -c log_min_duration_statement=200ms     # 慢查询日志(辅助定位)
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
      - ./seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d shortlink"]
      interval: 2s
      timeout: 3s
      retries: 30

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s

  # 观测栈(第 4 章)
  prometheus:
    image: prom/prometheus:v2.54.1
    ports: ["9090:9090"]
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro

  grafana:
    image: grafana/grafana:11.2.0
    ports: ["3000:3000"]
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin

volumes:
  pg-data:

关键配置:

配置 为什么
max_connections=100 后面容量规划要用到(第 8.7 节的约束检查)
log_min_duration_statement=200ms 慢查询日志(辅助 pg_stat_statements)
Redis maxmemory + allkeys-lru 有界缓存(避免 OOM)
healthcheck 便于脚本等待依赖就绪

三、建表:故意不加唯一索引

-- init.sql

CREATE TABLE IF NOT EXISTS links (
    id          BIGSERIAL PRIMARY KEY,
    code        TEXT NOT NULL,
    url         TEXT NOT NULL,
    user_id     BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    hits        BIGINT NOT NULL DEFAULT 0
);

-- ⚠️ 埋雷 ①:故意【不建】code 的唯一索引
-- CREATE UNIQUE INDEX CONCURRENTLY idx_links_code ON links(code);
-- 这一步留到步骤六(优化)时再做

-- 辅助索引:user_id(用于按用户查询的场景,不是主路径)
CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id);

-- 开启 pg_stat_statements(定位 N+1 与慢查询)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

注意:init.sql 里要明确写出注释说明"这里故意没有索引"——否则三个月后你自己都会忘记。


四、灌数据:100 万行 + 幂律分布

-- seed.sql
-- 生成 100 万条 links,user_id 用幂律分布(少数用户创建大量短链)

INSERT INTO links (code, url, user_id)
SELECT
    'c' || g,                                        -- 短码(简单起见用 c+序号)
    'https://example.com/path/' || g,
    -- 幂律分布:少数用户占多数(用 g^3 让值集中在小区间)
    1 + floor(10000 * power(random(), 3))::bigint
FROM generate_series(1, 1000000) g;

ANALYZE links;      -- ⚠️ 必须更新统计信息,否则执行计划不准

为什么用户 ID 用幂律:

真实的访问是幂律的:
  少数热门短链被反复访问(前 1% 承担 40%)
  大部分短链几乎没人访问

用均匀分布会:
  → 缓存命中率虚低(每个 key 均匀分布,缓存装不下)
  → 锁竞争虚低(写操作分散)
  → 结论不可迁移(第 5.5 节)

压测时的访问分布也要用幂律:

// k6 脚本里的幂律取样
function zipfCode(n) {
  const u = Math.random();
  // u^3 让值集中在小范围(前 1% 的 code 承担大部分访问)
  return 'c' + Math.max(1, Math.floor(n * Math.pow(u, 3)) + 1);
}

五、四层指标埋点

// 第 1 章 1.1 节的四层指标
fun registerFourLayers(ds: HikariDataSource, registry: PrometheusMeterRegistry) {
    // ① 业务层
    Counter.builder("app.requests.total").register(registry)
    Counter.builder("app.requests.failed").register(registry)

    // ② 延迟层(直方图 + 自定义桶)
    Timer.builder("app.request.duration")
        .publishPercentileHistogram(true)
        .serviceLevelObjectives(
            Duration.ofMillis(1), Duration.ofMillis(2), Duration.ofMillis(5),
            Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
            Duration.ofMillis(75), Duration.ofMillis(100),   // ← SLO 附近加密
            Duration.ofMillis(150), Duration.ofMillis(250), Duration.ofMillis(500),
        )
        .register(registry)

    // ③ 资源层(用 Micrometer binder)
    JvmMemoryMetrics().bindTo(registry)
    JvmGcMetrics().bindTo(registry)
    JvmThreadMetrics().bindTo(registry)
    ProcessorMetrics().bindTo(registry)

    // ④ 饱和度层 ⭐(最重要)
    Gauge.builder("db.pool.pending") { ds.hikariPoolMXBean?.threadsAwaitingConnection ?: 0 }
        .register(registry)
    Gauge.builder("db.pool.active") { ds.hikariPoolMXBean?.activeConnections ?: 0 }
        .register(registry)
    Gauge.builder("db.pool.total") { ds.hikariPoolMXBean?.totalConnections ?: 0 }
        .register(registry)
    Gauge.builder("app.coroutines.active") { activeCoroutines.get().toDouble() }
        .register(registry)
    Gauge.builder("app.cache.hit_rate") { cache.stats().hitRate() }
        .register(registry)
}

注意桶边界的设置:在 SLO(100ms)附近加密(第 1 章 1.2 节)——否则 P99 会失真。


六、验证「埋雷真的存在」

这一步很关键:如果埋雷没生效,后面的定位练习就是空的。

#!/usr/bin/env bash
# tools/verify-traps.sh
#
# 验证六颗埋雷都真的存在。
set -uo pipefail

echo "═══ 验证六颗埋雷 ═══"
echo

# 埋雷 ①:code 没有唯一索引
echo "① 检查 code 列的索引:"
psql "postgresql://app:app@localhost:5432/shortlink" -c "
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'links';"
if psql -tAc "SELECT 1 FROM pg_indexes WHERE tablename='links' AND indexdef LIKE '%(code)%'" \
     "postgresql://app:app@localhost:5432/shortlink" | grep -q 1; then
  echo "   ❌ code 已经有索引 —— 埋雷 ① 失效(应该先删掉)"
else
  echo "   ✅ code 无索引(埋雷 ① 生效)"
fi
echo

# 埋雷 ②:确认 UPDATE 语句存在
echo "② 检查是否每次跳转都 UPDATE:"
grep -q "UPDATE links SET hits" src/main/kotlin/**/*.kt 2>/dev/null \
  && echo "   ✅ 存在 UPDATE hits(埋雷 ② 生效)" \
  || echo "   ⚠️  未找到 UPDATE hits"
echo

# 埋雷 ③:确认缓存 TTL 无抖动
echo "③ 检查缓存 TTL 配置:"
grep -q "expireAfterWrite\|expireAfter" src/main/kotlin/**/*.kt 2>/dev/null \
  && echo "   ✅ 使用了固定 TTL(埋雷 ③ 生效)" \
  || echo "   ⚠️  未找到缓存配置"
echo

# 埋雷 ④:确认 Default 上有阻塞调用
echo "④ 检查是否在 Default 上执行 JDBC:"
if grep -q "withContext(Dispatchers.IO)" src/main/kotlin/**/*.kt 2>/dev/null; then
  echo "   ❌ 已切到 IO —— 埋雷 ④ 失效"
else
  echo "   ✅ 未见 withContext(Dispatchers.IO)(埋雷 ④ 生效)"
fi
echo

# 埋雷 ⑤:确认热路径日志
echo "⑤ 检查热路径日志:"
grep -c "log.info" src/main/kotlin/**/*.kt 2>/dev/null | head -1 | sed 's/^/   log.info 出现次数: /'
echo

# 埋雷 ⑥:确认 synchronized
echo "⑥ 检查 synchronized:"
grep -n "synchronized" src/main/kotlin/**/*.kt 2>/dev/null | head -3 | sed 's/^/   /'
echo

echo "═══ 结论 ═══"
echo "如果某颗埋雷失效,请在步骤二里补回来 —— 否则后面的定位练习会缺少素材。"

这个脚本的价值:它把「埋雷是否存在」变成一个可检查的事实——否则你可能会在步骤五发现问题不存在,然后浪费时间去查为什么。


七、这一步的验收

## 步骤二验收清单

### 环境
- [ ] `docker compose up -d` 能一键起 PostgreSQL + Redis
- [ ] `/metrics` 端点能访问,且**四层指标齐全**
- [ ] 桶边界围绕 SLO 设置(100ms 附近加密)
- [ ] `pg_stat_statements` 已启用

### 数据
- [ ] 100 万行数据灌入成功
- [ ] 访问分布用**幂律**(不是均匀随机)
- [ ] 执行了 `ANALYZE`(统计信息更新)
- [ ] 压测脚本用幂律取样

### 服务
- [ ] 两个接口(POST /links、GET /{code})都能正常工作
- [ ] **六颗埋雷全部存在**(用 `verify-traps.sh` 验证)
- [ ] 服务启动到可用 < 10 秒

### 记录
- [ ] 环境元数据已采集(`env.txt`)
- [ ] 记录了「故意保留的问题清单」

八、本节小结

  1. 这一步的目标是"写出能被测量的系统",不是"写出好代码"。
  2. 服务分四层:HTTP / Service / Repository / 基础设施——每层对应一个埋雷。
  3. 建表时故意不加 code 唯一索引,并在 SQL 里写明注释。
  4. 数据必须是幂律分布(不是均匀随机),否则缓存命中率与锁竞争都不真实。
  5. ANALYZE 必须执行——统计信息不全会让执行计划失真。
  6. 四层指标尤其饱和度和缓存命中率必须埋点。
  7. verify-traps.sh 验证埋雷是否真的存在——否则后面的定位练习会缺素材。

九、自测

  1. 为什么灌数据时要用幂律分布而不是均匀随机?请说出它对「缓存命中率」和「锁竞争」分别有什么影响。
  2. 为什么要在 init.sql 里用注释明确写出「这里故意不加索引」?请说出两个理由。
  3. verify-traps.sh 的作用是什么?如果某颗埋雷失效了会有什么后果?
  1. 为什么用幂律:真实业务的访问几乎总是幂律的(少部分 key 承担大部分访问)。对缓存命中率的影响:① 均匀随机时,100 万个 key 分散访问,缓存(假设 10 万容量)只能命中约 10%——虚低;② 幂律时,前 1% 的热门 key 承担 40% 访问,缓存能轻松覆盖它们,命中率可以到 80% 以上——这才是真实情况。对锁竞争的影响:① 均匀随机时,写操作(比如 UPDATE hits)分散在 100 万行上,几乎没有行锁冲突;② 幂律时,热门短链的 UPDATE 集中在少数行上,行锁竞争激烈——这才是真实的高并发场景。结论:用均匀分布会同时低估两个领域的问题(缓存收益和锁竞争),得出的结论不可迁移(第 5.5 节)。
  2. 两个理由:① 防止自己或同事"顺手修好"——一个懂数据库的人看到 code 列没有索引,很可能在 code review 时"顺手加一个"(因为这看起来是个明显的疏漏);有了注释说明这是故意的,就能避免这种"好心的破坏"。② 三个月后你还能记得为什么——实验做完后,代码会留在仓库里;如果不写注释,后来的读者(包括你自己)会认为这是疏忽,可能会"修复"它,导致埋雷失效、后续的定位实验失去素材。第三个理由:注释本身也是教学材料——它明确标注了"这是一个待发现的问题",让读者知道该往哪里看(但不知道具体症状,仍然需要自己去测)。
  3. 作用:验证六颗埋雷是否真的存在——它检查索引是否缺失、UPDATE 语句是否存在、缓存配置是否为固定 TTL、是否在 Default 上执行 JDBC、是否有热路径日志、是否有 synchronized。如果某颗埋雷失效的后果:① 定位练习缺少素材——你会花时间去找一个不存在的问题;② 更糟的是「虚假的失败」——比如你以为服务慢了是因为调度器污染,实际上埋雷 ④ 已经被无意中修掉了,慢的原因是别的(可能导致错误的结论);③ 浪费实验时间——你会反复压测、采火焰图,却发现"症状对不上预期"。所以这一步必须在开始压测前做——它把"埋雷是否存在"变成可检查的事实,避免后续的困惑。