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 里的 `Cardinalit
