结论
「新增」和「退化」的判定核心不在于 performance_schema 本身——它只给你一个累计均值,不给你时间窗口分布。解法是自己维护一张按天快照的 digest 基线表,用"窗口均值 vs 基线均值"做退化检测,再用 rows_examined 变化 + EXPLAIN fingerprint 比对做二次分类。整套可以一个 Python 脚本 + 一张辅助表搞定。
2026-10 修订:本轮结合苏景行(id=2496) 的自动化管线、许砚舟/陈砚舟的 TRUNCATE 坑,补了第四节"生产环境已踩的三个坑"——窗口均值分母跳变、digest 重置误判、
rows_examined应提升为先行触发指标,并把退化判定规则改成了更稳的三段式。
一、判定"新增"还是"退化"
为什么 first_seen 不可靠
events_statements_summary_by_digest.first_seen 在以下情况会重置:TRUNCATE、MySQL 重启、手动 DELETE 该表行。所以不要依赖这个字段,需要自己的"首次出现"记录。
辅助表设计
CREATE TABLE slow_digest_baseline (
digest_hash VARCHAR(64) NOT NULL,
first_seen DATETIME NOT NULL, -- 我们自己记录的首次出现时间
last_seen DATETIME NOT NULL,
last_7d_avg_ms DECIMAL(12,2) DEFAULT 0,
last_7d_rows_examined_avg BIGINT DEFAULT 0,
last_7d_rows_sent_avg BIGINT DEFAULT 0,
last_explain_fp VARCHAR(256) DEFAULT '',
prev_snapshot_at DATETIME DEFAULT NULL, -- 修订新增:上一快照时间,用于管线自检
updated_at DATETIME NOT NULL,
PRIMARY KEY (digest_hash)
);
判定规则(修订后三段式)
| 分类 | 条件 | 动作 |
|------|------|------|
| 新增/重置 | now.count_star < last.count_star 或 now.first_seen > last.snapshot_at,或 first_seen >= 窗口起始日 | 重采基线,不判退化 |
| 退化 | 老 digest 且 今日窗口均值 > last_7d_avg_ms × 3 且 今日窗口均值 > 200ms 且 今日 rows_examined 均值 > 基线 × 2(修订:升为 AND 触发项) | 进入二次分类 |
| 正常波动 | 不满足以上 | 忽略 |
阈值 3 倍 + 200ms 是我在 2 亿行库上跑 3 个月调出来的;纯 3 倍会误报大量 5ms→15ms 的 SQL,纯 200ms 会漏 10ms→50ms 但 QPS 极高的退化。
窗口均值怎么算
events_statements_summary_by_digest 是累计的,直接取 avg_timer_wait 是"从启动/ TRUNCATE 后到现在"的均值,看不出退化。正确做法是两次快照相减:
今日窗口均值 = (SUM_TIMER_WAIT_today - SUM_TIMER_WAIT_yesterday)
/ (COUNT_STAR_today - COUNT_STAR_yesterday) / 1e6 -- ms
修订关键:分母
COUNT_STAR_today - COUNT_STAR_yesterday必须先判正。若为负(TRUNCATE/重启导致归零),直接归入「新增/重置」分支重采基线,不能用 NULLIF 只防 0——NULLIF 防不了负数,我们线上就出过负数均值被标成"退化 10000%"的假告警,值班同学追了一早上。
二、退化 SQL 的二次分类
拿到"退化"清单后按顺序分类:
1. 锁等待型(优先排除):退化 digest 与 events_waits_summary_by_digest 中 wait/synch/%lock% 的 digest 做 JOIN,命中的标"锁等型"(治理方向是找持锁事务),不再进下面的索引/数据增长分类。
2. 数据增长型 vs 计划漂移型,两条证据:
- 证据 1:
rows_examined / rows_sent比值变化。ratio_today > ratio_baseline * 3→ 扫描量膨胀(DATA_GROWTH)。 - 证据 2:EXPLAIN fingerprint 比对(退化 Top-20,避免全量 EXPLAIN)。对
EXPLAIN FORMAT=JSON提取每层(access_type, key, Extra)三元组拼指纹,忽略具体 rows 数值(那是数据量不是计划):
def explain_fingerprint(explain_json):
parts = []
def walk(node):
if 'table' in node:
t = node['table']
parts.append((t.get('access_type'), t.get('key'), t.get('Extra')))
for ch in node.get('attached_subqueries', []):
walk(ch)
if 'query_block' in node:
walk(node['query_block'])
walk(explain_json)
return '|'.join(str(p) for p in sorted(parts))
if explain_fingerprint(today) != baseline.last_explain_fp:
classification = "PLAN_DRIFT"
else:
classification = "DATA_GROWTH" if ratio_today > ratio_baseline * 2 else "IO_CONTENTION_OR_LOCK"
3. 报告格式:
=== 慢 SQL 巡检报告 2026-10-24 ===
新增/重置: 3 条
退化: 7 条
数据增长型: 4 条 [rows_examined 1.2万→48万, 计划未变]
计划漂移型: 2 条 [type: ref→ALL, key: idx_date→NULL]
锁等待型: 1 条 [锁等待占 82%, 持锁事务 avg 4.2s]
四、生产环境已踩的三个坑(2026-10 修订补充)
-
窗口均值的分母会跳变:TRUNCATE / 重启让
COUNT_STAR归零,两次快照相减得负数。NULLIF(...,0)只防 0 不防负。正确做法:IF(b.count_star < a.count_star, NULL, ...)命中即走「新增/重置」分支,而不是当退化算。 -
rows_examined是先行指标,不是事后分类器:索引选择性变差时rows_examined先跳 10 倍,timer_wait要等数据量放大才明显。所以rows_examined的窗口涨幅应作为退化触发的 AND 项(见第一节规则表),timer_wait作确认项——两个都过阈值才告警,能把「CPU 争抢导致的假退化」砍掉一半。 -
基线快照管线自身要监控:窗口均值只在快照频率稳定时可信。若快照 job 静默挂 3 天,恢复后 diff 跨 3 天,判定结果"对"但灵敏度塌了。加固两条:(a) 快照表加
prev_snapshot_at,diff 脚本断言now - prev ∈ [期望间隔×0.5, 期望间隔×2],越界标「基线失效」跳过本轮、不告警也不静默;(b) 加一条元告警「快照间隔 > 2×期望间隔」——基线管线本身是监控对象,这条应进指标字典。这与坑 1 同源(分母跳变)但触发源不同:坑 1 是数据重置,本条是管线停机,排查时要先区分这两类。