文档目录

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%,留足余量)

七、本节小结

  1. CI 门禁的三条纪律:相对比较(不设绝对阈值)、宽松阈值(高于噪声)、先报告后失败。
  2. 适合放进 CI 的:纯算法、序列化、热点工具函数、启动时间、构建时间。
  3. 不适合的:数据库查询、全链路压测、HTTP 接口、GC(CI runner 不稳定)。
  4. 只在主干跑,不在每个 PR 上跑(噪声大、成本高)。
  5. 基线管理是最大的坑:存仓库、单独提交、写明理由、需 review;绝不自动更新(防温水煮青蛙)。
  6. 误报要记录:累计的误报率是调整阈值的依据;「重跑过了」不等于「是噪声」。
  7. 先报告后失败(头两周):这是避免门禁被过早讨厌的关键。

八、自测

  1. 一个团队的性能门禁阈值是「P99 退化超过 5% 就失败」,而他们的环境噪声底线是 ±8%。请预测会发生什么,并给出改进方案。
  2. 为什么基线的更新必须「单独提交 + 写明理由」?如果它和功能改动混在一起会有什么后果?
  3. 门禁失败了,你重跑一次通过了。请说出你应该做的两件事,以及为什么「重跑过了」不足以证明是噪声。
  1. 会发生什么:因为阈值(5%)低于噪声底线(8%),所以每次运行都有很大概率失败——即使代码完全没变。结果是:① 每天多次误报;② 工程师开始认为"这个门禁不重要,重跑一次就好";③ 三个月后出现真实退化时(比如 30%),同样被当作噪声忽略;④ 门禁实际上已经失效,但组织以为它还在保护——这比没有门禁更糟(虚假的安全感)。改进方案:① 先测准噪声底线(跑 5–10 轮相同代码的基准,算出 CV 与波动范围);② 阈值设为噪声底线的 1.5–2 倍(这里是 12%–16%);③ 先只报告不失败(观察两周,确认零误报后再开启失败);④ 记录每次触发,用累计的误报率来校准阈值;⑤ 如果发现 5% 级别的退化确实需要拦住,那说明当前 CI 环境噪声太大——应该改善环境(专用 runner、固定规格),而不是压低阈值。
  2. 必须单独提交的原因:基线是性能标准的定义——它决定了「什么算退化」。如果它和功能改动混在一起,那么一个 PR 可以悄悄放宽性能标准:比如某个改动让序列化慢了 20%,开发者顺手更新基线(把"变慢后"的值作为新基线),CI 就会通过。后果:① 性能标准被逐步侵蚀(温水煮青蛙);② 事后无法追溯到"标准是什么时候、因为什么被放宽的";③ 无法做 code review(reviewer 看不到基线变更,因为它埋在大段功能代码里)。正确做法:基线更新单独一个 commit/PR,message 写明理由(如「因切换序列化器,预期变化 +12%,已人工确认」),并且需要性能负责人 review。
  3. 应该做的两件事:① 记录这次触发——写进「门禁误报记录」,包括日期、提交、指标、变化幅度、是否可复现;② 判断真伪——不能因为重跑通过就下结论,要按流程检查:变化幅度是否在噪声的 1–1.5 倍之间、runner 是否变化、是否有其他 job 竞争、这次提交是否碰了热路径、本地能否复现。为什么「重跑过了」不足以证明是噪声:① 重跑过一次不能排除"这次重跑恰好赶上好的环境"——需要多次重跑(3 次以上);② 如果真实退化是条件触发的(比如某类输入、某个时序),重跑可能不复现但问题依然存在;③ 最关键的是:误报率本身是需要累计统计的指标——不记录就永远无法判断阈值是否合适,也无法发现"最近误报变多了"这个信号(可能意味着 CI 环境本身在变差)。