MySQL索引不生效?一文理解CBO成本模型

引言

你是否反复遇到过这些灵魂拷问:

  • 明明给查询字段建了索引,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 STATUSSHOW 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:二级索引范围扫描 + 回表的成本计算

这是业务中最常见的场景,也是索引最容易失效的场景。普通二级索引的查询,分为两步:

  1. 扫描二级索引的范围页,获取符合条件的主键 ID;
  2. 拿着主键 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成本)

分步代入计算
  1. 索引扫描 IO 成本 :范围扫描需要读取idx_age的 20 个数据页 → 20 * 1.0 = 20
  2. 索引扫描 CPU 成本:读取并校验 20000 条索引记录,包含键值比较和行校验 → 20000 * 0.1 + 20000 * 0.2 = 6000
  3. 回表 IO 成本:MySQL 默认「每一条回表记录对应一次随机 IO」(即使多条记录在同一数据页,也按行数保守估算)→ 20000 * 1.0 = 20000
  4. 回表后 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.避免过度建索引:每个索引都会增加增删改的成本,也会增加优化器的成本计算开销,只给必要的查询建索引。

结尾互动

你在工作中有没有遇到过「索引建了却不生效」的奇葩场景?欢迎在评论区留言,我们一起拆解分析。

  1. 索引区分度极低,筛选行数占比过高比如性别、状态这类仅 2-3 个唯一值的字段,索引基数极低,筛选后行数占比超过 20%,回表成本远超全表扫描,优化器直接放弃索引。

  2. 索引列使用函数、算术运算、隐式类型转换这些操作会直接破坏 B + 树索引的有序性,优化器无法通过索引定位范围,直接将该索引排除出候选列表,典型反例:

    javascript 复制代码
    -- 反例1:索引列使用函数
    WHERE YEAR(create_time) = 2026
    -- 反例2:隐式类型转换(phone为varchar类型,传入数字)
    WHERE phone = 13800138000
    -- 反例3:索引列算术运算
    WHERE age + 1 = 30
  3. 联合索引不满足最左前缀原则 联合索引(a,b,c),只有查询条件包含最左字段a,才能进入候选列表;跳过a直接查bc,索引直接失效。

  4. 前导通配符的 LIKE 查询 WHERE name LIKE '%abc'这类前导通配符,无法使用 B + 树的前缀匹配能力,索引直接失效,仅后缀匹配LIKE 'abc%'可以使用索引。

  5. 统计信息过期失真表大量增删改后,统计信息与真实数据偏差过大,导致优化器成本计算错误,出现「该走索引不走,不该走索引反而走了」的异常情况。

  6. OR 条件中包含无索引字段 WHERE age = 20 OR name = '张三',如果name没有索引,即使age有索引,优化器也无法使用,因为需要扫描全表匹配name的条件,全表扫描成本更低。

  7. 负向查询筛选范围过大 !=NOT INIS NOT NULL这类负向查询,如果筛选范围过大,回表成本超过全表扫描,优化器会放弃索引。

  8. 表数据量极小比如只有几百行的小表,全表扫描只需要几个数据页,成本远低于索引查找的两次 IO,优化器会直接选择全表扫描,建索引毫无意义。

相关推荐
pele2 小时前
怎么诊断MongoDB Config Server响应极慢的问题_高频Auto-split导致的元库写入压力
jvm·数据库·python
nLYA SCOL2 小时前
MySQL数据的增删改查(一)
android·javascript·mysql
qq_206901392 小时前
c++怎么在Linux下获取文件被最后一次访问的精确纳秒时间【进阶】
jvm·数据库·python
IRevers2 小时前
【Agent】基于Langchain的Agent数据库查询助手
数据库·人工智能·pytorch·sql·深度学习·langchain·agent
m0_748920362 小时前
如何让点击目标元素时随机移动到页面任意位置
jvm·数据库·python
他是龙5512 小时前
DVWA SQL 注入全级别通关笔记(Low / Medium / High / Impossible)
数据库·笔记·sql
qq_206901392 小时前
如何创建CDB公共用户_C##前缀强制规则与CONTAINER=ALL.txt
jvm·数据库·python
code bean2 小时前
MySQL 远程访问实战:从基础操作到真实踩坑记录
数据库·mysql
Hello World . .2 小时前
Linux驱动编程:内核同步的艺术-从互斥到底半部
linux·开发语言·数据库