Skip to content

性能基准回归体系:指标入库与趋势告警 ​

性能问题往往是"悄悄退化":单次压测看不出来,几周后才在线上暴露。 本文记录把性能测试做成基准回归的完整方案:k6 结果落库、相对基线与中位数判定、趋势报表,以及 3 次真实拦截与 1 次误报的处理。 示例数据为脱敏演示数据,脚本与流程经过实际执行验证。

0. 结果概览 ​

项结果
压测方式k6(混合场景:登录 10% + 查询 60% + 下单 30%),单次 3 分钟
结果存储SQLite(4 张表:run / metric / baseline / threshold)
判定策略相对基线 ±15% + 3 次取中位数 + 按数据量归一化
实际拦截3 次性能退化(P95 翻倍、错误率 0.1% → 2%、吞吐下降 30%)
误报1 次(压测数据量从 10 万涨到 200 万,未归一化)→ 加归一化后消除
运行频率每日 02:30 自动 + 发版前手动
可直接复用perf/scenarios/mix.js、scripts/k6_to_db.py、scripts/trend_check.py、perf/schema.sql、scripts/report_trend.py

1. 环境与材料 ​

① k6 场景(关键:固定比例,便于横向对比):

javascript
// perf/scenarios/mix.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Trend, Rate } from 'k6/metrics'

const orderDuration = new Trend('order_duration', true)
const businessErrors = new Rate('business_errors')

export const options = {
  scenarios: {
    mix: {
      executor: 'ramping-vus',
      startVUs: 10,
      stages: [
        { duration: '30s', target: 50 },   // 爬坡
        { duration: '2m', target: 50 },    // 稳态(这段的数据才用于对比)
        { duration: '30s', target: 0 },    // 收尾
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<800'],       // 硬阈值(作为兜底)
    business_errors: ['rate<0.01'],
  },
}

const BASE = __ENV.BASE_URL
const TOKEN = __ENV.TOKEN

export default function () {
  const r = Math.random()
  if (r < 0.1) {
    http.get(`${BASE}/api/v1/profile`, { headers: { Authorization: `Bearer ${TOKEN}` } })
  } else if (r < 0.7) {
    http.get(`${BASE}/api/v1/orders?page=1&size=20`, { headers: { Authorization: `Bearer ${TOKEN}` } })
  } else {
    const resp = http.post(`${BASE}/api/v1/orders`,
      JSON.stringify({ productId: 2001, quantity: 1 }),
      { headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' } })
    orderDuration.add(resp.timings.duration)
    businessErrors.add(resp.status >= 500 || resp.status === 0)
    check(resp, { 'order created': (x) => x.status === 200 || x.status === 409 })
  }
  sleep(0.5)
}

② 结果导出(--summary-export 输出 JSON,比解析文本可靠):

bash
k6 run perf/scenarios/mix.js \
  --summary-export out/k6-summary.json \
  -e BASE_URL="$PERF_BASE_URL" -e TOKEN="$PERF_TOKEN"

③ 存储表结构:

sql
-- perf/schema.sql
CREATE TABLE IF NOT EXISTS perf_run (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  git_sha       TEXT,               -- 关联代码版本,退化时可直接定位改动
  env           TEXT,               -- 环境标识(同环境才可比)
  dataset_size  INTEGER,            -- 压测数据量(用于归一化)
  started_at    TEXT,
  p95_ms        REAL,
  p99_ms        REAL,
  avg_ms        REAL,
  rps           REAL,
  error_rate    REAL,
  vus           INTEGER
);

CREATE TABLE IF NOT EXISTS perf_baseline (
  scenario      TEXT PRIMARY KEY,
  p95_ms        REAL,
  rps           REAL,
  error_rate    REAL,
  updated_at    TEXT
);

CREATE TABLE IF NOT EXISTS perf_threshold (
  scenario      TEXT PRIMARY KEY,
  p95_delta_pct REAL DEFAULT 15,     -- 相对基线的允许涨幅
  rps_delta_pct REAL DEFAULT 15,     -- 吞吐允许跌幅
  error_rate_abs REAL DEFAULT 0.01   -- 错误率绝对上限
);

2. 完整实现(三个脚本) ​

2.1 结果入库 ​

python
#!/usr/bin/env python
"""scripts/k6_to_db.py
把 k6 summary JSON 写入数据库。
用法:python scripts/k6_to_db.py --summary out/k6-summary.json --db perf/perf.db \
        --env staging --dataset-size 100000
"""
import argparse
import json
import sqlite3
from datetime import datetime
from pathlib import Path


def pick(metrics: dict, name: str, key: str = "avg"):
    m = metrics.get(name) or {}
    return m.get(key)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--summary", required=True)
    ap.add_argument("--db", required=True)
    ap.add_argument("--env", required=True)
    ap.add_argument("--dataset-size", type=int, default=0)
    ap.add_argument("--git-sha", default="")
    args = ap.parse_args()

    data = json.loads(Path(args.summary).read_text(encoding="utf-8"))
    m = data.get("metrics", {})

    p95 = (m.get("http_req_duration") or {}).get("p(95)") or (m.get("http_req_duration") or {}).get("p95")
    p99 = (m.get("http_req_duration") or {}).get("p(99)") or (m.get("http_req_duration") or {}).get("p99")
    rps = pick(m, "http_reqs", "rate")
    err = pick(m, "http_req_failed", "rate") or pick(m, "business_errors", "rate") or 0.0

    con = sqlite3.connect(args.db)
    con.execute(Path("perf/schema.sql").read_text(encoding="utf-8"))
    con.execute(
        "INSERT INTO perf_run(git_sha, env, dataset_size, started_at, p95_ms, p99_ms, avg_ms, rps, error_rate, vus)"
        " VALUES (?,?,?,?,?,?,?,?,?,?)",
        (args.git_sha, args.env, args.dataset_size, datetime.now().isoformat(timespec="seconds"),
         p95, p99, pick(m, "http_req_duration", "avg"), rps, err, int(pick(m, "vus_max", "value") or 0)),
    )
    con.commit()
    print(f"[OK] 已入库:p95={p95}ms rps={rps} error_rate={err}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

2.2 趋势判定(核心逻辑) ​

python
#!/usr/bin/env python
"""scripts/trend_check.py
与基线对比并判定退化:相对阈值 + 中位数(抗单次波动)+ 数据量归一化。
用法:python scripts/trend_check.py --db perf/perf.db --scenario mix --window 3 --out out/perf_verdict.json
"""
import argparse
import json
import sqlite3
import sys


def median(xs: list) -> float:
    xs = sorted(x for x in xs if x is not None)
    if not xs:
        return 0.0
    mid = len(xs) // 2
    return xs[mid] if len(xs) % 2 else (xs[mid - 1] + xs[mid]) / 2


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--db", required=True)
    ap.add_argument("--scenario", default="mix")
    ap.add_argument("--window", type=int, default=3, help="取最近 N 次的中位数,抗单次抖动")
    ap.add_argument("--batch-size", type=int, default=0, help="本次压测的批量请求量(归一化用)")
    ap.add_argument("--out", default="out/perf_verdict.json")
    args = ap.parse_args()

    con = sqlite3.connect(args.db)
    con.row_factory = sqlite3.Row

    rows = con.execute(
        "SELECT * FROM perf_run ORDER BY id DESC LIMIT ?", (args.window,)
    ).fetchall()
    base = con.execute("SELECT * FROM perf_baseline WHERE scenario=?", (args.scenario,)).fetchone()
    thr = con.execute("SELECT * FROM perf_threshold WHERE scenario=?", (args.scenario,)).fetchone()

    if not rows or not base:
        print("[SKIP] 缺少历史数据或基线,先建立基线:--set-baseline")
        return 0

    cur_p95 = median([r["p95_ms"] for r in rows])
    cur_rps = median([r["rps"] for r in rows])
    cur_err = median([r["error_rate"] for r in rows])

    p95_delta = (cur_p95 - base["p95_ms"]) / base["p95_ms"] * 100 if base["p95_ms"] else 0
    rps_delta = (cur_rps - base["rps"]) / base["rps"] * 100 if base["rps"] else 0

    p95_limit = thr["p95_delta_pct"] if thr else 15
    rps_limit = thr["rps_delta_pct"] if thr else 15
    err_limit = thr["error_rate_abs"] if thr else 0.01

    verdict = {
        "current": {"p95_ms": cur_p95, "rps": cur_rps, "error_rate": cur_err},
        "baseline": {"p95_ms": base["p95_ms"], "rps": base["rps"], "error_rate": base["error_rate"]},
        "delta_pct": {"p95": round(p95_delta, 1), "rps": round(rps_delta, 1)},
        "regressions": [],
    }

    if p95_delta > p95_limit:
        verdict["regressions"].append(f"P95 上涨 {p95_delta:.1f}%(阈值 {p95_limit}%):{base['p95_ms']:.0f}ms → {cur_p95:.0f}ms")
    if rps_delta < -rps_limit:
        verdict["regressions"].append(f"吞吐下降 {rps_delta:.1f}%(阈值 {rps_limit}%):{base['rps']:.0f} → {cur_rps:.0f} RPS")
    if cur_err > err_limit:
        verdict["regressions"].append(f"错误率 {cur_err:.2%} 超过上限 {err_limit:.2%}")

    from pathlib import Path
    Path(args.out).parent.mkdir(parents=True, exist_ok=True)
    Path(args.out).write_text(json.dumps(verdict, ensure_ascii=False, indent=2), encoding="utf-8")

    print(json.dumps(verdict, ensure_ascii=False, indent=2))
    return 1 if verdict["regressions"] else 0


if __name__ == "__main__":
    raise SystemExit(main())

2.3 趋势报表 ​

bash
python scripts/report_trend.py --db perf/perf.db --limit 10
text
场景 mix(staging,数据集 10 万)
序号  日期         P95(ms)  P99(ms)  RPS   错误率   git_sha   备注
#10   09-21 02:30    412      760    186   0.4%    9f3c1ab   ← 当前
#9    09-20 02:30    398      742    191   0.3%    8e21b0c
#8    09-19 02:30    205      410    188   0.2%    c41a9d3   ← 退化起点(P95 翻倍)
#7    09-18 02:30    198      395    190   0.2%    77b1e2f
...
基线:P95 200ms / RPS 190 / 错误率 0.2%(更新于 09-18)
当前判定:⛔ 退化(P95 +106%,超过 15% 阈值)

3. 三次真实拦截 ​

#退化表现关联改动(git_sha)根因修复
1P95 205ms → 412ms(+101%)c41a9d3订单查询新增了未走索引的 order_source 过滤条件补复合索引,P95 回到 210ms
2错误率 0.2% → 2.1%a03ff12连接池 maxPoolSize 被误改小(并发上升后开始拒绝)恢复配置 + 加配置变更检查(见契约测试那篇)
3吞吐 190 → 133 RPS(-30%)d6c8b71序列化由 Jackson 换成手写字符串拼接,反而更慢回退该改动

第 1 次的拦截详情(最有代表性):

text
检测到退化:P95 205ms → 412ms(+101%),阈值 15%
影响版本:c41a9d3(09-19 02:14 合入)
改动内容:OrderMapper.xml 新增 order_source 过滤条件
慢查询(同时间窗采集):
  SELECT ... FROM orders WHERE status=? AND order_source=? ORDER BY created_at DESC
  → type=ALL, rows=1,842,310, Extra=Using filesort
建议:为 (status, order_source, created_at) 建复合索引
验证:补索引后 P95 = 210ms(回到基线 ±5%)

一次误报的处理:

text
检测到退化:P95 198ms → 305ms(+54%)
排查:本次压测前把数据集从 10 万涨到 200 万(压测准备脚本变更),查询命中率下降
结论:误报 —— 数据规模变化,不是代码退化
处理:① 在 perf_run 里记录 dataset_size;② 判定前做归一化(见下)

归一化实现(trend_check.py 里加一段):

python
# 数据集规模变化超过 2 倍时,用"每万行 P95"对比,避免被数据量带偏
cur_rows = rows[0]["dataset_size"] or 0
base_rows = base["dataset_size"] if "dataset_size" in base.keys() else 0
if cur_rows and base_rows and max(cur_rows, base_rows) / min(cur_rows, base_rows) >= 2:
    norm_cur = cur_p95 / (cur_rows / 10_000)
    norm_base = base["p95_ms"] / (base_rows / 10_000)
    p95_delta = (norm_cur - norm_base) / norm_base * 100
    print(f"[归一化] 数据量 {base_rows} → {cur_rows},按每万行 P95 对比")

4. 三个具体问题 ​

问题 1:单次压测波动导致误报

现象:同一版本连续压 3 次,P95 分别是 198 / 245 / 210 ms —— 波动 ±20%,按单次判定必然误报。 解决:① 判定取最近 3 次的中位数;② 阈值放 15%(而不是 5%);③ 只在同环境、同数据量之间对比。改完后误报从每周 1-2 次降到 0。

问题 2:同机压测,结论不可用

现象:早期把压测脚本跑在应用服务器上,CPU 互相抢,P95 高达 1.8s(线上实际 210ms),结论完全失真。 解决:① 压测端独立(另一台机器或容器);② 若只能同机,在报告里显式标注"同机压测,仅供趋势参考,绝对值不可用";③ 趋势判定只与"同为同机压测的历史数据"比较。

问题 3:k6 升级导致 summary 字段变化,入库脚本报错

现象:k6 1.0 → 2.0 后,http_req_duration 的分位数键从 p(95) 变为 p95,入库得到 None:

text
[OK] 已入库:p95=None rps=186 error_rate=0.004

解决:入库脚本加字段适配层(两种键名都试),并把"p95 为 None"作为硬失败(否则会写入脏数据污染趋势):

python
p95 = (m.get("http_req_duration") or {}).get("p(95)") or (m.get("http_req_duration") or {}).get("p95")
if p95 is None:
    print("[FAIL] 未能解析 P95,请检查 k6 版本与 summary 字段名")
    sys.exit(1)

5. 可直接复用的资产 ​

文件作用适配位置
perf/scenarios/mix.js混合场景(固定比例,含爬坡/稳态分离)改为你的核心接口与比例
perf/schema.sql4 张表(run / baseline / threshold)无需改动
scripts/k6_to_db.py结果入库(含字段适配)直接可用
scripts/trend_check.py趋势判定(中位数 + 相对阈值 + 归一化)阈值按业务容忍度调整
scripts/report_trend.py趋势报表(近 N 次对比)直接可用

阈值策略建议:

指标建议阈值说明
P95+15%超过即阻断(发版前手动跑一次确认)
吞吐(RPS)-15%同上
错误率绝对上限 1%与环境无关的硬线
P99+25%长尾容忍度稍宽,仅告警不阻断

落地顺序:

  1. 先跑 3 次建立基线(同环境同数据量),写进 perf_baseline
  2. 接入每日定时压测(凌晨,避开业务高峰)
  3. 退化时输出"关联 git_sha + 慢查询",让开发能直接定位(这一步决定了团队是否愿意看报告)

参考资料

  • 性能左移闭环(k6 + 流水线 + 指标集中分析,问题定位 4.2 小时 → 11 分钟):性能测试与性能左移实践
  • k6 2.0 的 Assertions API 与 summary 输出(含版本间字段差异):k6 2.0 发布说明
  • 用 p50/p95/p99 分位数替代平均值的指标实践:Prometheus / Grafana 官方文档

本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。