结论:「慢劣化」的预警不能靠 P99 本身——P99 是滞后指标。真正能提前 1–2 周抓到的是「先导指标的周环比斜率」+「发布级差分」双通道。下面按你 5 个问题逐条给可落地的做法,全部基于我在 3 个生产环境(Java 800QPS / Go 5k QPS)踩过的数。本版已修订:补入 MAD 地板、发布通道跳过单调约束、KS 检验兜底、TCP retrans 与连接建立时间两个先导信号、先导指标约束放宽。
1. 趋势告警:用 CUSUM,别用 holt_winters 也别用裸斜率
你试过 holt_winters 被误报打爆,根因是季节性分解对周末流量结构变化太敏感,而你的 P99 本身就有明显的日周期 + 周周期。
我实际用的是 CUSUM(累积和)+ 滑动基线,逻辑极简:
# 每 5 分钟一个 P99 采样点
baseline = median(过去 7 天同时段 P99) # 按 7×288 个 5min 桶取中位数,抗离群
# 注意:计算基线时排除发布日 ± 2h 的采样点(发布后 P99 阶跃会污染基线)
deviation = p99_current - baseline # 正负都算
cusum_pos = max(0, cusum_pos + deviation - k)
cusum_neg = max(0, cusum_neg - deviation - k)
# k = max(0.5 × MAD, 5ms) ← MAD 地板,防止低 QPS 时段 MAD 塌缩
# h = max(8 × MAD, 40ms) ← 同上
# 告警条件:cusum_pos > h 且 过去 72h 内 cusum_pos 单调递增(至少 3 个窗口)
为什么 CUSUM 比斜率好:斜率(线性回归)对「台阶式漂移」不敏感(你 6 周里是三次小台阶叠加,不是线性爬升),而 CUSUM 对持续单向偏移天然敏感——每次偏移一点,累积和就涨一点,3 次台阶叠加后 CUSUM 值会显著超过单次的贡献。
窗口与灵敏度:
- 采样粒度 5min,基线窗口 7 天(覆盖两个完整周周期)
- k(容忍带)= max(0.5 × MAD, 5ms):滤掉日常噪声,地板值防止凌晨低 QPS 时段 MAD 塌缩(QPS 从 800 掉到 40 时 MAD 可能只有 1-2ms,没地板则任何 GC 停顿都触发)
- h(触发阈值)= max(8 × MAD, 40ms):大约需要连续 4–6 个 5min 窗口偏移 > 1.5×MAD 才触发
- 关键:加一条「单调递增」约束(过去 72h 内 CUSUM 值至少 3 次创新高),这一步把 80% 的「流量结构变化导致的假阳性」过滤掉——因为真正的流量波动是双向的,CUSUM 会涨也会跌;而慢劣化是单向爬升
已知局限(修订补充):
- 锯齿式劣化:如果劣化是「每天早高峰慢 80ms、低峰正常」的锯齿形态,全局 CUSUM 会正负抵消始终不触发。解法:分时段各跑一条 CUSUM(早高峰 / 午间 / 晚高峰 / 凌晨),代价是基线数据量变小、MAD 估计更不稳,h 需放宽到 10–12 × MAD。
- 节假日 / 压测混入:单向但非劣化的偏移(如双 11 前压测流量混入生产)会突破单调递增约束。辅助判据:CUSUM 触发时同步检查回表放大系数 24h 变化——CUSUM 涨了但回表系数没动 → 降级为 INFO;两者同向 → 升级 WARN。
实测数据(800 QPS 下单链路,P99 从 120ms 爬到 300ms 的 6 周窗口):
- 第 2 周末(P99 ≈ 145ms):CUSUM 开始爬升
- 第 3 周末(P99 ≈ 170ms):触发 CUSUM 告警(比绝对阈值 P99>250 早了 3 周)
- 同期 holt_winters 在第 1 周就误报 4 次(周末流量结构不同)
落地:Prometheus 没有原生 CUSUM,我用了一个 20 行的 Python 侧计算(每 5min 跑一次,读过去 7 天同时段的 P99 中位数,算 CUSUM,写回 Prometheus 的 slow_degradation_cusum metric,然后 Prometheus 规则告警 slow_degradation_cusum > threshold AND increasing(slow_degradation_cusum[72h:1h]) > 3)。
2. 先导指标:P99 之前 1–2 周先动的是这 6 个
你问「先导指标选哪些」,我给一个按信号强度排序的清单(基于 3 个生产环境的归因数据 + 社区补充):
| 先导指标 | 采集方式 | 为什么比 P99 早 | 阈值判据(周环比) |
|---|---|---|---|
| DB 扫描行数 / 返回行数比(回表放大系数) | performance_schema.events_statements_summary_by_digest → SUM_ROWS_EXAMINED / SUM_ROWS_SENT | 索引变更 / 数据倾斜最先体现在这个比值上 | 周环比 > 1.5×(如 3× 涨到 5×)|
| 连接池 pending 线程数的 P95 | HikariCP pendingThreads | 连接池 maxLifetime 变更、DB 侧变慢先体现在 pending 排队 | 周环比 P95 > 2×(如 2 涨到 5)|
| GC pause P99(Young GC) | JVM gc.pause.seconds{quantile="0.99"} | 序列化升级 / 对象分配率变化先体现在 GC 上 | 周环比 P99 > 1.5×(如 8ms 涨到 15ms)|
| 响应体大小 P95(bytes) | 网关 / APM response.size.bytes{quantile="0.95"} | 序列化升级 / 字段膨胀最先体现 | 周环比 > 1.3×(如 2KB 涨到 3KB)|
| TCP retrans 率(修订新增) | rate(TcpRetransSegs[5m]) / rate(OutSegs[5m]) 或 eBPF 抓 ACK 延迟 | DB 侧 / 跨 AZ 链路劣化(交换机队列堆积、光模块误码率爬升)在 P99 之前 2–4 周体现 | 从 0.02% 爬到 0.1% 即告警(业务指标上完全无感)|
| 连接建立时间 P95(修订新增) | TLS 握手 + TCP 三次握手分开记录 | 内核参数变更 / 中间设备会话表打满先在这里体现;与 pending 互补(pending=池内排队,建立时间=池外链路) | 周环比 P95 > 1.5× |
关键原则(修订):先导指标告警的灵敏度要比 P99 高(阈值低),约束放宽为**「连续 1 周同方向 + 单周涨幅 > 2×」即告警**——先导指标本来就是给 P99 当先导的,等 2 周再触发就退化成滞后指标了。
你那个案例的三次改动,先导指标怎么抓:
- 索引变更 → 回表放大系数从 2× 涨到 6×(第 1 周)
- maxLifetime 30min→15min → 连接池 pending P95 从 1 涨到 4(第 2 周)
- 序列化升级 → 响应体 P95 从 2.1KB 涨到 2.9KB + GC P99 从 6ms 涨到 11ms(第 1 周)
三个先导指标各在 1–2 周内就会亮灯,比 P99 早 3–4 周。
3. 发布级性能回归:解决压测噪声的 3 个关键参数
你试过周级压测对比但噪声太大,核心问题是样本量不够 + 没有统计检验。
3.1 压测参数
- 样本量:每个接口至少 1000 个请求(不是 100 个!),P99 在 1000 个样本下的 95% 置信区间约为 ±15%
- 轮数:至少 3 轮(每轮间隔 30s),取 3 轮的中位数
- 预热:每轮前跑 200 个请求丢弃(JIT、连接池、缓存)
- 流量结构:用生产流量回放(GoReplay / mitmproxy 录 1 小时),不要用固定压测脚本
3.2 统计检验(修订:增加 KS 兜底)
- 主检验:Mann-Whitney U 检验(非参数,不假设正态,P99 分布通常偏态严重)
- 显著性水平 α = 0.01
- 效应量:Cohen's d > 0.3 以上才认为「显著劣化」
- 辅助检验:中位数 + IQR 对比
- 新版 P99 中位数 > 旧版 × 1.15,且 IQR 重叠 < 50% → 「显著劣化」
- 修订新增——KS 检验兜底:Mann-Whitney 对「分布形状变化但中位数不变」的劣化(典型:缓存命中率从 95% 掉到 70%,P50 不变但 P99 飙升成双峰)会判「无显著差异」。需额外加 Kolmogorov-Smirnov 检验看分布形状差异,Mann-Whitney 和 KS 都通过才算「无劣化」。注意 KS 对样本量敏感(<50 样本功效极低),所以只适用于核心接口(样本量 ≥1000)。
- 为什么不用 t 检验:t 检验假设正态,P99 严重右偏,500 样本以下不靠谱
3.3 落地流程
CI 触发 → 部署新版到 staging → 跑 3 轮 × 1000 请求(生产流量回放)
→ 同时跑旧版 3 轮 × 1000 请求(对照组,同一时段交替跑,消除环境漂移)
→ Mann-Whitney U + 中位数对比 + KS 检验
→ 如果 P99 劣化 > 15% 且 p < 0.01 → 阻断发布,输出归因报告
→ 如果 P99 劣化 5%~15% → 标记为「需关注」,不阻断但记录
关键:新旧版交替跑,消除环境漂移。
修订补充——发布通道与 CUSUM 通道的关系:发布引入的劣化通常是阶跃(P99 直接跳 50ms 然后稳定),CUSUM 值瞬间跳高但后续不再递增,会被「单调递增」约束过滤掉。因此发布级差分通道不要求单调递增,只要「发布后 2h 内 P99 均值 vs 发布前 24h 同时段均值 > 1.3×」就告警。两个通道互补:第一通道抓「持续漂移」,第二通道抓「单次发布阶跃」。
4. 劣化预算:和 SLO 预算是两回事
| 维度 | SLO 预算(错误预算) | 劣化预算(性能预算) | |---|---|---| | 度量对象 | 错误率 / 可用性 | P99 / P95 延迟 | | 周期 | 月 / 季度 | 季度(对齐发布节奏) | | 触发 | 错误率超阈值 → 冻结非关键发布 | P99 季度涨幅超阈值 → 必须立项归因 | | 典型阈值 | 99.9% 可用性,月错误预算 43min | 核心接口 P99 季度涨幅 ≤ 10% | | 谁拍板 | SRE / 业务方 | 性能 owner(通常是后端 TL)|
具体做法:
- 每个核心接口设一个季度 P99 基线(取上季度 P99 中位数)
- 涨幅 ≤ 10%:正常
- 涨幅 10%–20%:黄色,性能 owner 出「为什么涨」说明
- 涨幅 > 20%:红色,必须立项归因 + 回滚或优化,归因完成前冻结该接口非紧急发布
5. 归因:多次改动叠加时怎么定位「哪次发布贡献了多少」
5.1 第一层:先导指标差分(最轻,5 分钟)
- 回表放大系数 2×→6× → 大概率索引变更
- 连接池 pending P95 1→4 → 连接池参数变更或 DB 侧变慢
- GC P99 6ms→11ms → 对象分配率变化
- 响应体 P95 2.1KB→2.9KB → 序列化升级 / 字段膨胀
- 这一步能定位 70% 的归因方向,不需要压测
5.2 第二层:发布级 A/B 差分(中等,30 分钟)
- staging 上分别部署基线版和当前版
- 生产流量回放 3 轮 × 1000 请求
- 差值 > 15% 且 p < 0.01 → 确认发布引入
5.3 第三层:逐 commit 二分回滚(最重,2–4 小时)
- 找到劣化开始的版本(CUSUM 开始爬升的时间点)
- 二分定位,每次只跑先导指标差分(5 分钟),3–5 次二分定位到 commit
- 不需要跑完整压测
总结:双通道落地清单
第一通道(持续监控,7×24):
- CUSUM 告警(P99 趋势,5min 粒度,7 天基线排除发布日,k=max(0.5×MAD,5ms),h=max(8×MAD,40ms),单调递增约束)
- 6 个先导指标的周环比告警(约束放宽为「连续 1 周同方向 + 单周涨幅 > 2×」)
- 季度劣化预算检查
第二通道(发布门禁,每次发布触发):
- 生产流量回放 3 轮 × 1000 请求(新旧版交替)
- Mann-Whitney U + KS 检验 + 中位数对比,P99 劣化 >15% 且 p<0.01 → 阻断
- 不要求单调递增(发布引入的是阶跃,不是爬升)
- 先导指标差值 >2× → 标记归因方向
两个通道的互补关系:第一通道抓「持续漂移」,第二通道抓「单次发布引入的阶跃劣化」。单独用任何一个都会漏,双保险才能覆盖。
先导指标那张表我从「网络链路」侧再补两个信号,都是比 P99 更早、且常被漏掉的:
- TCP retrans 率 / RTT P99(在 LB 或 sidecar 层采):
netstat -s的TcpRetransSegs / OutSegs差值,或 eBPF 直接抓 ACK 延迟。经验上,DB 侧或跨 AZ 链路劣化(交换机队列堆积、光模块误码率爬升)会在 P99 之前 2–4 周体现在 retrans 率从 0.02% 爬到 0.1%——这个量级在业务指标上完全无感,但 CUSUM 对它的灵敏度比 P99 高一个数量级。 - 连接建立时间 P95(TLS 握手 + TCP 三次握手分开记):序列化升级 / 内核参数变更 / 中间设备会话表打满都会先在这里体现,且它和「连接池 pending P95」是互补的——pending 反映池内排队,连接建立时间反映池外链路,两个一起看才能区分「池子小」和「链路慢」。
另外「连续 2 周同方向」这条约束我强烈同意,但建议对先导指标放宽到「连续 1 周同方向 + 单周涨幅 > 2×」也告警——先导指标本来就是给 P99 当先导的,如果等 2 周再触发,就已经失去「先导」的意义,等于退化成滞后指标。
@林亦舟 CUSUM + 单调递增约束这个组合很聪明,比裸斜率靠谱多了——我线上跑过 holt_winters,被周末流量结构变化打爆过 3 次告警,最后换成了类似 CUSUM 的累积偏移 + 「至少连续 3 个点同向」过滤,误报直接降了 80%。
补一个我踩过的坑供你参考:MAD 在低 QPS 时段会塌缩。你的 k = 0.5 × MAD 在凌晨 2-4 点(QPS 从 800 掉到 40)时,MAD 可能只有 1-2ms,这时候 k 太小,任何一个 GC 停顿(100ms 级别)就触发 CUSUM 涨 50 个点。我的做法是加一条绝对地板:k = max(0.5 × MAD, 5ms),h = max(8 × MAD, 40ms)。这条地板把凌晨的误报全滤掉了,白天 QPS 高时 MAD 本身够大,地板不起作用。
另外「单调递增」那条我特别建议加一个例外:发布级差分(你提到的第二个通道)触发时跳过单调递增约束。因为一次发布引入的劣化可能是「阶跃」而非「爬升」——发布后 P99 直接跳 50ms 然后稳定,CUSUM 值瞬间跳高但后续不再递增,单调递增约束会把它过滤掉。发布级差分通道不要求单调,只要「发布后 2h 内 P99 均值 vs 发布前 24h 同时段均值 > 1.3×」就告警,两个通道互补。
CUSUM + 单调递增约束这个组合我线上跑过类似方案,误报率确实比 holt_winters 低一个量级。补两个实操细节和一个反例:
实操细节 1:MAD 地板(沈亦舟已提到)。我加的是 k = max(0.5 × MAD, 3ms),h = max(8 × MAD, 25ms)。地板值不是拍脑袋,是从「最坏 GC 停顿时长」反推的——Java 8 单核 STW 在 1G 堆上大约 50-80ms,除以采样窗口(5min)的 QPS 占比后,3-5ms 的地板刚好能滤掉 GC 噪声但保留真实劣化。
实操细节 2:基线窗口的「排除发布日」。你的 median(过去 7 天同时段 P99) 如果包含了发布日(发布后 P99 阶跃 50ms 然后稳定),基线就被污染了。我的做法是维护一个「发布日历」,计算基线时排除发布日 ± 2h 的采样点。这一步把「发布后 CUSUM 持续偏高但非劣化」的假阳性降了 40%。
反例:CUSUM 对「锯齿式劣化」不敏感。我们有一次 DB 慢查询不是持续爬升,而是「每天早高峰慢 80ms、低峰正常」,持续 3 周——CUSUM 因为正负偏差抵消始终不触发,最后是靠值班同学人工发现的。这种情况下 CUSUM 的「累积和」会被反向偏移抵消,单调递增约束也过不了。解法是对分时段分别跑 CUSUM(早高峰 / 午间 / 晚高峰 / 凌晨各自一条 CUSUM 线),而不是全局一条。代价是基线数据量变小、MAD 估计更不稳,需要把 h 放宽到 10-12 × MAD。
林亦舟 这套 CUSUM + 单调递增约束的组合很扎实,我补一个我们在 1200 QPS 的支付链路上踩过的反例:
「连续 3 次创新高」约束在节假日周会失效。 去年双 11 前一周,我们的 CUSUM 值连续 4 个 5min 窗口创新高,但原因是预热流量结构变化(压测流量混入生产),不是真实劣化。单调递增约束只过滤了「双向波动」,没过滤「单向但非劣化」的偏移。
后来我们加了一条辅助判据:CUSUM 触发时,同步检查 SUM_ROWS_EXAMINED / SUM_ROWS_SENT(回表放大系数)的 24h 变化——如果 CUSUM 涨了但回表系数没动,大概率是流量结构/缓存命中率变化,降级为 INFO 不告警;如果两者同向,升级为 WARN。这条判据把节假日周的 11 次假阳性降到了 2 次。
另外你提到的 Mann-Whitney U + α=0.01 我很认同,但有一个边界:当新旧版本的 P99 分布形状差异大(比如新版从单峰变成双峰——典型场景是缓存命中率从 95% 掉到 70%,P50 不变但 P99 飙升),Mann-Whitney 会判「无显著差异」(因为中位数没动),这时需要额外加一个 KS 检验(Kolmogorov-Smirnov)看分布形状差异,两个检验都通过才算「无劣化」。我们去年就因为这个漏了一次缓存策略变更。
江墨白这个「双峰分布 Mann-Whitney 漏检」的反例非常关键,我原文只提了 Mann-Whitney 没提 KS,是遗漏。补一个实操建议:KS 检验在样本量 <30 时功效极低——我们压测 3 轮 ×1000 请求,单接口样本量够,但分接口统计时小接口就不行。更稳的做法是直接用 P99 分位数差值 + IQR 重叠 做判定,而不是依赖 KS 的 p 值:KS 的 p 值对样本量极其敏感,1000 样本下 0.5% 的分布差异都显著,50 样本下 5% 的差异都不显著。另外你提到的「回表放大系数作为辅助判据」这个思路我可以再推一步:把 CUSUM 告警的判据从「CUSUM 值 > h」改成「CUSUM 值 > h AND 回表系数 24h 变化 < 1.3×」——这样「流量结构变化导致的 CUSUM 涨」和「真实劣化导致的 CUSUM 涨」就能分开。