文档目录

8.3 全链路性能门禁

上一节:8.2 CI 微基准门禁 | 下一节:8.4 生产观测:SLI 与错误预算 配套代码:08-continuous-performance/03-e2e-perf-gate


一句话结论

全链路门禁成本高、噪声大,所以它不该出现在每个 PR 上。 正确的定位是:发布前的最后一道验收——用专用环境、固定机器、触发式运行。


一、用「飞机试飞」理解全链路门禁

汽车出厂前会做路试,但不会每装一个零件就上路试一次。路试的时机是:

时机 目的
每个零件装好后 台架试验(对应微基准)
整车装配完成 转鼓试验(对应集成基准)
出厂前 路试(对应全链路门禁)
上市后 用户反馈 + 定期检查(对应生产告警)

全链路门禁的定位就是「出厂前路试」——不是每次都做,但发布前必做。


二、什么时候跑全链路门禁

触发时机 是否合适 理由
每个 PR ❌ 不合适 成本太高(几十分钟),噪声大,会拖慢开发
主干合并后 ⚠️ 可以 比 PR 好,但仍是高频
发布候选(RC) ✅ 推荐 发布前的最后一道门
每日夜间 ✅ 推荐 与发布解耦,能发现趋势
手动触发 ✅ 推荐 用于验证重大改动

推荐组合:

① 主干合并后:跑【微基准门禁】(分钟级)
② 每日夜间:跑【全链路回归】(发现趋势退化)
③ 发布候选:跑【全链路门禁 + 容量复测】(发布前验收)
④ 重大架构改动:手动触发

三、全链路门禁的三个必要条件

条件一:专用环境

不能在共享的 CI runner 上跑全链路压测。

需求 说明
固定规格的机器 不能今天 4 核明天 8 核
与被测服务隔离的压测客户端 避免抢 CPU
固定的数据库/中间件 版本、配置、数据量都要固定
无其他任务干扰 独占环境

如果没有专用环境:不要做全链路门禁——用定期的手动压测(第 3 章)代替,并明确记录"数据不可用于容量规划"。

条件二:固定的数据集

数据集要求:
  ① 数据量固定(每次跑之前重置到同一状态)
  ② 分布固定(用同一份幂律分布的数据)
  ③ 缓存状态固定(每次清空或预热到同一程度)
  ④ 写操作有隔离(避免数据累积导致每次不一样)

为什么必须固定:否则你测的不是代码的性能变化,而是数据的变化。

❌ 第 1 次跑:100 万行 → P99 = 95ms
   第 2 次跑:130 万行(上次压测写的)→ P99 = 120ms
   → 你以为是代码退化,其实是数据涨了

条件三:相对基线 + 宽松阈值

与 CI 微基准门禁同样的纪律,但阈值要更宽(因为全链路的噪声更大):

指标 微基准的阈值 全链路的阈值
P50 噪声 × 2 噪声 × 2
P99 噪声 × 2 噪声 × 2~3(尾部波动更大)
QPS 噪声 × 2 噪声 × 2
错误率 绝对阈值(如 0.1%) 绝对阈值

四、门禁脚本的骨架

#!/usr/bin/env bash
# tools/e2e-perf-gate.sh <VERSION_TAG>
#
# 全链路性能门禁:发布候选阶段运行。
set -euo pipefail

VERSION="${1:?usage: e2e-perf-gate.sh <version_tag>}"
EXP_DIR="docs/experiments/gate-${VERSION}"
mkdir -p "$EXP_DIR/results"

echo "═══ 全链路性能门禁:$VERSION ═══"
echo

# ① 环境检查(必须通过)
echo "【1/6】环境检查"
tools/check-environment.sh "gate-${VERSION}"
if grep -q "❌" "$EXP_DIR/results/env-check.txt"; then
  echo "❌ 环境检查未通过 —— 本次门禁结果不可信,终止"
  exit 1
fi
echo

# ② 重置数据集(保证每次一致)
echo "【2/6】重置数据集"
scripts/reset-dataset.sh
scripts/restart-app.sh "$EXP_DIR/results/gc.log"
sleep 10
echo

# ③ 预热(不计入统计)
echo "【3/6】预热 60 秒"
BASE_URL="$TARGET_URL" k6 run --quiet --vus 20 --duration 60s \
  loadtest/profile-constant.js > /dev/null
echo

# ④ 正式测量(跑 3 轮,取中位数)
echo "【4/6】测量 3 轮"
for i in 1 2 3; do
  BASE_URL="$TARGET_URL" RATE=500 DURATION=5m \
  k6 run --summary-export="$EXP_DIR/results/round-$i.json" \
         loadtest/profile-constant.js > /dev/null
  echo "  第 $i 轮完成"
done
echo

# ⑤ 与基线对比
echo "【5/6】与基线对比"
python3 tools/compare_e2e.py "$EXP_DIR/results" perf/e2e-baseline.json
RESULT=$?
echo

# ⑥ 输出结论
echo "【6/6】结论"
if [ $RESULT -eq 0 ]; then
  echo "✅ 性能门禁通过"
  echo
  echo "后续:"
  echo "  ① 把本次结果存为新的候选基线"
  echo "  ② 继续发布流程"
else
  echo "❌ 性能门禁失败"
  echo
  echo "后续:"
  echo "  ① 查看 $EXP_DIR/results/ 下的详细数据"
  echo "  ② 判断是真退化还是噪声(第 8.2 节的排查流程)"
  echo "  ③ 【不要】在未验证的情况下更新基线"
  exit 1
fi

五、全链路门禁要检查的四件事

检查项 阈值 为什么
SLO 达标 P99 < 200 ms、错误率 < 0.1% 发布前的核心验收
拐点未后移 拐点 QPS ≥ 基线 × 0.9 检测容量退化
饱和度正常 同等负载下 pending = 0、队列深度接近 0 检测"看起来达标但已接近极限"
资源无异常 内存/连接数无单调上升(跑 30 分钟) 检测泄漏

第三项最容易被忽略:

❌ 只看 SLO:P99 = 190ms < 200ms ✅ 通过
   但连接池 pending 从基线的 0 涨到了 8
   → 系统已经在排队,只是还没反映到延迟上
   → 一旦流量再涨 10%,就会崩

✅ 同时看饱和度:pending > 0 → 拒绝通过(或标记为警告)

这就是第 1 章「饱和度是领先指标」在门禁上的应用。


六、如果门禁失败:决策树

门禁失败
    │
    ├─ ① 检查是不是环境问题
    │      ├─ 环境检查有没有 ❌?
    │      ├─ 数据集是否重置成功?
    │      └─ 是否在预期的时间窗口内跑(避开备份等)?
    │
    ├─ ② 判断幅度
    │      ├─ P99 变化 < 噪声底线 → 可能是噪声,重跑 1 次
    │      ├─ P99 变化 1~2 倍噪声 → 需要查清(用第 6 章的方法定位)
    │      └─ P99 变化 > 2 倍噪声 → 高度怀疑真退化
    │
    ├─ ③ 定位(用第 6 章的分析流程)
    │      ├─ 延迟分解:哪一段变慢了?
    │      ├─ 依赖指标:是数据库/缓存/下游?
    │      └─ 火焰图:有没有新的热点?
    │
    └─ ④ 决策
           ├─ 真退化 → 阻塞发布,修复后重跑
           ├─ 预期变化(如新增功能带来合理开销)→ 更新基线(单独提交 + 说明)
           └─ 环境噪声 → 重跑;记录到误报日志

关键:「预期变化」也需要走更新基线的流程——不能因为"知道原因"就直接改基线,因为这个变化对容量的影响需要评估。


七、一个现实的落地建议

如果你们还没有专用环境:

❌ 不要做:在开发机/共享 CI 上跑全链路门禁
   → 数据不可信,会不断误报,最终被关闭

✅ 应该做:
   ① 先建微基准门禁(成本低、见效快)
   ② 用「发布前手动压测」代替自动门禁(第 3 章的方法)
      - 每次发布前跑一次
      - 结果记录进实验档案
      - 与上次发布的基线对比
   ③ 等有了专用环境,再把手动流程自动化

手动流程也是「持续化」——它在机制上不如自动门禁,但比"什么都不做"好得多。关键是每次发布都做,且有记录可对比。


八、本节小结

  1. 全链路门禁不该出现在每个 PR 上——成本高、噪声大。
  2. 正确的触发时机:发布候选、每日夜间、手动触发。
  3. 三个必要条件:专用环境(固定规格、隔离客户端)、固定数据集(重置到同一状态)、相对基线 + 更宽的阈值。
  4. 门禁要检查四件事:SLO 达标、拐点未后移、饱和度正常、资源无泄漏。
  5. 「SLO 达标但 pending > 0」应该拒绝通过——这是领先指标的应用。
  6. 失败后的决策树:查环境 → 判幅度 → 定位 → 决策(修复 / 更新基线 / 重跑)。
  7. 没有专用环境时:用「发布前手动压测 + 记录对比」代替,不要在共享环境上跑自动门禁。

九、自测

  1. 一个团队把全链路压测放进了每个 PR 的 CI 里。请说出三个可能的问题,以及更合适的做法。
  2. 门禁显示 P99 = 190 ms(阈值 200 ms)通过,但连接池 pending 从基线的 0 涨到了 8。请评价这个结果,以及你会怎么处理。
  3. 门禁失败,P99 从 95 ms 涨到 125 ms(噪声底线是 13%)。请按决策树说明你的处理步骤。
  1. 三个问题:① 拖慢开发——全链路压测要几十分钟,每个 PR 都跑会让 CI 排队、反馈变慢,开发者会开始绕过它;② 数据不可信——PR 的 CI runner 是共享的、规格不固定、有邻居干扰,测出的差异大部分是环境噪声,会导致频繁误报;③ 成本高——需要完整环境与数据集准备,每个 PR 都做不经济;④ 第四个问题:数据集无法固定——多 PR 并行跑会互相污染数据库状态。更合适的做法:① 每个 PR 只跑微基准门禁(分钟级);② 主干合并后或每日夜间跑全链路回归;③ 发布候选阶段跑全链路门禁(用专用环境);④ 重大架构改动时手动触发。
  2. 应该拒绝通过(或至少标记为强制复查)。理由:① SLO 达标不等于系统健康——P99 = 190 ms 只是"还没反映到延迟上";② pending > 0 是领先指标——说明请求已经在排队等连接,系统的余量正在被耗尽(第 0 章 0.5 节的排队非线性:利用率从 80% 涨到 90%,延迟会非线性上升);③ 风险:一旦流量再涨 10% 或某个查询稍慢,延迟就会突然恶化(不是渐变)。处理方式:① 阻塞发布(或标记为高风险),因为这是一个明确的容量退化信号;② 按第 6.5 节排查:是查询变慢了(占用连接更久)还是并发上来了;③ 如果是预期的(比如新增了功能),要单独评估容量影响并更新基线 + 调整容量规划;④ 修复后重跑门禁,确认 pending 回到 0。
  3. 处理步骤(P99 从 95 → 125 ms,涨幅 32%,是噪声底线 13% 的 2.5 倍):① 检查环境——先看 env-check.txt 有没有 ❌(数据集是否重置成功、机器规格是否一致、是否有其他任务);② 判断幅度——32% > 2 × 13%,高度怀疑是真退化;③ 定位(用第 6 章的流程)——(a) 延迟分解:哪一段变慢了(用第 6.2 节的三点减法);(b) 依赖指标:是数据库(查 pg_stat_statements 的 calls 与 mean)、缓存(命中率)、还是下游;(c) 火焰图:有没有新的热点(对比基线的参考火焰图);(d) 查这次版本改了什么(用 git log 对比上一个基线版本);④ 决策——如果确认是真退化,阻塞发布并修复;如果是预期变化(比如新增了必要的功能开销),评估容量影响后再单独提交更新基线;如果最终确认是环境噪声(需要多次重跑验证),记录到误报日志并考虑调整阈值。