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 的结论是"测量能力",不是"性能结论"——它告诉你你能测出多大的差异,这是后续所有实验的基础。