文档目录

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 节的表格),并考虑调整阈值——累计的误报率是调整阈值的依据。