8.2 CI 微基准门禁
上一节:8.1 为什么需要门禁 | 下一节:8.3 全链路性能门禁 配套代码:08-continuous-performance/02-ci-benchmark-gate
一句话结论
CI 门禁的三条设计纪律:相对比较(不设绝对阈值)、宽松阈值(高于噪声底线)、先报告后失败。 违反任何一条,门禁都会沦为「重跑一下就过」的噪声源。
一、用「体重秤的警报」理解阈值
你家的体重秤误差是 ±1 kg。你设了一个警报:「体重涨了 0.5 kg 就提醒我」。
结果:警报天天响(因为噪声就有 1 kg),三天后你就把它关掉了。
正确做法:
① 先测出秤的实际误差(±1 kg)
② 警报阈值设成 2 kg(误差的 2 倍)
③ 只有真的涨了 2 kg 才提醒
CI 门禁完全一样:阈值必须高于噪声,否则就会被忽略。
二、三条设计纪律
纪律一:相对比较,不设绝对阈值
# ❌ 绝对阈值:换 runner 型号就全线失败
- name: Check performance
run: |
SCORE=$(read_score)
if [ "$SCORE" -lt 16000 ]; then exit 1; fi # 硬编码数字
# ✅ 相对比较:与仓库中的基线比
- name: Compare against baseline
run: python3 tools/compare_benchmark.py current.json perf/baseline.json --threshold 0.15
为什么:CI runner 的型号、负载、邻居会变化,绝对阈值意味着每次换环境都要重调,而且调的过程中会放松阈值(最终变成形式主义)。
纪律二:阈值高于噪声底线
噪声底线(实测)→ 阈值 = 噪声底线 × 1.5 ~ 2
例:微基准的轮间 CV = 4%
阈值设为 15%(留足安全边际)
只有退化超过 15% 才失败
为什么 ×1.5~2:噪声是随机的,正好跨过阈值的概率在阈值等于噪声时接近 50%——那意味着每天都有误报(第 5 章 5.7 节)。
纪律三:先报告后失败
# 阶段一(头两周):只报告,不失败
- name: Compare against baseline
run: |
python3 tools/compare_benchmark.py current.json perf/baseline.json \
--threshold 0.15 --report-only
# 阶段二(确认零误报后):开启失败
- name: Compare against baseline
run: |
python3 tools/compare_benchmark.py current.json perf/baseline.json --threshold 0.15
为什么:这是避免门禁被过早讨厌的关键。头两周你会看到实际波动幅度,据此调整阈值——如果不这么做,第一次误报就会让团队对门禁失去信心。
三、门禁该测什么、不该测什么
✅ 适合放进 CI 的
| 类型 | 例子 | 为什么适合 |
|---|---|---|
| 纯算法 | 排序、哈希、正则、加解密 | 无外部依赖,可重复 |
| 序列化 | JSON 编解码、Protobuf | 稳定,且退化常见 |
| 热点工具函数 | 字符串处理、集合操作 | 影响面广 |
| 启动时间 | 从启动到健康检查通过 | 影响滚动发布与扩容 |
| 构建时间 | Gradle build | 影响开发效率 |
| 镜像体积 | docker image size | 影响拉取与启动 |
❌ 不适合放进 CI 的
| 类型 | 为什么 | 替代方案 |
|---|---|---|
| 数据库查询 | CI runner 的 MySQL/PG 性能不稳定 | 组件基准(本地跑,或专用环境) |
| 全链路压测 | 需要完整环境,噪声太大 | 专用环境 + 关键分支 |
| HTTP 接口 | 受网络与并发影响大 | 同上 |
| GC 相关 | 需要长时间运行 | 定期回归(非 CI) |
判断标准:「这个测量在 CI 的共享 runner 上稳定吗?」 如果不稳定,就不要放进去——放进去了也只会制造误报。
四、一个完整的 CI 门禁配置
# .github/workflows/perf-gate.yml
name: perf-gate
on:
push:
branches: [main] # ⚠️ 只在主干跑(PR 上噪声大、成本高)
workflow_dispatch: # 允许手动触发
jobs:
micro-benchmark:
# ⚠️ 生产项目应使用自建、规格固定的 runner
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要历史基线
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: ~/.gradle
key: gradle-${{ hashFiles('**/*.gradle.kts') }}
# ① 跑微基准(kotlinx-benchmark 输出 JSON)
- name: Run benchmarks
run: ./gradlew benchmark
# ② 与基线对比(相对比较 + 宽松阈值)
- name: Compare against baseline
id: compare
continue-on-error: true # ⚠️ 先不失败,收集数据
run: |
python3 tools/compare_benchmark.py \
build/reports/benchmarks/main.json \
perf/baseline.json \
--threshold 0.15 \
--output perf-result.md
# ③ 把结果发到 PR 评论或 Slack
- name: Report result
if: always()
run: |
cat perf-result.md >> $GITHUB_STEP_SUMMARY
# ④ 关闭 continue-on-error 后,这一步会让 CI 失败
- name: Fail if regression
if: steps.compare.outcome == 'failure'
run: exit 1
- uses: actions/upload-artifact@v4
if: always()
with:
name: benchmark-results
path: build/reports/benchmarks/
四个关键设计:
| 设计 | 理由 |
|---|---|
只在 main 分支跑 |
PR 上噪声大且成本高;主干合并时把住关 |
continue-on-error: true + 独立失败步骤 |
便于「先报告后失败」的渐进启用 |
结果写入 $GITHUB_STEP_SUMMARY |
开发者不点开日志也能看到结果 |
| 上传 artifact | 便于事后分析(原始 JSON) |
五、基线怎么管理
这是门禁最容易出问题的地方。
❌ 错误做法
| 做法 | 问题 |
|---|---|
| 每次 CI 通过就自动更新基线 | 退化会被"沉淀"成新基线(温水煮青蛙) |
| 基线存在 CI 的缓存里 | 缓存会失效、会跨分支污染 |
| 基线手工维护在 wiki 里 | 一定会忘记更新,且无版本历史 |
| 基线跟代码同一个 commit | 无法对比(应该对比"上一个已知良好版本") |
✅ 正确做法
① 基线文件存放在仓库里:perf/baseline.json(有版本历史、可 review)
② 只在【明确的时机】更新:
- 发布新版本时(作为新基线)
- 或经过人工确认的"这是预期的性能变化"
③ 基线的更新必须【单独提交】并写明理由:
commit message: "perf: update baseline after index optimization (-45% P99)"
④ 更新基线的 PR 需要有性能负责人 review
⑤ 保留历史基线(按版本号命名):perf/baseline-v2.3.0.json
为什么「单独提交 + 写明理由」重要:如果基线和功能改动混在一起,就没人能发现"这次提交悄悄放宽了性能标准"。
基线的三种存放方式
| 方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 仓库内 JSON 文件 | 有版本历史、可 review | 需要手动更新 | 推荐(简单、可控) |
| 专用存储(S3/DB) | 自动更新、有历史 | 黑盒、易被污染 | 大型项目 |
| CI 服务的缓存 | 简单 | 易失效、易污染 | ❌ 不推荐 |
六、误报的排查流程
当门禁失败时,第一步不是重跑,而是判断真假。
门禁失败
│
├─ ① 看变化幅度
│ ├─ 在噪声底线的 1~1.5 倍之间 → 可能是噪声,但也要查
│ └─ 超过噪声底线的 2 倍以上 → 高度怀疑是真退化
│
├─ ② 看是不是环境问题
│ ├─ runner 型号变了?(CI 日志里有)
│ ├─ 同一批次的其他 job 也失败了?
│ └─ 是否有其他 job 在同一个 runner 上竞争?
│
├─ ③ 看是不是真的退化
│ ├─ 本地复现(用同样的基准)
│ ├─ 看这次提交改了什么(是不是碰了热路径)
│ └─ 看火焰图/分配速率有没有变化
│
└─ ④ 处理
├─ 真退化 → 修复代码(或明确接受并更新基线)
└─ 噪声 → 不改基线,重跑一次(并在 issue 里记录这次误报)
关键纪律:「重跑一次过了」不等于「这次是噪声」——必须在 issue 里记录,因为累计的误报率是调整阈值的依据。
## 门禁误报记录
| 日期 | 提交 | 指标 | 变化 | 是否复现 | 处理 |
| --- | --- | --- | --- | --- | --- |
| 2025-01-15 | abc123 | serialize | +16% | 否 | 重跑通过,疑似噪声 |
| 2025-01-18 | def456 | serialize | +18% | 否 | 重跑通过 |
| 2025-01-22 | ghi789 | serialize | +14% | **是** | 确认退化,已修复 |
→ 两周 3 次触发,其中 1 次真实。误报率 67%。
→ 阈值从 15% 调整到 20%(噪声 4% × 5 = 20%,留足余量)
七、本节小结
- CI 门禁的三条纪律:相对比较(不设绝对阈值)、宽松阈值(高于噪声)、先报告后失败。
- 适合放进 CI 的:纯算法、序列化、热点工具函数、启动时间、构建时间。
- 不适合的:数据库查询、全链路压测、HTTP 接口、GC(CI runner 不稳定)。
- 只在主干跑,不在每个 PR 上跑(噪声大、成本高)。
- 基线管理是最大的坑:存仓库、单独提交、写明理由、需 review;绝不自动更新(防温水煮青蛙)。
- 误报要记录:累计的误报率是调整阈值的依据;「重跑过了」不等于「是噪声」。
- 先报告后失败(头两周):这是避免门禁被过早讨厌的关键。
八、自测
- 一个团队的性能门禁阈值是「P99 退化超过 5% 就失败」,而他们的环境噪声底线是 ±8%。请预测会发生什么,并给出改进方案。
- 为什么基线的更新必须「单独提交 + 写明理由」?如果它和功能改动混在一起会有什么后果?
- 门禁失败了,你重跑一次通过了。请说出你应该做的两件事,以及为什么「重跑过了」不足以证明是噪声。
- 会发生什么:因为阈值(5%)低于噪声底线(8%),所以每次运行都有很大概率失败——即使代码完全没变。结果是:① 每天多次误报;② 工程师开始认为"这个门禁不重要,重跑一次就好";③ 三个月后出现真实退化时(比如 30%),同样被当作噪声忽略;④ 门禁实际上已经失效,但组织以为它还在保护——这比没有门禁更糟(虚假的安全感)。改进方案:① 先测准噪声底线(跑 5–10 轮相同代码的基准,算出 CV 与波动范围);② 阈值设为噪声底线的 1.5–2 倍(这里是 12%–16%);③ 先只报告不失败(观察两周,确认零误报后再开启失败);④ 记录每次触发,用累计的误报率来校准阈值;⑤ 如果发现 5% 级别的退化确实需要拦住,那说明当前 CI 环境噪声太大——应该改善环境(专用 runner、固定规格),而不是压低阈值。
- 必须单独提交的原因:基线是性能标准的定义——它决定了「什么算退化」。如果它和功能改动混在一起,那么一个 PR 可以悄悄放宽性能标准:比如某个改动让序列化慢了 20%,开发者顺手更新基线(把"变慢后"的值作为新基线),CI 就会通过。后果:① 性能标准被逐步侵蚀(温水煮青蛙);② 事后无法追溯到"标准是什么时候、因为什么被放宽的";③ 无法做 code review(reviewer 看不到基线变更,因为它埋在大段功能代码里)。正确做法:基线更新单独一个 commit/PR,message 写明理由(如「因切换序列化器,预期变化 +12%,已人工确认」),并且需要性能负责人 review。
- 应该做的两件事:① 记录这次触发——写进「门禁误报记录」,包括日期、提交、指标、变化幅度、是否可复现;② 判断真伪——不能因为重跑通过就下结论,要按流程检查:变化幅度是否在噪声的 1–1.5 倍之间、runner 是否变化、是否有其他 job 竞争、这次提交是否碰了热路径、本地能否复现。为什么「重跑过了」不足以证明是噪声:① 重跑过一次不能排除"这次重跑恰好赶上好的环境"——需要多次重跑(3 次以上);② 如果真实退化是条件触发的(比如某类输入、某个时序),重跑可能不复现但问题依然存在;③ 最关键的是:误报率本身是需要累计统计的指标——不记录就永远无法判断阈值是否合适,也无法发现"最近误报变多了"这个信号(可能意味着 CI 环境本身在变差)。