5.1 配套代码:A/B/A’ 对照编排与环境漂移检测
对应小节:5.1 单变量与对照组 核心:回滚验证(A→B→A’) 能把「环境漂移」和「真实的优化效果」区分开。
一、A/B/A’ 实验编排脚本
#!/usr/bin/env bash
# tools/run-aba-experiment.sh <EXP_ID> <VARIANT_A_REF> <VARIANT_B_REF>
#
# 执行 A → B → A' 三轮对照实验。
# A = 基线
# B = 优化后的版本
# A' = 回滚到基线(用于检测环境漂移)
#
# 用法示例:
# tools/run-aba-experiment.sh E06-index-opt main feature/add-index
set -euo pipefail
EXP_ID="${1:?usage: run-aba-experiment.sh <EXP_ID> <ref_A> <ref_B>}"
REF_A="${2:?}"
REF_B="${3:?}"
DIR="docs/experiments/${EXP_ID}"
ROUNDS="${ROUNDS:-3}" # 每个变体跑几轮
mkdir -p "$DIR/results"
echo "═══ A/B/A' 对照实验:$EXP_ID ═══"
echo " A = $REF_A"
echo " B = $REF_B"
echo " A' = $REF_A(回滚)"
echo " 每个变体 $ROUNDS 轮"
echo
# ① 环境元数据(只记录一次,因为环境应该不变)
tools/collect-env.sh "$EXP_ID"
tools/check-environment.sh "$EXP_ID"
run_variant() {
local variant="$1" # A / B / A2
local ref="$2"
echo "───────── 变体 $variant($ref)─────────"
# 切换到对应版本
git checkout "$ref" -- . 2>/dev/null || {
echo "❌ 无法切换到 $ref,请确认分支/commit 存在且工作区干净"
exit 1
}
echo "当前 commit: $(git rev-parse --short HEAD)"
# 构建
./gradlew build -q
for i in $(seq 1 "$ROUNDS"); do
echo " [轮 $i/$ROUNDS]"
# 重启服务(确保同一初始状态)
scripts/restart-app.sh "$DIR/results/gc-$variant-$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=5m \
k6 run --summary-export="$DIR/results/$variant-round-$i.json" \
loadtest/profile-constant.js > /dev/null
echo " ✅ 完成"
done
}
run_variant "A" "$REF_A"
run_variant "B" "$REF_B"
run_variant "A2" "$REF_A"
echo
echo "═══ 三轮完成,开始分析 ═══"
python3 tools/analyze-aba.py "$DIR/results"
二、A/B/A’ 结果分析(检测环境漂移)
# tools/analyze-aba.py <results_dir>
"""
分析 A/B/A' 三轮实验:
- 计算每个变体的指标中位数
- 检测环境漂移(A 与 A' 的差异)
- 用 A 与 A' 的漂移幅度校正 B 的效应
判据:
漂移 = |A' - A| / A
如果 漂移 < 5% → 环境稳定,B 的效应可信
如果 漂移 5% ~ 15% → 环境有轻微漂移,B 的效应需要打折看待
如果 漂移 > 15% → 环境不稳定,实验需要重做
"""
import json
import pathlib
import statistics as st
import sys
def load_variant(results_dir, variant):
"""加载某个变体的所有轮次"""
files = sorted(pathlib.Path(results_dir).glob(f"{variant}-round-*.json"))
rounds = []
for f in files:
m = json.loads(f.read_text())["metrics"]
d = m["http_req_duration"]
rounds.append({
"qps": m["http_reqs"]["rate"],
"p50": d.get("med", 0),
"p95": d.get("p(95)", 0),
"p99": d.get("p(99)", 0),
"err": m["http_req_failed"]["rate"],
})
return rounds
def summarize(rounds, key):
vals = [r[key] for r in rounds]
return {
"values": vals,
"median": st.median(vals),
"stdev": st.stdev(vals) if len(vals) > 1 else 0.0,
"cv": (st.stdev(vals) / st.median(vals) * 100) if len(vals) > 1 and st.median(vals) else 0.0,
}
def main(results_dir):
A = load_variant(results_dir, "A")
B = load_variant(results_dir, "B")
A2 = load_variant(results_dir, "A2")
if not (A and B and A2):
print(f"❌ 数据不全:A={len(A)} B={len(B)} A'={len(A2)} 轮")
print(" 请确认文件名格式为 A-round-1.json / B-round-1.json / A2-round-1.json")
return
print("═" * 72)
print("A/B/A' 对照实验分析")
print("═" * 72)
print()
for key, label in [("qps", "QPS"), ("p50", "P50(ms)"), ("p95", "P95(ms)"), ("p99", "P99(ms)")]:
sa, sb, sa2 = summarize(A, key), summarize(B, key), summarize(A2, key)
print(f"【{label}】")
print(f" A : {sa['median']:8.2f} 轮间CV {sa['cv']:5.1f}% 值={[round(v,1) for v in sa['values']]}")
print(f" B : {sb['median']:8.2f} 轮间CV {sb['cv']:5.1f}% 值={[round(v,1) for v in sb['values']]}")
print(f" A' : {sa2['median']:8.2f} 轮间CV {sa2['cv']:5.1f}% 值={[round(v,1) for v in sa2['values']]}")
# 环境漂移
drift = abs(sa2["median"] - sa["median"]) / sa["median"] * 100 if sa["median"] else 0
# B 的效应
effect = (sb["median"] - sa["median"]) / sa["median"] * 100 if sa["median"] else 0
# 用漂移校正后的效应(取 A 和 A' 的平均作为"真实基线")
true_baseline = (sa["median"] + sa2["median"]) / 2
corrected = (sb["median"] - true_baseline) / true_baseline * 100 if true_baseline else 0
print(f" 环境漂移 |A' - A| / A = {drift:5.1f}%")
print(f" B 的原始效应 = {effect:+6.1f}%")
print(f" 用漂移校正后的效应 = {corrected:+6.1f}%")
if drift < 5:
print(" ✅ 环境稳定,B 的效应可信")
elif drift < 15:
print(" ⚠️ 环境有轻微漂移,建议以【校正后的效应】作为结论")
else:
print(" ❌ 环境不稳定(漂移 > 15%),实验需要重做")
# 与噪声底线的对比提示
max_cv = max(sa["cv"], sb["cv"], sa2["cv"])
if max_cv > 10:
print(f" ⚠️ 轮间 CV 最大 {max_cv:.1f}% > 10%,环境噪声偏大(第 7 节)")
print()
print("═" * 72)
print("结论怎么写(模板):")
print()
print(" 变体 A(基线)P99 中位数 = ___ ms(轮间 CV ___%)")
print(" 变体 B(优化)P99 中位数 = ___ ms")
print(" 变体 A'(回滚)P99 中位数 = ___ ms")
print()
print(" 环境漂移 = ___%(< 5% 说明环境稳定)")
print(" 优化效应 = ___%(校正后 ___%)")
print(" 与噪声底线(___%)对比:☐ 大于 ☐ 小于")
print(" 统计检验:Mann-Whitney U, p = ___")
print(" 结论:☐ 优化有效且可信 ☐ 无法确认 ☐ 环境不稳定需重做")
print("═" * 72)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "docs/experiments/E06/results")
三、预期输出
════════════════════════════════════════════════════════════════════════
A/B/A' 对照实验分析
════════════════════════════════════════════════════════════════════════
【P99(ms)】
A : 210.30 轮间CV 2.1% 值=[207.5, 210.3, 213.1]
B : 95.20 轮间CV 3.1% 值=[93.2, 95.2, 96.1]
A' : 208.70 轮间CV 1.8% 值=[206.9, 208.7, 211.4]
环境漂移 |A' - A| / A = 0.8%
B 的原始效应 = -54.7%
用漂移校正后的效应 = -54.4%
✅ 环境稳定,B 的效应可信
这份结论的完整表述:
优化使 P99 从 210.3 ms 降到 95.2 ms(-54.7%)。回滚后基线复现为 208.7 ms(漂移 0.8%),说明环境稳定,效应可信。
反例(环境不稳定):
A : 210.30
B : 95.20
A' : 165.40
环境漂移 = 21.3%
❌ 环境不稳定(漂移 > 15%),实验需要重做
为什么这条 A’ 很重要:如果只看 A 和 B,你会得出「优化了 55%」;但 A’ 显示基线自己就降到了 165 ms——说明至少有 20% 的"优化效果"来自环境变化(比如其他服务下线了、机器变快了)。
四、动手改造
| 改动 | 观察什么 |
|---|---|
把 ROUNDS 从 3 改成 5 |
轮间 CV 是否下降?置信区间是否变窄? |
| 故意在 A’ 之前重启一次数据库 | 漂移会变大——理解"环境变化的来源" |
| 在 A 和 A’ 之间部署另一个服务 | 漂移检测能否捕获这个变化? |
| 把分析脚本里的漂移阈值从 5% 改成 3% | 更严格,但更容易判"需重做"——阈值取决于你的噪声底线 |
用 git stash 代替 git checkout 做回滚 |
更灵活,但要确保工作区状态干净 |
五、这段代码的局限
git checkout <ref> -- .会覆盖工作区——实验前必须确保没有未提交的改动(或用git stash保存)。- A/B/A’ 的成本是三倍:只在关键优化上做,日常小改动可以只用 A/B + 噪声底线判断。
- 漂移检测只能发现"整体漂移",无法发现"只在某类请求上漂移"的细微变化。
- 如果环境本身在持续变化(比如数据量增长),A’ 的漂移会很大——这说明实验设计有问题(应该给写操作加数据隔离,第 4 章 4.1 节)。