
引言
你是否反复遇到过这些灵魂拷问:
- 明明给查询字段建了索引,
EXPLAIN一看还是走了全表扫描? - 同一张表、同类型的查询,筛选 10% 数据走索引,筛选 25% 数据就放弃索引?
- 为什么加了函数、隐式类型转换,索引就直接失效了?
- 覆盖索引到底为什么是 SQL 优化的「神器」?
这些问题的答案,都藏在MySQL的核心机制里 ------基于成本的优化器(Cost-Based Optimization, CBO)。
MySQL 从来不是**「有索引就必须用」** 的规则驱动,而是一个精打细算的账房先生 :它会计算全表扫描、所有候选索引的执行总成本,最终选择成本最低的执行方案。索引只是它的可选方案之一,而非必选项。
一、MySQL 索引评估的完整流程
一条 SQL 进入 MySQL 后,会经过 4 个核心步骤,完成索引的筛选、评估与最终选择,全程由优化器主导:
1. 语法解析与语义校验
解析器先将 SQL 生成语法树,校验语法合法性、表 / 字段是否存在、操作权限是否合规,直接拦截非法 SQL,避免无效的成本计算。
2. 候选索引筛选
优化器会根据WHERE条件、JOIN关联条件、ORDER BY/GROUP BY字段,筛选出理论上可用于加速查询的候选索引,直接排除完全无法匹配的索引。
- 比如联合索引
(a,b,c),只有查询条件包含最左字段a,才会进入候选列表; - 比如对索引列使用了函数、前导通配符,会直接破坏索引的有序性,被排除出候选列表。
3. 执行计划成本量化计算
这是最核心的一步:优化器会对「全表扫描」「每个候选索引的执行路径」,分别量化计算执行总成本,成本单位是 MySQL 自定义的抽象单位,而非时间或 IO 次数。
4. 最优执行计划选择
对比所有执行路径的总成本,选择成本最低的方案,最终决定是否使用索引、使用哪个索引。
划重点:索引是否被选中,核心不是「有没有索引」,而是「用索引的成本,是不是比全表扫描更低」。
二、成本计算的核心基石:常量与统计信息
MySQL 的成本计算不是凭空估算,完全依赖两大核心:可配置的成本常量 、InnoDB 采样得到的统计信息。
2.1 核心成本常量(InnoDB 默认值)
MySQL 5.7+ 提供了optimizer_cost_model表,支持细粒度调整成本权重,默认值是基于机械硬盘的通用经验值,也是成本计算的基准。
| 成本常量 | 默认值 | 核心含义 |
|---|---|---|
| io_block_read_cost | 1.0 | 从磁盘读取 1 个 InnoDB 数据页(默认 16KB)的 IO 成本,是成本体系的核心权重 |
| memory_block_read_cost | 0.25 | 从缓冲池(Buffer Pool)内存中读取 1 个数据页的成本(MySQL 8.0+ 新增) |
| row_evaluate_cost | 0.2 | 读取并校验 1 行数据是否符合 WHERE 条件的 CPU 开销 |
| key_compare_cost | 0.1 | 比较 2 个索引键值的 CPU 开销(用于索引范围扫描、排序操作) |
这里有一个关键认知:磁盘 IO 的成本权重远高于 CPU 成本。机械硬盘的随机 IO 性能比内存低几个数量级,因此 MySQL 对随机 IO(比如回表操作)的成本估算会非常保守,这也是很多索引不被选中的核心原因。
2.2 成本计算的数据源:表与索引的统计信息
优化器的所有计算,都基于 InnoDB 通过采样数据页得到的统计信息(非实时精确值,是估算值)。核心统计项及查看方式如下:
| 统计项 | 查看方式 | 核心含义 |
|---|---|---|
| 表总行数 | SHOW TABLE STATUS LIKE '表名' → Rows 字段 |
估算的表总行数,由采样数据页的平均行数推算 |
| 聚簇索引总页数 | Data_length / 16384 |
Data_length 是聚簇索引总字节数,除以默认页大小 16KB,得到全表扫描需要读取的总页数 |
| 二级索引总页数 | Index_length / 16384 |
单个二级索引的总字节数,可通过INFORMATION_SCHEMA.STATISTICS查询 |
| 索引基数(Cardinality) | SHOW INDEX FROM 表名 → Cardinality 字段 |
索引列的唯一值数量,基数越高,索引区分度越好,筛选出的行数越少 |
| 采样页数 | innodb_stats_persistent_sample_pages |
InnoDB 统计信息的采样数据页数,默认 20 页,采样越多统计越准,但开销越大 |
注: 当表发生大量增删改(比如批量插入 / 删除 10 万行)后,统计信息会严重失真,直接导致优化器成本计算错误,出现索引失效问题。可通过**
ANALYZE TABLE 表名;**手动更新统计信息。
三、手把手实操:全场景 SQL 执行成本量化计算
下面我们以一张真实的用户表为例,贯穿所有常见场景,一步步拆解成本计算的完整公式与逻辑,保证你看完就能自己动手算。
3.1 示例表与基础数据准备
我们创建一张用户表user,包含主键索引和一个二级索引,统计信息均为真实场景的模拟值:
bash
CREATE TABLE `user` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`age` INT NOT NULL COMMENT '年龄',
`name` VARCHAR(100) NOT NULL COMMENT '姓名',
`create_time` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_age` (`age`) COMMENT '年龄二级索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
通过SHOW TABLE STATUS和SHOW INDEX获取核心统计信息:
- 表总行数:100000 行
- 聚簇索引总字节数:16777216(16MB)→ 总页数 = 16MB / 16KB = 1024 页
- 二级索引
idx_age总字节数:2097152(2MB)→ 总页数 = 2MB / 16KB = 128 页 idx_age索引基数:100(age 有 100 个唯一值,平均每个 age 对应 1000 行数据)
3.2 场景 1:全表扫描的成本计算
全表扫描是优化器的「兜底方案」,它会直接读取聚簇索引的所有数据页,逐行校验 WHERE 条件,成本公式固定且简单。
核心公式
全表扫描总成本 = 聚簇索引总页数 * io_block_read_cost + 表总行数 * row_evaluate_cost
- 前半部分:读取全表所有数据页的 IO 成本,是全表扫描的核心开销;
- 后半部分:逐行校验过滤条件的 CPU 成本。
代入示例数据计算
全表扫描总成本 = 1024 * 1.0 + 100000 * 0.2 = 1024 + 20000 = 21024
这个 21024,就是优化器判断其他执行方案是否更优的「基准线」------ 任何索引方案的成本高于这个值,都会被优化器放弃。
3.3 场景 2:二级索引范围扫描 + 回表的成本计算
这是业务中最常见的场景,也是索引最容易失效的场景。普通二级索引的查询,分为两步:
- 扫描二级索引的范围页,获取符合条件的主键 ID;
- 拿着主键 ID 去聚簇索引中查询完整行数据(回表操作,典型的随机 IO)。
示例 SQL
bash
SELECT * FROM user WHERE age BETWEEN 20 AND 30;
- 业务含义:查询 20-30 岁的用户完整信息;
- 筛选范围:age 在 20-30 之间有 20 个唯一值,对应 20*1000=20000 行数据。
核心公式
二级索引总成本 = 索引扫描成本 + 回表操作成本
= (索引扫描IO成本 + 索引扫描CPU成本) + (回表IO成本 + 回表后CPU成本)
分步代入计算
- 索引扫描 IO 成本 :范围扫描需要读取
idx_age的 20 个数据页 → 20 * 1.0 = 20 - 索引扫描 CPU 成本:读取并校验 20000 条索引记录,包含键值比较和行校验 → 20000 * 0.1 + 20000 * 0.2 = 6000
- 回表 IO 成本:MySQL 默认「每一条回表记录对应一次随机 IO」(即使多条记录在同一数据页,也按行数保守估算)→ 20000 * 1.0 = 20000
- 回表后 CPU 成本:校验 20000 条聚簇索引完整记录 → 20000 * 0.2 = 4000
最终总成本
二级索引总成本 = (20 + 6000) + (20000 + 4000) = 30020
关键结论
对比全表扫描的21024,这个索引方案的成本更高,因此优化器会直接放弃索引,选择全表扫描。
这就是行业内常说的「筛选行数超过全表 20%-30%,索引就会失效」的底层原因 ------ 回表的随机 IO 总成本,已经超过了全表扫描的顺序 IO 总成本,索引反而成了负担。
3.4 场景 3:覆盖索引的成本计算
覆盖索引是指:查询所需的所有字段,全部包含在二级索引中,无需回表查询聚簇索引,直接砍掉了成本最高的回表 IO 开销,也是 SQL 优化的核心手段。
示例 SQL
bash
SELECT id, age FROM user WHERE age BETWEEN 20 AND 30;
- 业务含义:仅查询 20-30 岁用户的 id 和 age;
- 覆盖索引说明:
idx_age是二级索引,InnoDB 的二级索引叶子节点天然存储主键 id,因此查询字段全部包含在索引中,无需回表。
核心公式
覆盖索引总成本 = 索引扫描IO成本 + 索引扫描CPU成本
代入示例数据计算
覆盖索引总成本 = 20 * 1.0 + (20000 * 0.1 + 20000 * 0.2) = 6020
关键结论
6020 的成本,远低于全表扫描的 21024,优化器会优先选择这个覆盖索引。这就是为什么覆盖索引被称为优化神器 ------ 它直接砍掉了占比超 60% 的回表随机 IO 成本。
3.5 场景 4:排序场景的成本计算
ORDER BY/GROUP BY是业务中高频使用的语法,而排序操作(MySQL 中称为 filesort)的 CPU 和 IO 成本极高,优化器会把「避免排序的成本节省」纳入总成本计算。
反例 SQL(无索引排序)
bash
SELECT * FROM user ORDER BY create_time LIMIT 10;
create_time无索引,需要先全表扫描,再对全表数据排序,即使只取 10 条,也需要完成全表扫描 + 排序的完整流程。- 排序成本简化公式:
排序总成本 = 全表扫描成本 + 排序数据行数 * key_compare_cost * log2(排序数据行数) + 临时文件IO成本
- 代入数据后,总成本会远超全表扫描的 21024,是典型的慢 SQL 场景。
优化方案(索引避免排序)
给create_time建立索引idx_create_time,索引本身是有序的,直接按索引顺序取前 10 条数据即可,无需排序,排序成本直接降为 0。此时即使查询全字段,索引方案的成本也会远低于全表扫描 + 排序的成本,优化器会优先选择索引。
四、一键验证:MySQL 自带的成本查看工具
手动计算是为了理解原理,实际工作中,我们可以通过 MySQL 自带的工具,直接查看优化器计算的真实成本明细,排查执行计划异常问题。
4.1 EXPLAIN FORMAT=JSON(全版本通用)
在 SQL 前加上EXPLAIN FORMAT=JSON,可以看到优化器完整的成本计算明细,是排查索引问题的核心工具。
示例用法
bash
EXPLAIN FORMAT=JSON SELECT * FROM user WHERE age BETWEEN 20 AND 30;
输出核心字段解读
javascript
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "30020.00" // 优化器估算的SQL执行总成本
},
"table": {
"table_name": "user",
"access_type": "range",
"key": "idx_age", // 最终选中的索引
"rows_examined_per_scan": 20000, // 估算需要扫描的行数
"cost_info": {
"read_cost": "26020.00", // IO成本明细
"eval_cost": "4000.00", // CPU成本明细
"prefix_cost": "30020.00" // 索引扫描+回表的总成本
}
}
}
}
这里的query_cost,和我们手动计算的30020一致,验证了成本公式的正确性。
4.2 EXPLAIN ANALYZE(MySQL 8.0.18+ 推荐)
这是 MySQL 8.0 推出的王炸工具,它不仅会显示优化器的估算成本 ,还会实际执行 SQL,输出真实的执行时间、扫描行数、循环次数,精准定位「估算值与实际值偏差过大」的问题。
示例用法
javascript
EXPLAIN ANALYZE SELECT * FROM user WHERE age BETWEEN 20 AND 30;
输出示例解读
Index range scan on user using idx_age over (20 <= age <= 30), with index condition: (user.age between 20 and 30) (cost=30020 rows=20000) (actual time=0.15..10.2 rows=20000 loops=1)
cost=30020:优化器的估算成本actual time=0.15..10.2:实际执行时间(单位:毫秒)rows=20000:实际扫描的行数
如果估算行数和实际行数偏差过大,大概率是统计信息过期,执行ANALYZE TABLE即可解决。
五、索引不被使用的8大核心场景
基于上面的成本模型,我们可以彻底搞懂所有索引失效场景的底层原因,提前避坑:
六、总结
MySQL 的索引选择逻辑,本质上是「成本最优」的数学题,而非玄学。理解了 CBO 成本模型,你就掌握了 SQL 优化的底层逻辑,再也不用死记硬背「索引失效的 N 种场景」。
最后给大家总结6个可直接落地的建议:
1.优先给高基数字段建索引:索引基数越高,区分度越好,筛选行数越少,成本越低,越容易被优化器选中;
2.优先设计覆盖索引:尽量让查询字段全部包含在索引中,砍掉回表这个成本最高的操作,是 SQL 优化性价比最高的手段;
3.禁止在索引列上做任何运算:函数、隐式转换、算术运算都会破坏索引有序性,直接导致索引失效;
4.定期更新统计信息 :对批量增删改后的表,执行ANALYZE TABLE更新统计信息,避免优化器误判;
5.用对排查工具 :日常优化用EXPLAIN FORMAT=JSON看成本明细,复杂慢 SQL 用EXPLAIN ANALYZE看真实执行情况;
6.避免过度建索引:每个索引都会增加增删改的成本,也会增加优化器的成本计算开销,只给必要的查询建索引。
结尾互动
你在工作中有没有遇到过「索引建了却不生效」的奇葩场景?欢迎在评论区留言,我们一起拆解分析。
-
索引区分度极低,筛选行数占比过高比如性别、状态这类仅 2-3 个唯一值的字段,索引基数极低,筛选后行数占比超过 20%,回表成本远超全表扫描,优化器直接放弃索引。
-
索引列使用函数、算术运算、隐式类型转换这些操作会直接破坏 B + 树索引的有序性,优化器无法通过索引定位范围,直接将该索引排除出候选列表,典型反例:
javascript-- 反例1:索引列使用函数 WHERE YEAR(create_time) = 2026 -- 反例2:隐式类型转换(phone为varchar类型,传入数字) WHERE phone = 13800138000 -- 反例3:索引列算术运算 WHERE age + 1 = 30 -
联合索引不满足最左前缀原则 联合索引
(a,b,c),只有查询条件包含最左字段a,才能进入候选列表;跳过a直接查b、c,索引直接失效。 -
前导通配符的 LIKE 查询
WHERE name LIKE '%abc'这类前导通配符,无法使用 B + 树的前缀匹配能力,索引直接失效,仅后缀匹配LIKE 'abc%'可以使用索引。 -
统计信息过期失真表大量增删改后,统计信息与真实数据偏差过大,导致优化器成本计算错误,出现「该走索引不走,不该走索引反而走了」的异常情况。
-
OR 条件中包含无索引字段
WHERE age = 20 OR name = '张三',如果name没有索引,即使age有索引,优化器也无法使用,因为需要扫描全表匹配name的条件,全表扫描成本更低。 -
负向查询筛选范围过大
!=、NOT IN、IS NOT NULL这类负向查询,如果筛选范围过大,回表成本超过全表扫描,优化器会放弃索引。 -
表数据量极小比如只有几百行的小表,全表扫描只需要几个数据页,成本远低于索引查找的两次 IO,优化器会直接选择全表扫描,建索引毫无意义。