成本计算:数据库的CPU算盘
优化器不是魔法,它是一台贪婪的成本计算器。你每发一条 SQL,它就把所有可能的执行路径枚举出来,挨个算成本,选那个成本最低的。问题就出在“成本”怎么定义。 在 MySQL 里,成本由几个因素决定:CPU 成本(处理每一行数据)、IO 成本(从磁盘读取一页数据)、还有内存成本。但 CPU 成本真能精确测量吗?不开玩笑,MySQL 8.0 之前,它假设每读取一个数据页并评估 WHERE 条件,成本是 0.2。这个值叫 row_evaluate_cost,从 2000 年代就没变过。而现在服务器的单核性能翻了几个数量级,0.2 早就不是 0.2 了。所以你常常看到优化器低估了全表扫描的 CPU 开销——因为它还活在奔腾 4 时代。
索引扫描的物理层博弈
说概念容易,真正震撼的是物理层的权衡。假设你有一个表,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 的架构已经死了,根本原因是面向磁盘的单线程执行模型。所以你看,执行计划的优劣,得在物理层重新评估。

执行计划的三个落地深坑
好了,理论说太多,真正让人头疼的是落地。坑一:参数化查询的计划固化。你肯定用过 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。爽!但那又是另一个故事了。 最终,执行计划这个看似枯燥的工程问题,充满了算法之美与工程之坑。我们所能做的,无非是比优化器多想一步,然后在深夜里,面对监控图,长长舒一口气——或者,骂一句:这什么鬼成本常数!作者|大讲堂
排版|大讲堂
审核|知知
大讲堂