文档目录

9.4 配套代码:基线与噪声底线

对应小节:9.4 步骤三:建立基线与噪声底线

一、先验证观测回路(不测性能,只验证"仪表能读数")

#!/usr/bin/env bash
# tools/step3a-verify-observability.sh
#
# ═══ 为什么先做这一步 ═══
# 如果仪表本身不准,后面所有数字都是错的。
# 这一步【不关心性能好坏】,只关心「数字能不能对上」。
set -euo pipefail

BASE="${BASE_URL:-http://localhost:8080}"
PG="${PG_CONN:-postgresql://app:app@localhost:5432/shortlink}"

pass=0; fail=0
ok()   { echo "   ✅ $1"; pass=$((pass+1)); }
bad()  { echo "   ❌ $1"; fail=$((fail+1)); }

echo "═══ 观测回路验证 ═══"
echo

# ── ① 指标端点可用 ──
echo "① Prometheus 端点"
if curl -sf "$BASE/metrics" -o /tmp/m.txt; then
  ok "可访问,共 $(wc -l < /tmp/m.txt) 行指标"
else
  bad "无法访问 $BASE/metrics"
fi

# ── ② 四层指标都在 ──
echo
echo "② 四层指标覆盖"
check_metric() {
  if grep -q "^$1" /tmp/m.txt 2>/dev/null; then ok "$1"; else bad "$1 缺失"; fi
}
check_metric "http_server_requests_seconds"
check_metric "app_request_duration"
check_metric "jvm_memory_used_bytes"
check_metric "jvm_gc_pause_seconds"
check_metric "hikaricp_connections_active"
check_metric "db_pool_pending"
check_metric "app_cache_hit_rate"

# ── ③ 计数守恒:客户端请求数 ≈ 服务端请求数 ──
echo
echo "③ 计数守恒(测 100 次请求)"
before=$(grep '^http_server_requests_seconds_count' /tmp/m.txt | awk '{s+=$NF} END{print s+0}')
for i in $(seq 1 100); do
  curl -sf -o /dev/null "$BASE/health" || true
done
sleep 1
after=$(curl -sf "$BASE/metrics" | grep '^http_server_requests_seconds_count' | awk '{s+=$NF} END{print s+0}')
delta=$((after - before))
if [ "$delta" -ge 100 ]; then
  ok "客户端 100 次 / 服务端计数 +$delta(对得上)"
else
  bad "客户端 100 次 / 服务端只 +$delta(有丢失 —— 指标采样或中间件问题)"
fi

# ── ④ 延迟直方图有分布(不是全落在 +Inf)──
echo
echo "④ 延迟直方图分布"
buckets=$(curl -sf "$BASE/metrics" | grep -c 'app_request_duration_bucket' || true)
if [ "$buckets" -ge 5 ]; then
  ok "有 $buckets 个桶(分布可算分位数)"
else
  bad "只有 $buckets 个桶 —— 直方图配置有问题"
fi

# ── ⑤ 依赖计时与端到端能对上 ──
echo
echo "⑤ 依赖级 vs 端到端"
cat > /tmp/probe.js <<'JS'
import http from 'k6/http';
export const options = { vus: 2, duration: '15s', thresholds: {} };
const codes = open('../data/hot-codes.txt').split('\n').filter(Boolean);
export default function () {
  http.get(`http://localhost:8080/${codes[Math.floor(Math.random()*codes.length)]}`,
    { redirects: 0 });
}
JS
(cd perf/k6 && k6 run --quiet --no-summary /tmp/probe.js >/dev/null 2>&1) || true
sleep 2
e2e=$(curl -sf "$BASE/metrics" | grep '^app_request_duration_seconds_sum' | awk '{s+=$NF} END{print s+0}')
dep=$(curl -sf "$BASE/metrics" | grep '^app_db_query_seconds_sum' | awk '{s+=$NF} END{print s+0}')
if [ -n "$e2e" ] && [ -n "$dep" ]; then
  ratio=$(python3 -c "print(f'{$dep/$e2e*100:.0f}' if $e2e>0 else 'n/a')")
  ok "依赖总耗时占端到端 ${ratio}%(埋雷 ① 生效时应 > 80%)"
else
  bad "缺少 app_db_query_seconds 指标 —— 依赖级计时未埋"
fi

echo
echo "═════════════════════════════════════"
echo "通过 $pass 项 / 失败 $fail 项"
if [ "$fail" -gt 0 ]; then
  echo
  echo "❌ 观测回路不可信 —— 【不要】开始测性能"
  echo "   先修仪表:数字对不上的压测结果没有讨论价值"
  exit 1
fi
echo "✅ 观测回路可信,可以开始基线"

预期输出:

① Prometheus 端点
   ✅ 可访问,共 412 行指标
② 四层指标覆盖
   ✅ http_server_requests_seconds
   ✅ app_request_duration
   ...
③ 计数守恒(测 100 次请求)
   ✅ 客户端 100 次 / 服务端计数 +100(对得上)
④ 延迟直方图分布
   ✅ 有 12 个桶(分布可算分位数)
⑤ 依赖级 vs 端到端
   ✅ 依赖总耗时占端到端 89%(埋雷 ① 生效时应 > 80%)
通过 12 项 / 失败 0 项
✅ 观测回路可信,可以开始基线

二、单轮基线

#!/usr/bin/env bash
# tools/step3b-baseline-one.sh —— 跑一轮基线并归档
set -euo pipefail

ROUND="${1:?用法: $0 <轮次号>}"
OUT="perf/results/step3-baseline/round-$ROUND"
mkdir -p "$OUT"

echo "═══ 基线第 $ROUND 轮 ═══"

# ── 环境元数据(每次都必须记,否则以后无法解释差异)──
{
  echo "# 环境元数据 —— $(date -Iseconds)"
  echo
  echo "## 主机"
  sw_vers 2>/dev/null || uname -a
  echo
  echo "## CPU"
  sysctl -n machdep.cpu.brand_string 2>/dev/null || lscpu | head -5
  echo "核心数:$(sysctl -n hw.ncpu 2>/dev/null || nproc)"
  echo
  echo "## 内存"
  echo "总内存:$(sysctl -n hw.memsize 2>/dev/null | awk '{print $1/1024/1024/1024 " GB"}')"
  echo
  echo "## 服务"
  echo "commit:$(git rev-parse --short HEAD 2>/dev/null || echo 'n/a')"
  echo "JVM:$(java -version 2>&1 | head -1)"
  echo "JVM 参数:${JAVA_OPTS:-<未设置>}"
  echo
  echo "## 数据库"
  psql -tAc "SELECT version();" "$PG_CONN" 2>/dev/null | head -1
  psql -tAc "SELECT count(*) FROM links;" "$PG_CONN" 2>/dev/null \
    | sed 's/^/links 行数:/'
  echo
  echo "## 压测机自身"
  echo "load average:$(uptime | sed 's/.*load average//')"
} > "$OUT/env.md"

# ── 静置 30s(让 GC 与后台任务沉降)──
echo "静置 30s,等待系统稳定..."
sleep 30

# ── 跑压测 ──
echo "开始压测(100 RPS × 3.5 分钟)..."
cd perf/k6
k6 run \
  --out json="$OLDPWD/$OUT/k6.json" \
  --summary-export="$OLDPWD/$OUT/summary.json" \
  redirect.js 2>&1 | tee "$OLDPWD/$OUT/k6.log"
cd "$OLDPWD"

# ── 抓服务端指标快照 ──
curl -sf http://localhost:8080/metrics > "$OUT/metrics-after.txt" || true

# ── 提取关键数字 ──
python3 - "$OUT" <<'PY'
import json, sys, pathlib

out = pathlib.Path(sys.argv[1])
s = json.loads((out / "summary.json").read_text())

def ms(metric, key):
    return s["metrics"].get(metric, {}).get(key)

row = {
    "round": out.name,
    "p50": ms("http_req_duration", "p(50)"),
    "p95": ms("http_req_duration", "p(95)"),
    "p99": ms("http_req_duration", "p(99)"),
    "max": ms("http_req_duration", "max"),
    "rps": s["metrics"].get("http_reqs", {}).get("rate"),
    "err": s["metrics"].get("http_req_failed", {}).get("value"),
}
(out / "summary.txt").write_text(
    "\n".join(f"{k:8s} {v}" for k, v in row.items()) + "\n"
)
print(json.dumps(row, indent=2, ensure_ascii=False))
PY

echo
echo "✅ 第 $ROUND 轮完成 → $OUT"

三、噪声底线(跑 N 轮,算 P99 的波动范围)

#!/usr/bin/env python3
"""tools/step3c-noise-floor.py —— 从多轮基线算噪声底线与 MDD

关键定义(第 5.7 节):
  噪声底线 = 同一份代码、同样环境下重复测得的波动范围
  MDD      = 能可靠分辨的最小差异 = 噪声 × 2

为什么是 ×2:
  如果差异只有噪声的 1 倍,你无法区分「真的变快了」还是「这轮运气好」。
  取 2 倍是工程上的保守约定——代价是更慢的迭代,收益是不会自欺。
"""
import json
import pathlib
import statistics
import sys


def main(results_dir: str) -> int:
    d = pathlib.Path(results_dir)
    rounds = sorted(d.glob("round-*/summary.json"))
    if len(rounds) < 5:
        print(f"❌ 只有 {len(rounds)} 轮 —— 至少 5 轮才能估噪声")
        return 1

    p99s, p95s, rpss = [], [], []
    for r in rounds:
        s = json.loads(r.read_text())["metrics"]
        p99s.append(s["http_req_duration"]["p(99)"])
        p95s.append(s["http_req_duration"]["p(95)"])
        rpss.append(s["http_reqs"]["rate"])

    def stats(name, xs):
        mean = statistics.mean(xs)
        sd = statistics.stdev(xs) if len(xs) > 1 else 0.0
        cv = sd / mean * 100 if mean else 0.0
        lo, hi = min(xs), max(xs)
        rng_pct = (hi - lo) / mean * 100 if mean else 0.0
        print(f"{name}:")
        print(f"  均值      {mean:10.2f}")
        print(f"  标准差    {sd:10.2f}")
        print(f"  变异系数  {cv:9.2f}%")
        print(f"  范围      {lo:.2f} ~ {hi:.2f}  (跨度 {rng_pct:.1f}%)")
        return {"mean": mean, "sd": sd, "cv": cv, "range_pct": rng_pct,
                "min": lo, "max": hi}

    print(f"═══ 噪声底线({len(rounds)} 轮)═══\n")
    st_p99 = stats("P99 (ms)", p99s)
    print()
    st_p95 = stats("P95 (ms)", p95s)
    print()
    st_rps = stats("吞吐 (RPS)", rpss)

    # ── 噪声取「变异系数」与「范围跨度」中较大者 ──
    noise = max(st_p99["cv"], st_p99["range_pct"] / 2)
    mdd = noise * 2

    print()
    print("════════════════════════════════════════")
    print(f"噪声底线(P99)   ±{noise:.1f}%")
    print(f"MDD(P99)        ±{mdd:.1f}%")
    print()
    print("结论:")
    print(f"  • 优化后 P99 变化 < {mdd:.1f}% → 判定为「无显著变化」")
    print(f"  • 优化后 P99 变化 ≥ {mdd:.1f}% → 判定为「有显著变化」")
    print(f"  • 门禁阈值(第 9.9 节)应取噪声的 1.5~2 倍,即 ±{noise*1.5:.1f}% ~ ±{mdd:.1f}%")
    print()
    print("⚠️  注意:本 Lab 的噪声约 ±6%,所以 2.1% 的改进(否决 1)")
    print("    在统计上【无法区分】—— 这不是「没效果」,是「测不出来」")

    # ── 写文件 ──
    (d / "noise-floor.json").write_text(json.dumps({
        "rounds": len(rounds),
        "p99": st_p99,
        "p95": st_p95,
        "rps": st_rps,
        "noise_pct": round(noise, 2),
        "mdd_pct": round(mdd, 2),
    }, indent=2, ensure_ascii=False))

    (d / "noise-floor.md").write_text(f"""# 噪声底线与 MDD

- 轮次:{len(rounds)}
- P99 均值:{st_p99['mean']:.2f} ms
- P99 变异系数:{st_p99['cv']:.2f}%
- **噪声底线:±{noise:.1f}%**
- **MDD:±{mdd:.1f}%**

## 判读规则

| 观察到的变化 | 判定 |
| --- | --- |
| < {mdd:.1f}% | 无显著变化(在噪声内) |
| {mdd:.1f}% ~ {mdd*2:.1f}% | 疑似有变化,需要更多轮次 |
| > {mdd*2:.1f}% | 显著变化,可以接受结论 |
""")
    print(f"\n✅ 已写入 {d}/noise-floor.json 与 noise-floor.md")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1
                  else "perf/results/step3-baseline"))

预期输出:

═══ 噪声底线(10 轮)═══

P99 (ms):
  均值          420.51
  标准差         17.83
  变异系数        4.24%
  范围          402.10 ~ 441.30  (跨度 9.3%)

P95 (ms):
  均值          268.14
  标准差          9.02
  变异系数        3.36%
  范围          254.80 ~ 279.60  (跨度 9.2%)

吞吐 (RPS):
  均值           99.87
  标准差          0.31
  变异系数        0.31%
  范围           99.40 ~ 100.30  (跨度 0.9%)

════════════════════════════════════════
噪声底线(P99)   ±5.9%
MDD(P99)        ±11.8%

结论:
  • 优化后 P99 变化 < 11.8% → 判定为「无显著变化」
  • 优化后 P99 变化 ≥ 11.8% → 判定为「有显著变化」
  • 门禁阈值(第 9.9 节)应取噪声的 1.5~2 倍,即 ±8.9% ~ ±11.8%

⚠️  注意:本 Lab 的噪声约 ±6%,所以 2.1% 的改进(否决 1)
    在统计上【无法区分】—— 这不是「没效果」,是「测不出来」

四、噪声底线怎么读

Q:为什么 P99 的噪声(5.9%)比 RPS 的噪声(0.31%)大这么多?

A:因为 RPS 是「平均值」——100 RPS 的到达率是压测机严格控制的,
   所以吞吐几乎不变。
   而 P99 是「尾部」——它由最慢的那 1% 请求决定,
   这些请求受 GC、调度、页缓存冷热的影响很大,天然波动大。

Q:那基线该怎么定?

A:不用「最好的一轮」,也不用「平均」——
   用【中位数轮】作为基线(本 Lab 是第 4 轮的 418.2ms)。
   理由:中位数轮的代表性最好,且不会被极端轮带偏。
#!/usr/bin/env python3
"""tools/pick-baseline.py —— 挑出作为「官方基线」的那一轮"""

import json
import pathlib
import statistics
import sys

d = pathlib.Path(sys.argv[1] if len(sys.argv) > 1
                 else "perf/results/step3-baseline")

rows = []
for r in sorted(d.glob("round-*/summary.json")):
    s = json.loads(r.read_text())["metrics"]
    rows.append((r.parent.name, s["http_req_duration"]["p(99)"]))

rows.sort(key=lambda x: x[1])
median = statistics.median([p for _, p in rows])

# ── 挑最接近中位数的那一轮 ──
pick, p99 = min(rows, key=lambda x: abs(x[1] - median))

print(f"共 {len(rows)} 轮,P99 中位数 {median:.1f}ms")
print(f"官方基线:{pick}(P99 = {p99:.1f}ms)")
print()
print("为什么用中位数轮而不是最快/最慢:")
print("  最快轮 → 基线过好,之后的优化都像变差了")
print("  最慢轮 → 基线过差,优化效果被高估")
print("  中位轮 → 最有代表性,且可复现 ✅")
print()
print(f"把这个数字记进 PERF-REPORT.md:基线 P99 = {p99:.1f}ms")

五、动手改造

改动 观察什么
把 10 轮减到 3 轮 噪声估计不可靠(标准差本身波动大)——理解为什么至少要 5 轮
压测时不设 sleep 30 上一轮的余温会污染下一轮(尤其 GC)——理解静置的作用
让压测机和被测服务抢 CPU 噪声从 ±6% 涨到 ±20%——环境噪声会吃掉优化效果
把噪声底线从"P99"改成"P50" 会发现 P50 噪声小得多——尾部指标天生更抖
记录环境元数据时省掉 JVM 参数 后来发现差异时无法解释——元数据是"未来的证据"

六、这段代码的局限

  • noise = max(cv, range/2) 是启发式:不是严格统计定义。严格做法用「置信区间」(第 5.6 节),但工程上这个近似够用。
  • 10 轮只能估噪声,不能做显著性检验:t 检验需要更多样本(或配对设计)。
  • 环境元数据的采集依赖 macOS 命令(sw_vers/sysctl):Linux 需要换成 lscpu//proc/cpuinfo。
  • 没有检测"压测机饱和":如果压测机自己成瓶颈(load average 飙高),RPS 会虚低——第 4.1 节的压测机自查脚本必须一起用。
  • pick-baseline.py 用中位数轮:这是一个选择偏差的隐患(挑数据)。更严谨的做法是用全部轮次的分布做对比,而不是挑一轮——但基线报告需要一个具体数字,折中如此。