文档目录

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 节)。