9.3 配套代码:搭服务与造数据
对应小节:9.3 步骤二:搭服务与数据
一、docker-compose
# perf/docker-compose.yaml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: shortlink
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports: ["5432:5432"]
volumes:
- ./sql:/docker-entrypoint-initdb.d:ro
- pgdata:/var/lib/postgresql/data
command:
- postgres
# ── 关键:明确资源上限,否则本机 PG 会吃掉所有内存,结果无法解释 ──
- -c
- shared_buffers=2GB
- -c
- effective_cache_size=6GB
- -c
- work_mem=64MB
- -c
- max_connections=200
- -c
- log_min_duration_statement=100 # 慢查询日志(>100ms 都记)
- -c
- track_io_timing=on # EXPLAIN BUFFERS 才有 I/O 时间
deploy:
resources:
limits: { cpus: "4", memory: "8g" }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d shortlink"]
interval: 5s
retries: 10
redis:
image: redis:7
ports: ["6379:6379"]
command: ["redis-server", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
volumes:
pgdata:
注意 deploy.resources.limits:
没有资源上限 → 容器和压测机抢 CPU → 结果不可解释(第 4.8 节)
设了上限 → 测出来的拐点是"这个配置下的拐点",可复现 ✅
二、建表(故意不加索引)
-- perf/sql/01-init.sql
-- ═══ 埋雷 ① 就在这里 ═══
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(8) NOT NULL, -- ❌ 没有 UNIQUE
url TEXT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
hits BIGINT NOT NULL DEFAULT 0
);
-- ❌ 故意不建这个索引:
-- CREATE UNIQUE INDEX idx_links_code ON links(code);
-- → 所有按 code 的查询都会走 Seq Scan
-- → 100 万行时约 380ms
-- 这个索引【有】——用于对比"有索引的路径有多快"
CREATE INDEX idx_links_user_id ON links(user_id);
-- 写在注释里提醒:正确做法是这样
COMMENT ON COLUMN links.code IS
'短码。生产必须有 UNIQUE 索引;本 Lab 故意不建,用于演示 Seq Scan(埋雷 ①)';
三、幂律灌数据
#!/usr/bin/env python3
"""tools/seed.py —— 灌 100 万行,命中分布服从幂律(模拟真实热点)
为什么不用「均匀随机」:
均匀分布下所有短链一样热 —— 缓存命中率会虚高,
且热门行的锁竞争(埋雷 ②)不会显现。
真实流量是幂律的:少数短链占绝大多数访问。
"""
import argparse
import random
import string
import time
import psycopg
BASE62 = string.digits + string.ascii_uppercase + string.ascii_lowercase
def gen_codes(n: int, seed: int = 42) -> list[str]:
"""生成 n 个不重复的 6 位短码(内存里去重,避免插入冲突)"""
rng = random.Random(seed)
seen: set[str] = set()
while len(seen) < n:
seen.add("".join(rng.choice(BASE62) for _ in range(6)))
return list(seen)
def zipf_indices(n: int, total: int, alpha: float) -> list[int]:
"""按 zipf(alpha) 生成 total 次访问落在 n 个短链上的下标
alpha 越大越集中:
alpha=0 → 均匀
alpha=1.2 → 前 1% 的短链拿到约 60% 的流量(接近真实)
"""
rng = random.Random(7)
weights = [1.0 / ((i + 1) ** alpha) for i in range(n)]
return rng.choices(range(n), weights=weights, k=total)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--rows", type=int, default=1_000_000)
ap.add_argument("--dsn", default="postgresql://app:app@localhost:5432/shortlink")
ap.add_argument("--batch", type=int, default=10_000)
ap.add_argument("--alpha", type=float, default=1.2)
args = ap.parse_args()
print(f"生成 {args.rows:,} 个短码...")
codes = gen_codes(args.rows)
print("写入数据库(分批 COPY)...")
t0 = time.perf_counter()
with psycopg.connect(args.dsn, autocommit=False) as conn:
with conn.cursor() as cur:
for start in range(0, args.rows, args.batch):
chunk = codes[start:start + args.batch]
# COPY 比 INSERT 快一个数量级 —— 灌数据不该成为瓶颈
with cur.copy(
"COPY links (code, url, user_id) FROM STDIN"
) as copy:
for i, c in enumerate(chunk):
copy.write_row((
c,
f"https://example.com/target/{start + i}",
(start + i) % 1000 + 1,
))
conn.commit()
done = min(start + args.batch, args.rows)
pct = done / args.rows * 100
elapsed = time.perf_counter() - t0
print(f" {done:>9,} / {args.rows:,} ({pct:5.1f}%) "
f"{elapsed:6.1f}s", end="\r")
elapsed = time.perf_counter() - t0
print(f"\n✅ 写入完成,耗时 {elapsed:.1f}s "
f"({args.rows / elapsed:,.0f} 行/秒)")
# ── 生成热点清单(k6 用)──
print(f"\n按 zipf(alpha={args.alpha}) 生成访问分布...")
hot = zipf_indices(args.rows, 100_000, args.alpha)
counts: dict[str, int] = {}
for idx in hot:
c = codes[idx]
counts[c] = counts.get(c, 0) + 1
top = sorted(counts.items(), key=lambda kv: -kv[1])[:100]
with open("perf/data/hot-codes.txt", "w") as f:
for code, _ in top:
f.write(code + "\n")
total = sum(counts.values())
print(f" 去重后热点短链数:{len(counts):,}")
print(f" 热门前 10 占比:{sum(c for _, c in top[:10]) / total * 100:.1f}%")
print(f" 热门前 100 占比:{sum(c for _, c in top) / total * 100:.1f}%")
print(" → 已写入 perf/data/hot-codes.txt")
# ── 关键:必须 ANALYZE,否则 Planner 没有统计信息 ──
print("\n执行 ANALYZE...")
with psycopg.connect(args.dsn, autocommit=True) as conn:
conn.execute("ANALYZE links")
print("✅ ANALYZE 完成")
if __name__ == "__main__":
main()
预期输出:
生成 1,000,000 个短码...
写入数据库(分批 COPY)...
1,000,000 / 1,000,000 (100.0%) 42.3s
✅ 写入完成,耗时 42.3s (23,641 行/秒)
按 zipf(alpha=1.2) 生成访问分布...
去重后热点短链数:17,842
热门前 10 占比:31.4%
热门前 100 占比:58.2%
→ 已写入 perf/data/hot-codes.txt
执行 ANALYZE...
✅ ANALYZE 完成
四、k6 压测脚本(用真实热点)
// perf/k6/redirect.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend, Counter } from 'k6/metrics';
import { thresholds } from '../thresholds.js';
// ── 用真实的热点短链(幂律分布)──
const codes = new SharedArray('codes', function () {
return open('../data/hot-codes.txt').split('\n').filter(Boolean);
});
const redirectLatency = new Trend('redirect_latency', true);
const cacheMiss = new Counter('app_cache_miss_total');
export const options = {
scenarios: {
redirect: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 2000,
stages: [
{ target: 100, duration: '30s' },
{ target: 100, duration: '3m' }, // 稳态
],
},
},
thresholds,
};
export default function () {
// 80% 打热点,20% 打长尾 —— 复现真实的「热点+长尾」
const code = Math.random() < 0.8
? codes[Math.floor(Math.random() * Math.min(100, codes.length))]
: codes[Math.floor(Math.random() * codes.length)];
const res = http.get(`http://localhost:8080/${code}`, {
redirects: 0, // 不跟随重定向(测的是服务本身)
tags: { name: 'redirect' },
});
redirectLatency.add(res.timings.duration);
check(res, {
'status is 302': (r) => r.status === 302,
'has Location': (r) => !!r.headers['Location'],
});
}
五、检查服务是否"埋雷完好"
#!/usr/bin/env bash
# tools/step2-verify.sh
set -euo pipefail
PG="${PG_CONN:-postgresql://app:app@localhost:5432/shortlink}"
echo "═══ 检查数据与环境 ═══"
echo
# ── ① 行数 ──
N=$(psql -tAc "SELECT count(*) FROM links;" "$PG")
echo "① 数据行数:$N"
if [ "$N" -lt 900000 ]; then
echo " ⚠️ 少于 90 万行 —— Seq Scan 可能不够慢(埋雷 ① 症状变弱)"
else
echo " ✅ 数量足够触发明显的 Seq Scan"
fi
# ── ② 索引情况(应该只有 user_id,没有 code)──
echo
echo "② 索引:"
psql -tAc "SELECT indexdef FROM pg_indexes WHERE tablename='links';" "$PG" | sed 's/^/ /'
if psql -tAc "SELECT count(*) FROM pg_indexes WHERE tablename='links' AND indexdef LIKE '%(code)%';" "$PG" | grep -q '^0$'; then
echo " ✅ code 无索引(埋雷 ① 完好)"
else
echo " ❌ code 有索引 —— 埋雷 ① 已失效"
fi
# ── ③ 慢查询日志配置 ──
echo
echo "③ 慢查询日志阈值:"
psql -tAc "SHOW log_min_duration_statement;" "$PG" | sed 's/^/ log_min_duration_statement = /'
# ── ④ 缓存命中率基线 ──
echo
echo "④ 服务缓存命中率(需服务已启动):"
curl -s http://localhost:8080/metrics 2>/dev/null \
| grep '^app_cache_hit_rate' | sed 's/^/ /' \
|| echo " ⚠️ 服务未启动或无该指标"
echo
echo "════════════════════════════════════"
echo "✅ 步骤二环境就绪"
echo
echo "下一步:"
echo " 1. 启动服务:./gradlew run"
echo " 2. 跑基线:tools/step3-baseline.sh"