1.6 配套代码:阶梯加压、拐点检测与容量推算
对应小节:1.6 基线与容量曲线 三件事:① 压出曲线 ② 找到拐点 ③ 推出安全容量与副本数。
一、阶梯加压脚本(k6)
// loadtest/capacity-staircase.js
import http from 'k6/http';
import { check } from 'k6';
// 阶梯:每一级"爬升 30s + 稳态 120s"
// 稳态窗口才是用来采数据的,爬升期不计入
const LEVELS = [100, 200, 400, 700, 1000, 1500, 2000];
const stages = [];
for (const target of LEVELS) {
stages.push({ target, duration: '30s' }); // 爬升
stages.push({ target, duration: '2m' }); // 稳态(采数据)
}
export const options = {
scenarios: {
staircase: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 500,
maxVUs: 5000, // 必须给够,否则实际到达率低于设定值
stages,
},
},
// 这里故意不设 thresholds:容量测试的目的是"找到拐点",不是"判定通过/失败"
discardResponseBodies: true,
};
export default function () {
// 用幂律分布取样,制造真实的热点
const u = Math.random();
const id = Math.max(1, Math.floor(1_000_000 * Math.pow(u, 3)) + 1);
const res = http.get(`${__ENV.BASE_URL}/orders/${id}?stage=${__ENV.STAGE ?? 'unknown'}`);
check(res, { ok: (r) => r.status === 200 || r.status === 404 });
}
# 运行并把原始数据落盘
k6 run --out json=docs/experiments/E02-capacity/results/k6-raw.json \
--summary-export=docs/experiments/E02-capacity/results/k6-summary.json \
loadtest/capacity-staircase.js
关键检查:跑完后先看实际 QPS 是否等于设定值。如果 k6 报告的到达率低于 LEVELS,说明 preAllocatedVUs 不够——此时曲线不可用(第 0 章 0.6 节的协调遗漏)。
二、拐点检测
先说清一件事:拐点检测是启发式的,最终必须人工看一眼曲线。 下面这个脚本给你一个候选点,不是标准答案。
# tools/find_knee.py
"""
从 k6 的分段结果里找拐点。
思路:把每一级的 P99 与 QPS 画成曲线后,"拐点"是延迟增长率开始显著超过负载增长率的那个位置。
判据:growth_ratio = (p99[i]/p99[0]) / (qps[i]/qps[0])
growth_ratio 明显 > 1 说明"延迟涨得比负载快" → 排队开始显现
"""
import json, sys, pathlib
def load_stages(path):
"""从 k6 原始 JSON 里按 stage 标签聚合(简化示例,实际按时间窗切片)"""
rows = []
with open(path) as f:
for line in f:
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
if d.get("type") == "Point" and d.get("metric") == "http_req_duration":
rows.append((d["data"]["time"], d["data"]["value"]))
rows.sort()
return rows
def summarize(rows, windows=7):
"""把时间轴等分成若干窗口,每个窗口算 P50/P99(简化:用最大近似 P99 上界)"""
if not rows:
return []
n = len(rows) // windows
out = []
for i in range(windows):
chunk = [v for _, v in rows[i * n:(i + 1) * n]]
if not chunk:
continue
chunk.sort()
out.append({
"p50": chunk[len(chunk) // 2],
"p99": chunk[min(int(len(chunk) * 0.99), len(chunk) - 1)],
})
return out
def find_knee(stats, qps_levels):
"""
返回 (拐点索引, 每个点的 growth_ratio)
growth_ratio 明显大于 1 → 延迟增长快于负载增长 → 排队开始
"""
if not stats:
return -1, []
base_p99 = stats[0]["p99"] or 1e-9
base_qps = qps_levels[0]
ratios = []
for i, s in enumerate(stats):
qps = qps_levels[min(i, len(qps_levels) - 1)]
latency_growth = s["p99"] / base_p99
load_growth = qps / base_qps
ratios.append(latency_growth / load_growth)
# 拐点 = growth_ratio 首次明显超过基线(> 1.5)的位置
for i, r in enumerate(ratios):
if r > 1.5:
return i, ratios
return len(ratios) - 1, ratios
if __name__ == "__main__":
path = sys.argv[1]
levels = [int(x) for x in sys.argv[2].split(",")]
stats = summarize(load_stages(path), windows=len(levels))
knee, ratios = find_knee(stats, levels)
print(f"{'窗口':<6}{'P50(ms)':>10}{'P99(ms)':>10}{'延迟/负载增长率':>18} 判断")
for i, s in enumerate(stats):
qps = levels[min(i, len(levels) - 1)]
verdict = ""
if i == knee:
verdict = "← 拐点候选"
elif ratios[i] > 1.5:
verdict = "已进入排队区"
print(f"{qps:<6}{s['p50']:>10.1f}{s['p99']:>10.1f}{ratios[i]:>18.2f} {verdict}")
print()
print("⚠️ 这只是候选点。请务必人工看一眼曲线,并对照饱和度指标确认")
print(" (连接池 pending / 线程池队列 / CPU 节流 是哪一类先动)。")
输出形态:
窗口 P50(ms) P99(ms) 延迟/负载增长率 判断
100 12.0 45.0 1.00
200 12.5 48.0 1.07
400 13.1 52.0 1.16
700 14.0 61.0 1.36
1000 18.2 95.0 2.11 ← 拐点候选
1500 41.0 310.0 9.17 已进入排队区
2000 120.0 1400.0 62.5 已进入排队区
注意 growth_ratio 从 1.36 跳到 2.11 再跳到 9.17 —— 这个非线性跳跃就是拐点的特征。
三、从拐点推算容量与副本数
// src/main/kotlin/capacity/CapacityPlan.kt
package capacity
import kotlin.math.ceil
data class CapacityInput(
val kneeQps: Int, // 压测找到的拐点(单实例)
val headroomRatio: Double, // 建议 0.3 ~ 0.5
val peakQps: Int, // 业务预估峰值
val redundancy: Int = 1, // N+1 冗余:坏一台不影响服务
val instanceSpec: String,
)
data class CapacityPlan(
val safeCapacityPerInstance: Int,
val requiredReplicas: Int,
val utilizationAtPeak: Double,
val warnings: List<String>,
)
fun plan(input: CapacityInput): CapacityPlan {
val warnings = mutableListOf<String>()
if (input.headroomRatio < 0.2) {
warnings += "headroom %.0f%% 偏小:排队是非线性的,余量不足会在突发时雪崩"
.format(input.headroomRatio * 100)
}
if (input.headroomRatio > 0.6) {
warnings += "headroom %.0f%% 偏大:成本可能浪费,建议先用压测确认拐点是否测准"
.format(input.headroomRatio * 100)
}
val safe = (input.kneeQps * (1 - input.headroomRatio)).toInt()
val replicas = ceil(input.peakQps.toDouble() / safe).toInt() + input.redundancy
warnings += "副本翻倍 ≠ 容量翻倍:共享的数据库/Redis/下游服务会成为新瓶颈,扩容后必须复测"
warnings += "数据量增长会移动拐点(数据从 100 万到 1000 万,索引效率变化),建议每季度复测"
return CapacityPlan(
safeCapacityPerInstance = safe,
requiredReplicas = replicas,
utilizationAtPeak = input.peakQps.toDouble() / (safe * replicas),
warnings = warnings,
)
}
fun main() {
println("=== 从拐点推算容量与副本数 ===")
println()
val scenarios = listOf(
CapacityInput(kneeQps = 900, headroomRatio = 0.40, peakQps = 3000, instanceSpec = "4C8G"),
CapacityInput(kneeQps = 900, headroomRatio = 0.10, peakQps = 3000, instanceSpec = "4C8G"),
CapacityInput(kneeQps = 900, headroomRatio = 0.40, peakQps = 12000, instanceSpec = "4C8G"),
)
scenarios.forEach { input ->
val p = plan(input)
println("拐点 %d RPS,headroom %.0f%%,峰值 %d RPS(%s)"
.format(input.kneeQps, input.headroomRatio * 100, input.peakQps, input.instanceSpec))
println(" → 单实例安全容量 : %d RPS".format(p.safeCapacityPerInstance))
println(" → 所需副本数 : %d(含 %d 台冗余)".format(p.requiredReplicas, input.redundancy))
println(" → 峰值时利用率 : %.0f%%".format(p.utilizationAtPeak * 100))
p.warnings.forEach { println(" ⚠️ $it") }
println()
}
}
输出:
拐点 900 RPS,headroom 40%,峰值 3000 RPS(4C8G)
→ 单实例安全容量 : 540 RPS
→ 所需副本数 : 7(含 1 台冗余)
→ 峰值时利用率 : 79%
拐点 900 RPS,headroom 10%,峰值 3000 RPS(4C8G)
→ 单实例安全容量 : 810 RPS
→ 所需副本数 : 5(含 1 台冗余)
→ 峰值时利用率 : 74%
⚠️ headroom 10% 偏小:排队是非线性的,余量不足会在突发时雪崩
注意这两个方案的成本差:headroom 从 40% 降到 10%,机器从 7 台变成 5 台——省了 2 台机器的钱,代价是没有任何余量应对突发。这是一个明确的工程权衡,不是"越省越好"。
四、完整工作流
① 用 staircase 脚本压出曲线
↓
② 先验证「实际 QPS == 设定 QPS」(否则数据不可用)
↓
③ 用 find_knee.py 找候选拐点
↓
④ 人工看曲线 + 对照饱和度指标确认(是哪类资源先动)
↓
⑤ 用 plan() 算出安全容量与副本数
↓
⑥ 把拐点、headroom、日期、实验编号记进容量表
容量表模板:
| 实例规格 | 拐点(实测) | headroom | 安全容量 | 峰值 | 所需副本 | 数据来源 | 测量日期 |
|---|---|---|---|---|---|---|---|
| 4C8G | 900 RPS | 40% | 540 RPS | 3000 | 7 | E02 | 2025-xx-xx |
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 preAllocatedVUs 从 500 改成 20 |
实际 QPS 会明显低于设定值,曲线完全失真——这就是协调遗漏在实操中的样子 |
| 把稳态时长从 2m 改成 20s | 曲线会变得很抖,拐点位置每次跑都不一样 |
把 Find_knee 的阈值从 1.5 改成 1.2 |
拐点会前移——说明这个阈值是个判断,不是真理,必须人工确认 |
| 用均匀分布代替幂律分布再压一次 | 拐点会明显后移(缓存命中率高、锁竞争少),这就是为什么数据分布必须写进 SLO 前提 |
给 plan() 加一个「数据量增长系数」 |
思考:数据量翻 10 倍,拐点大概会怎么变? |
六、这段代码的局限
- 拐点检测是启发式的。真实的曲线形态多种多样(有双拐点、有硬天花板、有缓慢退化),脚本只能给候选,判断必须靠人。
- 它假设瓶颈是单一资源。如果 CPU 和连接池同时在 700 RPS 附近饱和,曲线会更复杂。
- 容量不是一次测完就固定的:数据量增长、依赖变化、业务形态变化都会移动拐点。建议每季度或每次大版本发布后复测。
- 脚本里的
summarize()是按时间等分窗口的简化实现。严谨做法是让 k6 按 stage 打标签,或直接读k6-summary.json的各阶段阈值结果。