2415 字
约 8 分钟
19
S11-A21 · 执行计划漂移:索引还在,计划已经不一样了
2024-12-31
2026-11-10

一条关键查询用了三个月都正常,某天开始慢了十倍。查索引:还在,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. 自测

  1. 计划漂移的五个原因里,哪四个是自然发生的?这对"要不要监测"这个问题意味着什么?
  2. 为什么"估算与实际差 10 倍"是最有诊断价值的信号?
  3. 批量导入后不更新统计信息,优化器会按什么"世界"做决策?
  4. 计划指纹为什么比"等它变慢"更早发现问题?它的实现要点是什么?
  5. 强制索引(hint)为什么会埋雷?"改写 SQL"为什么更稳?
  6. 判断"是否需要监测计划漂移"的那个问题是什么?
S11-A21 · 执行计划漂移:索引还在,计划已经不一样了
https://www.clxhxhhr.top/posts/6447/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。