8.9 Lab 8:做一个不会误报的性能门禁
上一节:8.8 性能评审与组织落地 | 下一节:第 9 章 综合实战 配套代码:08-continuous-performance/09-lab8 预计时长:90 分钟
一、这个 Lab 的目标
做一个真正可用的性能门禁——它的验收标准只有一条:在代码不变时,连续跑多次零误报。
做完之后你会得到一个数字:在你自己的 CI 环境里,多大的退化才值得失败。 这个数字是门禁能否长期存活的关键。
二、任务清单
| # | 任务 | 产出 | 时长 |
|---|---|---|---|
| 1 | 选出 3–5 个核心基准 | 基准代码 | 20 min |
| 2 | 在 CI 环境测噪声底线(跑 10 次) | 噪声数据 | 25 min |
| 3 | 定阈值 + 写比较脚本 | 门禁脚本 | 20 min |
| 4 | 验证零误报(跑 5 次) | 验证记录 | 15 min |
| 5 | 写告警规则草案 | alerts.yml |
10 min |
三、任务 1:选出核心基准
选择原则:纯计算、无外部依赖、在 CI 环境稳定、退化后果严重。
| 候选 | 是否适合 CI | 理由 |
|---|---|---|
| 序列化/反序列化 | ✅ | 稳定,退化常见 |
| 核心算法(排序、匹配、计算) | ✅ | 纯计算 |
| 热点工具函数(字符串、集合操作) | ✅ | 影响面广 |
| 加解密/哈希 | ✅ | 稳定 |
| 启动时间(到健康检查通过) | ✅ | 影响滚动发布 |
| 构建时间 / 镜像体积 | ✅ | 影响开发效率 |
| 数据库查询 | ❌ | CI 环境不稳定 |
| HTTP 接口 | ❌ | 需要完整环境 |
建议的基准集合(用一个真实的业务对象):
// src/main/kotlin/bench/SerializationBenchmark.kt
package bench
import kotlinx.benchmark.*
import kotlinx.serialization.json.Json
import kotlin.random.Random
data class OrderItem(val sku: String, val qty: Int, val price: Long)
data class Order(
val id: Long, val userId: Long, val status: String,
val amount: Long, val items: List<OrderItem>,
)
@State(Scope.Benchmark)
class SerializationBenchmark {
private lateinit var order: Order
private lateinit var json: String
private val format = Json { encodeDefaults = true }
@Setup
fun setup() {
val rnd = Random(42)
order = Order(
id = 12345L, userId = 678L, status = "PAID", amount = 9999L,
items = List(20) { OrderItem("SKU-${rnd.nextInt(10000)}", rnd.nextInt(1, 10), rnd.nextLong(100, 10000)) },
)
json = format.encodeToString(order)
}
@Benchmark
fun encode(bh: Blackhole) {
bh.consume(format.encodeToString(order))
}
@Benchmark
fun decode(bh: Blackhole) {
bh.consume(format.decodeFromString<Order>(json))
}
}
// src/main/kotlin/bench/AlgorithmBenchmark.kt
@State(Scope.Benchmark)
class AlgorithmBenchmark {
private lateinit var data: IntArray
@Setup
fun setup() {
val rnd = Random(42)
data = IntArray(10_000) { rnd.nextInt() }
}
@Benchmark
fun sort(bh: Blackhole) {
bh.consume(data.copyOf().apply { sort() })
}
}
注意 SerializationBenchmark 用了一个真实的业务对象(20 个 item)——不要用两个字段的 DTO(那样测不出真实的序列化成本,第 2 章 2.8 节)。
四、任务 2:在 CI 环境测噪声底线
这一步决定了门禁能否存活。
#!/usr/bin/env bash
# tools/ci-noise-floor.sh <BENCH_TASK> [ROUNDS]
#
# 在 CI 环境跑 N 次相同的基准,测出噪声底线。
# ⚠️ 必须在【真实的 CI 环境】跑(本地开发机的噪声不同)。
set -uo pipefail
TASK="${1:-benchmark}"
ROUNDS="${2:-10}"
DIR="perf/ci-noise"
mkdir -p "$DIR"
echo "═══ CI 环境噪声底线测量 ═══"
echo " 任务: $TASK"
echo " 轮数: $ROUNDS"
echo
# 记录 CI 环境信息
{
echo "runner_os=$(uname -s -r -m)"
echo "cpus=$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
echo "mem=$(free -h 2>/dev/null | awk '/Mem:/{print $2}' || echo n/a)"
echo "java=$(java -version 2>&1 | head -1)"
echo "date=$(date -Iseconds)"
} > "$DIR/env.txt"
for i in $(seq 1 "$ROUNDS"); do
echo "── 第 $i / $ROUNDS 轮 ──"
./gradlew "$TASK" -q 2>&1 | tail -2
# 保存每一轮的原始 JSON
if [ -f build/reports/benchmarks/main.json ]; then
cp build/reports/benchmarks/main.json "$DIR/round-$i.json"
echo " ✅ 已保存 round-$i.json"
else
echo " ⚠️ 未找到基准结果文件"
fi
done
echo
echo "═══ 分析噪声 ═══"
python3 tools/analyze-ci-noise.py "$DIR"
analyze-ci-noise.py 会算出:
指标 中位数 最小 最大 波动范围 CV
bench.SerializationBenchmark.encode 1250.3 1180.2 1389.7 11.2% 4.1%
bench.SerializationBenchmark.decode 1840.5 1720.1 2010.3 15.8% 5.2%
bench.AlgorithmBenchmark.sort 820.4 780.9 901.2 14.7% 4.8%
═══ CI 环境的噪声底线 ═══
encode: ±16.8%(波动范围 × 1.5)
decode: ±23.7%
sort : ±22.1%
建议阈值:
encode: 25%
decode: 35%
sort : 33%
(取各指标噪声底线 × 1.5,再向上取整到 5% 的倍数)
观察:CI 环境的噪声(11%–16%)远大于本地开发机(通常 3%–5%)。这就是为什么阈值必须在 CI 环境测。
五、任务 3:门禁脚本
# tools/compare_benchmark.py
"""
比较当前基准结果与基线,超过阈值则失败。
三条设计纪律:
① 相对比较(与基线比,不用绝对阈值)
② 宽松阈值(高于 CI 噪声底线)
③ 支持 --report-only(先报告后失败)
"""
import argparse
import json
import pathlib
import sys
def load_scores(path):
data = json.loads(pathlib.Path(path).read_text())
# kotlinx-benchmark 的 JSON 结构
scores = {}
for b in data.get("benchmarks", []):
scores[b["name"]] = {
"score": b["score"],
"unit": b.get("scoreUnit", ""),
}
return scores
def main():
ap = argparse.ArgumentParser()
ap.add_argument("current")
ap.add_argument("baseline")
ap.add_argument("--threshold", type=float, default=0.25,
help="允许的退化比例(默认 25%%,应高于 CI 噪声 × 1.5)")
ap.add_argument("--report-only", action="store_true",
help="只报告不失败(用于门禁启用初期)")
ap.add_argument("--output", help="把结果写入文件(用于 CI 摘要)")
args = ap.parse_args()
current = load_scores(args.current)
baseline_path = pathlib.Path(args.baseline)
if not baseline_path.exists():
print(f"⚠️ 基线不存在({baseline_path})—— 首次运行,跳过对比")
print(" 请人工确认结果无误后,把当前结果提交为基线")
sys.exit(0)
baseline = load_scores(baseline_path)
lines = [
"═" * 84,
"性能门禁结果",
"═" * 84,
"",
f"阈值:退化超过 {args.threshold:.0%} 视为失败",
f"模式:{'仅报告' if args.report_only else '启用失败'}",
"",
f"{'基准':<52}{'基线':>10}{'当前':>10}{'变化':>10} 判定",
"-" * 84,
]
failures = []
for name, cur in current.items():
short = name.split(".")[-1] if "." in name else name
if name not in baseline:
lines.append(f"{short[:50]:<52}{'—':>10}{cur['score']:>10.2f}{'新基准':>10} ℹ️")
continue
base_score = baseline[name]["score"]
cur_score = cur["score"]
delta = (cur_score - base_score) / base_score if base_score else 0
if delta < -args.threshold:
verdict = "❌ 退化"
failures.append((name, delta))
elif delta < -args.threshold * 0.7:
verdict = "⚠️ 接近阈值"
else:
verdict = "✅"
lines.append(
f"{short[:50]:<52}{base_score:>10.2f}{cur_score:>10.2f}{delta:>9.1%} {verdict}")
lines.append("")
lines.append("═" * 84)
if failures:
lines.append(f"❌ 检出 {len(failures)} 项性能退化:")
for name, delta in failures:
lines.append(f" - {name}: {delta:+.1%}")
lines.append("")
lines.append("处理方式:")
lines.append(" ① 【不要】直接更新基线")
lines.append(" ② 先确认是真退化还是噪声:")
lines.append(" - 本地复现(用同样的基准)")
lines.append(" - 看这次提交是否碰了热路径")
lines.append(" - 重跑 3 次看是否稳定复现")
lines.append(" ③ 真退化 → 修复代码")
lines.append(" ④ 预期变化 → 单独提交更新基线(写明理由)")
lines.append(" ⑤ 噪声 → 记录到误报日志,考虑调整阈值")
else:
lines.append("✅ 门禁通过,无性能退化")
report = "\n".join(lines)
print(report)
if args.output:
pathlib.Path(args.output).write_text(report, encoding="utf-8")
if failures and not args.report_only:
sys.exit(1)
if __name__ == "__main__":
main()
六、任务 4:验证零误报
这是本 Lab 的验收核心。
#!/usr/bin/env bash
# tools/verify-gate-no-false-positive.sh [ROUNDS]
#
# 在【代码不变】的情况下连续跑 N 次门禁,验证零误报。
set -uo pipefail
ROUNDS="${1:-5}"
DIR="perf/gate-verification"
mkdir -p "$DIR"
echo "═══ 门禁零误报验证 ═══"
echo " 轮数: $ROUNDS"
echo " 要求: 代码完全不变"
echo
# 确认代码干净
if [ "$(git status --porcelain | wc -l | tr -d ' ')" -gt 0 ]; then
echo "❌ 工作区有未提交改动 —— 验证必须用固定的代码"
exit 1
fi
echo "✅ 代码干净(commit: $(git rev-parse --short HEAD))"
echo
FALSE_POSITIVES=0
for i in $(seq 1 "$ROUNDS"); do
echo "── 第 $i / $ROUNDS 轮 ──"
./gradlew benchmark -q > /dev/null 2>&1
if python3 tools/compare_benchmark.py \
build/reports/benchmarks/main.json perf/baseline.json \
--threshold "${THRESHOLD:-0.25}" > "$DIR/round-$i.txt" 2>&1; then
echo " ✅ 通过"
else
echo " ❌ 失败(误报!)"
FALSE_POSITIVES=$((FALSE_POSITIVES + 1))
grep -A3 "检出" "$DIR/round-$i.txt" | head -6
fi
done
echo
echo "═══ 验证结果 ═══"
echo " 轮数 : $ROUNDS"
echo " 误报次数 : $FALSE_POSITIVES"
echo " 误报率 : $(python3 -c "print(f'{$FALSE_POSITIVES/$ROUNDS*100:.0f}%')")"
echo
if [ "$FALSE_POSITIVES" -eq 0 ]; then
echo "✅ 零误报 —— 门禁可以启用了"
else
echo "❌ 有 $FALSE_POSITIVES 次误报 —— 阈值太紧"
echo
echo "调整建议:"
echo " ① 把阈值提高(当前 ${THRESHOLD:-0.25})"
echo " ② 或者先在 CI 环境重测噪声底线(tools/ci-noise-floor.sh)"
echo " ③ 检查 CI runner 是否变化(型号、负载)"
exit 1
fi
验收标准:5 次运行,误报 0 次。
如果做不到零误报,就提高阈值——宁可漏报,不可误报(第 8.1 节)。
七、任务 5:告警规则草案
# observability/alerts.yml(草案,需按你的实际指标名调整)
groups:
# ═══ P0:page(立即叫人)═══
- name: slo-burn-rate
rules:
- alert: ErrorBudgetBurnFast
expr: |
(
1 - sum(rate(http_requests_total{status!~"5.."}[1h]))
/ sum(rate(http_requests_total[1h]))
) / (1 - 0.999) > 14.4
and
(
1 - sum(rate(http_requests_total{status!~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
) / (1 - 0.999) > 14.4
for: 2m
labels: { severity: page }
annotations:
summary: "错误预算燃烧过快(1h + 5m 双窗口同时超标)"
runbook: |
① 看 Grafana 的错误率分接口面板,找出问题接口
② 查最近 30 分钟的发布记录
③ 查依赖健康状况(数据库/缓存/下游)
④ 必要时回滚最近的发布
# ═══ P1:ticket(当天处理)═══
- name: leading-indicators
rules:
- alert: ConnectionPoolSaturated
expr: db_pool_pending > 5
for: 10m
labels: { severity: ticket }
annotations:
summary: "连接池排队(pending > 5 持续 10 分钟)"
runbook: |
① 【先查慢查询,不要先加池】
② psql -c "SELECT calls, mean_exec_time, total_exec_time
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;"
③ 查长事务与锁等待
④ 详见第 6.5 节
- alert: ContainerCpuThrottled
expr: |
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m]) > 0.25
for: 10m
labels: { severity: ticket }
annotations:
summary: "容器被 CPU 配额节流 > 25%"
runbook: |
① 确认 CPU limit ② 看 CPU 需求是否合理 ③ 详见第 4.8 节
- alert: ExecutorQueueBacklog
expr: executor_queue_depth > 100
for: 10m
labels: { severity: ticket }
annotations:
summary: "线程池队列积压 > 100"
runbook: |
① 查线程在忙什么(阻塞 IO 还是真的在算)
② jcmd <pid> Thread.print | grep -A3 BLOCKED
③ 详见第 6.5 节
# ═══ P2:log(定期 review)═══
- name: observe-only
rules:
- alert: GcPauseHigh
expr: |
histogram_quantile(0.99,
sum by (le) (rate(jvm_gc_pause_seconds_bucket[5m]))) > 0.2
for: 10m
labels: { severity: log }
annotations:
summary: "GC 停顿 P99 > 200ms(先观察,确认与 P99 尖刺是否对齐)"
runbook: "① 与 P99 尖刺做时间对齐 ② 不对齐则考虑删除此告警(第 6.6 节)"
注意 GC 告警被放在 P2——因为它经常与 P99 尖刺不对齐(第 6.6 节)。先观察积累数据,再决定是否升级或删除。
八、验收标准
- 选了 3–5 个适合 CI 的基准(纯计算、无外部依赖)。
- 在真实的 CI 环境跑了 10 次噪声测量(不是在本地)。
- 算出了每个基准的噪声底线,并据此定了阈值(噪声 × 1.5~2)。
- 门禁脚本支持
--report-only(先报告后失败)。 - 代码不变时连续跑 5 次,零误报(这是核心验收项)。
- 基线文件在仓库里(
perf/baseline.json),且有更新流程说明。 - 写了告警规则草案,且每条都有 runbook。
- 告警有分级(page / ticket / log),且 GC 类告警在 log 级。
- 记录了「误报日志」的格式(用于后续校准阈值)。
九、常见问题
Q:CI 环境跑 10 次基准太慢了,能不能只跑 3 次?
A:可以,但要知道代价:3 次样本的波动范围估计不准(可能低估噪声)。两个折中:① 用更短的基准(减少 iterationTime),把 10 次的总时间压下来;② 跑 3 次但在多个 CI run 之间累计(把每天的基线数据积累起来,一周后就有足够样本)。关键是:阈值宁松勿紧。
Q:我的 CI 环境噪声是 ±30%,这个门禁还有意义吗?
A:意义有限,但仍有一些:① 它能拦住大幅退化(比如引入 N+1 导致 3 倍变慢);② 它能作为趋势记录(即使不失败,数据也积累下来了)。但更好的做法是先降噪声:① 用自建、规格固定的 runner;② 确保每次跑的机器相同(GitHub 的 ubuntu-latest 会变);③ 关掉同 runner 上的并行 job;④ 减少基准的迭代时间(更短但更多轮)。如果噪声无法降低到 20% 以内,就不要做自动失败——改成只报告 + 人工看趋势。
Q:基线该怎么更新? A:单独提交 + 写明理由 + 需要 review。例如:
git checkout -b perf/update-baseline-v2.4
# 更新 perf/baseline.json
git commit -m "perf: update baseline after serialization refactor
预期变化:encode +12%,decode -8%
原因:切换到 kotlinx.serialization(代码生成型)
影响评估:P99 影响 < 5%(序列化只占总延迟 8%)
验证:见 experiments/E09-serialization/"
git push
Q:门禁失败了,我怀疑是噪声,怎么办? A:① 不要直接更新基线;② 重跑 3 次看是否稳定复现;③ 检查 CI runner 是否变化(型号、是否共享);④ 检查这次提交是否碰了热路径;⑤ 如果确认是噪声,记录到误报日志(第 8.2 节的表格),并考虑调整阈值——累计的误报率是调整阈值的依据。