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 要求的差距」
七、本节小结
- 阶梯加压的关键是「每级稳态 2–5 分钟」——太短会得到平坦虚低的曲线。
- 必须先验证「实际到达率 == 设定值」——否则数据作废。
- 在埋雷服务上,拐点会低得惊人(400–700 RPS vs SLO 要求的 3000)——一个缺失的索引让容量差了 20 倍。
- 同时记录饱和度时间线——它告诉你「哪一类资源先饱和」。
pending上升与拐点吻合,但要记住pending是结果不是原因(连接被慢查询占住)。- 这一步的产出不只是曲线,还有「可以被排除的假设」——它是步骤五的输入。
八、自测
- 你的阶梯加压每级只跑 30 秒,得到一条平坦且偏低的曲线(P99 一直在 60ms 以内)。请说明这个结论有什么问题,以及正确的做法。
- 饱和度时间线显示:CPU 42%、连接池 pending 从 700 RPS 开始上升、队列深度为 0、GC 停顿正常。请说出你能排除哪些假设,以及指向什么方向。
- 为什么说「
pending上升是结果而不是原因」?如果直接按"连接池太小"去加大池子,会发生什么?
- 问题:每级 30 秒太短,导致三个后果——① 队列还没建立:刚升到这一级时连接池是空的、队列是空的,延迟短暂偏低,测出的是"系统还没开始排队"的状态;② 缓存还是热的:如果上一级刚跑完,缓存命中率偏高;③ 覆盖不到 GC 周期与缓存过期:可能完全错过了周期性的延迟尖刺。结果:曲线整体偏低且平坦,找不到真正的拐点,可能误判"能扛 2000 RPS"(实际拐点可能在 400)。正确做法:每级改成「爬升 30 秒 + 稳态 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确认是"在算"还是"在等锁"。 - 因为
pending反映的是「连接池里有多少请求在等」,而请求为什么要等,取决于连接被占用多久。在埋雷服务里,连接被占用的原因是查询慢(无索引导致全表扫描,每次 380ms)——所以:① 慢查询是原因(占用连接 380ms);② pending 上升是结果(10 个连接被慢查询占满,新请求只能排队)。如果直接加大池子:① 把压力转移到数据库——原来 10 个并发慢查询,现在变成 30 个(甚至更多),数据库的 CPU、IO、锁竞争都会加剧;② 整体可能更慢——因为慢查询之间的锁竞争与上下文切换增加;③ 掩盖真实问题——应用层的pending消失了,但查询仍然慢,只是排队位置从"应用连接池"移到了"数据库的内部队列",更难定位;④ 可能拖垮数据库——影响所有依赖它的服务。正确顺序:先优化查询(加索引),让单次查询从 380ms 降到 5ms,那么pending会自然消失(因为连接释放得快了)。