系列专栏:测试工程师每日一博 · Day 15
上一篇:Day 14 · 测试数据治理:从 fixture 到 synthetic data 三层栈拆解
这一篇是 Day 6(JMeter / Locust 引子)的展开,也是系列里第一次把右移的 SLO(Day 12)倒灌到压测输入侧 ------ 让压测不再"凭感觉压一压",而是用错误预算决定"压到多少算够"。
一、为什么是 Day 15
Day 6 我们埋下了一颗种子:JMeter 跑一次 1000 并发,看 P99 延迟,绿灯就过、红灯就调。这是基线压测,够入门,但远远不够"工程化"。
那工程化要解决哪几个问题?
| 问题 | 基线压测的答案 | SLO 驱动的答案 |
|---|---|---|
| 压多少并发? | 拍脑袋 1000 | 错误预算 ÷ 请求量 × 多余冗余 |
| 压多久? | 5 分钟走一遍 | 至少覆盖 1 个错误预算窗口(1h or 30d) |
| 什么时延算"过"? | P99 < 500ms | SLI 达标率 × burn rate 双窗口 |
| 跑完看啥? | 一张聚合报告 | SLO 合规度 + 容量曲线 + 错误预算消耗速率 |
| 谁来读结果? | 测试工程师 | 测试 + SRE + 业务(三方对齐) |
也就是说,Day 15 把 Day 6 的"测试工程师技能"升级为"测试 + SRE 共有语言"。
二、Day 6 的基线压测长什么样
先快速回顾下我们的起点:
xml
<!-- jmeter/order_query.jmx 节选 -->
<ThreadGroup>
<num_threads>1000</num_threads>
<Duration>300</Duration> <!-- 5 分钟 -->
<HTTPSampler path="/api/orders?uid=${userId}" method="GET"/>
</ThreadGroup>
参数全是硬编码的经验值------为什么 1000?为什么 5 分钟?为什么是订单查询接口?Day 6 当时都没追问,因为"基线"已经够演示压测思路。
但生产里这一套会有 4 个坑(都对应着系列前文):
| 坑 | 后果 | 在前文出现在 |
|---|---|---|
| 1000 并发拍的脑袋 | 真实流量峰值来临前不知道系统能否扛 | Day 6 / Day 12 |
| 5 分钟跑不出"长尾事件" | JVM GC、DB 连接池打满等周期性事件抓不到 | Day 10 flaky |
| 测试数据是"${userId}=1~100" | 99% 命中索引,完全压不到慢 SQL | Day 14 §5.5 分布漂移 |
| 一张聚合报告做判定 | P99 中位数掩盖 timeout 的尖刺 | Day 12 §3 可观测性三件套 |
Day 15 的工程化动作就是一个一个把这 4 个坑填掉。
三、SLO 驱动:把右移的 SLI 接回压测输入侧
Day 12 我们把 SLO 定义为"服务等级目标 = SLI(指标) + SLO(阈值) + 错误预算(剩余可燃配额)"。记得那一份 YAML:
yaml
# Day 12 的 slo.yaml(简版)
slo:
- name: order-query-availability
sli: "成功响应数 / 总请求数"
target: 0.999 # 99.9% 可用
window: 30d
- name: order-query-latency
sli: "P99 延迟 < 500ms 的请求比例"
target: 0.99 # 99% 请求在 500ms 内返回
window: 30d
那这一份 YAML 谁在用 ?过去是右移 Prometheus + Burn Rate 告警用的(Day 12 §3-4)。但它应该倒灌到压测输入侧 ------ 压测脚本启动前先读这份 YAML,把 SLO 翻译成压测参数:
python
def slo_to_load_profile(slo_yaml: dict) -> dict:
"""
把 SLO 翻译为 JMeter 压测 profile
关键:错误预算 ÷ 期望请求量 = 系统允许的最大失败请求数
"""
target_availability = 0.999
error_budget = 1 - target_availability # 0.001 = 千分之一
expected_qps_in_30d = 50 * 86400 * 30 # 50 QPS × 30 天总请求量
# 允许失败的请求数:总请求量 × 错误预算
allowed_failures = expected_qps_in_30d * error_budget # = 129,600
# 压测目标:让系统在"双倍生产峰值"下,仍能在 1h 内保持错误预算不耗尽
production_peak_qps = 50
stress_factor = 2.0
return {
"target_qps": production_peak_qps * stress_factor, # 100 QPS
"duration_minutes": 60, # 1h 窗口
"allowed_failures": int(allowed_failures / 30 / 24), # 1h 内允许 ~180 个
"latency_slo": {"P99": "500ms"},
"shutdown_if_failures_exceed": True,
}
这就是"SLO 驱动"的字面含义------把右移的 SLO 当作压测输入,而不是另写了一个 1000 并发的硬编码脚本。
3.1 三个关键派生量
从 SLO 派生压测参数时,要算清三个量:
- 错误预算(Error Budget) = 1 − SLO 可用性目标。99.9% → 0.001;
- 允许失败数(Allowed Failures) = 错误预算 × 时间窗口内总请求量;
- 压力系数(Stress Factor) = 压测使用的 QPS ÷ 生产峰值 QPS。一般取 1.5--2x,留出"线性扩展"的判定空间。
压测的成功标准不再是"P99 < 500ms",而是"在 2x 生产峰值下,1h 内消耗的错误预算 ≤ 期望值"。这是工业级压测跟课本级压测的核心区分。
四、压测形态的四种模型
工程师常把"压测"当成一个动作,但实际有四种,目的全不一样:
4.1 baseline(基线)
单接口、稳态并发,目的是摸底。Day 6 的就是这种。
┌─────┐
并发 ─────────────────│█████│ ← 持续 5 min
└─────┘
4.2 load(负载)
模拟"生产峰值",持续较长时间(1--12h),目的是看长尾。
并发 ┌───────────────┐
│███████████████│ ← 持续 1h+
└───────────────┘
专门用于抓 GC 尖刺、缓存预热、连接池打满这类Day 10 flaky 同款"周期性事件"。
4.3 stress(压力)
加压到系统挂为止,目的是找极限。
并发 ╱
╱
╱ ← 每分钟 +100 并发,直到 timeouts 暴增
╱
╱
╱
"系统在多少并发时崩溃 5% 的请求"是 stress 问题的答案。
4.4 spike(尖峰)
瞬间 10x / 100x 跳变并发,模拟外卖爆单 / 抢购开闸,目的是看 autoscalers 响应。
并发 ┃
┃ ← 100x 跳变,持续 30s
┌─────────────────┘
│
└──────────────── ← 平时稳态
四种形态的判定标准都不一样:
| 形态 | 成功标准 | 主要矛盾 |
|---|---|---|
| baseline | P99 合理 | 单接口代码效率 |
| load | 长尾指标稳定 | 资源耗尽 / GC |
| stress | 极限点符合容量曲线 | 容量规划 |
| spike | autoscaler 跟上,损伤可控 | 弹性扩容 |
很多团队的"性能问题"其实只是选错了模型------以为基线过了就稳了,实际缺的是 load 模型下 1h 的稳态测试。
五、容量曲线
压测的产出不应是一张聚合报告,而是一组容量曲线 ------ "并发数 × 时延 × 错误率"的三维曲面:
python
def capacity_curve(test_plan: str, concurrency_steps=(50, 100, 200, 400, 800)):
"""
阶梯加压,每档跑 10 min,记录所有指标
"""
curve = []
for c in concurrency_steps:
result = run_jmeter(test_plan, threads=c, duration=600)
curve.append({
"concurrency": c,
"p50_ms": result.latency_p50,
"p99_ms": result.latency_p99,
"error_rate": result.errors / result.total,
"throughput": result.throughput,
})
return curve
画出来后,典型的曲线长这样:
错误率 │ ━━━━━━━━━━ ← breakdown point
│ ━━━━
│ ━━━━
│ ━━━━
│ ━━━━
│ ━━━━━
└────────────────────────────────────
50 100 200 400 800 并发
这条**拐点(Knee)**极其重要 ------ 拐点之前是"线性扩展",拐点之后是"加速退化"。生产峰值应当设在拐点 × 0.7,留下 30% 安全冗余。
注意很多团队卡在"压到 800 才挂"这种结论上就停了 ------ 错。关键是找出拐点在哪,而不是"挂在哪里"。前者是规划,后者只是事故复盘。
六、自动化数据承上:接 Day 14 fixture/synthetic
Day 14 §5.5 给出了 synthetic_pipeline(...) + 分布漂移校验。压测是大客户 ------ 不接 synthetic,会直接撞到 Day 6 的硬编码 \${userId}=1~100 的坑(全部命中索引,99% 数据 cached,慢 SQL 完全没被压到)。
python
def perf_pipeline(slo_yaml: str, test_plan: str):
# 1. SLO → 负载 profile
load = slo_to_load_profile(yaml_load(slo_yaml))
# 2. 用 synthetic 生成符合业务分布的 10w 条测试数据
users = synthetic_pipeline(
spec=json_load("perf_user_spec.json"),
n=100_000,
mode="fast",
)
write_csv("testdata/perf_users.csv", users)
# 3. 分布校验(防 99% V1 等级 + 小金额"假合成")
drift = drift_score(
sample=[u["amount"] for u in users],
reference=load_prod_stats("user.amount"), # 已脱敏
)
assert drift < 0.1, f"分布漂移 {drift:.3f},压测结果不可信"
# 4. 跑 JMeter / k6 / Locust
return run_load_test(test_plan, csv="testdata/perf_users.csv", **load)
这就是 Day 14 跟 Day 15 的实际衔接点:没有 Layer 3 synthetic,可靠压测无从谈起。读者如果跳着读,需要在脑子里把这"数据 → 压测"的线画清楚。
七、自动化判断:让 CI 做红灯
Day 7(CI)我们讲过预提交 → PR → 每夜的时间门控。压测不进 PR (太慢),但进每夜 + 发版前两层:
yaml
# .github/workflows/nightly-perf.yml
name: Nightly Performance Gate
on:
schedule: [{cron: "0 2 * * *"}] # 每晚 2 点
workflow_dispatch:
jobs:
perf-gate:
runs-on: perf-runner # 专用 runner,避免 CI 机器本身抖动污染数据
steps:
- uses: actions/checkout@v4
- name: Generate synthetic data
run: python tools/synthetic_pipeline.py --n 100000 --out testdata/perf.csv
- name: Run load test (1h, 2x peak QPS)
run: |
jmeter -n -t perf/order_query.jmx \
-Jusers=testdata/perf.csv \
-Jtarget_qps=100 -Jtest_minutes=60 \
-l result.jtl
- name: SLO Compliance Gate
run: python tools/slo_gate.py --result result.jtl --slo slo.yaml
slo_gate.py 的红线是:
python
def slo_gate(result_jtl: str, slo_yaml: str):
samples = parse_jtl(result_jtl)
total = len(samples)
failed = sum(1 for s in samples if not s.success)
p99_slow = sum(1 for s in samples if s.latency_ms > 500)
availability = (total - failed) / total
latency_ok_ratio = 1 - p99_slow / total
# 红灯:任一 SLI 低于 SLO 即拒掉发版
assert availability >= 0.999, f"AVAILABILITY FAIL: {availability:.4f}"
assert latency_ok_ratio >= 0.99, f"LATENCY FAIL: {latency_ok_ratio:.4f}"
# 警告:错误预算消耗过快(burn rate),即使达标也要告警
burn_rate_1h = failed / total / (1 - 0.999)
if burn_rate_1h > 4: # 4x 是 1h 窗口的标准告警阈值
notify_sre(f"Burn rate high: {burn_rate_1h:.2f}x")
注意第二个红线------burn rate 提前预警。即便 SLI 还达标,只要消耗速率过快,就该告警 ------ 这是 Day 12 的 burn rate 双窗口在压测侧的镜像。
八、k6 vs JMeter vs Locust 三选
每个团队都问过 "用什么压测工具好":
| 工具 | 语言 | 优势 | 劣势 |
|---|---|---|---|
| JMeter | Java + GUI | 老牌、生态广、企业右移交接友好 | 单机吞吐有限,横向扩展难 |
| Locust | Python | 写法轻,代码即脚本 | 海量并发靠 Master/Worker,部署重 |
| k6 | Go (JS 写脚本) | 单机吞吐极高,原生指标导出 Prometheus | 团队 JavaScript 习惯门槛 |
我推荐的技术决策k6 居多,理由有三:
javascript
// k6 脚本示例:JSON / JS,代码即压测
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '5m', target: 100 }, // warm up
{ duration: '60m', target: 100 }, // 1h 稳态
],
},
},
thresholds: {
// 直接在脚本里写 SLO 阈值,跟 CI gate 共用
'http_req_failed': ['rate<0.001'], // 可用性 99.9%
'http_req_duration': ['p(99)<500'], // P99 < 500ms
},
};
export default function () {
const userId = __VU + '-' + __ITER;
const res = http.get(`http://api/orders?uid=${userId}`);
check(res, { 'status 200': r => r.status === 200 });
}
三个推荐 k6 的理由:① 单机吞吐比 JMeter 高 5--10x;② thresholds 跟 CI gate 同一份 SLO,共用;③ 原生 Prometheus exporter,直接喂到右移的监控栈(Day 12)。
九、容量预算 + 灰度发布对位(Day 12 闭环)
容量曲线出来后,没结束 ------ 下一站是把它写入容量预算(Capacity Budget),接到 Day 12 的灰度发布上:
yaml
# capacity-budget.yaml(Day 12 右移和 Day 15 压测的衔接 YAML)
service: order-api
planned_slo:
availability: 0.999
latency_p99_ms: 500
production_peak_qps: 50
stress_factor: 2.0 # 压测用 2x
capacity_curve_knee_qps: 120 # 拐点
production_safe_ceiling: 84 # 拐点 × 0.7
# 灰度策略(Day 12 §6 Argo Rollouts)读这一行
rollout:
max_qps_per_canary_pod: 30 # 单 canary pod 不超过 30 QPS
canary_traffic_percent: [5, 25, 50, 100]
pause_between_steps: 5m # 每个 step 至少观察 5 min 错误预算
这就是 Day 12 的灰度(Day 15 的容量曲线作为它的输入)真正接力:
Day 14 synthetic → Day 15 capacity curve → Day 12 canary rollout → Day 12 Prometheus burn rate → 闭环
完整链路:用数据压出拐点,用拐点定安全水位,用安全水位定灰度策略,用灰度策略上生产,用生产 burn rate 反向验证 SLO。
十、反模式清单
| 反模式 | 后果 | 干掉的办法 |
|---|---|---|
| 拍脑袋定 1000 并发 | 拐点没找到,容量规划没依据 | SLO 驱动 + 容量曲线 |
用 \${userId}=1~100 做数据 |
99% 命中索引,慢 SQL 没压 | Day 14 synthetic + 漂移校验 |
| 用画聚合报告判断 | P99 中位数掩盖 timeout 尖刺 | SLI + burn rate 双窗口 |
| 压一次就雪藏 | 系统演进后曲线漂移 | 每夜 + 发版前两道门 |
| 压测用 CI 共享 runner | runner 自身负载污染数据 | 专用 perf-runner |
| 压测期间不发"压测流量"标识 | 灰度 + 可观测混淆 | http header X-Load-Test: true |
最后一条很关键 ------ 压测流量必须可识别,这样 Day 12 的可观测三件套才能区分"生产真实异常"和"压测流量"。否则 SLI 永远被压测数据"加噪"。
十一、回扣系列
| 当我们说... | 这一节里它在做 |
|---|---|
| Day 6 JMeter 基线 | §2 起点 + §8 工具三选 |
| Day 7 CI 分层 | §7 每夜门控 |
| Day 10 flaky 周期事件 | §4.2 load 形态抓长尾 |
| Day 12 SLO + 错误预算 | §3 倒灌到压测输入侧 |
| Day 12 灰度发布 | §9 容量预算接 canary |
| Day 14 synthetic data | §6 压测数据承上 |
| Day 13 LLM 测试 | 压测报告解读(下一节展开) |
跟 Day 13 / Day 14 一样,性能测试也是横切钢筋之一 ,但本节没把它叫"钢筋" ------ 因为它本来在金字塔的右上区域有自己的位置(Day 1)。它会接到 Day 12(右移侧)和 Day 14(数据侧),这就够本篇的回归定位。
十二、Day 13 LLM 接进来:压测报告解读
Day 13 §3 给了"报告解读"作为 LLM 四块拼图之一。压测的 100MB JTL 文件正是它的大客户:
python
def analyze_perf_report(jtl_path: str, slo: dict) -> str:
samples = parse_jtl(jtl_path)
hot_paths = group_by_endpoint(samples)
# 给一个 fast LLM:top 5 异常点 + 关联 trace + 3 个候选根因
summary = llm_gateway.complete(
prompt=build_prompt(hot_paths, slo),
max_tokens=400,
temperature=0,
)
return summary
# 周一早上测试工程师收到:
# """
# 上周每夜压测 7 次中,有 2 次在并发 200 时出现 P99 > 800ms,
# top 1 hot path: /orders?status=PENDING (23% 流量,贡献 60% 慢请求)
# top 1 嫌疑:status 字段缺索引(关联 trace: OrderMapper.selectByStatus)
# 建议修复:
# 1. ALTER TABLE orders ADD INDEX idx_status (status, created_at);
# 2. 复跑容量曲线,确认拐点右移
# """
把 Day 13 §6 + Day 15 §6 + Day 15 §12 三段读一遍,会发现测试工程师天天面对"茫茫日志" --- 但有了 LLM 摘要 + synthetic 数据 + SLO 阈值,他从"人眼读日志"升级为"端到端自动判定"。这不是科幻,是写完读完这一天就能落地的工程动作。
十三、原则三句话
- SLO 不是用来监控的,是用来倒灌到压测输入侧的 ------ 压测成功标准应当从 SLO 派生,不是拍脑袋;
- 拐点比极限重要 ------ 找出拐点 × 0.7 当生产安全水位,而不是"系统在 800 并发时挂"这种事故结论;
- 压测流量必须可识别 ------ 否则它本身就是噪音源,污染右移监控。
十四、如何起步(给没做过 SLO 压测的团队)
如果你团队还在 Day 6 的基线压测阶段,这一篇定量很大。一个温和的 3 步迁移路径:
- 本周 :把现有
slo.yaml(如果有)的位置摸清。没有就先写一份"可用性 + P99 延迟"两条 SLI,30d 窗口 ------ Day 12 §3 给过模板; - 本月 :写一份
capacity_curve()脚本,跑一次 5 档并发(50/100/200/400/800),画出拐点。不要立刻接 CI,先自己看曲线; - 本季度:把 k6 + SLO gate 接到每夜 CI(本篇 §7 模板),并配上 synthetic 数据(Day 14 §5.3)。这一步完整后,才算"工业级压测体系"落地。
重点是先把"SLO → 压测"这条线打通,再装自动化------很多团队一上来就接 CI,但 SLO 还没定义清楚,等于 gate 没刷线,即便跑半天也只是噪音。
十五、思考题
留给今晚:
- 你团队最近一次压测是基线 / 负载 / 应力 / 尖峰四种里的哪种?如果只能说出"性能压测"四个字,大概率错选了模型;
- 容量曲线系统化了吗?或者最近一次压测还停在"压完拍一张聚合报告截图发群"?
- 你的 SLO 文件(
slo.yaml)在哪里被使用?如果只在 Prometheus 告警用,它在压测侧就是"死契约"------ 没倒灌到输入,等于没用; - 如果明天合规要求"所有压测流量必须可识别"用于审计,你能在多快时间内给所有 JMeter / k6 脚本加
X-Load-Test: trueheader?
十六、TL;DR
- SLO 驱动 = 把错误预算翻译为压测 QPS / 时长 / 允许失败数;
- 四种压测形态(baseline / load / stress / spike)目的不同,不要混用;
- 容量曲线 + 拐点 是工业级压测的核心交付物,不是聚合报告;
- Day 6 + Day 12 + Day 14 + Day 13 四条线在 Day 15 闭合:synthetic → 压测 → SLO → 灰度 → 反向验证 → LLM 摘要;
- 压力系数 取 2x,安全水位取拐点 × 0.7。
下一篇:Day 16 · 测试工程师的危机时刻:线上故障复盘的标准动作
Day 12 右移 + Day 15 压测都解决"上线前的健康度",但上线后坏了怎么办?下一篇把"故障复盘(blameless postmortem)"的工程动作做透 ------ 时间线还原、5 why 根因分析、行动项追踪、与 SRE 协作模式。下篇见 👋