5.3 配套代码:稳健统计与异常值归因
对应小节:5.3 重复、抖动与异常值 核心:报中位数与四分位距,而不是平均值;异常值要归因,不能随手删。
一、稳健统计(纯 Python,无第三方依赖)
# tools/robust_stats.py <results_dir_or_files...>
"""
对多轮实验结果做稳健统计。
为什么不用平均值:
- 平均值被异常轮拉动
- 5 轮结果 93/96/94/210/95 的均值是 117.6,而真实"典型值"是 95
- 中位数 95 + 四分位距能同时表达"典型值"与"波动范围"
"""
import json
import pathlib
import sys
def median(xs):
s = sorted(xs)
n = len(s)
if n == 0:
return float("nan")
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
def percentile(xs, q):
s = sorted(xs)
if not s:
return float("nan")
idx = (len(s) - 1) * q
lo, hi = int(idx), min(int(idx) + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (idx - lo)
def stdev(xs):
n = len(xs)
if n < 2:
return 0.0
m = sum(xs) / n
return (sum((x - m) ** 2 for x in xs) / (n - 1)) ** 0.5
def load_rounds(paths):
"""从 k6 summary json 里加载每轮的关键指标"""
rounds = []
for p in paths:
try:
m = json.loads(pathlib.Path(p).read_text())["metrics"]
d = m["http_req_duration"]
rounds.append({
"file": pathlib.Path(p).name,
"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"],
})
except Exception as e:
print(f"⚠️ 跳过 {p}: {e}")
return rounds
def report(rounds):
if len(rounds) < 2:
print("❌ 至少需要 2 轮数据")
return
print("═" * 78)
print(f"稳健统计({len(rounds)} 轮)")
print("═" * 78)
print()
# 逐轮列出
print("逐轮结果:")
print(f"{'轮次':<6}{'QPS':>10}{'P50':>10}{'P95':>10}{'P99':>10}{'错误率':>10}")
for i, r in enumerate(rounds, 1):
print(f"{i:<6}{r['qps']:>10.1f}{r['p50']:>10.1f}{r['p95']:>10.1f}{r['p99']:>10.1f}{r['err']:>10.2%}")
print()
# 各指标的稳健统计
print(f"{'指标':<8}{'中位数':>10}{'Q1':>10}{'Q3':>10}{'IQR':>10}{'最小':>10}{'最大':>10}{'CV':>8}")
print("-" * 78)
stats = {}
for key, label in [("qps", "QPS"), ("p50", "P50"), ("p95", "P95"), ("p99", "P99")]:
vals = [r[key] for r in rounds]
q1, q3 = percentile(vals, 0.25), percentile(vals, 0.75)
med = median(vals)
cv = (stdev(vals) / med * 100) if med else 0.0
stats[key] = {"median": med, "q1": q1, "q3": q3, "iqr": q3 - q1,
"min": min(vals), "max": max(vals), "cv": cv, "values": vals}
print(f"{label:<8}{med:>10.2f}{q1:>10.2f}{q3:>10.2f}{q3-q1:>10.2f}"
f"{min(vals):>10.2f}{max(vals):>10.2f}{cv:>7.1f}%")
print()
# 异常值检测(Tukey 方法:超出 Q1-1.5*IQR 或 Q3+1.5*IQR)
print("异常值检测(Tukey 方法,1.5 × IQR):")
found_any = False
for key, label in [("qps", "QPS"), ("p50", "P50"), ("p95", "P95"), ("p99", "P99")]:
s = stats[key]
lo, hi = s["q1"] - 1.5 * s["iqr"], s["q3"] + 1.5 * s["iqr"]
outliers = [(i + 1, v) for i, v in enumerate(s["values"]) if v < lo or v > hi]
if outliers:
found_any = True
for idx, v in outliers:
direction = "偏高" if v > hi else "偏低"
print(f" ⚠️ {label}: 第 {idx} 轮 = {v:.1f}({direction},正常范围 {lo:.1f}~{hi:.1f})")
print(f" → 必须解释成因:查环境事件时间线(tools/events-*.log)")
print(f" → 不能只因为「不好看」就剔除")
if not found_any:
print(" ✅ 未发现异常值")
print()
# 与噪声底线的对比提示
print("═" * 78)
print("下一步:")
print(" ① 把上面的 CV 与你的噪声底线对比(第 5.7 节)")
print(" ② 如果有异常值,去查环境事件时间线,给出成因解释")
print(" ③ 用 Mann-Whitney U 检验判断与基线的差异是否显著(tools/stats_test.py)")
print("═" * 78)
return stats
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: robust_stats.py <round-*.json>... 或 <results_dir>")
sys.exit(1)
arg = sys.argv[1]
if pathlib.Path(arg).is_dir():
paths = sorted(pathlib.Path(arg).glob("round-*.json")) or sorted(pathlib.Path(arg).glob("*.json"))
else:
paths = [pathlib.Path(p) for p in sys.argv[1:]]
report(load_rounds(paths))
二、异常值归因模板
## 异常值归因记录
### 异常概况
- 轮次:第 4 轮
- 异常指标:P99 = 210 ms(其余 4 轮 93–96 ms)
- 偏离幅度:+120%
### 排查过程
1. **环境事件时间线**(`events-4.log`)
- T+2:15 检测到 `logrotate` 进程运行
- T+2:18 磁盘写入速率从 5 MB/s 上升到 80 MB/s
- T+2:40 logrotate 结束
- ⚠️ 第 4 轮的压测窗口是 T+2:00 ~ T+3:00 → **时间吻合**
2. **服务端数据**
- GC 日志:未见异常停顿(与正常轮一致)
- wall 火焰图:大量栈停在 `FileOutputStream.write`(日志刷盘)
- 连接池 pending:全程为 0(排除数据库方向)
3. **可复现性验证**
- 重跑 3 次:异常未再出现
- 手动触发 `logrotate` 再压测:**异常复现**
### 归因结论
**环境异常**(日志轮转导致的磁盘 IO 争抢)。已通过手动复现验证。
### 处理
- 剔除第 4 轮,用其余 4 轮的中位数(94.5 ms)
- 修正实验流程:实验期间临时调大 logrotate 阈值,或避开轮转时刻
- 记录进假设台账:H7「日志轮转会造成 P99 尖刺」→ **已验证(成立)**
关键:「手动复现」是归因的最强证据——它把「怀疑」变成了「确认」。
三、区分环境异常与系统异常
# tools/classify_outlier.py
"""
辅助判断异常值属于「环境异常」还是「系统异常」。
逻辑:
1. 如果异常期间有明确的外部事件(备份、轮转、部署)→ 环境异常
2. 如果异常期间服务端指标异常(GC/锁/连接池)→ 系统异常
3. 如果异常可复现 → 系统异常(必须追查)
4. 如果都没有 → 未知,保留并记录
"""
import re
import sys
import pathlib
def scan_events(events_file):
"""扫描环境事件时间线,返回检测到的事件类型"""
found = []
if not pathlib.Path(events_file).exists():
return found
text = pathlib.Path(events_file).read_text(errors="ignore")
patterns = {
"备份任务": r"backup|dump|pg_dump",
"日志轮转": r"logrotate",
"定时任务": r"cron|systemd-timers",
"部署": r"deploy|kubectl|docker.*(run|restart)",
"系统更新": r"apt|yum|dnf|unattended",
}
for name, pat in patterns.items():
if re.search(pat, text, re.IGNORECASE):
found.append(name)
return found
def classify(has_external_event, server_metrics_abnormal, reproducible):
if reproducible:
return "系统异常", "异常可复现 → 必须追查(这是真实缺陷,不能剔除)"
if server_metrics_abnormal:
return "系统异常", "服务端指标异常(GC/锁/连接池)→ 追查具体原因"
if has_external_event:
return "环境异常", "有明确外部事件 → 可剔除并重跑,同时修复环境"
return "未知", "无明确证据 → 保留并记录,作为后续线索"
if __name__ == "__main__":
events_file = sys.argv[1] if len(sys.argv) > 1 else "events.log"
external = scan_events(events_file)
print("═══ 异常值归因辅助 ═══")
print()
print(f"① 环境事件扫描({events_file}):")
if external:
for e in external:
print(f" ⚠️ 检测到:{e}")
else:
print(" (未检测到常见的环境事件,或事件文件不存在)")
print()
print("② 请人工确认以下两项:")
print(" - 服务端指标是否异常?(GC 停顿/锁等待/连接池 pending)")
print(" - 异常是否可复现?(重跑 3 次)")
print()
print("③ 判定表:")
print(" ┌────────────────────┬──────────────────────────────┐")
print(" │ 可复现 │ 系统异常 → 必须追查 │")
print(" │ 服务端指标异常 │ 系统异常 → 追查具体原因 │")
print(" │ 有明确外部事件 │ 环境异常 → 可剔除 + 修环境 │")
print(" │ 以上都不是 │ 未知 → 保留并记录 │")
print(" └────────────────────┴──────────────────────────────┘")
print()
print(" ⚠️ 最危险的错误:把「系统异常」当成「环境异常」删掉。")
四、动手改造
| 改动 | 观察什么 |
|---|---|
用一个含异常轮的数据跑 robust_stats.py |
看它能否检出并提示归因 |
| 把 IQR 系数从 1.5 改成 3.0 | 检出的异常变少(更保守) |
| 对比「平均值」与「中位数」 | 在含异常轮的数据上,两者差距可能很大——这就是不用平均值的原因 |
在实验期间故意跑 logrotate |
制造一个可归因的环境异常,验证整个流程 |
把 classify_outlier.py 接进实验驱动器 |
每次实验后自动扫描环境事件 |
五、这段代码的局限
- Tukey 的 1.5 × IQR 是个经验规则:小样本(3–5 轮)时检出能力有限,可能漏掉异常,也可能误报。
- 环境事件扫描依赖事件文件的质量:如果没开事件记录(第 4 节),就无从归因。
- 「可复现」的判断需要额外实验:本脚本只给判定表,实际的复现实验要手工做。
- 中位数在偶数个样本时取平均:这是标准做法,但对重尾分布来说,“中间两个数的平均"可能不代表任何真实观测值——如果样本量大,影响可忽略。