主题
性能基准回归体系:指标入库与趋势告警
性能问题往往是"悄悄退化":单次压测看不出来,几周后才在线上暴露。 本文记录把性能测试做成基准回归的完整方案: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 10text
场景 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) | 根因 | 修复 |
|---|---|---|---|---|
| 1 | P95 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.sql | 4 张表(run / baseline / threshold) | 无需改动 |
scripts/k6_to_db.py | 结果入库(含字段适配) | 直接可用 |
scripts/trend_check.py | 趋势判定(中位数 + 相对阈值 + 归一化) | 阈值按业务容忍度调整 |
scripts/report_trend.py | 趋势报表(近 N 次对比) | 直接可用 |
阈值策略建议:
| 指标 | 建议阈值 | 说明 |
|---|---|---|
| P95 | +15% | 超过即阻断(发版前手动跑一次确认) |
| 吞吐(RPS) | -15% | 同上 |
| 错误率 | 绝对上限 1% | 与环境无关的硬线 |
| P99 | +25% | 长尾容忍度稍宽,仅告警不阻断 |
落地顺序:
- 先跑 3 次建立基线(同环境同数据量),写进
perf_baseline - 接入每日定时压测(凌晨,避开业务高峰)
- 退化时输出"关联 git_sha + 慢查询",让开发能直接定位(这一步决定了团队是否愿意看报告)
参考资料
- 性能左移闭环(k6 + 流水线 + 指标集中分析,问题定位 4.2 小时 → 11 分钟):性能测试与性能左移实践
- k6 2.0 的 Assertions API 与 summary 输出(含版本间字段差异):k6 2.0 发布说明
- 用 p50/p95/p99 分位数替代平均值的指标实践:Prometheus / Grafana 官方文档
本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。