执行计划之成本魔方:为什么你的查询总是事倍功半?

昨晚线上炸了。一个再简单不过的查询,CPU瞬间飙到100%。我盯着慢查询日志,执行计划那里赫然写着 FULL TABLE SCAN。一万多行数据,它竟然不走索引!我愤怒地敲下 EXPLAIN,看到 estimated rows 和 actual rows 差了十倍。优化器那一刻的脑回路,如同醉汉:我觉得全表扫描更便宜。便宜你个大头鬼。 说真的,执行计划这玩意儿,大多数人只停留在 EXPLAIN 的初阶用法。看个 type 列是不是 ALL,然后叫声“哦不走索引”,接着草草加个 force index 了事。但底层到底发生了什么?它怎么就决定了一个能把服务器干趴下的路径?

成本计算:数据库的CPU算盘

优化器不是魔法,它是一台贪婪的成本计算器。你每发一条 SQL,它就把所有可能的执行路径枚举出来,挨个算成本,选那个成本最低的。问题就出在“成本”怎么定义。 在 MySQL 里,成本由几个因素决定:CPU 成本(处理每一行数据)、IO 成本(从磁盘读取一页数据)、还有内存成本。但 CPU 成本真能精确测量吗?不开玩笑,MySQL 8.0 之前,它假设每读取一个数据页并评估 WHERE 条件,成本是 0.2。这个值叫 row_evaluate_cost,从 2000 年代就没变过。而现在服务器的单核性能翻了几个数量级,0.2 早就不是 0.2 了。所以你常常看到优化器低估了全表扫描的 CPU 开销——因为它还活在奔腾 4 时代。
MySQL查询优化器成本计算常数演变示意图
MySQL查询优化器成本计算常数演变示意图
更恶心的是统计信息。InnoDB 的统计数据是通过采样获取的,默认采样页数量是 20。听着就不是个靠谱的数字。假如你的数据分布极度不均匀,这 20 页可能全是同一种类型,优化器就据此判断索引选择性极好,然后果断选择索引。实际上,那个索引返回的数据占比超过 50%,回表开销大到飞起。那么,面对这种情况,正确的姿势是什么?手动触发 ANALYZE TABLE ?不。你应该调高采样率:`innodb_stats_persistent_sample_pages = 100`,并且开启持久化统计信息。但即使这样,也只能缓解,无法根治。因为成本模型本身就是个近似值游戏。

索引扫描的物理层博弈

说概念容易,真正震撼的是物理层的权衡。假设你有一个表,500 万行,在字段 `created_at` 上有索引,查询条件是 `WHERE created_at BETWEEN ‘2024-01-01’ AND ‘2024-01-02’ AND status = ‘done’`。优化器有两种选择:一,用 `created_at` 索引,扫描出那一天的 10 万行,再逐行检查 `status`;二,如果存在 `(created_at, status)` 联合索引,那就直接精准定位。然而,如果那个联合索引只是恰好存在但选择性不佳呢?我曾见过一个案例:因为联合索引的第二部分 `status` 只有两个值,导致索引扫描后依然要过滤大量行,但优化器却因统计信息错觉,认为该索引高效,强行使用,结果查询耗时 1.2 秒。而逼它走全表扫描,只用了 0.3 秒。 因为你想不到:全表扫描底层是顺序读,索引扫描是随机读。顺序读在 SSD 上可以跑到每秒数百 MB,而随机读的 IOPS 有瓶颈,即使有索引覆盖,如果扫描范围过大,随机 IO 的延迟累积起来要命。机械硬盘时代的老经验“索引一定快于全表”在今天的硬件上早已不完全成立。图灵奖得主 Michael Stonebraker 在 2013 年就喊过:传统 RDBMS 的架构已经死了,根本原因是面向磁盘的单线程执行模型。所以你看,执行计划的优劣,得在物理层重新评估。
SSD顺序读取与随机读取性能对比图
SSD顺序读取与随机读取性能对比图
于是,我养成了一个习惯:面对慢查询,先不看索引设计,而是跑一个 `EXPLAIN ANALYZE`(MySQL 8.0.18+),直接看 actual time。那个树状输出,每一行的 loops 和 per-row time,比任何理论都诚实。你会发现,有时候优化器认为 cost 最低的路径,实际运行时间却是其他路径的几十倍。这偏差直接指向统计信息过期、成本常数失真,或者就是优化器缺陷。例如 MySQL 的贪心搜索算法,在 join 多表时,只做 depth-first 的局部最优,而不考虑全局最优。8表 join,它的评估时间可能爆炸,最后给出一个随机计划。这不是玩笑,是源码里 `greedy_search` 函数的操作。

执行计划的三个落地深坑

好了,理论说太多,真正让人头疼的是落地。坑一:参数化查询的计划固化。你肯定用过 Prepared Statement,绑定变量 @begin_date, @end_date 传入。优化器在第一次调用时生成计划,然后缓存。然而,如果后续传入的日期范围变化巨大,比如第一次查一天的数据,第二次查一年,那个缓存计划会因为扫描范围扩大而彻底败给全表扫描。MySQL 8.0 引入了 自适应计划切换,仅限极少场景。更通用的解法是 `OPTIMIZER_USE_CONDITION_FANOUT_FILTER=ON` 和及时 `FLUSH STATUS`,但治标不治本。我目前的土办法:对这类查询,干脆不绑定,每次硬解析。CPU 开销微乎其微,比跑偏的计划好太多。或者用 ProxySQL 这类中间件,按时间范围 Hash 路由到不同实例,用不同计划。 坑二:统计信息锁。听起来简单,但生产环境里,当你对大表执行 ALTER TABLE 或者 大批量更新后立即 ANALYZE,可能会触发表级锁,所有查询排队,服务瞬间假死。正确姿势:`innodb_stats_auto_recalc=OFF`,并且用 pt-online-schema-change 做 DDL,统计信息更新则由定时任务在低峰期运行,如凌晨3点。同时,监控 Information_schema.INNODB_TABLESTATS,发现 rows 变化超过阈值时告警。我们曾经有一个表,统计信息显示只有 10 行,实际 2000 万行,优化器认为全表扫描成本极低,于是场场全表,导致磁盘读写飙到 500MB/s。发现时,已是 3 天之后。 坑三:复合索引的顺序陷阱。这老生常谈,但我强调一个细节点:当你创建索引 (A, B, C),查询条件是 WHERE B=2 AND C=3 AND A=1 时,优化器能认出来等同于 (A, B, C) 吗?能。MySQL 会重排条件顺序以匹配索引。但一旦查询变成 WHERE A>1 AND B=2 AND C=3,那 B, C 就废了,只用到 A 的范围扫描。这是最基础的左前缀原理。可很多人不知道,优化器会预估 B=2 和 C=3 的过滤效果,叫做 condition fanout。如果统计信息不准,这个预估就可能错得离谱,导致它高估了索引过滤能力,选错索引。遇到这种情况,除了修正统计信息,还可以用 `index hint` 强制指定,但 hint 是技术债,能不用就不用。更好的办法是 通过改写查询引导优化器,例如将范围条件后的等值条件前置,或者使用 Generated Column 固定索引顺序。我调过一个查询,加了 `(A, B, C)` 索引,执行时间从 3.8 秒降到 0.04 秒,就是用了这个 trick。爽!但那又是另一个故事了。 最终,执行计划这个看似枯燥的工程问题,充满了算法之美与工程之坑。我们所能做的,无非是比优化器多想一步,然后在深夜里,面对监控图,长长舒一口气——或者,骂一句:这什么鬼成本常数!
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:执行计划之成本魔方:为什么你的查询总是事倍功半?
文章链接:https://m.lfdjt.com/info_23_7717.html