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是可选依赖:脚本设计成"没装也能继续",避免为了画图卡住流程。