☰
从B+树到覆盖索引:MySQL索引优化与慢SQL排查实战
2026/10/10 3:45:20 网站建设 项目流程

很多朋友一聊到 MySQL 索引优化,张口就是三句话:要避免回表、要用覆盖索引、要符合最左匹配。这些词我最早也背得滚瓜烂熟,但真正在千万级表上排查慢查询时,光会背口诀完全不够。我自己吃过一次亏:一张订单流水表,查询条件里已经把用户ID和创建时间都放进 WHERE 了,联合索引也建了,EXPLAIN 却显示全表扫描。后来把 B+树 的叶子节点结构、二级索引里到底存了什么字段想明白,才发现问题出在索引字段顺序和隐式类型转换上。

这篇文章就围绕这条完整的知识线展开:从 InnoDB 的 B+树 存储结构说起,讲清楚聚簇索引和二级索引,再顺着“二级索引拿到主键之后发生了什么”引出回表;然后说覆盖索引为什么能省掉那次回表;最后把最左匹配原则放到联合索引的物理结构里去理解。适合正在背面试题但没真正调过慢查询的同学,也适合被线上慢 SQL 折磨过、想系统性整理一遍索引知识的人。

1. 先搞清楚 InnoDB 为什么把索引做成一棵 B+树

1.1 一棵树如何决定一条 SQL 的生死

前几天线上有个接口变慢,我抓出执行计划后发现是这么一条查询:

SELECT * FROM user_order WHERE user_id = 123456;

数据量只有几十万的时候,这条 SQL 一百毫秒内就能返回。到了千万行之后,有时候能跑到五六百毫秒。关键在于:user_id 上明明有普通二级索引,为什么还会慢?很多人到这里就开始死记“可能是索引失效了”,但真正的原因往往不是失效,而是查询过程里发生了大量回表,以及整个索引结构适合解决什么问题、不适合解决什么问题,没有想清楚。

要解释这件事,只能从 B+树 开始。

InnoDB 里,表数据本身不是一堆松散的行文件,而是按主键从小到大组织成了一棵 B+树。这棵树有个专门的名字,叫聚簇索引。聚簇索引的叶子节点里存的不是索引项,而是完整的一行数据。你可以把 InnoDB 表直接理解成“一棵巨大的 B+树”,这棵树的每个叶子页里就是一行行完整的用户记录,它们按主键顺序排列。所以当你用主键 id 查询时,MySQL 不需要“先查索引再回表”,它直接从这棵树的根节点开始二分查找,一路找到叶子页,拿到整行数据。

那为什么树的高度能控制在很小范围?因为 InnoDB 的每个数据页默认 16KB,非叶子节点里存的不是整行数据,而是“主键值 + 下一页的页号”。按索引项大约十几字节来算,一个 16KB 的页大概能放下上千个索引项。三层高的 B+树,大体能支撑千万甚至上亿行的数据量。这也是 B+树在海量数据场景下牛逼的核心原因:树的高度决定了查询从根到叶要经历多少层磁盘 IO,而 B+树几乎可以把高度压到三到四层。

1.2 聚簇索引和二级索引是两棵分工不同的树

除了主键索引之外,其他所有索引在 InnoDB 里都叫二级索引,也叫辅助索引。二级索引本身也是一棵独立的 B+树,但它的叶子节点不存整行数据,只存两块东西:

  • 索引列自身的值;
  • 对应的主键值。

举个例子,如果表上有索引:

KEY idx_user_status (user_id, status)

那么这棵二级索引的叶子节点里,实际存储的逻辑结构大概是(user_id, status, id)这样的三元组。它先按 user_id 排序,user_id 相同的再按 status 排序。注意:整行数据始终只在聚簇索引里存在,二级索引里只有“半成品”。你通过二级索引找到一条记录时,拿到的是主键 id,然后还必须拿这个 id 回聚簇索引里再查一次,才能取到完整的行。

这个关系特别像一个场景:你在一本技术书后面查关键词索引,目录条目写着“覆盖索引,见第 268 页”。你只有翻到第 268 页,才能看到正文内容。目录本身是二级索引,正文是聚簇索引,“翻到第 268 页”就是回表。

很多人不理解为什么 InnoDB 要这样设计,直接让二级索引叶子节点存整行不是更方便吗?如果那样做,每张表有多少个二级索引,每个索引就相当于一份全量数据的副本。数据量大时,磁盘空间、写入开销都会被无限放大。现在这种设计虽然让查询多了一次回表,但能把二级索引的尺寸控制得很小,也让多个二级索引可以共存。

理解了这两棵树,后面所有问题都会顺理成章。

1.3 为什么不选哈希索引,也不选 B树

很多刚入门的人会疑惑:既然等值查询那么频繁,为什么不直接用哈希索引?哈希索引的等值查询确实能做到 O(1) 级别,但致命弱点是没有顺序。你一旦要查“某个时间范围”“金额前十”“created_at 排序”,哈希结构就只能全量扫描,完全无法利用索引的有序性。业务查询从来不是只有一种模式,等值和范围、排序必须兼顾。

那为什么不用 B树 而用 B+树?B树 和 B+树 的区别在于:B树 的每个节点既存索引项,也存数据;B+树 的非叶子节点只存索引项,数据全部集中在叶子节点,并且叶子节点之间通过双向链表串起来。

这个区别带来两个直接收益。第一,B+树 的非叶子节点因为不存数据,每个页能容纳的索引项数量比 B树 多得多,相同数据量下树高更矮,查询时的磁盘 IO 更少。第二,B+树 的叶子节点天然是有序链表,范围查询只需要在链表上顺序往后扫,而不需要像 B树 一样反复从根节点回溯中序遍历。所以 InnoDB 选择 B+树,可以说是把等值查询、范围查询、排序需求的平衡做到了极致。

2. 回表:二级索引查到主键之后又发生了什么

2.1 一条查询的完整回表路径

用这个查询来看:

SELECT * FROM user_order WHERE user_id = 999999;

假设 user_id 上的索引叫idx_user_id,这条 SQL 的执行路径是这样的:

  1. 从idx_user_id这棵二级索引的根节点开始,向下搜索,在叶子节点中找到user_id = 999999对应的若干条记录;
  2. 每条二级索引记录里都带着主键 id,例如 10001、10002、10003;
  3. 拿着这些主键 id,去聚簇索引树里重新搜索;
  4. 每找到一个主键对应的叶子记录,就取出完整的一行数据返回。

如果这个 user_id 命中 100 条订单,理论上就要回表 100 次。虽然每次回表走的都是聚簇索引,树高一般只有三四层,看起来不贵,但请注意:每一次回表都是独立的随机访问。如果一个数据页不在缓冲池里,就要产生一次磁盘 IO。100 次回表在最坏情况下可能是 300 到 500 次磁盘页访问。千万行表加高并发,慢就慢在这些不起眼的随机 IO 上。

这也是为什么 SELECT * 在业务表数据大时总是比只查必要字段要慢得多。因为你多 SELECT 一个不在索引里的字段,就可能把一次本来可以完全在二级索引里完成的小查询,硬生生拖成“每行都要回表”的慢查询。

2.2 用 EXPLAIN 确认是否在回表

判断一条 SQL 是不是在回表,最直接的办法是看执行计划:

EXPLAIN SELECT * FROM user_order WHERE user_id = 999999;

正常情况下,执行结果里会出现:

  • type = ref,说明用到了非唯一索引;
  • key = idx_user_id,说明选用的索引是它;
  • Extra没有出现Using index。

这里的关键就是Extra。当 Extra 里出现Using index时,表示当前查询所需字段已经全部包含在这个二级索引里,不需要回表,也就是后面要说的覆盖索引。反过来,如果 Extra 没有Using index,大概率意味着查询还需要回到聚簇索引取行。

常见的 Extra 含义我整理成了一个自己常用的参考表:

Extra 内容含义是否回表
无特殊信息普通索引访问通常需要回表
Using where在存储引擎返回后做过滤通常需要回表,也可能已经在二级索引上过滤
Using index覆盖索引不需要回表
Using index condition使用了索引条件下推可能回表,但通常能减少回表次数
Using filesort需要额外排序和回表无关,但说明索引没解决排序
Using temporary使用了临时表和回表无关,但要注意性能

这里有个容易踩的坑:Using index condition不等于覆盖索引。它只是把部分 WHERE 条件下推到二级索引存储引擎层,在读取索引记录时先过滤一批,减少回表次数。如果查询最终还需要返回索引里没有的字段,依然要回表。

2.3 回表代价什么时候会变得无法接受

回表不是任何时候都是洪水猛兽。几千行的表,回表几千次也就几次毫秒的事。真正让回表变成性能问题的,是海量数据下的随机访问,以及回表次数被放大后的连锁反应。

一个典型场景是深分页:

SELECT * FROM user_order WHERE user_id = 999999 ORDER BY created_at DESC LIMIT 10 OFFSET 10000;

如果(user_id, created_at)上有联合索引,排序和定位能走索引,但查询列表是*,二级索引里没有完整行。于是 MySQL 要为 OFFSET 之前的 10000 条记录全部回表一次,再丢弃其中的 9990 条。想象一下:你只是想拿第十页的 10 条数据,却让数据库把前面一万条记录都翻出来。这种浪费比起深分页本身更隐蔽。

更合理的写法是延迟关联,先让索引尽可能把主键和排序处理完,只回表最后少数几行:

SELECT t.* FROM user_order t INNER JOIN ( SELECT id FROM user_order WHERE user_id = 999999 ORDER BY created_at DESC LIMIT 10 OFFSET 10000 ) tmp ON t.id = tmp.id;

子查询里在(user_id, created_at)索引上完成了条件过滤、排序和分页,只返回 10 个主键,最后再来一次小范围回表。这套路在高分页场景下立竿见影,也是我对分页接口优化的默认第一步。

3. 覆盖索引:让二级索引把活一次干完

3.1 覆盖索引的本质和写法

覆盖索引并不像普通索引那样需要额外创建什么特殊对象,它描述的是“当前查询的所有字段,恰好都被某个二级索引包含”的状态。

比如表里有联合索引(user_id, status),查询是:

SELECT user_id, status FROM user_order WHERE user_id = 123456;

查询列表里的user_id、status都存在于二级索引叶子节点里,主键 id 也天然存在,所以 MySQL 可以直接在这棵二级索引树上完成扫描和取值,不需要回表。EXPLAIN 里 Extra 会明确显示Using index。

注意一个细节:即使你写的是SELECT id, user_id, status,只要 where 条件和返回字段都在二级索引里,同样可以走覆盖索引,因为二级索引叶子节点本来就有主键值。很多人以为覆盖索引必须把主键也显式建进索引里,这是误解。

但如果你写的是SELECT amount, status,而 amount 不在(user_id, status)索引里,那 MySQL 就必须回表取 amount。哪怕只差一个字段,覆盖状态立刻消失。

3.2 哪些查询场景天然适合覆盖索引

覆盖索引最适合三类场景。

第一类是统计查询。比如:

SELECT user_id, COUNT(*) FROM user_order WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY user_id;

如果存在(created_at, user_id)索引,那么 InnoDB 可以在二级索引上完成范围扫描和分组计数,不需要回表取每一行完整数据。统计类 SQL 经常全表数据量很大,一旦覆盖索引生效,节省的 IO 非常可观。

第二类是排序+分页。前面提到的分页查询,如果把查询列表改成分页需要的字段,或给高频分页接口建一个包含排序字段和过滤字段的联合索引,就能在索引里直接完成排序和分页。

第三类是高频接口只查少量字段。很多下单接口只需要返回订单状态、金额、下单时间,这时候接口 SELECT 的字段就应该是那三四个字段,而不是 SELECT *。然后让联合索引覆盖这些字段,接口查询可以做到完全不回表。

我自己的原则是:为热接口单独设计“窄索引”,让接口查询路径尽量短;报表类的宽查询,让回表发生但控制查询次数,而不是一股脑把所有字段塞进索引。

3.3 覆盖索引的成本和“过度设计”警示

覆盖索引不是免费的。每多一个索引,写入数据时就要多维护一棵 B+树。索引字段越多,单个索引存储空间越大,缓冲池缓存索引页的效率也越低,更新和插入的代价也会线性上升。

有一种常见错误想法:既然覆盖索引这么好,那我干脆把所有查询可能用到的字段全部塞进一个联合索引里,比如建一个(user_id, status, amount, created_at, order_no)这样的大索引。听起来很完美,实际执行时很快会发现:索引体积变大后,扫描成本上升,写入变慢,而且字段顺序一旦不符合最左匹配,多出来的字段根本用不上。

更合理的做法是:覆盖索引只服务最频繁、最核心的查询路径。一个索引包含三到五个字段比较常见,超过这个范围就要重新思考是不是有其他更优路径。低频报表查询,回表就回表,不必强行覆盖;高频接口查询,才值得用额外字段换取稳定的低延迟。

还要特别注意覆盖索引和索引条件下推的区别。覆盖索引是“不回表”,索引条件下推是“先利用索引里的字段预过滤,遇到不满足条件的直接跳过,减少回表次数”。两者都能优化性能,但覆盖索引的效果通常更直接。EXPLAIN 看到Using index condition时别误以为就不用回表了。

4. 最左匹配原则:联合索引内部到底怎么排序

4.1 从联合索引的叶子节点看最左匹配

最左匹配原则是新手最容易背错的一条规则。很多面试答案说“联合索引查询时必须从第一个字段开始”,这个说法对,但没说清楚“为什么”。

核心原因在于联合索引在 B+树 里的排序方式。

以联合索引(user_id, created_at)为例,它的二级索引叶子节点并不是把两个字段打散存放,而是有严格的排序规则:

  • 先按 user_id 升序排列;
  • 当 user_id 相同时,再按 created_at 升序排列;
  • 当两个字段完全相同时,最后按主键 id 排序。

也就是说,这张索引树的比较顺序,从左到右就是字段顺序。从根节点往下搜索时,第一步必须能用上 user_id 来定位分支。如果你只给 created_at,MySQL 此刻根本不知道该往树根的哪个孩子方向走,因为这棵树的全局第一排序键是 user_id,不是 created_at。它只能退化成全索引扫描,逐条看 created_at 是否满足条件。

这就是最左匹配:不是数据库故意限制你,而是联合索引本身就是这么排序的。你不能跳过第一列去用第二列,就像查一本按“用户 ID”排序的通讯录,你却只报“注册时间”,谁也帮不了你精确定位。

4.2 等值、范围、排序三种情况的实际影响

等值条件最简单。WHERE 里同时给出 user_id 和 created_at,联合索引两个字段都可以用于精确定位。这种情况下,索引提供的过滤能力是最强的。

范围条件稍微复杂。比如:

WHERE user_id = 1 AND created_at > '2024-01-01'

联合索引(user_id, created_at)可以先用 user_id 精确定位到一批记录,然后在 created_at 上执行范围扫描。但注意,一旦某个字段上出现范围,它右边的字段就无法继续用于索引排序和精确定位。比如索引(a, b, c),查询a = 1 AND b > 10 AND c = 5,c 字段在 b 的范围判断后已经“无序”,无法继续作为索引键参与精确定位。

排序条件同样受最左匹配约束。如果查询是:

WHERE user_id = 1 ORDER BY created_at

索引(user_id, created_at)不仅能过滤 user_id,还能直接按顺序读取 created_at,避免额外的 filesort。但如果查询是:

WHERE status = 1 ORDER BY created_at

而索引是(user_id, created_at),由于最左匹配没满足,索引本身也帮不上排序,MySQL 只能先去过滤再排序,Extra 里大概率出现Using filesort。

4.3 破解几个“看似匹配但没走索引”的 SQL

我总结了几个平时最容易被误判的 SQL 写法。

第一种,直接跳过第一列:

SELECT * FROM user_order WHERE status = 1;

假设联合索引是(user_id, status),这个查询走不了索引,因为第一列 user_id 缺失。这时候要么单独建 status 索引,要么根据查询频率重建联合索引为(status, user_id)。

第二种,对索引字段使用函数:

SELECT * FROM user_order WHERE user_id = 1 AND LEFT(order_no, 4) = 'ABC';

如果 order_no 本身有索引,LEFT 函数会让索引失去作用。正确写法是把条件转换成前缀匹配:order_no LIKE 'ABC%'。函数导致索引失效的本质,是 B+树 里保存的是原始值,而函数返回值与排序顺序不一致,数据库无法用索引完成定位。

第三种,索引列是字符串但传入了数字:

SELECT * FROM user_order WHERE order_no = 123456;

order_no 是 VARCHAR 类型,但查询里写成数字,就会发生隐式类型转换,相当于在索引列上套了一层函数,索引失效。这种问题很难通过 EXPLAIN 一眼发现,需要检查表结构和传入参数类型。

第四种,前导模糊查询:

SELECT * FROM user_order WHERE order_no LIKE '%ABC%';

前导 % 让数据库无法从索引有序结构的第一字符开始匹配,所以索引用不上。'ABC%'这种只在开头写死的写法才能走索引。

第五种,OR 条件连接不同字段:

SELECT * FROM user_order WHERE user_id = 1 OR status = 1;

如果 MySQL 能识别 index merge,它可能会分别用两个索引再合并;如果条件太复杂或统计信息不支持,也可能直接全表扫描。OR 不是绝对不能用索引,但绝对比 AND 更危险,需要单独 EXPLAIN 验证。

最后还要提醒一类特殊情况:即使 SQL 写法完全匹配索引,优化器也可能放弃索引,比如返回行数占全表的比例太高时,全表扫描的代价反而更小。索引不是万能药,它是“少数精确取数”的最优解,不是“全量扫数据”的最优解。

5. 实战:联合索引字段顺序和慢SQL排查思路

5.1 判断字段顺序的三个依据

设计联合索引时,很多人上来就问“哪个字段区分度高放前面”。这个说法不完整,字段顺序要综合三个维度判断。

第一个依据是查询模式。等值条件字段优先放前面,范围条件字段放最后;ORDER BY 字段要尽量排在已经等值确定的范围右边,让索引直接提供排序。高频查询里被 WHERE 写死的字段,几乎必然放最左。比如所有查询都带 user_id,那 user_id 就是联合索引铁打的第一列,哪怕它区分度不是最高。

第二个依据是区分度。区分度极高的字段放在靠左位置,可以在 B+树 搜索时更快收缩范围。比如订单号、用户ID这类字段,值域很大,一个值对应的行数很少,非常适合放最左。而 status 这种只有少数几个值的字段,如果单独放最左,索引里会有大量重复键值,过滤效果很弱。区分度和查询条件要一起看,不要只看其中一个。

第三个依据是查询列表的覆盖需求。高频接口如果只需要几个固定字段,可以考虑把剩余字段挂到联合索引尾部,形成覆盖索引。但每多加一个字段都会增加索引体积,是否值得要结合接口调用频率来判断。

举个例子,订单表有这些高频查询:

-- 按用户查最近的订单 SELECT order_no, amount, status, created_at FROM user_order WHERE user_id = 123456 ORDER BY created_at DESC LIMIT 20; -- 按状态统计某天订单 SELECT COUNT(*) FROM user_order WHERE status = 1 AND created_at BETWEEN '2024-01-01 00:00:00' AND '2024-01-02 00:00:00';

第一条查询显然需要(user_id, created_at)联合索引,并且 order_no、amount、status 也可以考虑挂进去,让高频分页接口尽量覆盖。第二条查询高频的形态是 status + created_at,所以(status, created_at)是比(created_at, status)更好的选择,因为 status 是等值条件,created_at 是范围条件。等值优先原则在这里直接决定了顺序。

5.2 一条慢SQL的完整排查链路

排查慢 SQL,我一般按固定顺序走。

第一步,看 pos 于 EXPLAIN 的 type。type 从优到差大致是:const、eq_ref、ref、range、index、ALL。如果看到 ALL,说明全表扫描;看到 index,说明虽然扫了索引,但可能是全索引扫描。这两种都要警惕。注意 type=index 不代表好用,它只是“不如全表扫那么烂”而已。

第二步,看 key 和 key_len。key 表示实际用的索引,key_len 表示索引键使用的字节数。通过 key_len 可以判断联合索引到底用了几个字段。比如(user_id, status)索引,如果 key_len 只等于 user_id 的字节长度,说明只用到第一列,status 没有参与定位。这种信息比看 Extra 更细腻。

第三步,看 rows 和 filtered。rows 是估算扫描行数,filtered 是返回行占比。如果 rows 很大而 filtered 很低,说明大量数据被扫完之后才过滤掉,索引设计还有优化空间。比如只有 user_id 条件,却扫了几百万行,大概率是缺一个更合适的联合索引。

第四步,看 Extra。Using filesort说明排序没走索引;Using temporary说明分组或去重使用了临时表;Using index说明覆盖;Using index condition说明 ICP 生效。这几个标志位能快速定位瓶颈方向。

第五步,如果条件允许,用EXPLAIN ANALYZE看实际执行时间。MySQL 8.0 里可以直接看到每个步骤消耗的时间、扫描行数和实际返回行数。它比普通 EXPLAIN 更接近真相,但只适合在测试环境或可控压力下执行。

5.3 几条写在最后的实操心得

索引优化做到后面,其实拼的不是个别技巧,而是把“数据存储形态”刻在脑子里。每次写一条 SQL,我都习惯先问自己几个问题:这张表的聚簇索引是什么结构?二级索引叶子节点里到底有哪些字段?查询列能不能在索引里全部找到?排序字段能不能直接由索引顺序提供?

我还会维护一张简单的查询清单,把每个核心接口的关键 SQL、涉及索引、EXPLAIN 的 type、key_len、Extra 都记录下来。上线新索引或者改 SQL 后,对照清单看变化。这个方法看起来笨,但实际排查问题时非常快。

还有一个容易忽略的点:索引字段顺序调整,一定要结合真实查询频率,而不是一次把所有字段全堆上去。我见过不少表建了六七个索引,查询没变快多少,UPDATE 反而越来越慢。索引优化的目标是让高频查询路径尽可能短,不是让所有查询都零回表。低频离线任务偶尔回表几千行,完全是可以接受的业务成本。

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

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

立即咨询