MySQL索引优化实战:从B+树到联合索引与失效排查
2026/9/16 3:37:10 网站建设 项目流程

做了几年后端,MySQL 相关的性能问题里我印象最深的永远是索引。不管你是刚把 MySQL 装好、准备建第一张业务表,还是已经在线上被慢查询折磨过几轮,索引都是绕不过去的一关。联合索引、覆盖索引、索引失效这些进阶话题,几乎每次面试都会问,每套线上系统的性能优化也都跟它们直接相关。今天这篇我把自己在实际项目中验证过的索引原理、设计方法和排查经验完整梳理一遍,不写教科书腔,尽量全是能直接拿去用的东西。

1. 索引到底在解决什么问题

1.1 没有索引时,SQL是怎么执行的

先把底子打好。MySQL 默认的存储引擎是 InnoDB,数据并不是乱糟糟地摊在一个大文件里,而是按页(Page)来组织,默认一页 16KB。InnoDB 在做查询时,会先把目标数据所在的页加载进内存,再在页内部逐行匹配。问题来了:你怎么知道目标数据在哪个页?

没有索引的情况下,MySQL 只能老老实实从第一个数据页开始,一页一页往后翻,把整张表所有页都扫一遍,逐行判断 WHERE 条件是否满足,这就是全表扫描,执行计划里的 type 会显示为 ALL。这个过程特别像查一本没有目录的字典,你想找“索引”这个词,只能从第 1 页翻到最后一页,翻到最后一页才看到,非常费劲。

全表扫描倒也不是一无是处。表特别小的时候,它反而是最优策略,省掉了索引查找的随机 IO。小表随便扫,速度比走索引还快。但数据量一旦上来,比如百万行、千万行,全表扫描的代价就非常可观:一次查询要读几千个数据页,磁盘和 CPU 全被拖垮,接口延迟直接飙红。这也就是为什么我们常说“SQL 慢,十有八九是没走对索引”。

1.2 索引的本质:额外维护一份“目录”

既然直接在数据页上翻阅太慢,那就给数据加一份“目录”,这就是索引。索引是独立于原始数据之外的一种额外结构,它提取出某些列的值,并和对应的数据位置(在 InnoDB 里通常就是主键值)建立映射,再按照一定的顺序排列,让查询可以快速定位,而不必翻遍整张表。

为什么不用改原表?因为业务表的数据是不断增删改的,行本身在磁盘上也没有固定顺序。如果为了加速查询,直接在数据页上维护一个有序查找结构,那每次 INSERT 和 UPDATE 都要去动数据页的物理布局,写入性能会被严重拖累。所以 InnoDB 选择在表之外另建一套有序结构,专门服务查询——这就是索引。本质上,索引是在用额外的磁盘空间和写入开销,换取查询速度的大幅提升,典型的空间换时间。

1.3 为什么数据库最终选了B+树

索引可以有很多数据结构实现,MySQL 里最常见的 InnoDB 索引用的是 B+ 树。为什么不是哈希表,不是红黑树?

哈希表的等值查询确实快,O(1) 就能定位,但它有两个硬伤:第一,不支持范围查询,比如WHERE age > 20这种条件,哈希结构只能全表扫;第二,哈希值是无序的,无法利用索引排序,ORDER BY也帮不上忙。业务 SQL 里最常用的就是范围查询和排序,哈希直接出局。

再看二叉树和红黑树。二叉树在极端情况下会退化成链表,查找效率变成 O(n);红黑树虽然能保持平衡,树的高度依然随数据量增长。数据量到百万、千万级时,红黑树的高度会很高,而数据库的查询瓶颈是磁盘 IO——树每多一层,就可能多一次磁盘读取。这一层层的代价,在线上的延迟里非常明显。

B+ 树专门为磁盘场景设计,它有几个关键特性:

  • 非叶子节点只存索引键值和指向子节点的指针,不存真实数据,所以一个页能放下非常多的键值,树的高度被压得很低。
  • 叶子节点才存数据(在 InnoDB 里,主键索引的叶子节点存整行数据;二级索引的叶子节点存主键值),并且叶子节点之间用链表串联,范围查询走链表非常高效。
  • 树的高度通常只有 3 到 4 层,也就是说,哪怕几千万行的表,从根节点定位到叶子节点,最多也就 3 到 4 次磁盘 IO。

我经常拿一个估算来向别人说明 B+ 树的厉害:假设主键是 bigint,占 8 字节,指针占 6 字节,一个 16KB 的页大约能存 1170 个索引项;假设一行数据约 1KB,一个叶子页能放约 16 行。一个三层 B+ 树大概能存 1170 × 1170 × 16,差不多 2190 万行。也就是说两千万行的表,从根节点到叶子节点只需要 3 次 IO。这个结论第一次听确实反直觉,但这就是 B+ 树成为数据库索引主流选择的根本原因。

注意:以上是粗略估算,实际页利用率、记录大小都会影响具体值,但“B+树层数少、磁盘IO稳定”这个结论是可靠的。

2. 索引类型与适用场景全拆解

2.1 聚簇索引、二级索引与主键的关系

InnoDB 里有一对非常基础的概念:聚簇索引(Clustered Index)和二级索引(Secondary Index)。

主键索引就是聚簇索引,它的叶子节点直接保存整行数据。所以一张 InnoDB 表只能有一个聚簇索引,因为数据行不可能按两种顺序物理存储。如果你建表时没有显式指定主键,InnoDB 会找一个非空的唯一列来当主键;如果也没有,它就会隐式生成一个 6 字节的 rowid 作为聚簇索引。这也是我反复建议大家一定要主动设计主键的原因——让数据库隐式生成的主键,不受你控制,后续很多操作会很被动。

二级索引,也叫非聚簇索引,是你在业务表上额外创建的普通索引。它的叶子节点不存整行数据,而是存索引列的值再加上主键值。当我们通过二级索引查数据时,会先在二级索引的 B+ 树里找到对应主键,再拿着这个主键回到聚簇索引里查整行数据,这个动作就叫“回表”。

回表是有代价的,它相当于一次额外的主键查询。这也是为什么有些查询即使走了索引,依然觉得不够快。理解了回表,你就能理解覆盖索引为什么那么重要,下面会细说。

2.2 联合索引与最左前缀原则

联合索引是 MySQL 进阶里最常考的内容之一。所谓联合索引,就是在一张表上,把多个列合起来建一个索引,比如CREATE INDEX idx_a_b_c ON t(a, b, c)

很多人以为建了联合索引,查询的时候只要条件里带上了这三个字段就能用。实际上不是。联合索引在 B+ 树里的排序规则,是先按第一个字段 a 排序,a 相同的情况下再按 b 排序,b 也相同的情况下再按 c 排序。这就导致了一个非常核心的规则:最左前缀原则

最左前缀原则的意思是,查询条件里的等值或范围条件,必须从联合索引最左边的列开始连续命中,索引才能被高效使用。举例来说,索引(a, b, c)

  • WHERE a = 1能用索引。
  • WHERE a = 1 AND b = 2能用索引。
  • WHERE a = 1 AND b = 2 AND c = 3能用索引,覆盖率最高。
  • WHERE a = 1 AND c = 3能用到 a,但 c 无法直接利用索引做精确定位。
  • WHERE b = 2直接用不到这个索引,因为 b 在联合索引里不是最左列,全局是无序的。
  • WHERE c = 3同理,用不到。

为什么单独查 b 用不上?你可以把联合索引想象成一本电话簿,先按姓排序、再按名排序。如果你只知道某个人的名而不知道姓,那这套按“姓+名”排序的目录就帮不上忙,因为“名”在整个电话簿里是乱序的。这个类比能帮你记住最左前缀原则。

2.3 覆盖索引:让查询不“回表”

前面说了,二级索引的叶子节点存的是主键值。假设有这样一个表:

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(20) NOT NULL, `age` int DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_name_age` (`name`, `age`) ) ENGINE=InnoDB;

执行下面这条 SQL:

SELECT name, age FROM user WHERE name = '张三';

MySQL 会去idx_name_age这个索引里找name = '张三'的记录,然后发现要查询的列nameage都整整齐齐地放在索引的叶子节点上,根本不需要回表,直接就能返回。执行计划里的 Extra 会显示Using index,这就是覆盖索引。

但如果是SELECT id, phone FROM user WHERE name = '张三'phone字段不在索引里,二级索引的叶子节点上没有,MySQL 就必须拿着主键 id 回表去聚簇索引里查 phone,Extra 里看不到Using index,而会出现Using where之类的标识。

覆盖索引是优化高频查询的利器。很多慢查询其实不需要动表结构,只需要把单列索引改成多列联合索引,让查询涉及的所有列都覆盖在索引里,就能把回表次数直接降到 0。这也是为什么“根据 SQL 来设计索引”这句话如此重要——索引是为了喂饱你的业务查询,而不是为了建索引而建索引。

2.4 索引下推:一个容易被忽略的加速器

索引下推(Index Condition Pushdown,ICP)是 MySQL 5.6 引入的一个优化,很多人没注意到。它的作用是:在存储引擎层遍历索引时,提前把可以在索引层面判断的条件过滤掉,减少回表次数。

举个例子。有联合索引(name, age),查询:

SELECT * FROM user WHERE name LIKE '张%' AND age = 20;

在没有索引下推的情况下,MySQL 会先在索引里把所有name LIKE '张%'的记录找出来,然后一条条回表,读取完整行数据,再在 Server 层判断age = 20。假设张%命中了 1000 条记录,就要回表 1000 次,最后可能只剩 50 条满足条件。

开了索引下推后,存储引擎在遍历索引时就会先把age = 20这个条件也带上,索引叶子节点上本来就有 age 字段,可以直接判断,只有满足条件的 50 条才回表。回表次数从 1000 次降到 50 次,性能提升非常明显。执行计划里如果出现Using index condition,就说明 ICP 生效了。

这个优化对联合索引意义特别大。它意味着即使查询条件没有完全遵循最左前缀原则,只要有一部分条件能在索引里过滤,也能减少大量无效回表。所以说现在的 MySQL 优化器比很多人想象中要聪明,我们在建索引时可以把“哪些条件最有利于在索引里过滤”作为排序依据之一。

3. 实操:用explain定位索引问题

3.1 explain关键字段怎么读

理论说了半天,到了实战环节,第一步永远是认识执行计划。MySQL 提供了 explain 命令,用法非常简单:

EXPLAIN SELECT * FROM orders WHERE user_id = 1001;

它会返回一行执行计划,关键字段我整理成了表格:

字段含义关注点
type访问类型从好到坏:system > const > eq_ref > ref > range > index > ALL。看到 ALL 就要警惕
key实际使用的索引如果为 NULL,说明这条 SQL 没用上任何索引
rows预估需要扫描的行数越小越好,能直观反映这次查询的“体力活”有多少
filtered存储引擎返回数据在 Server 层再过滤的比例百分比越高越好
Extra补充信息重点关注 Using filesort、Using temporary、Using index、Using index condition

type 字段是最直观的警报器。ALL是全表扫描,index是扫描整棵索引树,虽然比ALL好一点,通常也是一种近乎全量的扫描。range代表范围扫描,算是比较健康的级别,比如WHERE id > 100ref表示走的是普通索引的等值查询,非常理想。const则是通过主键或唯一索引等值查询,性能最好。

Extra 里只要出现Using filesort或者Using temporary,大概率就是排序或分组没有用好索引,这类 SQL 在数据量一大后会非常拖沓。如果看到Using index,说明覆盖索引生效,值得庆幸;看到Using index condition,说明索引下推在帮忙。

3.2 一个慢查询优化案例分析

直接上一个我简化过的真实场景。有一张订单表 orders,已经有两百多万行数据。业务上有这样一个高频查询:查某个用户最近一段时间的订单。

SELECT * FROM orders WHERE user_id = 12345 AND create_time >= '2024-01-01 00:00:00';

第一次执行 explain,结果非常难看:type 是ALL,key 是NULL,rows 直接飙到 268 万。说明每次查询都在做全表扫描,接口该慢不慢。

当时的表里其实已经有一个user_id单列索引了,但 explain 显示它没有被使用。为什么?因为优化器发现这条 SQL 里既有user_id又有create_time,从user_id索引进去之后,还是要把该用户的所有订单都回表查出来,再过滤create_time。如果这个用户的订单量很大,优化器一算账,还不如直接全表扫。

优化动作是调整索引设计,改成联合索引:

ALTER TABLE orders ADD INDEX idx_user_create (user_id, create_time);

再次执行 explain,type 变成了range,key 变成idx_user_create,rows 从 268 万降到了几百行。查询秒回。

这里有一个很多人会踩的坑:既然单列user_id已经建了,单列create_time也建了,为什么不直接用两个索引?SQL 里同时用到两个条件,MySQL 确实有可能做索引合并(Index Merge),但它的稳定性和效率远不如一个联合索引。联合索引(user_id, create_time)先在索引里定位到 user_id,再在相同 user_id 内利用 create_time 的有序性做范围定位,这一下就把回表量降到了最低。

注意:加索引前一定要评估业务场景。如果create_time的范围条件非常宽,联合索引的效果也会打折。索引设计是跟着查询条件走的,没有一个索引能包打天下。

3.3 索引设计与创建的几条原则

结合上面的案例,我把索引设计原则总结成几条可落地的建议:

第一,给区分度高的列建索引。区分度可以粗略用COUNT(DISTINCT 列) / COUNT(*)来衡量。性别只有 0 和 1 两个值,区分度极低,就算建了索引,优化器也可能放弃它走全表扫描,因为一个值对应了太多行,回表成本太大。而订单号、手机号这类几乎每个值都不同的列,区分度极高,索引效果就非常好。

第二,根据 SQL 设计联合索引,而不是堆单列索引。很多人喜欢给每个查询字段都建一个单列索引,结果一张表十几个索引,写入越来越慢,查询却不一定变快。正确的做法是先梳理核心 SQL 的 WHERE、ORDER BY、GROUP BY 条件,然后设计少数几个能覆盖高频场景的联合索引。

第三,主键尽量简短而且有序。因为二级索引的叶子节点都存着主键值,主键越大,二级索引就越大,占用空间也越多。自增主键不仅能保持聚簇索引的插入有序,避免频繁页分裂,还能让每个二级索引都轻量一些。UUID 主键在数据量大的表上,写入性能通常都会被有序整型主键甩开一个身位。

第四,字符串字段考虑前缀索引。长字符串列(比如 URL、备注)如果整列建索引,索引体积巨大。可以用ALTER TABLE t ADD INDEX idx_url(url(20));的方式,只对前 20 个字符建索引。代价是可能损失一部分区分度,这需要测试和权衡。

4. 索引失效的典型场景与排查实录

4.1 最常见的六种索引失效场景

索引失效是面试高频题,也是线上问题重灾区。我整理了一个速查表,后面逐个展开:

场景经典写法失败原因优化方案
隐式类型转换WHERE phone = 13800138000对索引列做了类型转换参数与列类型保持一致
对索引列使用函数WHERE DATE(create_time) = '2024-01-01'函数处理后的结果无法利用原索引改写为范围条件
模糊匹配前导通配符WHERE name LIKE '%张'无法从有序结构定位起点考虑全文索引或改写
or 连接非索引列WHERE a = 1 OR b = 2需要合并两个结果集给 b 建索引或拆分 SQL
违反最左前缀原则联合索引 (a,b),只查 bB+树复合排序限制调整字段顺序或另建索引
优化器认为全表扫描更快数据量小、区分度低成本计算后放弃索引不一定需要处理,属于正常行为

先看隐式类型转换。表里 phone 是 varchar 类型,但 SQL 写成了数字:SELECT * FROM t WHERE phone = 13800138000。MySQL 会把 varchar 列转成数字再比较,这就等于对索引列做了类型转换,索引自然就失效了。这类问题在代码里非常隐蔽,尤其是 Java 之类的语言里,Long 类型参数传进来,SQL 自动拼接后很容易踩中。

再看函数操作。WHERE DATE(create_time) = '2024-01-01'会对每一行的 create_time 都执行一次 DATE 函数,索引里存储的是原始时间戳,根本没法直接定位。正确写法是把它改成范围查询:

WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00';

这样既能走索引,语义也完全一致。MySQL 8.0 里虽然支持函数索引,但那是通过新建隐藏列实现的,不是让原列的函数操作变得能用索引,这点要区分清楚。

LIKE '%张'这种前导通配符失效,是因为 B+ 树按顺序排列,只有知道前缀才能快速找到起点,而%开头的条件没有确定的起点。不过LIKE '张%'是可以走索引的。

or 连接条件的情况也经常遇到。WHERE a = 1 OR b = 2,如果 a 有索引而 b 没有,MySQL 需要把两个条件的结果合并,但 b 那边只能全表扫,整个查询就可能退化为全表扫描。一个稳妥的处理是给 b 也建上索引,或者把 SQL 拆成两条再 UNION。

4.2 快速定位索引失效的方法

排查索引失效,我的习惯是三步走。

第一步,先跑 explain,看 key 字段。如果 key 为 NULL,或者 type 是 ALL,基本可以断定这条 SQL 没走索引。再结合 where 条件,对照前面说的几个失效场景逐条排查。

第二步,开启慢查询日志,把生产环境的慢 SQL 收集起来。MySQL 里可以这样临时开启:

SET global slow_query_log = ON; SET global long_query_time = 1;

然后去日志文件里捞那些执行时间超过 1 秒的 SQL,用 explain 逐个分析。慢查询日志是发现索引问题最直接的入口。

第三步,如果 explain 看不出名堂,怀疑是优化器自身的选择问题,可以用 optimizer trace 查看优化器的详细决策过程:

SET optimizer_trace = 'enabled=on'; SELECT * FROM t WHERE phone = 13800138000; SELECT * FROM information_schema.OPTIMIZER_TRACE;

它会列出优化器为什么选择全表扫描而不是某个索引,成本计算过程一目了然。这个方法在工作里帮我解决过好几个“明明有索引却不走”的诡异问题。

4.3 实战中踩过的坑

分享两个我实际遇到过的问题,给大家提前排雷。

第一个坑:状态字段建了索引却完全不生效。曾经有个订单表,order_status 只有 0 和 1 两个值,我觉得这个字段是查询高频条件,就单独建了索引。结果 explain 一看,type 还是 ALL。优化器一算账,这个字段区分度太低,走索引要回表的行太多,成本比全表扫描还高,干脆放弃。后来我把这个字段和其他字段一起做成联合索引,只在联合条件里发挥作用,效果才正常。这个案例再次验证了那个原则:不是每个查询字段都值得单独建索引。

第二个坑:手机号字段隐式转换,排查了一个下午。业务反馈一个用户查询接口偶发超时,我 explain 之后发现 key 是 NULL,第一反应是索引没建好,前前后后查了表结构、确认索引存在、重建索引,都没用。最后仔细一看 SQL,发现参数是 Long 类型传进来的,拼出来的 SQL 里手机号变成了数字,触发了隐式类型转换。把参数改成字符串后,索引立刻生效,慢查询消失。从那以后,我养成了一个习惯:但凡 varchar 列,一定检查 SQL 里对应的参数类型。

5. 关于索引设计,我最后的几点体会

5.1 索引不是越多越好

很多人有一个误区,觉得索引是万能的,一张表建了十几个索引,保险。实际上索引是有代价的:每次 INSERT、UPDATE、DELETE,都要同步维护索引树,索引越多,写入越慢;每个索引都占用磁盘空间,二级索引的叶子节点里还存了主键,空间翻倍;而且优化器在面对一堆索引时也会犯难,挑错索引反而导致性能下降。

我在项目中更倾向于“精而少”的策略。先统计出整个业务里最核心的几十条 SQL,再根据这些 SQL 设计少数几个联合索引,尽量做到一个索引服务多个相关查询。那种“每个字段单独建索引”的偷懒做法,短期看不出问题,等数据量上来就会变成噩梦。

5.2 一个值得养成的上线习惯

最后分享一个我坚持了很多年的习惯:上线前把核心 SQL 用 explain 过一遍,重点看 type、key、rows 三个字段。只要发现 type 是 ALL 且 rows 在百万级,哪怕当前数据量还不大,我也会重新审视索引是否到位。这个习惯帮我挡掉了不少线上事故——性能问题最怕的不是慢,而是慢在流量高峰才暴露,那时候一切优化都显得仓促。

索引这门技术,原理说难并不难,难的是在真实场景里保持克制和严谨。B+ 树、聚簇索引、联合索引、覆盖索引、索引下推、失效场景,这些概念串起来其实就是一条主线:让 SQL 尽量少读数据页,少回表,少做无用功。把这条主线想通了,遇到再复杂的慢查询,你也能从容拆解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询