文档目录

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"