文档目录

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 节),就无从归因。
  • 「可复现」的判断需要额外实验:本脚本只给判定表,实际的复现实验要手工做。
  • 中位数在偶数个样本时取平均:这是标准做法,但对重尾分布来说,“中间两个数的平均"可能不代表任何真实观测值——如果样本量大,影响可忽略。