一、现象与背景:全绿的用例,掩盖了一次 8 倍的延迟劣化
先给数字。我们一条 B 端订单查询链路(Java 17 + Spring Boot 3.2 + PostgreSQL 15),接口自动化套件 1200 个用例,CI 常年 100% 通过,平均响应时间从 40ms 缓慢涨到 62ms——看平均值的曲线完全"正常"。
但 2025 年 Q1 一次发布后,线上 P99 延迟从 180ms 直接飙到 1450ms,告警响了 40 分钟才定位到:有人在一个 ORDER BY 排序字段上加了 LOWER() 函数包装,导致索引失效、走了全表扫描。问题是——我们的回归套件一个用例都没红,因为断言只校验了业务字段和 HTTP 200,完全没有对延迟设门槛。
影响面:这个接口日均调用 230 万次,P99 劣化 8 倍,首屏加载超时率从 0.3% 升到 2.1%。
二、根因分析:为什么"全绿"拦不住性能劣化
三个机制叠加,让性能问题在测试阶段彻底隐形:
- 平均值是统计陷阱。 平均值对长尾不敏感。1200 个用例里,哪怕只有 3% 的请求变慢,平均值的波动也会被剩下 97% 的快请求稀释到看不出来。而用户感知的是 P95/P99,不是平均。
- 断言只校验"对不对",不校验"快不快"。 我们的
assertThat(response.statusCode()).isEqualTo(200)+ 字段断言,本质是功能正确性断言,对执行耗时零约束。性能回归在测试模型里根本没有被建模。 - 缺少可复现的基准。 没有"基线 P99 是多少、允许劣化多少"的量化标准,性能问题只能靠线上监控或人肉压测发现,等发现时已经上线。
核心结论:功能测试和性能测试是两套正交的验收标准,把它们合并进同一个 CI 门禁,才能让性能劣化在合并前被拦住。
三、具体做法:三步把性能纳入回归门禁
1. 用分位数替代平均值做断言
压测脚本用 Gatling(3.10.2),对每个核心接口单独建一个"性能哨兵"用例,断言 P99 而非 avg:
// Gatling 场景(简化)
setUp(
scenario("orderQuery")
.exec(http("query")
.get("/api/orders")
.queryParam("size", "20")
.check(status.is(200))
.check(bodyJson("$[0].orderNo").exists())
)
.injectOpen(rampUsersPerSec(10 to 50).during(60))
).protocols(httpProtocol)
关键不是脚本本身,而是给 P99 设硬阈值。我们在 CI 里把 Gatling 的 simulation status 判定从"无错误即通过"改为"读取 metrics,P99 > 基线 × 1.5 即失败":
# CI 步骤:解析 Gatling 输出的 metrics
P99=$(python3 -c "import json,sys; d=json.load(open('simulation.log')); print(d['orderQuery']['p99'])")
BASELINE=180
THRESHOLD=$(( BASELINE * 150 / 100 )) # 允许劣化到 270ms
if [ "$P99" -gt "$THRESHOLD" ]; then
echo "❌ P99=${P99}ms 超过阈值 ${THRESHOLD}ms,性能回归,阻断合并"
exit 1
fi
2. 捕获慢 SQL,把"索引失效"变成可见信号
光断言接口 P99 还不够,得知道慢在哪个 SQL。在测试环境开启 PostgreSQL 的慢查询日志,阈值设 200ms:
-- 测试库 postgresql.conf(或 ALTER SYSTEM)
ALTER SYSTEM SET log_min_duration_statement = 200; -- 单位 ms
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
CI 里在跑完用例后 grep 慢查询日志,命中即列出 Top SQL 并阻断:
SLOW=$(grep "duration" pg-slow.log | awk '{print $NF}' | sort -rn | head -5)
if [ -n "$SLOW" ]; then
echo "⚠️ 命中慢 SQL(>200ms):"
echo "$SLOW"
exit 1
fi
那次 LOWER() 索引失效的问题,正是靠这条慢 SQL 在测试库被直接抓出来的。
3. 用 pg_stat_statements 量化执行计划变化
对核心查询接口,在 CI 里额外记录 pg_stat_statements 的 mean_exec_time,对比上一个 release 的快照。执行计划一旦从 Index Scan 退化成 Seq Scan,mean_exec_time 通常会上一个量级——这比单纯看 P99 更早暴露问题。
四、踩过的坑与反直觉之处
- 坑 1:CI 环境硬件不一致导致 P99 抖动。 早期我们把阈值设得太死(P99 > 200ms 就红),结果 CI 机器负载波动导致每周都有 2~3 次假红。反直觉的解法不是放宽阈值,而是用相对基线(×1.5)而非绝对值,并保留 15% 的抖动余量,假红降到接近 0。
- 坑 2:并发数选低了会"自欺"。 我们最早用 5 并发跑 60s,几乎所有劣化都测不出来——因为连接池没打满、锁竞争没触发。反直觉的是:性能回归用例必须用接近线上峰值的并发(我们取 50 并发),低并发下很多问题根本不发生。
- 坑 3:把性能哨兵和 1200 个功能用例混在一起跑。 混合跑会让功能用例占用资源,污染 P99 数据。正确做法是性能哨兵单独一个 pipeline job、独占 runner,否则数据不可信。
- 坑 4(2026-10 修订补充):GC region 状态是 P99 的隐藏噪声源。 我们在裸金属 CI 上跑 Gatling 50 并发 × 60s 时发现,同一版本、同一台机器,第 1 轮 P99=42ms,第 5 轮 P99=55ms——差异不是来自代码,而是 G1 的 humongous region 在前几轮被"用脏"后,Concurrent Mark 耗时非线性放大(8G 堆、4MB region 配置下,一个 2.8MB 的 byte[] 就触发 humongous,独占 1 个完整 region,Mark 耗时可翻 3 倍)。反直觉的是:P99 的 2~5ms 抖动里,有 1~2ms 是 GC 贡献的,而不是网络或 DB。 解法:每轮压测之间插入 30s 冷却 +
System.gc()提示,并把jvm_gc_humongous_allocations_per_gc(JDK 17+ 默认 MBean 暴露)作为压测前的「环境就绪检查」指标——如果该比率 > 0.5,说明上一轮的 GC 残留还没消化完,本轮数据不可信,应重跑而非记录。 - 坑 5(2026-10 修订补充):慢 SQL 阈值 200ms 对 CI 环境偏松。 在独占 runner 上 200ms 够用,但如果 CI 和开发共用机器(我们的早期环境),200ms 的阈值会把 CI 自身的 GC pause 和 I/O 抖动混进来,导致「慢 SQL」误报。后来我们把 CI 环境的慢 SQL 阈值提到 350ms(线上仍用 200ms),并在 CI 里额外加一条
pg_stat_activity检查:如果wait_event_type = 'Lock'且wait_event = 'transactionid',说明是锁等待而非真正慢 SQL,不触发阻断。阈值要跟着环境走,不是跟着 SQL 走。
五、结论与可复用清单
功能正确性(断言 200 + 字段)和性能正确性(P99 + 慢 SQL)是两套独立标准,必须在 CI 里分别建模、分别设门禁。落地后,那条 LOWER() 索引失效在合并前就被慢 SQL 拦截,线上 P99 再也没有出现过 >300ms 的劣化。
可复用清单:
- 性能断言一律用 P95/P99,禁用平均值做门禁。
- 阈值用相对基线(×1.5) 而非绝对值,并留 ~15% 抖动余量防假红。
- 性能哨兵用例单独 pipeline job、独占 runner,与功能用例隔离。
- 并发数取接近线上峰值(我们 50 并发 / 60s),低并发测不出锁竞争与连接池问题。
- 测试库开
log_min_duration_statement=200,CI 里 grep 慢 SQL 并阻断;CI 共享环境下阈值提到 350ms 并排除锁等待。 - 对核心接口记录
pg_stat_statements的mean_exec_time快照,捕捉执行计划退化。 - 每轮压测之间冷却 30s +
System.gc(),压测前检查jvm_gc_humongous_allocations_per_gc < 0.5,GC 未就绪则重跑而非记录。
小结:性能不是上线后才该关心的事,它应该像字段断言一样,是一个可被 CI 自动判定的验收标准。但「可判定」的前提是环境噪声可控——GC 状态、CPU steal、连接池残留,任何一个不控制,P99 的 2ms 抖动就足以让门禁在「真退化」和「环境噪声」之间摇摆。