文档目录

5.9 配套代码:Lab 5 编排与验收

对应小节:5.9 Lab 5 把本章的工具串成一条完整流程,并自动验收。

一、Lab 5 一键编排

#!/usr/bin/env bash
# tools/run-lab5.sh <EXP_ID> [ROUNDS]
#
# Lab 5 完整流程:
#   ① 环境检查
#   ② 跑 N 轮噪声底线测量
#   ③ 计算噪声底线与 MDD
#   ④ 验证 A:制造 5% 改动 → 应判"不显著"
#   ⑤ 验证 B:制造 30% 改动 → 应判"显著"
set -euo pipefail

EXP_ID="${1:?usage: run-lab5.sh <EXP_ID> [rounds]}"
ROUNDS="${2:-10}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results" "$DIR/scripts"

echo "═══════════════════════════════════════════════════════════════"
echo "Lab 5:量化噪声底线"
echo "  实验编号: $EXP_ID"
echo "  轮数    : $ROUNDS"
echo "═══════════════════════════════════════════════════════════════"
echo

# ── 步骤 0:确认代码干净 ──────────────────────────────────
if [ "$(git status --porcelain | wc -l | tr -d ' ')" -gt 0 ]; then
  echo "❌ 工作区有未提交改动 —— 噪声底线必须在干净代码上测"
  echo "   请先 commit 或 stash"
  exit 1
fi
BASE_COMMIT=$(git rev-parse HEAD)
echo "✅ 代码干净,基线 commit: ${BASE_COMMIT:0:8}"
echo

# ── 步骤 1:环境检查 ──────────────────────────────────────
echo "═══ 步骤 1:环境检查 ═══"
tools/check-environment.sh "$EXP_ID"
echo

# ── 步骤 2:任务 1-3 噪声底线 ─────────────────────────────
echo "═══ 步骤 2:噪声底线测量($ROUNDS 轮)═══"
echo "⚠️  这一步会持续较长时间(每轮 = 重启 + 预热 + 测量)"
echo
tools/noise-floor.sh "$EXP_ID" "$ROUNDS" 500 3m
echo

# 提取噪声底线,保存为 JSON(供后续门禁使用)
python3 - "$DIR/results" "$DIR/results/noise-floor.json" <<'PY'
import json, pathlib, statistics as st, sys

d, out = pathlib.Path(sys.argv[1]), sys.argv[2]
rounds = []
for f in sorted(d.glob("round-*.json")):
    try:
        m = json.loads(f.read_text())["metrics"]
        dr = m["http_req_duration"]
        rounds.append({"qps": m["http_reqs"]["rate"], "p50": dr["med"],
                       "p95": dr["p(95)"], "p99": dr["p(99)"]})
    except Exception:
        pass

if len(rounds) < 3:
    print("❌ 数据不足"); sys.exit(1)

noise = {}
for key in ("qps", "p50", "p95", "p99"):
    vals = [r[key] for r in rounds]
    med = st.median(vals)
    swing = max(abs(max(vals) - med), abs(med - min(vals))) / med * 100
    noise[key] = round(swing * 1.5, 2)   # 含 1.5 安全系数

json.dump(noise, open(out, "w"), indent=2, ensure_ascii=False)
print(f"噪声底线已保存 → {out}")
print(json.dumps(noise, indent=2, ensure_ascii=False))
PY
echo

# ── 步骤 3:验证 A(约 5% 改动)───────────────────────────
echo "═══ 步骤 3:验证 A —— 制造约 5% 的改动 ═══"
echo
echo "请手工完成:"
echo "  ① 在热路径上制造一个约 5% 的开销(例如加一次小的无用计算)"
echo "  ② commit 这个改动"
echo "  ③ 运行:tools/run-variant.sh ${EXP_ID}-delta5 3"
echo "  ④ 期望结果:与噪声底线对比,判定为【不显著】"
echo
read -r -p "完成验证 A 后按回车继续(或输入 s 跳过)..." ans
if [ "${ans,,}" != "s" ]; then
  tools/compare-with-noise.sh "$EXP_ID" "${EXP_ID}-delta5" "$DIR/results/noise-floor.json"
fi
echo

# ── 步骤 4:验证 B(约 30% 改动)──────────────────────────
echo "═══ 步骤 4:验证 B —— 制造约 30% 的改动 ═══"
echo
echo "请手工完成:"
echo "  ① 制造一个约 30% 的开销(例如 Thread.sleep 或去掉一个优化)"
echo "  ② commit"
echo "  ③ 运行:tools/run-variant.sh ${EXP_ID}-delta30 3"
echo "  ④ 期望结果:判定为【显著】"
echo
read -r -p "完成验证 B 后按回车继续(或输入 s 跳过)..." ans
if [ "${ans,,}" != "s" ]; then
  tools/compare-with-noise.sh "$EXP_ID" "${EXP_ID}-delta30" "$DIR/results/noise-floor.json"
fi
echo

# ── 步骤 5:验收 ──────────────────────────────────────────
echo "═══ 步骤 5:验收 ═══"
tools/verify-lab5.sh "$EXP_ID"
echo

echo "═══════════════════════════════════════════════════════════════"
echo "✅ Lab 5 完成"
echo
echo "接下来(手工):"
echo "  1. 填写 $DIR/README.md(用 docs/experiments/_TEMPLATE/)"
echo "  2. 把噪声底线写进档案,并记住:低于 MDD 的差异不该声称"
echo "  3. 把这个数字告诉团队 —— 它是你们后续所有实验的标尺"
echo "═══════════════════════════════════════════════════════════════"

二、变体实验运行脚本

#!/usr/bin/env bash
# tools/run-variant.sh <VARIANT_EXP_ID> <ROUNDS>
#
# 跑一个变体的多轮实验(用于验证 A / 验证 B)。
# 假定当前工作区的代码已经是你想要的变体。
set -euo pipefail

EXP_ID="${1:?usage: run-variant.sh <EXP_ID> <rounds>}"
ROUNDS="${2:-3}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results"

echo "变体实验:$EXP_ID($ROUNDS 轮)"
echo "当前 commit: $(git rev-parse --short HEAD)"
echo

{
  echo "commit=$(git rev-parse HEAD)"
  echo "dirty=$(git status --porcelain | wc -l | tr -d ' ')"
} > "$DIR/results/code-state.txt"

for i in $(seq 1 "$ROUNDS"); do
  echo "───────── 第 $i / $ROUNDS 轮 ─────────"
  scripts/restart-app.sh "$DIR/results/gc-$i.log"
  sleep 5
  BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 60s \
    loadtest/profile-constant.js > /dev/null
  BASE_URL=http://127.0.0.1:8080 RATE=500 DURATION=3m \
  k6 run --summary-export="$DIR/results/round-$i.json" \
         loadtest/profile-constant.js > "$DIR/results/round-$i-stdout.txt" 2>&1

  python3 - "$DIR/results/round-$i.json" <<'PY'
import json, sys
m = json.load(open(sys.argv[1]))["metrics"]
d = m["http_req_duration"]
print(f"  QPS={m['http_reqs']['rate']:.1f}  P50={d['med']:.1f}  P99={d['p(99)']:.1f}")
PY
done

echo
echo "✅ 完成 → $DIR"

三、与噪声底线对比

#!/usr/bin/env bash
# tools/compare-with-noise.sh <BASELINE_EXP_ID> <VARIANT_EXP_ID> <NOISE_FLOOR_JSON>
set -uo pipefail

BASE="${1:?usage: compare-with-noise.sh <base_exp> <variant_exp> <noise_json>}"
VAR="${2:?}"
NOISE_JSON="${3:?}"

echo "═══════════════════════════════════════════════════════════════"
echo "与噪声底线对比"
echo "  基线   : docs/experiments/$BASE/results"
echo "  变体   : docs/experiments/$VAR/results"
echo "═══════════════════════════════════════════════════════════════"
echo

python3 - "docs/experiments/$BASE/results" "docs/experiments/$VAR/results" "$NOISE_JSON" <<'PY'
import json, pathlib, statistics as st, sys

bdir, vdir, noise_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3]
noise = json.loads(open(noise_path).read())

def med_of(d, key):
    vals = []
    for f in sorted(d.glob("round-*.json")):
        try:
            m = json.loads(f.read_text())["metrics"]
            dr = m["http_req_duration"]
            vals.append({"qps": m["http_reqs"]["rate"], "p50": dr["med"],
                         "p95": dr["p(95)"], "p99": dr["p(99)"]}[key])
        except Exception:
            pass
    return st.median(vals) if vals else None

print(f"{'指标':<6}{'基线':>10}{'变体':>10}{'差异':>10}{'噪声底线':>12}{'MDD':>10}  判定")
print("-" * 74)

for key, label in [("p50", "P50"), ("p95", "P95"), ("p99", "P99")]:
    mb, mv = med_of(bdir, key), med_of(vdir, key)
    if mb is None or mv is None:
        continue
    delta = (mv - mb) / mb * 100
    n = noise.get(key, 0)
    mdd = n * 2

    if abs(delta) < n:
        verdict = "❌ 在噪声范围内"
    elif abs(delta) < mdd:
        verdict = "⚠️  超过噪声,未达 MDD"
    else:
        verdict = "✅ 超过 MDD,可信"

    print(f"{label:<6}{mb:>10.2f}{mv:>10.2f}{delta:>9.1f}%{n:>11.1f}%{mdd:>9.1f}%  {verdict}")

print()
print("解读:")
print("  ❌ 在噪声范围内      → 无法声称有变化")
print("  ⚠️  超过噪声未达 MDD → 方向可信,幅度需更多样本验证")
print("  ✅ 超过 MDD          → 差异可信(仍需统计检验佐证)")
PY
echo
echo "下一步:用统计检验佐证"
python3 tools/stats_test.py --baseline-dir "docs/experiments/$BASE/results" \
                            --candidate-dir "docs/experiments/$VAR/results"

四、Lab 5 验收脚本

#!/usr/bin/env bash
# tools/verify-lab5.sh <EXP_ID>
set -uo pipefail

EXP_ID="${1:?usage: verify-lab5.sh <EXP_ID>}"
DIR="docs/experiments/${EXP_ID}"
PASS=0; FAIL=0

ok()  { echo "  ✅ $1"; PASS=$((PASS+1)); }
bad() { echo "  ❌ $1"; FAIL=$((FAIL+1)); }

echo "═══ Lab 5 验收:$EXP_ID ═══"
echo

echo "① 代码状态(必须干净)"
if [ -f "$DIR/results/code-state.txt" ]; then
  DIRTY=$(grep '^git_dirty_files=' "$DIR/results/code-state.txt" | cut -d= -f2)
  [ "${DIRTY:-1}" = "0" ] && ok "工作区干净(噪声底线可信)" || bad "工作区有 $DIRTY 个改动"
else
  bad "缺 code-state.txt"
fi
echo

echo "② 环境检查"
[ -f "$DIR/results/env-check.txt" ] && ok "env-check.txt 存在" || bad "缺 env-check.txt(跑 tools/check-environment.sh)"
echo

echo "③ 轮数(至少 5 轮)"
ROUNDS=$(ls "$DIR/results"/round-*.json 2>/dev/null | wc -l | tr -d ' ')
if [ "$ROUNDS" -ge 5 ]; then ok "共 $ROUNDS 轮"
elif [ "$ROUNDS" -ge 3 ]; then bad "只有 $ROUNDS 轮(建议 ≥5;3 轮只能勉强用)"
else bad "只有 $ROUNDS 轮,严重不足"; fi
echo

echo "④ 噪声底线与 MDD"
if [ -f "$DIR/results/noise-floor.json" ]; then
  ok "noise-floor.json 存在"
  echo "     内容:"
  cat "$DIR/results/noise-floor.json" | sed 's/^/     /'
else
  bad "缺 noise-floor.json(跑 tools/noise_floor.py)"
fi
echo

echo "⑤ 趋势漂移检查"
python3 - "$DIR/results" <<'PY'
import json, pathlib, sys
vals = []
for f in sorted(pathlib.Path(sys.argv[1]).glob("round-*.json")):
    try:
        vals.append(json.loads(f.read_text())["metrics"]["http_req_duration"]["p(99)"])
    except Exception:
        pass
if len(vals) < 3:
    print("     ⚠️  样本不足,无法检查趋势")
else:
    n = len(vals); xs = list(range(n))
    mx, my = sum(xs)/n, sum(vals)/n
    num = sum((x-mx)*(y-my) for x,y in zip(xs, vals))
    den = (sum((x-mx)**2 for x in xs) * sum((y-my)**2 for y in vals)) ** 0.5
    r = abs(num/den) if den else 0
    if r > 0.7:
        print(f"     ❌ 存在趋势漂移(r={r:.2f})—— 环境在变,噪声底线不可信")
    else:
        print(f"     ✅ 无趋势漂移(r={r:.2f})")
PY
echo

echo "⑥ 显著性验证(两个变体)"
for V in "${EXP_ID}-delta5" "${EXP_ID}-delta30"; do
  if [ -d "docs/experiments/$V/results" ] && [ "$(ls docs/experiments/$V/results/round-*.json 2>/dev/null | wc -l)" -ge 3 ]; then
    ok "$V 已完成"
  else
    bad "$V 未完成(验证 A/B 是本章的核心练习)"
  fi
done
echo

echo "⑦ 实验档案"
[ -f "$DIR/README.md" ] && ok "README.md 存在" || bad "缺 README.md"
echo

echo "═══════════════════════════════"
echo "通过 $PASS 项,失败 $FAIL 项"
[ "$FAIL" -eq 0 ] && echo "✅ Lab 5 完成" || echo "❌ 还有 $FAIL 项待补"

五、实验档案模板(本章新增字段)

<!-- docs/experiments/E05-noise/README.md -->
---
id: E05
title: 噪声底线测量
date: 2025-xx-xx
chapter: 5
kind: noise
status: done
hypothesis: "在代码不变的情况下,重复实验的 P99 波动应 < 10%"
variable: "无(噪声测量)"
control: "自身(多轮重复)"
commit: <hash>
git_dirty_files: 0
rounds: 10
rate: 500
duration: "3m"
tags: [noise, baseline, lab5]
---

## 1. 假设与预期
- 预期:P99 轮间 CV < 5%
- 若 > 10%:环境有问题,需要先修环境

## 2. 环境元数据
(粘 results/env.txt + env-check.txt)

## 3. 原始结果
(逐轮表格 + 各指标的中位数/波动范围/CV)

## 4. 噪声底线与 MDD

| 指标 | 波动范围 | 噪声底线(×1.5) | MDD(×2) |
| --- | --- | --- | --- |
| QPS | | | |
| P50 | | | |
| P95 | | | |
| P99 | | | |

**关键结论**:本环境只能检测大于 ___% 的 P99 变化。

## 5. 趋势漂移检查
(列出趋势系数 r,判断是否存在漂移)

## 6. 显著性验证结果

### 验证 A(约 5% 改动)
- 实际差异:___%
- p 值:___
- 判定:☐ 不显著(符合预期)☐ 显著

### 验证 B(约 30% 改动)
- 实际差异:___%
- p 值:___
- 判定:☐ 显著(符合预期)☐ 不显著(测量系统有问题)

## 7. 结论
(写出噪声底线与 MDD,并说明这对后续实验的意义)

## 8. 被推翻的假设

## 9. 遗留问题
(例如:某个指标的 CV 仍然偏高,需要进一步改善环境)

六、动手改造

改动 观察什么
把 ROUNDS 从 10 改成 5 噪声底线是变大还是变小?(样本少 → 可能低估噪声)
把 DURATION 从 3m 改成 1m 每轮更快,但 CV 可能变大(覆盖不到完整 GC 周期)
在某一轮期间故意跑重负载 波动范围变大——这就是"环境决定精度"
把噪声底线结果告诉团队 讨论:「我们以后能声称多大的改进?」
每季度重跑一次 Lab 5 观察噪声底线是否随时间变化

七、这段代码的局限

  • 验证 A/B 需要手工改代码:脚本只能编排与对比,改什么由你决定。关键是"制造已知幅度的改动"。
  • 噪声底线是本环境、本时段的:换机器、换时间段、换数据量都可能变化。
  • 3 分钟的稳态可能偏短:如果 GC 老年代周期超过 3 分钟,会漏掉长周期抖动。可以用第 5.2 节的规划器算出合适时长。
  • 本 Lab 的结论是"测量能力",不是"性能结论"——它告诉你你能测出多大的差异,这是后续所有实验的基础。