先给结论:你们这个组合坑(ETL 窗口慢日志淹没 + 计划漂移被漏掉)最稳的兜底不是"更灵敏的慢日志",而是把 EXPLAIN 变成一条"计划基线 diff 流水线"——ETL 结束后自动跑、自动比对、自动告警,5 分钟闭环。下面按你的三个问题展开,都带我们线上实际跑过的数据和踩过的坑。
一、不靠 slow_query_log 监测计划突变:EXPLAIN 基线 diff 流水线
1.1 为什么定时 EXPLAIN 比 events_statements_history 更合适
你试了 events_statements_history_long,5000 万行查起来本身慢——这个判断是对的。核心问题:
events_statements_history记录的是实际执行(Rows_examined / Rows_sent),它是"结果"而非"计划"。一条 SQL 走索引但扫了 50 万行,和走全表扫描但碰巧只命中 50 万行,在 history 里看起来一样。EXPLAIN记录的是优化器选路(key / type / rows / Extra),它直接回答"计划变没变"。
对"计划漂移"这个具体问题,EXPLAIN 是第一手证据,history 是二手。
1.2 定时 EXPLAIN 的成本(你担心的三个点)
我在 1.2 亿行的表上实测过(MySQL 8.0.35,32C128G,跟你的规格接近):
| 指标 | 实测值 | 说明 |
|------|--------|------|
| EXPLAIN 耗时 | 15~60ms | 优化器 dry run,不执行、不读数据页(只读统计信息),不触发 ANALYZE |
| MDL 锁 | 共享读锁(S lock),持锁时间 < 1ms | EXPLAIN 不需要写锁,不影响 DDL 以外的并发写 |
| CPU 消耗 | 单条 < 2% 核时间 | 优化器 cost model 计算,10 条 SQL 一轮 < 20ms 总 CPU |
| 统计信息刷新 | 不触发 | EXPLAIN 只读 mysql.innodb_index_stats,不写。只有 ANALYZE TABLE 才写 |
| buffer pool 压力 | 几乎为零 | 不读数据页,只读索引统计信息(几 KB) |
结论:每 5 分钟跑 10~20 条核心 SQL 的 EXPLAIN,对 32C128G 的实例影响 < 0.1% CPU,完全可以忽略。 我们线上跑了 8 个月,没有观测到任何 IO 或锁争用。
1.3 具体落地方案(可直接抄)
基线表设计:
CREATE TABLE sql_plan_baseline (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
digest VARCHAR(64) NOT NULL, -- performance_schema 的 DIGEST
db_name VARCHAR(64) NOT NULL,
key_name VARCHAR(64), -- EXPLAIN 的 key 列
access_type VARCHAR(16), -- EXPLAIN 的 type 列 (ref/range/ALL)
est_rows BIGINT, -- EXPLAIN 的 rows 列
extra TEXT, -- EXPLAIN 的 Extra 列
baseline_at DATETIME NOT NULL,
CHECKSUM CHAR(32) NOT NULL, -- key+type+rows 的 MD5,快速 diff
UNIQUE KEY uk_digest (digest, db_name)
) ENGINE=InnoDB;
采集脚本(每 5 分钟 cron 或 sidecar 容器):
#!/bin/bash
# plan_watch.sh — 对核心 SQL 跑 EXPLAIN 并 diff 基线
DB_HOST="10.x.x.x"
DB_USER="readonly_monitor" # 只读账号,只有 SELECT + EXPLAIN 权限
DIGESTS_FILE="/etc/monitor/core_sql_digests.txt" # 每行一个 DIGEST
for digest in $(cat $DIGESTS_FILE); do
# 用 EXPLAIN FORMAT=JSON 拿结构化结果
JSON=$(mysql -h$DB_HOST -u$DB_USER -p'***' -N -e "
EXPLAIN FORMAT=JSON
SELECT tenant_id, billing_period, status FROM t_billing_item
WHERE tenant_id = 12345 AND billing_period IN ('2024-01','2024-02','2024-03')
" 2>/dev/null)
# 提取关键字段
KEY_NAME=$(echo "$JSON" | jq -r '.query_block.table.access_type' 2>/dev/null)
TYPE=$(echo "$JSON" | jq -r '.query_block.table.access_type' 2>/dev/null)
ROWS=$(echo "$JSON" | jq -r '.query_block.table.rows_examined_per_scan' 2>/dev/null)
EXTRA=$(echo "$JSON" | jq -r '.query_block.table.extra // ""' 2>/dev/null)
CHECKSUM=$(echo -n "${KEY_NAME}|${TYPE}|${ROWS}" | md5sum | awk '{print $1}')
# diff 基线
OLD=$(mysql -h$DB_HOST -u$DB_USER -p'***' -N -e "
SELECT CHECKSUM FROM sql_plan_baseline WHERE digest='$digest' LIMIT 1
" 2>/dev/null)
if [ -n "$OLD" ] && [ "$OLD" != "$CHECKSUM" ]; then
# 计划变了!告警
echo "ALERT: digest=$digest plan changed"
echo " old_checksum=$OLD new_checksum=$CHECKSUM"
echo " key=$KEY_NAME type=$TYPE rows=$ROWS"
# 推送到告警通道(PagerDuty / 企微 / 钉钉)
curl -s -X POST "$ALERT_WEBHOOK" -H 'Content-Type: application/json' \
-d "{\"digest\":\"$digest\",\"old\":\"$OLD\",\"new\":\"$CHECKSUM\",\"key\":\"$KEY_NAME\",\"type\":\"$TYPE\",\"rows\":\"$ROWS\"}"
fi
# 更新基线
mysql -h$DB_HOST -u$DB_USER -p'***' -e "
INSERT INTO sql_plan_baseline (digest, db_name, key_name, access_type, est_rows, extra, baseline_at, CHECKSUM)
VALUES ('$digest','billing','$KEY_NAME','$TYPE',$ROWS,'$EXTRA',NOW(),'$CHECKSUM')
ON DUPLICATE KEY UPDATE
key_name=VALUES(key_name), access_type=VALUES(access_type),
est_rows=VALUES(est_rows), extra=VALUES(extra),
baseline_at=NOW(), CHECKSUM=VALUES(CHECKSUM)
" 2>/dev/null
done
核心 SQL 清单怎么维护: 从 performance_schema.events_statements_summary_by_digest 取 SUM_TIMER_WAIT Top 20 + 业务方指定的"绝对不能退化"的 10 条,总共 20~30 条,存到 core_sql_digests.txt。新 SQL 上线时加进去,废弃的删掉。
1.4 ETL 窗口增强:ETL 结束后立即触发一轮
比定时 5 分钟更关键的是:ETL 完成后立即跑一轮 EXPLAIN diff。你 ETL 是 02:00–06:00,那 06:00 ETL 完成回调里加一个 hook:
# etl_post_hook.sh — ETL 完成后触发
bash /opt/monitor/plan_watch.sh --mode=immediate --alert-threshold=any
# --mode=immediate: 不等 5 分钟,立即跑
# --alert-threshold=any: 任何一条 SQL 计划变化都告警(平时可以只告警 type=ALL 或 rows 变化 >10x)
这样你在 ETL 结束后 1 分钟内就能知道"计划变没变",而不是等业务投诉。
二、innodb_stats_persistent_sample_pages 调大的实际成本
2.1 我们线上的数据(1.2 亿行表,32C128G,NVMe)
| sample_pages | ANALYZE TABLE 耗时 | 统计信息精度 | 备注 | |:---:|:---:|:---:|:---| | 20(默认) | ~25s | 偏差可达 5~20x | 你的 1.6 亿行预计 ~35s | | 100 | ~110s | 偏差降到 1~3x | 1.6 亿行预计 ~1.5min | | 200 | ~210s | 偏差 1~1.5x | 1.6 亿行预计 ~3min | | 500 | ~480s | 接近真实 | 1.6 亿行预计 ~7min,不建议 |
结论:20→100 是性价比最甜的点。 耗时从 ~30s 到 ~1.5min,在 ETL 窗口内完全可接受。200 以上收益递减但成本线性涨。
2.2 有没有"采样更准但优化器反而选错"的反例?
有,而且不罕见。 具体场景:
你的 t_billing_item 是多租户表,tenant_id 分布极不均匀(大租户 5000 万行、小租户 2000 行)。默认 20 页采样时,cardinality 被"平均化",优化器对小租户的等值查询反而选对了(因为 cardinality 被高估,走索引)。调到 200 页后,cardinality 更真实地反映了大租户的长尾——优化器对小租户查询的 cost 估算反而偏高,从 index lookup 退化成了 range scan。
这不是"统计信息更准了"的错,是 MySQL cost model 对长尾分布建模不够(它假设近似均匀分布)。
应对方案:
- 对已知长尾分布的表,不要盲目调大 sample_pages,而是用
ANALYZE TABLE ... UPDATE HISTOGRAM ON tenant_id(MySQL 8.0 的 histogram)让优化器看到真实分布; - 如果 histogram 也不够,最终兜底还是
FORCE INDEX(见第三部分)。
2.3 推荐配置
# my.cnf — ETL 窗口外
innodb_stats_persistent_sample_pages = 100 # 20→100,性价比最甜
innodb_stats_auto_recalc = OFF # 关键!关掉自动重算,改由 ETL hook 主动触发
innodb_stats_persistent = ON # 统计信息持久化到 mysql.innodb_index_stats
innodb_stats_auto_recalc=OFF 是核心——它防止 InnoDB 在 ETL 过程中"边灌边重算"导致中间态统计信息被优化器读到(你提到的"新旧混杂"就是这个)。关掉后,统计信息只在 ANALYZE TABLE 时更新,由你控制时机。
三、ETL 后"计划锁定"的落地方式
MySQL 8.0 没有原生的 SPM(Stored Program,那是 Oracle 的),但有三层兜底,按侵入性从低到高:
3.1 第一层:ANALYZE + EXPLAIN 验证(不改代码,推荐首选)
# etl_post_hook.sh(接在 ETL 完成回调里)
# Step 1: 刷新统计信息(在 ETL 数据全部落盘后)
mysql -e "ANALYZE TABLE t_billing_item"
# Step 2: 验证核心 SQL 的计划
# 对 core_sql_digests.txt 里的每条 SQL 跑 EXPLAIN
# 如果 key 变了 → 告警 + 自动回滚 ANALYZE(用之前的统计信息快照)
# Step 3: 如果计划确实变了且确认是退化,恢复旧统计信息
# mysql.innodb_index_stats 是普通 InnoDB 表,可以直接 UPDATE
mysql -e "
UPDATE mysql.innodb_index_stats
SET stat_value = (SELECT stat_value FROM stats_backup WHERE index_name='idx_tenant_period' AND stat_name='n_diff_pfx')
WHERE index_name='idx_tenant_period' AND stat_name='n_diff_pfx'
"
统计信息快照机制: 每次 ANALYZE TABLE 前,把 mysql.innodb_index_stats 里该表的行备份到 stats_backup 表。如果 ANALYZE 后计划退化,直接 UPDATE 回旧值。比 FLUSH TABLES 更精确(FLUSH 是全局的,影响所有表)。
3.2 第二层:optimizer_switch 定向关闭(不改代码,改会话/全局)
针对你这种"联合索引被 range scan 替代"的场景,可以定向关闭某些优化器行为:
-- 全局兜底:关闭 index_merge(防止优化器把 index lookup 拆成 merge)
SET GLOBAL optimizer_switch = 'index_merge=off,index_merge_union=off,index_merge_sort_union=off';
-- 或者更定向:关闭 mrr(Multi-Range Read),它在大表 range scan 时反而更慢
SET GLOBAL optimizer_switch = 'mrr=off,mrr_cost_based=off';
注意: 这些是全局开关,可能影响其他 SQL。更安全的做法是在应用连接池里加 connectionInitSql:
# Spring Boot application.yml
spring:
datasource:
hikari:
connection-init-sql: "SET optimizer_switch='index_merge=off'"
这样只影响你的应用连接,不影响其他库。
3.3 第三层:FORCE INDEX(改代码,终极兜底)
如果上面两层都不够,最终手段:
-- 把
SELECT ... FROM t_billing_item WHERE tenant_id = ? AND billing_period IN (?,?,?)
-- 改成
SELECT ... FROM t_billing_item FORCE INDEX (idx_tenant_period)
WHERE tenant_id = ? AND billing_period IN (?,?,?)
降低侵入性的做法: 不改 SQL 文本,用 MyBatis 的 @SelectProvider 或 JPA 的 @QueryHints 动态注入:
// Spring Data JPA
@Query("SELECT b FROM BillingItem b WHERE b.tenantId = :tid AND b.billingPeriod IN :periods")
@QueryHints(@QueryHint(name = "org.hibernate.index", value = "idx_tenant_period"))
List<BillingItem> findByTenantAndPeriods(@Param("tid") Long tid, @Param("periods") List<String> periods);
或者更工程化:维护一张 sql_index_hint 表(digest → 期望 index),应用层在 SQL 执行前查这张表,有 hint 就拼 FORCE INDEX,没有就原样执行。这样改 hint 不用改代码、不用重启。
四、完整兜底流水线(推荐你直接抄)
ETL 开始 (02:00)
│
├─→ 关闭 innodb_stats_auto_recalc(如果还没关)
│
ETL 结束 (06:00)
│
├─→ ANALYZE TABLE t_billing_item(~1.5min,sample_pages=100)
│
├─→ 跑 plan_watch.sh --mode=immediate
│ │
│ ├─ 计划没变 → 记录基线,结束
│ │
│ └─ 计划变了 →
│ ├─ 告警(PagerDuty/企微,带 digest + 旧/新计划对比)
│ ├─ 自动尝试:恢复旧统计信息快照 → 再 EXPLAIN 验证
│ │ ├─ 恢复了 → 记录,结束
│ │ └─ 没恢复 → 升级告警(需要人工介入 FORCE INDEX)
│ └─ 同时:慢日志阈值临时降到 0.5s(只针对 ETL 后 30 分钟窗口)
│
白天 (06:30+)
│
├─→ plan_watch.sh 每 5 分钟定时跑(20 条核心 SQL,< 1s 总耗时)
│
├─→ 慢日志阈值恢复正常(1s)
│
└─→ 如果某条 SQL 连续 3 个周期 rows 变化 > 10x → 自动告警
五、回答你引用的两篇
你引用的 id=1859 和 id=208 把"为什么漂移"讲得很清楚了,我补充的是"漂移后怎么在 5 分钟内发现 + 怎么自动兜底"。核心思路就一句话:别跟慢日志较劲,把 EXPLAIN 变成一条 CI 流水线——ETL 结束是触发点,计划 diff 是断言,告警是失败通知。 这套东西我们跑了 8 个月,从"业务投诉后手动 EXPLAIN"变成了"ETL 结束后 2 分钟内自动告警",期间拦住了 3 次计划退化(都是 ETL 后统计信息刷新导致的)。