9.4 步骤三:建立基线与噪声底线
上一节:9.3 步骤二:搭服务与数据 | 下一节:9.5 步骤四:容量曲线与拐点 用到:第 4 章(观测)、第 5.7 节(噪声底线) 配套代码:09-capstone/04-step3-baseline 产出:
experiments/E09-baseline/(基线数据 + 噪声底线)
一句话结论
这一步衡量的是「你的测量工具有多准」。 在测出噪声底线之前,任何"优化了 X%“的说法都是没有依据的——因为你不知道 X% 是不是噪声。
一、三件事,按顺序做
① 观测回路验证(10 分钟)
→ 确认指标、日志、火焰图三条链路都通
↓
② 基线测量(30 分钟)
→ 在 SLO 目标负载下跑 3 轮,得到基线数据
↓
③ 噪声底线测量(1 小时)
→ 代码不变,重复 10 轮,得到"我的尺子有多准"
顺序不能反:如果观测回路没通,基线数据就是不可信的。
二、① 观测回路验证
#!/usr/bin/env bash
# tools/verify-observability.sh
set -uo pipefail
echo "═══ 观测回路验证 ═══"
echo
# ① 四层指标
echo "① 检查四层指标"
M=$(curl -s --max-time 5 localhost:8080/metrics)
check() { echo "$M" | grep -qE "$1" && echo " ✅ $2" || echo " ❌ $2(缺这组)"; }
check "^app_requests_total" "业务层"
check "^app_request_duration.*bucket" "延迟层(直方图)"
check "^jvm_memory_used_bytes" "资源层"
check "^db_pool_pending" "饱和度层 ⭐"
check "^app_cache_hit_rate" "缓存命中率"
echo
echo "② 检查桶边界(应该在 100ms 附近加密)"
echo "$M" | grep 'app_request_duration_seconds_bucket' | grep -oE 'le="[0-9.]+"' | head -12 | sed 's/^/ /'
echo
echo "③ 检查指标基数(应该是个位数)"
echo " 序列数: $(echo "$M" | grep -c 'app_request_duration_seconds_bucket')"
echo
echo "④ 检查火焰图能采到"
PID=$(jcmd 2>/dev/null | grep app.jar | awk '{print $1}')
if [ -n "$PID" ]; then
TMP=$(mktemp)
asprof -d 5 -e cpu -o collapsed -f "$TMP" "$PID" > /dev/null 2>&1
LINES=$(wc -l < "$TMP")
[ "$LINES" -gt 10 ] && echo " ✅ 火焰图有 $LINES 条栈(压测中采集会更多)" \
|| echo " ⚠️ 只有 $LINES 条栈(可能没在压测,或权限问题)"
rm -f "$TMP"
else
echo " ⚠️ 未找到应用进程"
fi
echo
echo "⑤ 检查 Prometheus 抓取"
curl -sf http://localhost:9090/-/ready > /dev/null && echo " ✅ Prometheus 就绪" \
|| echo " ⚠️ Prometheus 未就绪"
关键检查:db_pool_pending 与 app_cache_hit_rate——这两个是后面定位的关键指标,如果没有,后面的分析会缺一大块。
三、② 基线测量
在 SLO 目标负载下跑(不是随便跑一个负载):
# 目标:SLO 定义的峰值 QPS = 3000
# 但注意:埋雷 ①(无索引)可能让 3000 达不到
# → 先用较低的负载建立基线,再逐步提升(步骤四会找拐点)
tools/run-baseline.sh E09-baseline
#!/usr/bin/env bash
# tools/run-baseline.sh <EXP_ID>
set -uo pipefail
EXP_ID="${1:?usage: run-baseline.sh <exp_id>}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results"
# ① 环境
tools/collect-env.sh "$EXP_ID" > /dev/null 2>&1 || true
tools/check-environment.sh "$EXP_ID" > /dev/null 2>&1 || true
# ② 重置数据与缓存(保证每轮同一初始状态)
scripts/reset-state.sh
# ③ 三轮基线测量
for i in 1 2 3; do
echo "── 第 $i 轮 ──"
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/read-path.js > /dev/null
# 测量
BASE_URL=http://127.0.0.1:8080 RATE="${BASELINE_QPS:-500}" DURATION=5m \
k6 run --summary-export="$DIR/results/round-$i.json" \
loadtest/read-path.js > /dev/null
# 饱和度快照
curl -s localhost:8080/metrics > "$DIR/results/metrics-$i.txt"
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} "
f"P95={d['p(95)']:.1f} P99={d['p(99)']:.1f} 错误率={m['http_req_failed']['rate']:.2%}")
PY
done
echo
python3 tools/robust_stats.py "$DIR/results"
预期的基线数据(有埋雷的情况下):
轮次 QPS P50 P95 P99 错误率
1 498.2 12.3 68.4 420.5 0.00%
2 499.1 12.8 71.2 438.1 0.00%
3 497.5 12.1 67.9 415.2 0.00%
指标 中位数 最小 最大 波动范围 CV
QPS 498.80 497.50 499.70 0.2% 0.1%
P50 12.30 12.10 12.80 4.1% 2.4%
P95 68.40 67.90 71.20 4.1% 2.1%
P99 420.50 415.20 438.10 4.2% 2.3%
注意 P99 = 420 ms——远超 SLO 的 100 ms。这正是埋雷 ① 的效果(无索引导致全表扫描)。
基线的价值:它告诉你「起点在哪」。420 ms 是 before,后面所有优化都要与它对比。
四、③ 噪声底线测量(最关键的一步)
tools/noise-floor.sh E09-noise 10 500 3m
预期结果:
═══ 噪声底线分析(10 轮,代码未改动)═══
指标 中位数 最小 最大 波动范围 CV 趋势
P99 420.50 411.20 436.80 3.9% 2.1% 无趋势
噪声底线(波动范围 × 1.5):P99 ±5.9%
最小可检测差异(MDD = 噪声底线 × 2):P99 ±11.8%
环境质量评估:
✅ P99 的轮间 CV = 2.1% —— 环境很干净
这份数据的意义:
本环境里:
P99 改善 < 5.9% → 无法区分是优化还是噪声
P99 改善 > 11.8% → 可以确信是真实的改善
而 P99 = 420ms,SLO = 100ms,需要改善 76%
→ 远大于 MDD,因此【任何有效的优化都应该能被检出】
注意这个好消息:当问题足够大时(76% > 11.8%),你的环境精度是够用的。
反过来说:如果 SLO 只要求改善 5%,那这个环境就测不出——需要更干净的环境或更多轮次(第 5.7 节)。
五、四层指标的基线快照
除了延迟,还要记录其他三层——它们是后面定位的依据:
## 基线快照(500 RPS 下)
| 层级 | 指标 | 值 | 备注 |
| --- | --- | --- | --- |
| 业务 | QPS | 498.8 | 达到设定值 ✅ |
| 业务 | 错误率 | 0.00% | |
| 延迟 | P50 / P95 / P99 | 12.3 / 68.4 / 420.5 ms | **P99 超标 4.2 倍** |
| 资源 | CPU | 42% | 不高 |
| 资源 | 堆使用 | 380 MB | 稳定 |
| 资源 | GC 停顿 P99 | 18 ms | 正常 |
| **饱和度** | **连接池 pending** | **0** | 无排队 |
| **饱和度** | **活跃协程数** | **~50** | 稳定 |
| **饱和度** | **缓存命中率** | **~75%** | 接近预期(幂律分布) |
| 数据库 | 单条查询 P99 | **~380 ms** | ⭐ 这就是问题所在 |
**初步推论**:
- CPU 不高、连接池无排队 → **不是资源不足,也不是排队**
- 数据库查询 P99 = 380ms → **问题在数据访问路径**
- 与延迟分解(P99 420ms 中数据库占 380ms)一致
→ 这已经指向了埋雷 ①(缺索引),但**还不能下结论**——需要在步骤五用 `EXPLAIN` 验证。
注意最后一句:基线数据只能"指向”,不能"确认"。确认要在步骤五用具体的证据(例如 EXPLAIN 的输出)。
六、这一步的验收
## 步骤三验收清单
### 观测回路
- [ ] 四层指标齐全(**尤其 `db_pool_pending` 和 `cache_hit_rate`**)
- [ ] 桶边界在 SLO 附近加密
- [ ] 火焰图能采到(权限正常)
- [ ] Prometheus 抓取正常
### 基线
- [ ] 在**固定负载**下跑了 3 轮(≥3)
- [ ] 每轮都**重启服务 + 独立预热**
- [ ] 记录了四层指标的快照(不只是延迟)
- [ ] 数据落盘(`round-N.json`)
### 噪声底线 ⭐
- [ ] 在**代码完全不变**的情况下跑了 10 轮
- [ ] 算出了**噪声底线**与**最小可检测差异(MDD)**
- [ ] 检查了**趋势漂移**(确认无单调上升)
- [ ] 明确了「本环境能检测多大的差异」
### 记录
- [ ] 实验档案含「预期 vs 实际」表
- [ ] 至少两条假设进台账
七、本节小结
- 三件事按顺序做:观测回路验证 → 基线测量 → 噪声底线测量。
- 基线要在固定负载下跑 3 轮,每轮重启 + 独立预热。
- 噪声底线必须"代码完全不变"跑 10 轮——它衡量的是"你的尺子有多准"。
- 基线快照要记录四层指标,不只是延迟——其他三层是后面定位的依据。
- 基线数据只能"指向"问题,不能"确认"——确认要在步骤五用具体证据。
- 当问题足够大(76% > MDD 11.8%)时,环境精度是够用的;反过来说,如果 SLO 只要求小改善,可能需要更干净的环境。
八、自测
- 为什么必须先验证观测回路,再做基线测量?请举一个具体的后果。
- 你的噪声底线是 ±5.9%,MDD 是 ±11.8%,而 SLO 需要改善 76%。请说明你的环境精度是否够用,以及为什么。
- 基线快照里为什么要记录「连接池 pending」和「CPU」这些与延迟无关的指标?
- 因为基线数据的价值依赖于「能解释它」。如果观测回路没通:① 你只有一个延迟数字,不知道原因——比如基线显示 P99 = 420ms,但如果没有
db_pool_pending、没有依赖级指标、没有火焰图,你无法知道这 420ms 花在哪;② 可能会做出错误判断——比如看到 CPU 42% 就觉得"资源充足,问题在代码",而实际上问题可能在数据库(CPU 低但查询慢);③ 浪费后续的实验——步骤五的定位会缺少必要的指标,你可能要重新回到步骤三补埋点、重跑基线。具体后果举例:假设你的db_pool_pending指标没埋,那么当基线显示 P99 超标时,你无法排除"连接池排队"这个假设——只能再去查别的地方,或者增加埋点后重跑基线(浪费一整天)。所以:先确认工具能回答你的问题,再开始测量。 - 环境精度够用。理由:① 需要检出的改善幅度(76%)远大于 MDD(11.8%)——即优化后的 P99 应该从 420ms 降到 100ms,这个变化是 MDD 的 6.4 倍,不可能被噪声掩盖;② 换句话说,任何能把 P99 改善到 SLO 的优化,都必然能被这个环境检出;③ 反过来说,这个环境无法证实一个"5% 的改善"——如果你的 SLO 只要求从 100ms 优化到 95ms,那这个环境就不够(需要更干净的环境、更多轮次,或用组件基准来测)。关键结论:环境的精度需求取决于「你要检出的最小改善幅度」——不是"越精确越好",而是"够用就好"。所以步骤一(定义目标)和步骤三(测噪声)是配套的:先知道要改善多少,再确认能不能测出来。
- 因为它们的作用是「排除假设」,而不只是"看系统健康不健康"。具体来说:① 连接池 pending = 0 可以排除「连接池排队」这个假设——如果当时 pending > 0,那么 P99 超标的主要原因是排队,而不是数据库查询本身;② CPU 42%(不高) 可以排除「CPU 密集瓶颈」——说明时间花在等待上(等数据库),而不是在计算;③ 缓存命中率 75% 说明读路径大部分走了缓存——那么剩下的 25% 未命中请求才是慢的来源,这提示我们要重点看未命中的那条路径;④ GC 停顿 18ms 可以排除 GC(因为 18ms 远小于 420ms)。这些"排除"和"指向"同样重要——它们把假设空间从"任何可能"缩小到"数据访问路径",让步骤五的定位有明确方向(第 6.1 节的流程)。