文档目录

9.5 步骤四:容量曲线与拐点

上一节:9.4 步骤三:建立基线与噪声底线 | 下一节:9.6 步骤五:剖析定位 用到:第 1.6 节(容量曲线)、第 3.4 节(阶梯加压) 配套代码:09-capstone/05-step4-capacity-curve 产出:experiments/E10-capacity/(曲线 + 拐点)


一句话结论

「CPU 打满时的 QPS」不是容量,「延迟开始非线性上升的那个点」才是。 而且在埋雷服务上,拐点会低得惊人——这正是埋雷的价值:让你亲眼看到「一个缺失的索引如何把容量砍掉一半」。


一、阶梯加压设计

// loadtest/capacity-staircase.js
export const options = {
  scenarios: {
    staircase: {
      executor: 'ramping-arrival-rate',
      startRate: 100,
      timeUnit: '1s',
      preAllocatedVUs: 1000,          // ⚠️ 必须给够,否则实际到达率低于设定值
      maxVUs: 5000,
      stages: [
        { target: 200,  duration: '30s' }, { target: 200,  duration: '2m' },
        { target: 400,  duration: '30s' }, { target: 400,  duration: '2m' },
        { target: 700,  duration: '30s' }, { target: 700,  duration: '2m' },
        { target: 1000, duration: '30s' }, { target: 1000, duration: '2m' },
        { target: 1500, duration: '30s' }, { target: 1500, duration: '2m' },
        { target: 2000, duration: '30s' }, { target: 2000, duration: '2m' },
      ],
      // 每级:爬升 30s + 稳态 2m(⚠️ 稳态窗口才是采数据的区间)
    },
  },
  // 故意不设 thresholds:容量测试的目的是找拐点,不是判定通过/失败
  discardResponseBodies: true,
};

关键设计(第 3.4 节):

设计 为什么
每级稳态 2 分钟 太短(30 秒)会得到平坦且偏低的曲线,找不到拐点
preAllocatedVUs: 1000 不够会导致实际到达率低于设定值(数据作废)
不设 thresholds 容量测试要跑到崩溃点,thresholds 会提前中断
递增倍数 1.5–2 太大跳过拐点,太小耗时太久

二、执行与验证

tools/run-capacity.sh E10-capacity
#!/usr/bin/env bash
# tools/run-capacity.sh <EXP_ID>
set -uo pipefail

EXP_ID="${1:?usage: run-capacity.sh <exp_id>}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results"

tools/collect-env.sh "$EXP_ID" > /dev/null 2>&1 || true
scripts/reset-state.sh
scripts/restart-app.sh "$DIR/results/gc.log"
sleep 5

# 预热
BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 60s \
  loadtest/read-path.js > /dev/null

# 运行阶梯(同时记录饱和度指标)
(
  while true; do
    echo "$(date +%s) $(curl -s --max-time 3 localhost:8080/metrics \
      | grep -E 'db_pool_pending|db_pool_active|app_coroutines_active' | tr '\n' ' ')"
    sleep 10
  done
) > "$DIR/results/saturation-timeline.txt" &
MONITOR=$!

trap 'kill $MONITOR 2>/dev/null || true' EXIT

BASE_URL=http://127.0.0.1:8080 \
k6 run --out json="$DIR/results/k6-staircase.json" \
       --summary-export="$DIR/results/k6-summary.json" \
       loadtest/capacity-staircase.js | tee "$DIR/results/k6-stdout.txt"

kill $MONITOR 2>/dev/null || true

echo
echo "═══ 首先验证数据可用性 ═══"
python3 - "$DIR/results/k6-summary.json" <<'PY'
import json, sys
m = json.load(open(sys.argv[1]))["metrics"]
print(f"  总请求数: {m['http_reqs']['count']:.0f}")
print(f"  实际到达率: {m['http_reqs']['rate']:.1f} req/s")
print(f"  错误率: {m['http_req_failed']['rate']:.2%}")
print()
print("  ⚠️ 如果实际到达率明显低于阶梯设定,说明 preAllocatedVUs 不够")
print("     或压测机是瓶颈 —— 此时曲线不可用(第 3.4 节)")
PY

echo
echo "═══ 找拐点 ═══"
python3 tools/find-knee.py "$DIR/results/k6-staircase.json" "200,400,700,1000,1500,2000"

三、预期结果(在埋雷服务上)

窗口    P50(ms)   P99(ms)   延迟/负载增长率  判断
200        12.0     180.0              1.00
400        12.5     310.0              0.86   ← 延迟涨得比负载快
700        18.2     620.0              1.72   ← 拐点候选
1000       45.0    1200.0              3.33   已进入排队区
1500      180.0    2800.0             10.4    已进入排队区
2000      420.0    5200.0             23.1    接近崩溃

关键观察:

观察 含义
拐点约在 400–700 RPS 远低于"看起来应该能扛"的量级
P99 在 200 RPS 时就已经 180ms 已经超过 SLO(100ms)——说明埋雷 ① 从低负载就存在
增长率从 0.86 跳到 1.72 再跳到 3.33 典型的非线性拐点

与 SLO 的对比:

SLO 要求:P99 < 100ms,峰值 3000 RPS

实测:
  P99 < 100ms 的负载 → 约 150 RPS(曲线最左端)
  拐点 → 约 400–700 RPS
  3000 RPS 时 → P99 = 5200ms(完全不可用)

结论:当前系统离 SLO 目标差了 20 倍容量

这就是埋雷的价值:一个缺失的索引,让一个"设计上能扛 3000 RPS"的服务只能扛 150 RPS。


四、同时观察饱和度(关键)

只看 QPS 和延迟是不够的——要知道哪一类资源先饱和:

# 从 saturation-timeline.txt 里看趋势
python3 - <<'PY'
import re, pathlib
p = pathlib.Path("docs/experiments/E10-capacity/results/saturation-timeline.txt")
if not p.exists():
    print("(无饱和度时间线数据)")
else:
    print(f"{'时间点':<12}{'pending':>10}{'active':>10}{'协程数':>10}")
    print("-" * 45)
    for i, line in enumerate(p.read_text().splitlines()):
        if i % 3 != 0:      # 每 30 秒打一个点
            continue
        parts = line.split()
        ts = parts[0]
        m = dict(re.findall(r"(\S+)\s+([\d.]+)", " ".join(parts[1:])))
        pend = m.get("db_pool_pending", "?")
        act = m.get("db_pool_active", "?")
        cs = m.get("app_coroutines_active", "?")
        print(f"{ts:<12}{pend:>10}{act:>10}{cs:>10}")
PY

预期发现:

时间点        pending    active     协程数
...
1700000000        0.0       10.0       50.0    ← 200 RPS:正常
1700000030        0.0       10.0       85.0    ← 400 RPS:正常
1700000060        3.0       10.0      240.0    ← 700 RPS:pending 开始上升 ⭐
1700000090       18.0       10.0      680.0    ← 1000 RPS:池打满
1700000120       42.0       10.0     1420.0    ← 1500 RPS:严重排队

关键发现:pending 开始上升的点,与拐点吻合(都在 700 RPS 附近)。

但注意:pending 上升是"结果"而不是"原因"——连接被慢查询占住了(埋雷 ①),所以新请求只能排队。

这正是第 6.5 节强调的:pending > 0 时要先查慢查询,不要先加池。


五、用饱和度定位「哪一类资源先饱和」

这一步的意义:在进入步骤五(剖析)之前,你已经有了假设的方向。

饱和度指标 观察 推论
CPU 42%(不高) 排除计算瓶颈
连接池 pending 700 RPS 后上升 指向数据库连接被占用
活跃协程数 从 50 涨到 1420 协程在等(不是 CPU 不够)
队列深度 0 排除线程池饥饿
GC 停顿 P99 18 ms(正常) 排除 GC
缓存命中率 75%(稳定) 未命中的 25% 走数据库

综合推论:

CPU 不高 + 协程数暴涨 + 连接池排队 + 协程在等
→ 假设:数据库查询慢,占住了连接,导致请求排队
→ 具体是什么慢?需要步骤五的 EXPLAIN / 火焰图

注意这里已经能"指向"了,但还不能"确认"——确认需要 EXPLAIN 看到 Seq Scan。


六、这一步的验收

## 步骤四验收清单

### 数据可用性
- [ ] **实际到达率与设定值一致**(偏差 < 5%)
- [ ] 每级稳态窗口 ≥ 2 分钟
- [ ] 错误率在拐点前为 0(否则延迟数据可能被污染)

### 曲线
- [ ] 画出了「到达率 → P99」曲线(或至少列出各窗口的数据)
- [ ] 找到了**拐点**(延迟增长率明显 > 1 的位置)
- [ ] 记录了**崩溃点**(错误率开始上升的负载)

### 饱和度
- [ ] 同时记录了饱和度时间线
- [ ] 明确了**哪一类资源先饱和**(这是步骤五的输入)
- [ ] 记录了「哪些假设可以被排除」(比如 CPU 不高 → 排除计算瓶颈)

### 记录
- [ ] 曲线数据落盘
- [ ] 明确了「当前容量 vs SLO 要求的差距」

七、本节小结

  1. 阶梯加压的关键是「每级稳态 2–5 分钟」——太短会得到平坦虚低的曲线。
  2. 必须先验证「实际到达率 == 设定值」——否则数据作废。
  3. 在埋雷服务上,拐点会低得惊人(400–700 RPS vs SLO 要求的 3000)——一个缺失的索引让容量差了 20 倍。
  4. 同时记录饱和度时间线——它告诉你「哪一类资源先饱和」。
  5. pending 上升与拐点吻合,但要记住 pending 是结果不是原因(连接被慢查询占住)。
  6. 这一步的产出不只是曲线,还有「可以被排除的假设」——它是步骤五的输入。

八、自测

  1. 你的阶梯加压每级只跑 30 秒,得到一条平坦且偏低的曲线(P99 一直在 60ms 以内)。请说明这个结论有什么问题,以及正确的做法。
  2. 饱和度时间线显示:CPU 42%、连接池 pending 从 700 RPS 开始上升、队列深度为 0、GC 停顿正常。请说出你能排除哪些假设,以及指向什么方向。
  3. 为什么说「pending 上升是结果而不是原因」?如果直接按"连接池太小"去加大池子,会发生什么?
  1. 问题:每级 30 秒太短,导致三个后果——① 队列还没建立:刚升到这一级时连接池是空的、队列是空的,延迟短暂偏低,测出的是"系统还没开始排队"的状态;② 缓存还是热的:如果上一级刚跑完,缓存命中率偏高;③ 覆盖不到 GC 周期与缓存过期:可能完全错过了周期性的延迟尖刺。结果:曲线整体偏低且平坦,找不到真正的拐点,可能误判"能扛 2000 RPS"(实际拐点可能在 400)。正确做法:每级改成「爬升 30 秒 + 稳态 2 分钟」,并且只用稳态窗口的数据做分析(爬升段的数据混合了多个负载水平,不可用)。
  2. 能排除的假设:① CPU 计算瓶颈(CPU 只有 42%,说明时间花在等待上);② 线程池饥饿(队列深度为 0);③ GC 停顿(GC 正常,且与延迟尖刺不对齐);④ 也能部分排除**「并发量本身太高」(QPS 加到 700 才出现 pending,说明不是设计缺陷)。指向的方向:数据库方向——pending 上升说明连接被长时间占用**,也就是查询变慢(连接池是结果不是原因)。下一步:① 查 pg_stat_statements 找最贵的查询(看 calls 和 mean_exec_time);② 用 EXPLAIN (ANALYZE, BUFFERS) 看执行计划(预期会看到 Seq Scan,因为埋雷 ① 没建索引);③ 用 pg_stat_activity 确认是"在算"还是"在等锁"。
  3. 因为 pending 反映的是「连接池里有多少请求在等」,而请求为什么要等,取决于连接被占用多久。在埋雷服务里,连接被占用的原因是查询慢(无索引导致全表扫描,每次 380ms)——所以:① 慢查询是原因(占用连接 380ms);② pending 上升是结果(10 个连接被慢查询占满,新请求只能排队)。如果直接加大池子:① 把压力转移到数据库——原来 10 个并发慢查询,现在变成 30 个(甚至更多),数据库的 CPU、IO、锁竞争都会加剧;② 整体可能更慢——因为慢查询之间的锁竞争与上下文切换增加;③ 掩盖真实问题——应用层的 pending 消失了,但查询仍然慢,只是排队位置从"应用连接池"移到了"数据库的内部队列",更难定位;④ 可能拖垮数据库——影响所有依赖它的服务。正确顺序:先优化查询(加索引),让单次查询从 380ms 降到 5ms,那么 pending 会自然消失(因为连接释放得快了)。