性能测试进阶:从 JMeter 基线到 SLO 驱动的压测体系

系列专栏:测试工程师每日一博 · 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 派生压测参数时,要算清三个量:

  1. 错误预算(Error Budget) = 1 − SLO 可用性目标。99.9% → 0.001;
  2. 允许失败数(Allowed Failures) = 错误预算 × 时间窗口内总请求量;
  3. 压力系数(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 阈值,他从"人眼读日志"升级为"端到端自动判定"。这不是科幻,是写完读完这一天就能落地的工程动作。

十三、原则三句话

  1. SLO 不是用来监控的,是用来倒灌到压测输入侧的 ------ 压测成功标准应当从 SLO 派生,不是拍脑袋;
  2. 拐点比极限重要 ------ 找出拐点 × 0.7 当生产安全水位,而不是"系统在 800 并发时挂"这种事故结论;
  3. 压测流量必须可识别 ------ 否则它本身就是噪音源,污染右移监控。

十四、如何起步(给没做过 SLO 压测的团队)

如果你团队还在 Day 6 的基线压测阶段,这一篇定量很大。一个温和的 3 步迁移路径:

  1. 本周 :把现有 slo.yaml(如果有)的位置摸清。没有就先写一份"可用性 + P99 延迟"两条 SLI,30d 窗口 ------ Day 12 §3 给过模板;
  2. 本月 :写一份 capacity_curve() 脚本,跑一次 5 档并发(50/100/200/400/800),画出拐点。不要立刻接 CI,先自己看曲线;
  3. 本季度:把 k6 + SLO gate 接到每夜 CI(本篇 §7 模板),并配上 synthetic 数据(Day 14 §5.3)。这一步完整后,才算"工业级压测体系"落地。

重点是先把"SLO → 压测"这条线打通,再装自动化------很多团队一上来就接 CI,但 SLO 还没定义清楚,等于 gate 没刷线,即便跑半天也只是噪音。

十五、思考题

留给今晚:

  1. 你团队最近一次压测是基线 / 负载 / 应力 / 尖峰四种里的哪种?如果只能说出"性能压测"四个字,大概率错选了模型;
  2. 容量曲线系统化了吗?或者最近一次压测还停在"压完拍一张聚合报告截图发群"?
  3. 你的 SLO 文件(slo.yaml)在哪里被使用?如果只在 Prometheus 告警用,它在压测侧就是"死契约"------ 没倒灌到输入,等于没用;
  4. 如果明天合规要求"所有压测流量必须可识别"用于审计,你能在多快时间内给所有 JMeter / k6 脚本加 X-Load-Test: true header?

十六、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 协作模式。下篇见 👋

相关推荐
Albart5751 小时前
2026年Python入门路线图:从零到实战的30天逐日详解(三)
学习·flask·#python30 天入门·python 实战教程·pytest 单元测试·python 虚拟环境·logging 日志
caimouse2 小时前
MM 学习笔记 09:进程会话与工作集
笔记·学习·reactos
星恒随风2 小时前
C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计
开发语言·c++·笔记·学习
电子云与长程纠缠2 小时前
UE中使用TGuardValue与TInlineComponentArray数据结构
开发语言·数据结构·学习·ue5·游戏引擎
caimouse3 小时前
MM 学习笔记 10:页面文件与系统加载器
笔记·学习·reactos
一只小菜鸡..3 小时前
南京大学 操作系统 (JYY) 学习笔记:Hello, OS World! (程序的机器视角)
java·笔记·学习
lazy H4 小时前
从入门到日常开发,一篇文章掌握 Git 核心操作
git·后端·学习·github
Ameilide13 小时前
51单片机学习 day1
嵌入式硬件·学习·51单片机