文档目录

9.5 配套代码:容量曲线与拐点

对应小节:9.5 步骤四:容量曲线与拐点

一、阶梯加压脚本

// perf/k6/capacity.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend } from 'k6/metrics';

const codes = new SharedArray('codes', function () {
  return open('../data/hot-codes.txt').split('\n').filter(Boolean);
});

export const options = {
  scenarios: {
    // ── 阶梯加压:每级 2 分钟稳态 ──
    // 为什么是 2 分钟而不是 30 秒:
    //   30 秒太短,还没进入稳态(JIT 预热、缓存填充、连接池稳定)
    //   2 分钟足够让每一级都"沉淀"下来
    staircase: {
      executor: 'ramping-arrival-rate',
      startRate: 0,
      timeUnit: '1s',
      preAllocatedVUs: 500,
      maxVUs: 4000,
      stages: [
        { target: 100,  duration: '30s' },   // 升温
        { target: 100,  duration: '2m'  },   // 级 1 稳态
        { target: 300,  duration: '15s' },
        { target: 300,  duration: '2m'  },   // 级 2
        { target: 500,  duration: '15s' },
        { target: 500,  duration: '2m'  },   // 级 3
        { target: 700,  duration: '15s' },
        { target: 700,  duration: '2m'  },   // 级 4 ← 预期接近拐点
        { target: 900,  duration: '15s' },
        { target: 900,  duration: '2m'  },   // 级 5 ← 预期已过拐点
        { target: 1100, duration: '15s' },
        { target: 1100, duration: '2m'  },   // 级 6 ← 预期严重排队
        { target: 300,  duration: '30s' },   // 回落(观察恢复能力)
        { target: 300,  duration: '1m'  },
      ],
    },
  },
  // ── 关键:不设 thresholds!──
  // 容量测试是"探索性"的,设阈值会让它提前中止,拿不到完整曲线
  thresholds: {},
  discardResponseBodies: true,
};

const latency = new Trend('redirect_latency', true);

export default function () {
  const code = codes[Math.floor(Math.random() * codes.length)];
  const res = http.get(`http://localhost:8080/${code}`, {
    redirects: 0,
    tags: { name: 'redirect' },
  });
  latency.add(res.timings.duration);
}

二、采集时间线(压测与服务端指标同步)

#!/usr/bin/env bash
# tools/step4a-capacity.sh —— 跑阶梯加压 + 同步采集服务端指标
set -euo pipefail

OUT="perf/results/step4-capacity"
mkdir -p "$OUT"

echo "═══ 容量曲线测试 ═══"

# ── 后台采集服务端指标(每 2 秒一个点)──
collect() {
  : > "$OUT/timeline.csv"
  echo "ts,cpu_pct,rps,p99_ms,pool_pending,pool_active,cache_hit,gc_pause_ms_sum,threads_runnable" \
    > "$OUT/timeline.csv"
  while true; do
    ts=$(date +%s)
    m=$(curl -sf http://localhost:8080/metrics 2>/dev/null || echo "")
    [ -z "$m" ] && { sleep 2; continue; }

    cpu=$(ps -A -o %cpu,comm 2>/dev/null \
          | grep -i java | awk '{s+=$1} END{printf "%.1f", s+0}')
    rps=$(echo "$m" | grep '^http_server_requests_seconds_count' \
          | awk '{s+=$NF} END{printf "%.1f", s+0}')
    p99=$(echo "$m" | grep '^app_request_duration_seconds{.*quantile="0.99"' \
          | awk '{print $NF}')
    pend=$(echo "$m" | grep '^db_pool_pending' | awk '{print $NF}')
    act=$(echo "$m"  | grep '^db_pool_active'  | awk '{print $NF}')
    hit=$(echo "$m"  | grep '^app_cache_hit_rate' | awk '{print $NF}')
    gc=$(echo "$m"   | grep '^jvm_gc_pause_seconds_sum' | awk '{s+=$NF} END{print s+0}')
    run=$(echo "$m"  | grep '^jvm_threads_states_threads{state="runnable"' | awk '{print $NF}')

    echo "$ts,${cpu:-0},${rps:-0},${p99:-0},${pend:-0},${act:-0},${hit:-0},${gc:-0},${run:-0}" \
      >> "$OUT/timeline.csv"
    sleep 2
  done
}
collect &
COLLECT_PID=$!
trap 'kill $COLLECT_PID 2>/dev/null || true' EXIT

# ── 跑阶梯 ──
cd perf/k6
k6 run --out json="$OLDPWD/$OUT/k6.json" capacity.js 2>&1 \
  | tee "$OLDPWD/$OUT/k6.log"
cd "$OLDPWD"

kill $COLLECT_PID 2>/dev/null || true
sleep 1

echo
echo "✅ 原始数据 → $OUT/"
echo "   服务端时间线:timeline.csv"
echo "   压测明细:k6.json"
echo
echo "下一步:python3 tools/step4b-find-knee.py"

三、从曲线找拐点

#!/usr/bin/env python3
"""tools/step4b-find-knee.py —— 从容量曲线找拐点

拐点(knee)= 延迟开始非线性上升的那个吞吐点。
它【不是】最大吞吐 —— 最大吞吐是曲线的最右端(此时延迟已经不可接受)。
容量规划要的是拐点,因为拐点之后延迟增长快于容量增长。
"""
import csv
import json
import pathlib
import sys


def load_timeline(path: pathlib.Path):
    rows = []
    with path.open() as f:
        for r in csv.DictReader(f):
            r["ts"] = int(r["ts"])
            rows.append(r)
    return rows


def main(d: str) -> int:
    d = pathlib.Path(d)
    tl = load_timeline(d / "timeline.csv")

    if len(tl) < 10:
        print("❌ 时间线数据太少")
        return 1

    # ── ① 算每段的「增量延迟 / 增量吞吐」= 边际延迟 ──
    # 稳态下的正常响应应该是常数 → 边际延迟 ≈ 0
    # 进入饱和后,每增加 1 RPS 都会让延迟明显上升 → 边际延迟陡增
    print("═══ 边际延迟分析 ═══\n")
    print(f"{'时刻':>6s} {'CPU%':>6s} {'累计请求':>12s} {'P99(ms)':>9s} "
          f"{'池等待':>7s} {'ΔP99/ΔRPS':>11s}")

    points = []
    for r in tl:
        try:
            p99 = float(r["p99_ms"] or 0)
            rps = float(r["rps"] or 0)
        except ValueError:
            continue
        if p99 <= 0 or rps <= 0:
            continue
        points.append((r["ts"], float(r["cpu_pct"] or 0), rps, p99,
                       float(r["pool_pending"] or 0)))

    # 降采样(每 5 个点取一个),避免刷屏
    prev = None
    knees = []
    for i, (ts, cpu, rps, p99, pend) in enumerate(points):
        if prev is None:
            prev = (rps, p99)
            continue
        drps = rps - prev[0]
        dp99 = p99 - prev[1]
        ratio = dp99 / drps if abs(drps) > 100 else None   # 累计计数增量太小则跳过
        prev = (rps, p99)

        # ── 拐点判据:CPU 高 && 池有等待 && P99 明显上升 ──
        if cpu > 70 and pend > 0 and p99 > 150:
            knees.append((ts, cpu, rps, p99, pend, ratio))

        if i % 5 == 0:
            rs = f"{ratio:11.3f}" if ratio is not None else f"{'-':>11s}"
            print(f"{ts:>6d} {cpu:6.1f} {rps:12.0f} {p99:9.1f} {pend:7.0f} {rs}")

    print()
    if knees:
        first = knees[0]
        print(f"🎯 拐点(首次满足 CPU>70% & 池等待>0 & P99>150ms):")
        print(f"   时刻 {first[0]}  CPU {first[1]:.1f}%  "
              f"累计请求 {first[2]:.0f}  P99 {first[3]:.1f}ms  "
              f"池等待 {first[4]:.0f}")
    else:
        print("⚠️  未找到明显拐点 —— 加压力度可能不够")

    # ── ② 计算「最优工作点」──
    # 定义:延迟 <= SLO 的最大吞吐
    slo = 100.0
    ok_points = [(r, p) for _, _, r, p, _ in points if p <= slo]
    if ok_points:
        best = max(ok_points, key=lambda x: x[0])
        print()
        print(f"📊 满足 SLO(P99<{slo:.0f}ms) 的最大吞吐:{best[0]:.0f} 累计请求数"
              f"(对应 P99 {best[1]:.1f}ms)")
        print(f"   → 这是【优化前】的可用容量")
    else:
        print()
        print(f"❌ 所有测点的 P99 都超过 SLO({slo:.0f}ms) —— 当前完全不可用")

    # ── ③ 饱和度时间线(找"第一块倒下的多米诺")──
    print()
    print("═══ 饱和度时间线(第一个越界的指标)═══\n")
    thresholds = [
        ("db_pool_pending",   lambda v: v > 0,    "连接池出现等待"),
        ("cpu_pct",           lambda v: v > 80,   "CPU > 80%"),
        ("p99_ms",            lambda v: v > 100,  "P99 超过 SLO(100ms)"),
        ("gc_pause_ms_sum",   None,               "GC 停顿快速累积"),
        ("threads_runnable",  lambda v: v > 8,    "RUNNABLE 线程数接近核数"),
    ]
    firsts = {}
    for name, pred, desc in thresholds:
        for r in tl:
            try:
                v = float(r.get(name) or 0)
            except ValueError:
                continue
            if pred and pred(v):
                firsts.setdefault(desc, r["ts"])
                break

    for desc, ts in sorted(firsts.items(), key=lambda kv: kv[1]):
        print(f"  t={ts}  {desc}")

    print()
    print("判读:")
    print("  第一个越界的指标就是【第一块倒下的多米诺】")
    print("  → 优化要打【第一块】,而不是最后一根稻草")

    (d / "capacity.md").write_text(
        "# 容量曲线结论\n\n"
        f"- 拐点:{'未找到' if not knees else str(knees[0][2]) + ' 累计请求'}\n"
        f"- 满足 SLO 的最大吞吐:"
        f"{'无' if not ok_points else str(round(best[0]))}\n\n"
        "## 饱和度时间线\n\n"
        + "\n".join(f"- `t={ts}` {desc}" for desc, ts in
                    sorted(firsts.items(), key=lambda kv: kv[1]))
        + "\n"
    )
    print(f"\n✅ 已写入 {d}/capacity.md")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1
                  else "perf/results/step4-capacity"))

预期输出(关键部分):

═══ 边际延迟分析 ═══

  时刻   CPU%       累计请求   P99(ms)   池等待  ΔP99/ΔRPS
1717...   12.3         4200     418.2       0        -
1717...   34.1        12600     421.5       0      0.001
1717...   58.7        21400     428.9       0      0.002
1717...   81.2        30200     612.4       3      0.061   ← 拐点
1717...   94.6        38800    1842.7      18      0.412
1717...   97.8        45100    4903.1      41      1.024

🎯 拐点(首次满足 CPU>70% & 池等待>0 & P99>150ms):
   时刻 1717...  CPU 81.2%  累计请求 30200  P99 612.4ms  池等待 3

📊 满足 SLO(P99<100ms) 的最大吞吐:0 累计请求数(对应 P99 ...)
   → 这是【优化前】的可用容量
❌ 所有测点的 P99 都超过 SLO(100ms) —— 当前完全不可用

═══ 饱和度时间线(第一个越界的指标)═══

  t=1717...  连接池出现等待
  t=1717...  CPU > 80%
  t=1717...  P99 超过 SLO(100ms)
  t=1717...  RUNNABLE 线程数接近核数

判读:
  第一个越界的指标就是【第一块倒下的多米诺】
  → 优化要打【第一块】,而不是最后一根稻草

注意这里的关键洞察:

「连接池出现等待」与「CPU > 80%」几乎同时出现
→ 说明不是"池太小",而是"每个请求在池里占的时间太长"
→ 占的时间长是因为查询慢(埋雷 ①)
→ 所以第一块多米诺是【慢查询】,不是【池大小】

如果只看"池等待 > 0"就加池 → 加多少都没用(第 6.5 节)

四、可视化(可选,但推荐)

#!/usr/bin/env python3
"""tools/step4c-plot.py —— 用 matplotlib 画容量曲线(可选)

图表比表格更容易看出"拐点" ——
人眼对"曲线突然变陡"的识别远快于对数字的识别。
"""
import csv
import pathlib
import sys

try:
    import matplotlib
    matplotlib.use("Agg")          # 无 GUI 环境
    import matplotlib.pyplot as plt
except ImportError:
    print("⚠️  未安装 matplotlib:pip install matplotlib")
    print("   不画图也能继续 —— capacity.md 里已有结论")
    sys.exit(0)


def main(d: str) -> int:
    d = pathlib.Path(d)
    ts, rps, p99, cpu, pend = [], [], [], [], []
    with (d / "timeline.csv").open() as f:
        for r in csv.DictReader(f):
            try:
                ts.append(int(r["ts"]))
                rps.append(float(r["rps"] or 0))
                p99.append(float(r["p99_ms"] or 0))
                cpu.append(float(r["cpu_pct"] or 0))
                pend.append(float(r["pool_pending"] or 0))
            except ValueError:
                continue

    if not rps:
        print("❌ 无数据")
        return 1

    t0 = ts[0]
    x = [(t - t0) / 60 for t in ts]      # 分钟

    fig, axes = plt.subplots(3, 1, figsize=(10, 9), sharex=True)

    # ── 图 1:延迟 vs 时间 ──
    axes[0].plot(x, p99, color="crimson", linewidth=1.5)
    axes[0].axhline(100, color="gray", linestyle="--", linewidth=1)
    axes[0].text(x[-1] * 0.02, 110, "SLO P99 = 100ms", color="gray", fontsize=9)
    axes[0].set_ylabel("P99 (ms)")
    axes[0].set_title("容量曲线:延迟随负载的变化")
    axes[0].set_yscale("log")

    # ── 图 2:累计请求数(代理吞吐)+ 连接池等待 ──
    ax2 = axes[1]
    ax2.plot(x, rps, color="steelblue", linewidth=1.5, label="累计请求数")
    ax2.set_ylabel("累计请求数", color="steelblue")
    ax2.tick_params(axis="y", labelcolor="steelblue")
    ax2b = ax2.twinx()
    ax2b.plot(x, pend, color="darkorange", linewidth=1.2, label="池等待")
    ax2b.set_ylabel("连接池等待数", color="darkorange")
    ax2b.tick_params(axis="y", labelcolor="darkorange")

    # ── 图 3:CPU ──
    axes[2].plot(x, cpu, color="seagreen", linewidth=1.5)
    axes[2].axhline(80, color="gray", linestyle="--", linewidth=1)
    axes[2].set_ylabel("CPU %")
    axes[2].set_xlabel("时间(分钟)")

    plt.tight_layout()
    out = d / "capacity-curve.png"
    plt.savefig(out, dpi=120)
    print(f"✅ 已保存 {out}")
    print()
    print("看图要点:")
    print("  1. 图 1 中 P99 从平坦突然上翘的位置 = 拐点")
    print("  2. 图 2 中池等待【开始上升】的时刻 = 饱和度起点")
    print("  3. 图 3 中 CPU 到 80% 的时刻 —— 应与图 2 的起点接近")
    print("  如果 CPU 只有 30% 但延迟已经爆了 → 不是 CPU 问题,是等待问题")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1
                  else "perf/results/step4-capacity"))

五、动手改造

改动 观察什么
把每级稳态从 2m 改成 30s 曲线变得锯齿状、拐点位置漂移——理解"稳态"需要时间
给 capacity.js 加上 thresholds k6 在超过阈值时提前中止,拿不到完整曲线——探索性测试不设阈值
把压力上限从 1100 降到 700 看不到"过拐点后的崩溃",无法判断拐点是否真的是拐点
去掉最后的"回落段"(300 RPS 那一分半) 看不到系统能否恢复——恢复能力是重要信号
只采服务端指标、不采时间线 无法做"饱和度时间线"分析——只能看到结果,看不到过程

六、这段代码的局限

  • cpu_pct 来自 ps 的瞬时采样:精度有限,且包含所有 java 进程。生产应该用 container_cpu_usage_seconds_total。
  • 拐点判据是硬编码的阈值(CPU>70% && 池等待>0 && P99>150ms):对本 Lab 有效,换项目要重新定。
  • rps 用的是累计计数而非速率:因为简化为"直接读计数器",所以边际延迟计算并非严格增量。严格做法是读 rate(...) 或自己差分。
  • k6.json 可能非常大(几 GB):生产应该用 --out experimental-prometheus-rw 或降低采样。
  • 没有做重复测试:容量曲线本身也应该跑 3 次取包络——本 Lab 为了节省时间只跑 1 次,这是已知的简化。
  • matplotlib 是可选依赖:脚本设计成"没装也能继续",避免为了画图卡住流程。