5.3 重复、抖动与异常值
上一节:5.2 预热、稳态与运行时长 | 下一节:5.4 环境隔离清单 配套代码:05-experiment-design/03-repetition-and-outliers
一句话结论
一次运行的结果不是结论,是一个样本。 你至少需要 3–5 轮重复才能看到分布。而异常值不能"觉得不好看就删"——必须能解释成因,否则它可能正是你需要面对的真实情况。
一、用「投篮命中率」理解重复
判断一个球员的命中率,你不会只看他投一次:
| 做法 | 问题 |
|---|---|
| 只投 1 次(进了) | 可能是运气 |
| 只投 1 次(没进) | 也可能是运气 |
| 投 100 次 | 开始有意义 |
| 投 100 次,但把没进的都算作"手滑了" | 自欺 |
性能实验完全一样:
❌ 「跑了一次,P99 = 95ms,达标」
✅ 「跑了 5 次,P99 = 93, 96, 94, 210, 95 ms,中位数 95ms」
↑ 这一轮需要解释
二、该跑几轮
| 轮数 | 能做什么 | 局限 |
|---|---|---|
| 1 轮 | 只能发现「明显的崩溃」 | 无法区分差异与噪声 |
| 3 轮 | 能看到波动范围,取中位数 | 推荐的最低要求 |
| 5 轮 | 能做简单的统计检验(非参数检验需要足够样本) | 时间成本 |
| 10 轮以上 | 能估算置信区间 | 只在关键决策时值得 |
权衡:3–5 轮是性价比最高的区间。如果环境噪声大(CV > 10%),增加轮数的收益不如先改善环境。
注意:这里的「轮」是指完整的独立实验(重启服务、重新预热、重新压测),而不是「压测过程中的多次采样」。压测过程中每 10 秒一个点的采样,是同一个实验内部的波动。
三、怎么报告「多轮结果」
❌ 常见但不合格的报告
「5 轮平均 P99 = 118 ms」
问题:平均值被异常轮拉动,而且掩盖了波动幅度。
✅ 合格的报告
| 轮次 | P50 | P95 | P99 | 错误率 | 备注 |
| --- | --- | --- | --- | --- | --- |
| 1 | 32 | 68 | 93 | 0.0% | |
| 2 | 33 | 70 | 96 | 0.0% | |
| 3 | 32 | 67 | 94 | 0.0% | |
| 4 | 41 | 142 | 210 | 0.3% | ⚠️ 期间有备份任务,见说明 |
| 5 | 33 | 69 | 95 | 0.0% | |
中位数(全部 5 轮):P50=33, P95=69, P99=95
剔除第 4 轮后:P50=32.5, P95=68.5, P99=94.5(几乎无差异)
结论:第 4 轮异常由外部因素造成,已定位原因;其余 4 轮稳定。
注意这份报告的三个特点:
- 逐轮列出,不是只给一个数。
- 给出了中位数,不是平均值。
- 异常轮被保留并解释,而不是删掉。
四、异常值的正确处理流程
发现异常值
│
├─ ① 能解释成因吗?
│ ├─ 能(如:备份任务、日志轮转、其他服务部署、机器负载)
│ │ → 记录成因,可以剔除并重跑该轮
│ │
│ └─ 不能
│ → ② 保留它,并把它当作重要线索
│
└─ ③ 判断它是「环境异常」还是「系统异常」
├─ 环境异常(如备份任务)→ 修复环境后重跑
└─ 系统异常(如某种输入触发慢路径)→ 这是真实问题,必须追查
关键区分:环境异常 vs 系统异常
| 类型 | 例子 | 处理 |
|---|---|---|
| 环境异常 | 备份任务、日志轮转、邻居抢占、网络抖动 | 修环境,重跑 |
| 系统异常 ⭐ | 某种数据分布触发了慢路径、缓存击穿、锁竞争爆发 | 这是真实问题,不能删! |
最危险的错误:把「系统异常」当成「环境异常」删掉。
❌ 「这一轮 P99 突然变成 800ms,肯定是机器抽风,重跑一下吧。」
(实际上:这一轮恰好触发了缓存击穿,是一个真实的缺陷)
怎么区分:看异常是否可复现。
重跑 3 次:
- 异常不再出现 → 可能是环境异常
- 异常稳定复现 → 系统异常(必须追查)
- 间歇性出现(比如 5 次里出现 1 次)→ 可能是系统问题 + 特定条件,需要进一步定位
五、别被「平均值」和「最好的一次」骗到
| 做法 | 问题 |
|---|---|
| 报平均值 | 被异常值拉动;掩盖波动幅度 |
| 报最好的一次 | Cherry-picking,最典型的自欺 |
| 报最差的一次 | 过度悲观(除非你要评估最坏情况) |
| 报「平均 ± 标准差」 | 延迟分布非正态,标准差没有解释力(第 6 节) |
推荐:报 中位数 + 四分位距(IQR),或直接给出所有轮次的值。
中位数 = 95 ms
四分位距(Q1–Q3)= 94 – 96 ms ← 说明波动很小
最大值 = 210 ms(第 4 轮,已解释原因)
六、四个必须记录的信息
| 信息 | 为什么 |
|---|---|
| 每一轮的原始值 | 让别人能自己判断,而不是只看你的结论 |
| 中位数 / 四分位距 | 比平均值更能代表"典型情况"与波动 |
| 异常轮及其成因 | 保留证据,供后续参考 |
| 环境事件时间线 | 备份、部署、其他任务的时间点(用于解释异常) |
建议:在压测脚本里自动记录环境事件:
# 记录实验期间的系统事件
(
while true; do
date +%s
# 记录后台任务
pgrep -a -f 'backup|logrotate|cron' || true
# 记录关键资源
cat /proc/loadavg
sleep 10
done
) > events.log &
事后把 events.log 与延迟曲线对齐,就能解释大多数异常。
七、本节小结
- 一次运行不是结论,是一个样本——至少需要 3–5 轮。
- 报告要逐轮列出 + 中位数 + 四分位距,不要只给平均值。
- 异常值必须能解释成因才能剔除,否则要保留——它可能是真实问题。
- 区分环境异常(备份任务等,可剔除重跑)和系统异常(真实缺陷,必须追查)。
- 判断方法:重跑看是否可复现。稳定复现 = 系统问题。
- 自动记录环境事件时间线,是解释异常的低成本手段。
八、自测
- 你跑了 5 轮,P99 分别是 95、96、94、210、95 ms。这一轮 210 ms 该怎么处理?
- 一个团队报「5 轮平均 P99 = 118 ms,达标」。请指出这份报告的两个问题,并给出改进后的报告方式。
- 你怀疑「这一轮异常是机器抽风」,重跑 3 次后发现异常出现了 1 次。请说明你接下来该做什么,以及为什么不能简单地把它算作环境问题。
- 先不要删,先查成因。步骤:① 检查环境事件时间线(是否有备份、日志轮转、其他部署、邻居抢占)——如果找到明确的外部原因,记录后可以剔除并重跑;② 检查服务端数据(这一轮期间的 GC 日志、火焰图、连接池 pending)——如果是 GC 停顿或连接池排队,那是系统异常;③ 如果找不到原因,保留这一轮,并在报告里注明「第 4 轮异常,成因待查」;④ 用它做进一步线索:这一轮的输入或时序条件与其余轮有什么不同?(例如是不是恰好触发了缓存失效、或者是某个定时任务)。关键原则:不要因为"不好看"就删。
- 两个问题:① 用平均值——会被异常轮拉动,而且掩盖波动幅度(如果 5 轮是 50/50/50/50/390,平均值 118 完全不能反映「通常 50ms,偶尔 390ms」这个事实);② 只给一个数字——读者无法判断稳定性、无法判断是否有异常轮、无法复现。改进后的报告:逐轮列出 P50/P95/P99 + 错误率;给出中位数与四分位距(或最小值/最大值);注明轮数、每轮的样本量、是否有异常轮及其成因;补充环境元数据(或指向 env.txt)与实验编号。
- 这 1 次异常是重要线索,不能简单归为环境问题。理由:① 「1/4 的概率出现」意味着它在特定条件下会复现——这不是随机噪声,而是一个条件触发的缺陷(例如某种数据分布、某个时序窗口、缓存失效瞬间);② 环境噪声通常表现为「幅度较小的抖动」,而 3 倍以上的 P99 跳变更可能指向系统行为(缓存击穿、GC、锁竞争、连接池排队、重试风暴)。接下来该做:① 采集异常轮的服务端数据(GC 日志、wall 火焰图、连接池指标、线程快照),与正常轮对比;② 找出异常轮与正常轮的输入差异(数据 ID 分布、时序、并发模式);③ 如果能找到触发条件,就有机会在生产复现并修复——这正是「异常值」最有价值的地方;④ 如果确实无法定位,就在报告里明确写「存在偶发 P99 尖刺(约 25% 概率),成因未明,风险已记录」——诚实比好看更重要。