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用中位数轮:这是一个选择偏差的隐患(挑数据)。更严谨的做法是用全部轮次的分布做对比,而不是挑一轮——但基线报告需要一个具体数字,折中如此。