1. 现象:明明建了索引,EXPLAIN 却给了全表扫描
1.1 一个让所有后端都皱眉头的问题
先还原一个很常见的场景。你在订单表上建了索引,SQL 也写得规规矩矩,等值查询、范围条件、排序字段都考虑到了,结果 EXPLAIN 一看,type = ALL,key = NULL,优化器直接无视索引去扫全表。这时候第一反应基本都是“索引是不是坏了”,或者“MySQL 优化器是不是有毛病”。
我先说结论:索引没有坏,优化器也没有毛病,它只是基于当前的数据和统计信息算了一笔账,认为全表扫描比走索引更便宜。这个问题核心不在索引本身,而在于你还没有理解 MySQL 优化器做决策的底层逻辑。
标题里的“索引在手却选择全表扫描”这句话,其实有个容易误导人的地方。优化器不是把索引当成宝贝,能走就走,它只关心一件事:怎么用最低的成本把数据捞出来。一个二级索引如果能帮你快速定位到 3 条记录,那肯定走索引;但同样一个索引,如果条件命中了几百万条记录,每条还要回表去主键索引里捞完整行,那成本可能比顺序读整个表的页面还高。
sql复制EXPLAIN SELECT * FROM order_info WHERE status = 5;
这段 SQL 看起来没有任何问题,等值查询,status 字段上有索引。但 EXPLAIN 结果里 rows 可能显示的是几百万,type 是 ALL。这个现象既可能在数据量几百行的小表上出现,也可能出现在几千万行的大表上,背后的原因完全不同,下面我会逐个拆开讲。
1.2 先从 B+ 树索引的“查找逻辑”说起
要理解优化器为什么放弃索引,先得知道查询走二级索引时到底发生了什么。MySQL 的 InnoDB 表存储模型里,数据主键索引(聚簇索引)的叶子节点直接存整行记录;普通索引(二级索引)的叶子节点只存索引列的值加主键值。
当你的 SQL 是 SELECT * 且条件字段是普通索引列时,执行流程是:先在二级索引的 B+ 树里找到满足条件的叶子节点,拿到一批主键值,然后再拿这些主键去聚簇索引里回查完整记录。
这个“回表”操作是成本的大头。二级索引树本身可以顺序扫描,但回表需要按主键去聚簇索引随机读。每回表一条记录,就类似一次主键点查。如果满足条件的行数很多,这一连串随机 IO 会把性能拖垮。相比之下,全表扫描只需要把聚簇索引的叶子节点从头到尾顺序读一遍,虽然数据量大,但读取方式是完全线性的,对磁盘和内存都更友好。
这就是优化器眼中“全表扫描”和“走索引”的真实差异。很多人以为索引是加速神器,其实它更擅长“精确制导,小规模打击”。一旦目标范围被统计信息或者查询条件放大到了很大比例,顺序读的优势就显现了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 优化器到底在算一本什么账
2.1 成本模型的两个核心数字:IO 成本和 CPU 成本
MySQL 优化器从 5.7 版本开始,成本模型已经比较成熟了。它估算一条 SQL 执行成本时,并不是只看“有没有索引”,而是按照计划树逐层累加开销。成本主要分两类:IO 成本和 CPU 成本。
IO 成本是指读取数据页的开销,InnoDB 默认每个数据页大小 16KB,扫描一个页的成本是一个固定基数;CPU 成本是指对每行记录做条件判断、排序、聚合这类操作的开销,也是按行数乘以一个基数计算。
我们能看到这两个参数的影子:
sql复制SELECT * FROM mysql.server_cost;
SELECT * FROM mysql.engine_cost;
拿 MySQL 8.0 举例,默认的 row_evaluate_cost 大约是 0.2,意思是每评估一行记录是否满足条件,成本基数是 0.2;io_block_read_cost 是 1.0,意思是每读取一个数据页的开销是 1.0。如果你在表里执行一次全表扫描,优化器会这样做估算:
全表扫描总成本约等于(总数据页数量 × 1.0 + 总记录行数 × 0.2),再叠加上一些额外处理成本。这就是为什么表行数越大,全表扫描的成本看起来越高,但它和走索引的回表成本谁高谁低,还得看第二条记录数。
2.2 最容易被忽略的“回表成本”与随机 IO 惩罚
既然二级索引只存主键值,大部分普通查询都逃不过回表。我们假设一个订单表有 1000 万行,聚簇索引叶子页数量大约 5 万个,查询条件是 status = 2,预估命中 300 万行。
如果走 idx_status 这个二级索引,优化器会估算:
- 先扫二级索引定位满足条件的记录,读取一定数量的索引页;
- 对命中的 300 万行记录逐条回表,每次回表都要到聚簇索引里随机读取一条记录的页面。
问题就出在第二步。300 万次随机回表,即使数据全部在内存里,也要经历大量 B+ 树查找和指针跳跃;如果数据量大于缓冲池,很多回表还会触发磁盘 IO,这个成本会瞬间爆炸。
但如果你直接全表扫描,只需要顺序读取 5 万个数据页。每读一个 16KB 的页,就能连续拿到页内所有满足条件或不满足条件的记录。MySQL 存储引擎从磁盘顺序读页的效率比随机读高得多,所以优化器在这个场景下会做出判断:全表扫描更快。
这里有一个经验数值供参考。当 SELECT * 需要回表时,如果预估返回行数占总行数比例超过 10%~25% 这个区间,优化器通常会倾向于全表扫描。这个比例不是固定值,它受行宽、页大小、数据是否在内存、统计信息等多方面影响,但你可以把它当成一个心理锚点:等值查询预计会命中大量行时,别指望索引一定能救你。
2.3 rows 和 filtered 到底在骗谁?统计信息的局限
EXPLAIN 结果里的 rows 字段是最容易被误读的。很多开发看到 rows = 5 就觉得查询很理想,看到 rows = 6000000 就以为需要调优。其实 rows 不是真实返回行数,而是优化器根据统计信息估算出来的一张表或一个索引可能扫描的行数。
MySQL 的统计信息依赖 InnoDB 对 B+ 树做采样。比如 SHOW INDEX FROM table 里的 Cardinality 字段,就是索引列的区分度估算值。这个值怎么来的?InnoDB 会随机抽取一部分索引页,统计页面上不同值的数量,再推算整棵索引树的区分度。
filtered 字段同样关键。EXPLAIN 输出里 filtered = 10.00 表示存储引擎层返回 100 行后,MySQL 服务层再做一次条件过滤,最终只有 10 行能用。这个比例是硬编码估算的,优化器并不知道具体字段的真实分布情况。如果数据分布非常不均匀,rows 和 filtered 估算就会严重失真,导致整份成本账全部算错。
很多“优化器选错执行计划”的线上事故,根源都在这里:SQL 本身很简单,索引也没有失效,但优化器拿着错误的数据分布假设去做成本决策,最后选了一条糟糕的路。
3. 为什么索引会失效?从优化器视角看典型场景
3.1 索引列参与计算或函数处理,树的有序性直接作废
这里说的失效,是指优化器即使“想用索引”也没办法用,因为条件经过处理后已经破坏了索引有序性。
最常见的反面教材是:
sql复制SELECT * FROM order_info WHERE DATE(create_time) = '2024-01-01';
如果你在 create_time 字段上建了普通索引,这条 SQL 走索引非常困难。原因在于 B+ 树里存储的是原始的时间值,叶子节点按照时间顺序排列。但 DATE(create_time) 相当于把每个原始值先做了一次函数变换,MySQL 不能直接在 B+ 树上进行区间定位,它只能把全表每一行的 create_time 都取出来,计算 DATE 函数后的结果再做比较。
这种场景的正确写法是把函数从列上挪走,改造成范围查询:
sql复制SELECT * FROM order_info
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
类似的处理还包括对索引列做四则运算,比如 WHERE amount + 100 > 500,或者使用 FIND_IN_SET(column, '1,2,3')、IF()、CASE WHEN 等函数。优化器对这些条件基本束手无策,只能老老实实做全表扫描。面试题里经常问“FIND_IN_SET 能走索引吗”,答案很直接:不能,因为它是一个函数包裹索引列,B+ 树帮不上忙。
3.2 隐式类型转换、字符集不一致,让“等值”变成“全表”
这类问题隐蔽性很强。SQL 看起来是标准的等值查询,索引也建了,但 EXPLAIN 就是全表扫描。
第一种情况是字段类型和传入参数类型不一致。如果 user_no 是 varchar 类型,代码里却把参数当成了整数型传入,MySQL 会把字段值转换成数字进行比较。一旦发生了隐式类型转换,原来的字符串索引失去了预排序的意义,优化器只能扫描每一行并做转换。
sql复制SELECT * FROM user_info WHERE user_no = 10086;
如果 user_no 的索引是普通索引,但类型是 varchar,这条 SQL 很可能走不上索引。正确做法是传入字符串:
sql复制SELECT * FROM user_info WHERE user_no = '10086';
第二种情况是表连接时两个字段字符集不同。比如一张表用了 utf8mb4,另一张表用了 utf8,关联条件 a.user_name = b.user_name 时,MySQL 需要把其中一个字段做字符集转换再比较。如果转换加在索引列上,索引就会失效。这种情况在旧系统升级字符集时特别常见,排查时可以用 SHOW CREATE TABLE 看一下两张表对应字段的 collation 是否一致。
3.3 前导模糊查询为什么只能老老实实全扫
LIKE '%关键词%' 是非走全表扫描不可的典型。因为 B+ 树的叶子节点存储数据时,是按完整字符串的从左到右顺序排列的。索引能帮你高效定位开头是“order”的记录,因为 order 前缀可以直接做排序比较;但如果你不知道最左边几个字符是什么,优化器就无法确定要从树的哪个位置开始扫描。
LIKE 'abc%' 可以走索引,因为优化器能把这个条件等价转换成 >= 'abc' AND < 'abd' 这样的范围扫描。而 LIKE '%abc%' 没有这种转换办法,只能把每个字符串都拖出来匹配一遍。
如果业务上确实需要全文搜索,不要指望数据库用 LIKE 解决,直接上全文索引或者外部搜索引擎更合适。DBA 日常会收到一些“查询慢是因为索引没命中”的工单,其中大量问题都出在模糊搜索前缀丢了,这不算优化器 bug,而是查询本身没有利用索引的能力。
3.4 OR 条件与 index merge:优化器也在做取舍
看到 OR 条件时,很多人以为只要有多个索引,MySQL 就会分别走索引然后合并结果。实际上不一定。
WHERE status = 1 OR type = 5 这种条件,MySQL 在 5.0 之前基本无法用索引合并,之后版本逐步引入了 index merge 优化,可以走多个索引再取并集。但并集操作需要把多个 B+ 树扫描得到的 rowid(即主键)做合并排序去重,再去聚簇索引回表。如果每棵树的命中量都不小,合并去重的临时结果集就很大,优化器算下来可能比全表扫描还慢,于是它毅然选择全表扫描。
如果 OR 两侧的字段只有一边有索引,另一边没有索引,结果会更明显:优化器很难只凭其中一个条件走索引,因为全表扫描是能同时评估两边条件的唯一方案。把 OR 改写成 UNION ALL 有时候可以救回来,两段分别走各自索引,再把结果合并,但前提是两段查询都能真正利用上索引,否则只是形式上的改动。
sql复制SELECT * FROM order_info WHERE status = 1
UNION ALL
SELECT * FROM order_info WHERE type = 5;
3.5 NULL 和 NOT NULL 的坑
NULL 值在索引里的处理与普通值不太一样。MySQL 索引并不会完全不支持 IS NULL,实际上很多场景下 IS NULL 是可以走索引的。但 IS NOT NULL 的情况要看数据分布。
举个例子,订单表的 pay_time 字段表示支付时间,新下单未支付的记录为 NULL,已支付的有时间值。当查询 WHERE pay_time IS NOT NULL 时,如果表中 99% 的记录都已经支付过,那这个条件等价于“查几乎全部数据”,优化器必然会走全表扫描。同样,当 WHERE pay_time IS NULL 命中大多数记录时,也会全表扫描。
所以这些场景不能简单归为“索引对 NULL 无效”,要回到成本模型去理解:条件命中的行数比例决定了优化器的选择,而不是 NULL 本身有什么魔法。
3.6 排序带来的全表扫描:select 与 filesort 的成本博弈
还有一种场景,明明条件字段可以走索引,但 SQL 里带了 ORDER BY 另一个字段,优化器可能放弃条件上的索引,改走全表扫描加 filesort。
假设表有联合索引 (status, create_time),SQL 是:
sql复制SELECT * FROM order_info
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;
这条 SQL 其实很完美,索引可以定位 status = 1,然后 create_time 天然有序,直接倒序取 20 条就行,MySQL 通常不会让你失望。
但如果索引只是 (status),create_time 没有进索引,条件 WHERE status = 1 又命中了大量记录,MySQL 就有两个选择:一是走 status 索引把所有命中行的主键捞出来,回表取完整记录,然后再在内存或磁盘临时文件里按 create_time 排序;二是直接全表扫描,把所有行都读出来,过滤 status = 1,再排序。
当命中量很大时,第一种方案的回表开销可能比第二种方案还高。优化器算完账,往往会选择全表扫描,然后 filesort。很多人看到 Using filesort 就以为是性能恶化的信号,但实际上 filesort 不一定慢,优化器选择它是因为整体方案更便宜。要解决这类问题,核心思路是调整索引结构,尽量让排序字段进入索引,形成覆盖。
另外需要提一下索引下推(Index Condition Pushdown,ICP)。如果你用的是联合索引 (status, create_time),并且 SQL 条件里有部分无法用于索引定位、但能用于索引内过滤的条件,ICP 可以把过滤下推到存储引擎层,减少回表。例如联合索引 (a, b),查询条件 a = 1 AND b LIKE 'abc%',没有 ICP 的话,MySQL 必须把 a = 1 的所有记录全部回表再过滤;开启 ICP 后,存储引擎直接在索引页里判断 b 条件,只对满足条件的记录回表。这个优化也是影响优化器成本核算的一环,很多慢查询加上 ICP 后就会选择走联合索引而不是全表扫描。
4. 统计信息失真:优化器拿着错账做决策
4.1 统计信息是从哪来的:采样页数与默认参数
索引失效里有一类特殊的“假失效”:SQL 没有任何问题,索引也正确设计好了,唯一的麻烦是 MySQL 统计信息太久没更新,与实际数据分布严重脱节。
InnoDB 并不会在每次数据变更后都精确统计索引信息。为了性能,它只会随机抽取一部分索引页来估算。MySQL 5.6 开始引入了 persistent stats,统计信息可以持久化存储,避免每次重启都重新计算。但持久化不代表永远准确。
影响采样精度的参数是 innodb_stats_persistent_sample_pages。在 MySQL 8.0 中默认大约是 20 页,意味着每次 analyze table 时,MySQL 只抽查 20 个索引页来推断整个索引的分布。如果一张索引很大的表,数据分布本身就很不均匀,拿 20 页就去估算全部的 Cardinality,误差很容易从“千万级”夸大到“百万级”,成本模型自然全线跑偏。
手动更新统计信息的方式很简单:
sql复制ANALYZE TABLE order_info;
执行完后再次 EXPLAIN,如果执行计划恢复正常,说明就是统计信息过期。很多 DBA 在线上排查慢查询时,第一招就是 analyze table,原因就在这里。
但要注意,analyze 不是银弹。频繁执行 ANALYZE 在大表上会造成额外的 IO 和锁开销,不能每次慢查询都无脑刷统计。更合理的做法是把 innodb_stats_auto_recalc 打开,让 InnoDB 在表数据变更量超过一定阈值时自动重算统计信息。
4.2 8.0 直方图:给优化器一副“看清数据分布”的眼睛
MySQL 8.0 加入了一个很有用的能力:直方图(Histogram)。在这之前,优化器对字段值分布的假设非常粗糙:它只知道这张表某个索引大概有多少不同的值,但不知道“值 A 占了 80%,值 B 只占 0.1%”这种真实分布。
没有直方图时,如果字段上某个值实际只占 0.01%,但因为索引统计里这个值没有单独记录,优化器可能按均匀分布估算,把命中行数算大了成百上千倍。刚才提到的坑就是这样出现的。
创建直方图的方法:
sql复制ANALYZE TABLE order_info UPDATE HISTOGRAM ON status WITH 16 BUCKETS;
这条命令会把 status 字段的取值分布拆成 16 个桶,每个桶记录对应的频率。优化器看到直方图之后,就能更精确地估算 WHERE status = 某值 大概会命中多少行,而不是无脑均匀分布。
查看直方图信息:
sql复制SELECT * FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'order_info';
直方图也不是建得越多越好。它只在你查询条件里出现、但优化器又很难准确估算分布时才有价值。一般优先给低基数字段、数据严重倾斜的字段建立。如果你已经通过联合索引覆盖了核心查询,直方图更多的是一个补充手段,不是用来替代索引设计的方案。
4.3 小表全扫是常态:合理的全表扫描不用焦虑
另一种“全表扫描焦虑”来自数据量非常小的表。比如一张配置表只有 1000 行,你给某个字段建了索引,然后发现 EXPLAIN 还是 ALL。这种情况不用紧张,优化器不是傻,它在计算成本时发现:全表扫描可能只读 2~3 个数据页,走索引却需要先读索引页,再回表读数据页,折腾下来的路程反而更长。
你可能会想,1000 行的表走全表扫描大概 1 毫秒,走索引也大概 1 毫秒,差别在哪?真正的差别是索引扫描在数据量变大后会显现优势,但在几百行、几千行的表上,索引的定位能力几乎没有用武之地。所以小表上看到 ALL,大多数时候是正确的选择,不需要为了“用上索引”去 force index,那样只会让 SQL 更难维护。
5. 如何让优化器改变决定:调优实战
5.1 先做诊断三步:analyze table、explain format=json、看 handler 状态
遇到“索引在手却全表扫描”的问题,不要急着改 SQL 或者加索引。按顺序做三步诊断:
第一步,执行 ANALYZE TABLE 刷新统计信息,再看 EXPLAIN。我在排查线上问题时,至少有一半的概率在这一步就直接解决了。很多人看到慢查询就开始改 SQL,从头到尾没有考虑过统计信息过期的问题,导致改了半天代码,真正的病根还躺在那里。
第二步,用更精确的方式查看执行计划:
sql复制EXPLAIN FORMAT=JSON
SELECT * FROM order_info WHERE status = 5;
JSON 格式会输出一份估算成本明细,包括 read_cost、eval_cost、prefix_cost 等字段。你可以对比全表扫描和走索引的 prefix_cost,一眼看出优化器到底在担心什么。
第三步,实测数据分布。如果 EXPLAIN 显示 rows 是 700 万,但实际查询条件只返回 300 条,那问题就是统计信息失真;如果实际返回真有 700 万,那优化器选择全表扫描其实是合理的,此时该改的是数据量或查询逻辑。
这三个步骤做完,问题基本能定位清楚:是条件写法导致索引失效,还是统计信息误导,还是这次扫描确实该全表扫。
5.2 SQL 改写与索引设计,比 force index 更可靠
如果确认优化器是因为“命中行数占比过高”才放弃索引,优化方向应该是减少扫描范围,而不是强迫它走索引。
最有效的办法是建立多列索引。比如订单查询业务经常用 status 和 create_time 同时筛选,那不要只建 status 的单独索引,建一个联合索引:
sql复制ALTER TABLE order_info
ADD INDEX idx_status_create_time (status, create_time);
联合索引可以让查询在 status 过滤的基础上,进一步利用 create_time 的有序性做范围裁剪。更重要的是,如果查询需要返回的字段都能被索引覆盖,连回表都省了,优化器就会更愿意走这个二级索引。这种覆盖索引思路也解释了为什么 SELECT COUNT(*) 在有辅助索引时可以走索引而不是全表扫描:辅助索引叶子节点只存主键,比聚簇索引叶子行的宽度小很多,扫描页数少,成本低。
SQL 改写方面也有一些文章可做。比如原来一次性查询三天内的全部已取消订单,导出或同步的数据量非常大,MySQL 要保留大量中间结果,这时与其让一个巨无霸 SQL 全表扫描,不如按小时分片,每次只捞一小段。
5.3 什么时候才考虑 force index?以及它的副作用
FORCE INDEX 是可以直接干预优化器手段,但我不建议把它写进生产代码长期使用。因为优化器选择的执行计划是基于当前统计信息,而统计信息会随数据变化而变。你今天强制它走某个索引,可能在当下数据分布下是快的,等到数据量涨了几倍,同一个索引的效率可能大幅下降,但由于你加了 FORCE INDEX,优化器无法根据新数据换方案,慢查询会一直持续到有人来改代码。
FORCE INDEX 真正的使用场景是临时验证。当你想确认“如果走这个索引会不会更快”时,可以手动指定索引跑一次对比:
sql复制SELECT * FROM order_info FORCE INDEX (idx_status)
WHERE status = 5;
如果用了 FORCE INDEX 后查询反而更慢,那说明真实数据分布确实不适合走这个索引,你接下来要做的是调整索引结构,而不是继续想方设法骗优化器。
还有一些场景需要考虑表数据量和 SQL 变更对执行计划的影响。比如业务高峰期大批量更新了某个字段,改变了一个值的分布比例,如果没有及时 analyze,执行计划很可能在某个时间点突然“劣化”。这不是 MySQL 随机抽风,而是统计数据与实际分布长期不匹配导致的。
6. 一个线上案例:统计信息不准让订单表状态字段翻车
6.1 案例背景和现象
去年我处理过一个线上订单系统的慢查询工单。订单表 order_info 大概 2600 万行,订单状态字段叫 status,其中 5 代表“已取消”。表上建立了普通索引 idx_status,查询 SQL 非常简单:
sql复制SELECT order_id, user_no, amount, status
FROM order_info
WHERE status = 5
ORDER BY order_id DESC
LIMIT 1000;
按理说,status = 5 的订单量占比不高,且查询条件只返回索引中已有的几列,走二级索引是很自然的选择。但生产环境的慢查询日志显示,这条 SQL 平均执行时间超过了 8 秒,有时甚至跑到 20 秒。
EXPLAIN 的结果让人很意外:
| 字段 | 值 |
|---|---|
| type | ALL |
| key | NULL |
| rows | 21000000 |
| filtered | 10.00 |
明明有 idx_status,EXPLAIN 却显示 key 为 NULL,走了全表扫描。这个表现和标题里的问题一模一样。
6.2 逐层排查找到关键原因
先检查 SQL 写法,条件里没有任何函数,类型也是字符串转数字的安全范围,没有隐式转换问题。再看表结构,idx_status 确实存在于 status 字段上。最后执行了一次 analyze table,然后再看 EXPLAIN:
sql复制ANALYZE TABLE order_info;
执行计划瞬间变成了 type = ref,key = idx_status,rows 从 2100 万降到了 3000 多。为什么 ANALYZE 前后差异这么大?
问题出在表里 status = 5 的数据分布发生过剧烈变化。业务系统做了一次历史数据修复,把大量早期状态为 5 的订单改成了其他状态,导致 status = 5 的记录数从原来的接近 1800 万骤降到 3000 多。InnoDB 的自动重算统计信息没有及时跟上这种大批量变更,优化器仍然以为 status = 5 能命中 2000 万行。
按照优化器的成本估算:全表扫描顺序读 2000 万行,比通过索引取 2000 万行后再大量回表要便宜,所以它放弃了 idx_status。但实际上 status = 5 只有 3000 行,走索引哪怕逐条回表也只查 3000 条,性能和全表扫描是天壤之别。
6.3 优化落地和后续守护
这个案例的最终处理分了三步:
第一步,立刻执行 ANALYZE TABLE order_info 刷新统计信息,让优化器拿到真实的数据分布,慢查询当天恢复。
第二步,检查 innodb_stats_auto_recalc 的配置和自动统计触发条件。大批量数据变更后,如果表超过一定比例没被触发重算,就需要在业务脚本里主动 analyze,或者安排定时任务,在数据修复等大批量操作后执行一次统计信息刷新。
第三步,和开发重新确认索引结构。由于查询只关心 order_id、user_no、amount、status 四个字段,我建议把索引改成覆盖索引:
sql复制ALTER TABLE order_info
ADD INDEX idx_status_order_id (status, order_id, user_no, amount);
这样 MySQL 在满足 status 条件后,可以完全从二级索引叶子节点获取所需列,不需要回表,后续即使 status 为 5 的记录数增长到几十万条,性能依然可控。
6.4 这个案例能给你什么启发
回顾整个过程,最值得记录的并不是“使用 analyze table 救了一条 SQL”,而是:优化器的执行计划不是恒定的,它建立在统计信息之上,而统计信息会过期。一个 SQL 今天走索引,不代表明天还会走索引;反过来,今天全表扫描的 SQL,在统计信息刷新后可能自动走回索引。
所以在排查慢查询时,不要一上来就怀疑 SQL 写法或索引有问题。第一件事永远是看 EXPLAIN 里的 rows 是否与真实数据量匹配。如果 rows 严重虚高或虚低,先更新统计信息,再下结论。
7. 常见误区和面试高频题速查
下面这张表整理了“索引在手却全表扫描”最常见的几种场景,方便你归档查阅,也适合拿来当面试前的速记卡。
| 场景 | 现象 | 根本原因 | 解决思路 |
|---|---|---|---|
| 小表查询 | EXPLAIN 走 ALL,但 SQL 很快 | 全表成本低于索引+回表成本 | 不用处理,是正确选择 |
| 统计信息过期 | rows 严重虚高/虚低 | analyze 没执行或自动重算没触发 | ANALYZE TABLE,配置自动重算 |
| 隐式类型转换 | 字段是 varchar,参数是数字 | 索引列被函数转换 | 传入正确类型 |
| 字符集不一致 | 关联表字段 collation 不同 | 需要转换后比较 | 统一字符集排序规则 |
| 条件里写函数 | DATE(col)、FIND_IN_SET 等 | 索引有序性被破坏 | 改写为范围查询或外部搜索 |
| 前导模糊查询 | LIKE '%abc%' | B+ 树前缀定位失效 | 换前缀查询/全文索引 |
| OR 两侧不均衡 | 多个条件结果集大 | index merge 后成本高 | 改写 UNION ALL |
| NULL 比例失衡 | IS NULL / IS NOT NULL 走全表 | 命中行数占比过高 | 结合业务调整查询/索引 |
| 命中行数占比高 | 等值条件命中大量行 | 回表成本 > 顺序扫表成本 | 联合索引+覆盖索引 |
| ORDER BY 字段不能避免排序 | filesort 代价大 | 索引无法同时满足条件和排序 | 调整联合索引字段顺序 |
面试时如果被问到“索引明明建了,优化器却选择全表扫描”,可以按三层逻辑回答:先讲 B+ 树索引和回表机制,再说成本模型里 IO 和 CPU 如何估算,最后补充统计信息失真或者查询写法导致索引失效的细节。能把这三层讲清楚,比背一堆“索引失效场景”更有说服力。
我踩过很多次这种坑之后,已经习惯把全表扫描当成一种“优化器的合理怀疑”去对待,而不是一看到 ALL 就紧张。最怕的其实不是 ALL,而是 EXPLAIN 显示走了索引,实际返回行数和预判差了三个数量级,那种情况更隐蔽,排查起来也更痛苦。所以每次调优,我都会先问自己一个问题:EXPLAIN 里的 rows 和真实返回行数对得上吗?这个答案,往往才是破解全表扫描谜题的第一把钥匙。
