一条关键查询用了三个月都正常,某天开始慢了十倍。查索引:还在,EXPLAIN 也用了它。再仔细看,计划里的 rows 从几百变成了几十万,检索方式仍是 range——只是范围变大了。原因是三个月里数据长了四倍,而统计信息决定"该不该换索引"的那一刻,从来没有被检查过。
索引是静态的,计划是动态的。同一个 SQL 在不同数据量、不同统计信息下会走不同的计划,而这个变化不会有人通知你。所以"查询变慢"常常不是代码变了,而是优化器的选择变了。
1. 计划会变的五个原因
| 原因 | 变化方向 |
|---|---|
| 数据量增长 | 索引选择性与代价估算翻转 |
| 数据分布倾斜 | 某些值占比升高 |
| 统计信息过期 | 估算与实际严重偏离 |
| 索引增删 | 候选集合改变 |
| 参数值不同 | 同一 SQL 不同计划 |
这五项还有一个共同点:它们都不改变 SQL 文本。所以"代码没改"与"性能没变"是两件事,而监控系统通常只盯前者——版本号、发布记录、配置变更,没有一项能反映数据与统计信息的变化。
五项里只有第四项是有人为动作的,其余四项都在系统运行中自然发生。这意味着计划漂移是一个必然事件,问题只是你有没有监测它。
2. 快查询与慢查询的分界
| 判据 | 说明 |
|---|---|
rows 估算 vs 实际 |
偏差超过 10 倍即需关注 |
filtered 比例 |
扫描后大量被丢弃 |
type 从 ref 变 range |
检索方式降级 |
| 排序是否走索引 | 出现 Using filesort |
第一项最有诊断价值:估算与实际差 10 倍以上,说明统计信息已经不能代表现实,此时优化器的一切决策都可能是错的,而它自己不知道。
第三项(type 降级)是最直观的观察点:从 ref 掉到 range 再到 ALL,是计划退化的三步。把这三步写进巡检脚本,退化趋势就能提前一周被发现。
第二项说明"找到了但大部分不要":索引帮你定位到 10 万行,条件只留下 100 行,代价被放大了三个数量级。
-- 对比估算与实际:把两者放一起看
EXPLAIN ANALYZE
SELECT id, amount FROM orders
WHERE status = 'PAID' AND created_at >= '2026-11-01';
-- 关注两行:est rows 与 actual rows,以及 actual time 的累计
3. 统计信息:什么时候它会失效
| 情形 | 后果 |
|---|---|
| 大量写入后未更新 | 估算停留在旧分布 |
| 采样率太低 | 倾斜值被平均掉 |
| 直方图未覆盖新值 | 新出现的值被当作平均值 |
| 分区的统计独立 | 跨分区估算偏差大 |
第一项在批量导入后最典型:导入了 500 万行,统计信息还是导入前的,于是优化器按 50 万行的世界做决策。
第二项(采样率)在小表上无害、在大表上致命:默认采样往往只取几万行,占 5000 万行的 0.1%,于是倾斜分布被抹平,而优化器据此认为"每个值都差不多"。
第三项更难:新出现的状态值(如新增的订单状态)占比很小但查询频繁,直方图里没有它,估算就按平均分布来算——可能差几十倍。
-- 更新统计信息并检查新鲜度
ANALYZE TABLE orders; -- MySQL
-- 或 PostgreSQL
ANALYZE orders;
SELECT relname, last_analyze, last_autoanalyze FROM pg_stat_user_tables
WHERE relname = 'orders';
-- 若 last_analyze 是几天前而表每天增长百万行 → 估算必然失真
4. 监测:把计划漂移变成可观测事件
| 手段 | 说明 |
|---|---|
| 慢查询日志的频率变化 | 看"同一条 SQL"出现次数 |
| 定期抓计划快照 | 每天对 Top N SQL 跑 EXPLAIN |
| 性能回归对比 | 同 SQL 的历史耗时曲线 |
| 计划指纹 | 对计划结构做哈希,变化即告警 |
第四项是最有效的:把 EXPLAIN 的结构(不含具体值)哈希成一个指纹,指纹变了就说明计划漂移发生了——这比等它变慢要早得多。
监测的覆盖面要"按代价排序",而不是按表排序:先覆盖慢查询日志里累计耗时最高的 20 条。这 20 条通常占全部数据库时间的七成以上,覆盖它们的收益远高于逐表巡检。
第二项的成本很低:每天对 20 条关键 SQL 跑一次 EXPLAIN,把结果存下来做 diff。
// 计划指纹:结构变了就告警
sql="SELECT id,amount FROM orders WHERE status=? AND created_at>=?"
fp=$(mysql -N -e "EXPLAIN FORMAT=JSON $sql" | jq -S 'del(.query_block.cost_info)' | sha256sum)
echo "$(date +%F) $fp" >> /var/log/plan-fingerprints/orders-q7.txt
// 与前一天的指纹比较,不同则输出告警
5. 修正:三种做法与代价
| 做法 | 代价 |
|---|---|
| 更新统计信息 | 成本低,可能几分钟内又过期 |
| 加/改索引 | 写入成本上升,需评估 |
| 强制索引(hint) | 掩盖问题,数据再变就错 |
| 改写 SQL | 工作量大,但最稳 |
第三项是最多人选、也最容易埋雷的:hint 把"当前最优"冻结下来,数据分布再变一次,它就变成了"当前最差",而没人会再去看一眼。
修正之后还要防"改回去":改写过的 SQL 应当保留原版与新版两段注释,说明为什么改、改之前是什么计划。否则下一个人看到这段奇怪的两段式查询,会以为是历史遗留而"顺手简化"回去。
-- 改写原因:2026-11 大范围+排序导致 filesort,见 tickets/DB-1182
-- 原写法:WHERE status=? AND created_at>=? ORDER BY amount DESC LIMIT 20
-- 现写法:先按时间收敛 2000 行再排序;若业务允许更大误差可调小该值
-- 复核条件:若 PAID 状态占比超过 5%,需重新评估该改写
SELECT * FROM ( ... ) t ORDER BY t.amount DESC LIMIT 20;
第四项的稳定性来自一个事实:把"范围 + 排序"改成"先按索引收敛再排序",计划的可选空间变小了,优化器出错的机会也随之变小。
-- 改写示例:把"大范围 + 排序"改成"先收敛再排序"
-- 原:SELECT ... WHERE status='PAID' AND created_at>=? ORDER BY amount DESC LIMIT 20
-- 改:先按时间收敛到小集合(子查询),再在外层排序
SELECT * FROM (
SELECT id, amount FROM orders
WHERE status='PAID' AND created_at >= ?
ORDER BY created_at DESC LIMIT 2000
) t ORDER BY t.amount DESC LIMIT 20;
-- 代价:语义需确认(外层只在前 2000 条里排序)
6. 排错:五种"计划看着没问题但慢"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 用了索引但仍全表扫 | 选择性太差 | 换复合索引或改写 |
| 估算行数远小于实际 | 统计过期 | ANALYZE + 检查采样 |
| 同一 SQL 时快时慢 | 参数敏感 | 检查参数化与计划缓存 |
| 加索引后写入变慢 | 索引维护成本 | 评估写读比 |
| 预发快生产慢 | 数据量与分布不同 | 用生产规模数据验证 |
排错时还有一条高效路径:先看这条 SQL 的执行频率有没有变。频率上升会放大"每次多扫一点"的代价,表现为"慢了十倍"——而单次耗时可能只涨了 20%。
第三项是"参数嗅探"类问题:同一个参数化查询,不同参数值共用了一个计划。修法包括拆分查询模式或调整计划缓存策略,而"加索引"往往无效。
7. 边界:什么情况不必监测
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数据量稳定的小表 | 计划几乎不变 | 不做 |
| 只按主键访问 | 计划无选择空间 | 不做 |
| 一次性分析查询 | 不重复执行 | 不做 |
| 已由数据库自动管理(如自动索引) | 有内置机制 | 保留慢查询观察 |
判断口径一句话:这条 SQL 会不会在数据持续增长的情况下长期运行。会,就值得抓计划快照;不会,慢查询日志足够。
8. 自测
- 计划漂移的五个原因里,哪四个是自然发生的?这对"要不要监测"这个问题意味着什么?
- 为什么"估算与实际差 10 倍"是最有诊断价值的信号?
- 批量导入后不更新统计信息,优化器会按什么"世界"做决策?
- 计划指纹为什么比"等它变慢"更早发现问题?它的实现要点是什么?
- 强制索引(hint)为什么会埋雷?"改写 SQL"为什么更稳?
- 判断"是否需要监测计划漂移"的那个问题是什么?