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