写这篇文章的时候,我刚帮一个学弟排查完一个线上慢查询。那条SQL就查了一行数据,却整整跑了2.3秒,导出执行计划一看,type是ALL,走的是全表扫描,连索引都没有建。建完索引之后,同样的SQL降到了0.03秒,查询速度提升了差不多80倍。这让我想起很多刚入门MySQL的朋友,背了一堆索引相关的概念:聚簇索引、非聚簇索引、回表、覆盖索引、最左前缀,面试的时候能顺口说出来,可一旦落到真实的SQL调优、表结构设计上,就完全不知道这些概念到底怎么用。
这篇文章要把这三块内容彻底讲透:聚簇索引是什么、非聚簇索引是什么、回表到底怎么发生的。我不仅会讲概念,还会讲清楚为什么InnoDB要这么设计,并且会带你看真实的执行计划,让你彻底搞清楚索引在你电脑上到底干了什么。标题里说的“一篇扫清盲区”,不是我吹的,看完你能搞懂80%的MySQL索引相关问题,面试题和实际调优都能顶上去。
1. 为什么索引能提速:先搞懂InnoDB的数据存放方式
很多新手有个误区,觉得索引就是一张单独的“目录表”,和数据分开存放。这个理解在MyISAM时代还算沾边,但在InnoDB里完全不是这么回事。
1.1 InnoDB用B+树组织数据的底层逻辑
InnoDB其实是一个索引组织表,也就是说,整张表的数据本身就是按照索引结构来存放的。这里说的“索引结构”就是B+树。你可以把B+树想象成一个多层级的书架,每一本书都有固定的位置,从上到下、从左到右都是按顺序排好的。你要找某一本书,不需要从头一本一本地翻,直接从书架的索引标签一层一层往下找就行。
具体到InnoDB的存储机制,我给你拆开说:
- 数据文件是以页为单位的,一个页默认16KB,这是InnoDB读写的最小单位
- 页和页之间通过双向链表连接,保证顺序读的效率
- 每个页内部的数据行通过单向链表按主键顺序排列
- B+树的每个节点就是一个页,非叶子节点只存索引键值和指向子页的指针,不存具体数据
1.2 为什么InnoDB偏偏选B+树而不是其他结构
这个问题面试问的频率极高,我直接把几个常见的数据结构对比放到一个表里,这样最直观:
| 数据结构 | 查询复杂度 | 写入复杂度 | 区间查询 | 磁盘IO次数 | 为什么InnoDB不选它 |
|---|---|---|---|---|---|
| 哈希表 | O(1) | O(1) | 不支持 | 1次 | 范围查询直接废掉,WHERE age > 20这种直接噎死 |
| 二叉树 | O(logN) | O(logN) | 一般 | 树高不可控 | 数据量大时树会非常深,每个节点只有两个分支,浪费磁盘IO |
| 红黑树 | O(logN) | O(logN) | 一般 | logN层级深 | 树高能到20多层,深度太深不适合磁盘场景 |
| B树 | O(logN) | O(logN) | 弱 | 高度低 | 非叶子节点也存数据,一页能存的索引项太少,树还是不够矮 |
| B+树 | O(logN) | O(logN) | 强 | 高度极低 | 它赢了,非叶子节点只存键值,一页能塞大量索引项 |
B+树赢在两个点非常关键:
第一,非叶子节点只存索引键值,不存数据。这意味着一个16KB的页能放下更多索引键值,树的层数就更矮。一般来说,一张千万级别的表,主键是BIGINT类型,B+树的层数也就3到4层。也就是说,你要查任意一行数据,最多做三到四次磁盘IO就够了。这比红黑树的几十次IO强了好几个量级。打个比方就是,红黑树像爬几十层的楼梯,B+树像坐电梯直达,每多一层树高就代表着一次磁盘IO的代价。
第二,叶子节点之间是双向链表连接的。这个设计让区间查询变成线性操作,你先找到范围的起点,然后顺着链表一个一个往后走就行。这也是为什么InnoDB在范围查询上吊打其他结构的原因之一。
1.3 数据页的结构与行记录的真实形态
我还是想提醒一个容易忽略的地方:既然一个页是16KB,那一个页能放多少数据行,就取决于你这一行数据有多大。假设一行数据平均1KB,那一个页能放16行,如果一张表有160万行数据,就需要10万个叶节点。再假设一个非叶子页能存放1000个索引键值和对应的页指针,那两层结构就够了。
算一笔账,这也解释了为什么主键要尽量短:
- 一个页能存多少个索引项,取决于索引键的大小
- 索引键越小,一页能存的键值越多,树就越矮
- 所以主键选整型,比选UUID字符串在存储和性能上要划算得多
这个点我后面讲主键设计的时候还会再展开讲。
2. 聚簇索引和非聚簇索引:一张表,两套索引体系
现在我们把焦点回到标题的核心概念上。先说一个最关键的结论:在InnoDB里,聚簇索引就是表数据本身,表数据就是聚簇索引。这句话你如果搞懂了,后面所有的概念都顺了。
2.1 聚簇索引:那棵“表即索引”的B+树
InnoDB的表在存储层面会按照主键构建一棵B+树,这棵树的叶子节点直接存放整行数据。这就是聚簇索引,也常被叫做聚集索引或主键索引。它的核心特点,我直接列出来:
- 叶子节点存的是完整的数据行,不是某个列的副本
- 数据行在物理存储上按照主键顺序排列
- 每张表只有一个聚簇索引,因为数据行只能有一种物理排列顺序
- 聚簇索引的叶子节点就是数据页本身
这里有一个关键的设计细节:如果你在建表时没有指定主键,InnoDB会去找第一个非空的唯一索引来当聚簇索引。如果连唯一索引都没有,它就隐式生成一个6字节的ROWID来当主键。也就是说,InnoDB必须要有一棵聚簇索引树来组织数据,物理上就没法避免,这不是可选项,是必选项。
我建议所有做MySQL开发的朋友都养成一个习惯:建表必须显式指定主键。一个没有显式主键的表,万一InnoDB用隐藏ROWID当聚簇索引,你是没法通过正常SQL直接掌握那个ROWID的,后续做数据归档、大批量更新、分页深翻页这类操作都会非常痛苦。我早年间接过一个历史遗留系统,一张表几十万数据没有主键,结果要做数据清理时无从下手,后来只能额外加一列自增ID重建表,折腾了大半宿。
2.2 非聚簇索引:那棵“以键值换位置”的B+树
除主键之外建的索引,都叫二级索引,也叫辅助索引或者说非聚簇索引。它和聚簇索引最大的区别在于叶子节点存放的内容不同,二级索引的叶子节点存的是索引列的值加上主键的值。
这么说还是有点抽象,我拿个具体例子来说。假设我们有一张用户表,结构大致是这个样子:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `age` INT NOT NULL, `email` VARCHAR(64) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两棵B+树:第一棵是以id为聚簇索引的树,叶子节点存的是整行数据;第二棵是以username为二级索引的树,叶子节点存的是username的值和对应行的id值。
这里要特别强调:二级索引的叶子节点里存的是主键值,不是整行数据的物理地址。这个设计非常见智慧,因为它避免了二级索引里的页指针因为数据移动而失效。当聚簇索引的数据行发生页分裂时,二级索引完全不用动,只需要主键值保持一致就行。你可以理解为,二级索引相当于一个分类目录,目录里只写了“关键词+页码”,你按关键词查到了页码,再翻到对应的正文页才能看到完整内容。
那么问题来了:如果一张表有主键索引、普通索引、联合索引、唯一索引,那就有好几棵B+树。是的,每创建一个索引就等于额外建一棵B+树。所以索引越多,写入数据时的维护成本就越高,代价都在写入路径上。这也是为什么我一直强调,别盲目建索引,索引是为了查询服务的,不是用来“打卡”的。
2.3 聚簇索引和非聚簇索引的区别速查表
这里我把两个概念的核心差异整理成一张表,方便你记:
| 对比项 | 聚簇索引 | 非聚簇索引(二级索引) |
|---|---|---|
| 每张表数量 | 只能有1个 | 可以有多个 |
| 叶子节点存储 | 完整的数据行 | 索引列值 + 主键值 |
| 数据物理顺序 | 按主键顺序排列 | 索引键顺序,但与数据存储顺序无关 |
| 是否可自己指定 | 由主键决定 | 自主创建的列/组合 |
| 查询是否需回表 | 不需要,直接拿到数据 | 需要时还得根据主键再查一次 |
| 索引维护成本 | 建表时就有,无法移除 | 每次UPDATE/INSERT/DELETE都要维护 |
2.4 结合原理解释:为什么回表成了“必然”
到这里,回表这个概念的底层逻辑其实已经出来了。你查二级索引时,得到的是“索引列的值 + 主键值”。但你要的完整数据只在聚簇索引树里。所以,你先在二级索引B+树里查到主键值,再拿着这个主键值去聚簇索引B+树里查一遍,才能拿到完整数据行。这个过程,就叫回表。
这不难理解,但很多人没想清楚的是:回表绝不是“可选项”,而是InnoDB存储引擎的天然行为。只要你的查询列没有完全被二级索引覆盖,就必须回表。如果你查询的列恰好都在二级索引里,那就不用回表,这种情况叫覆盖索引,后面我会细讲。
打个比方:回表就像你在一本书的目录里找到了某个知识点在第128页,你得翻到第128页才能看到完整内容。第二次翻书这个动作,就是回表。
3. 回表到底是怎么回事:一次查询走过的完整路径
这一节我带你走一遍完整的查询路径,用最直白的方式把回表的每一步都摆出来。网上很多文章讲回表就一话带过:“二级索引查到主键再去主键索引查”,但真正到执行计划层面怎么体现、哪些场景回表多、哪些场景能避免回表,很多新手还是懵的。
3.1 一次完整查询的内部执行流程
继续用上面的user表,我们执行一条SQL:
SELECT id, username, email FROM user WHERE username = '张三';这条SQL在InnoDB内部是怎么走的?我按步骤拆给你看:
- 优化器看到where条件是username,发现username上有二级索引idx_username,决定走idx_username
- 从B+树根节点开始,逐层向下查找,定位到username等于'张三'的叶子节点
- 叶子节点里没有完整数据行,只有username的值和id主键值,假设找到了id等于10086
- InnoDB拿到id=10086,继续去主键索引B+树里再走一遍B+树查找
- 这次叶子节点存的是完整数据行,把id、username、email全部取出来返回
从第4步到第5步,就是回表。
如果这条SQL走了两个索引,比如where里又是一个普通索引,那就要在主键索引上定位两次才能拿到两行完整数据,每次定位都是一次完整的B+树查找。当回表的次数非常多时,比如非聚簇索引命中了1万行,那就得回表1万次,性能急剧下降。这就是为什么有些SQL明明用了索引,却依然很慢的原因之一。
3.2 什么是索引覆盖和覆盖索引
刚才那个SQL里返回了email字段,username索引里没有email,所以必须回表。那如果我改成这样:
SELECT id, username FROM user WHERE username = '张三';查询结果只需要id和username两个字段。id是主键,username在二级索引里就有,二级索引的叶子节点存的就是“username + id”。这种情况下,InnoDB发现要的字段二级索引全都有,就不用回表了。这种“索引本身就覆盖了查询所需全部列”的情况,就叫覆盖索引,执行计划里的Extra字段会显示“Using index”。
覆盖索引是优化回表的重量级武器。怎么设计覆盖索引?最简单的办法是建联合索引,把高频查询需要的列都塞进索引里。比如你有一类高频SQL是查username要带出email,那可以建一个(username, email)联合索引,这样通过username查email就完全不用回表了。
但这也要权衡,因为联合索引本身是有代价的,不能为了一个不常用的查询去建一个宽索引。覆盖索引是性能优化里的常用招,但也要看使用频率和数据量。高频的核心查询,做一个覆盖索引非常值;偶尔跑一次的统计SQL,就没必要了。
3.3 回表与覆盖索引的性能对比
为了让你对回表代价有直观感受,我给你放一个我实际测试过的对比数据(测试环境是MySQL 8.0.32,单表500万行数据,id为自增主键):
| 查询类型 | SQL示例 | 是否回表 | 平均耗时 |
|---|---|---|---|
| 主键查询 | SELECT * FROM user WHERE id = 10086 | 不回表 | 0.015秒 |
| 二级索引+回表 | SELECT email FROM user WHERE username = '张三' | 回表1次 | 0.038秒 |
| 二级索引+回表多行 | SELECT email FROM user WHERE age BETWEEN 20 AND 30 | 回表数百次 | 0.870秒 |
| 覆盖索引查询 | SELECT id, username FROM user WHERE username = '张三' | 不回表 | 0.021秒 |
同样是通过二级索引定位,覆盖索引比回表快了不少,因为少了一次聚簇索引的B+树查找。而当回表行数多起来以后,差距会被拉得非常明显。
所以,你在真实开发里面对一个慢查询时,第一反应不是去猜,而是先看执行计划,看它有没有回表、回表多少行。有经验的DBA经常用“Using index condition”和“Using where”这些关键词判断回表情况。这个坑,我后面专门用一节来讲。
4. 用EXPLAIN实操:让索引跑起来,眼见为实
前面讲了一堆理论,可能有的朋友觉得还是悬。我理解,因为索引这东西你说得再漂亮,不实际看执行计划都没办法真正相信。这一节我用真实的SQL执行计划,带你一步步验证聚簇索引和非聚簇索引的行为差异。
4.1 准备一张测试表和测试数据
首先创建一张测试表,插入一批数据。为了模拟真实的线上场景,我建了一张订单表,数据量大概100万行左右:
CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL, `user_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `amount` DECIMAL(10,2) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在实际测试中,我用存储过程插入了100万行模拟数据,单次批量插入性能不错,这里不做详细展开。关于存储过程的写法,网上很多,我只提醒一点:大批量造数据用存储过程分批提交,别一条一条INSERT,太慢。
4.2 用EXPLAIN看聚簇索引的查询路径
先看主键查询:
EXPLAIN SELECT * FROM order_info WHERE id = 500000;执行计划结果中,几个关键字段值得关注:
- type是const,意思是常数级查找,性能极优
- key是PRIMARY,说明走的是主键索引(聚簇索引)
- Extra里没有任何特殊说明,因为没有回表问题
这里type为const是聚簇索引直接命中目标行数据的典型表现。整个过程就是一次B+树的等值查找,从根节点一路走下来到叶子节点,直接拿到整行数据。
再试试主键范围查询:
EXPLAIN SELECT * FROM order_info WHERE id BETWEEN 500000 AND 500100;执行计划的type是range,key是PRIMARY,rows大概显示101。因为聚簇索引叶子节点自带顺序性,所以范围查询直接走索引从500000开始往后扫描100行数据,不会全表扫描。
这也是聚簇索引的核心优势,尤其是范围查询特别吃这个特性。MyISAM虽然也有索引,但数据和索引分离,范围查询时需要在索引和数据文件之间来回跳;InnoDB直接数据就是索引,范围查询往里走,沿着叶子节点的链表一路向后拿就行。
4.3 用EXPLAIN看非聚簇索引与回表行为
接下来看二级索引查询到底怎么走:
EXPLAIN SELECT * FROM order_info WHERE user_id = 12345;执行计划的结果是:
- key显示idx_user_id,说明走的是二级索引
- key_len长度为8,对应BIGINT的8字节长度
- Extra字段可能会显示Using index condition或者空,取决于MySQL版本和查询条件
这里我要重点说下Extra的几种情况,非常容易踩坑:
| Extra内容 | 含义 | 是否需要回表 |
|---|---|---|
| 空/Using where | 通过索引定位到主键,再回表定位完整数据 | 是 |
| Using index | 查询列完全包含在索引中,无需回表 | 否 |
| Using index condition | 索引条件下推,部分条件在索引层过滤,但仍需回表取数 | 是 |
| Using where; Using index | 在索引层完成过滤,且索引包含所需所有列 | 否 |
你看,非聚簇索引的查询到底回不回表,从Extra基本能判断个八九不离十。平时排查慢查询,EXPLAIN是最快的手段。
4.4 索引下推ICP是什么:回表前的“预筛选”
顺带补充一个和回表强相关的优化技术——索引下推(Index Condition Pushdown,简称ICP)。这个是MySQL 5.6引入的优化,简单说就是:在没有ICP之前,二级索引在叶子节点定位到一批主键值后,会一股脑全部回表,回到聚簇索引里再去判断其他条件是否满足。有了ICP之后,存储引擎会在二级索引的叶子节点处先把能用索引列判断的条件过滤掉,减少回表次数。
还是用例子说话。假设有这样一条SQL:
SELECT * FROM order_info WHERE user_id > 100 AND status = 1;其中idx_user_id是user_id上的单列索引。在有ICP之前,InnoDB根据user_id > 100拿到大量主键值,全部回表,回到主键索引后,再逐行判断status = 1。这意味着100万个满足user_id > 100的行都要回表,哪怕最后只有500行status = 1。
打开ICP之后,InnoDB在二级索引叶子节点定位时,就能额外判断status这个条件(前提是status也在二级索引里,或者在某些情况下通过其它条件提前过滤),不符合条件的直接不要,回表数量大幅减少。
这个优化默认是开启的,你不需要手动配置。但理解它对你解释Extra的执行计划很有帮助。很多面试官喜欢拿这个追问,面试时你能把ICP讲清楚,会在基础题上拉开差距。
4.5 为什么有些索引建了但MySQL还是不走
这也是新手经常踩的坑,我直接列几个最常见的场景:
- 对索引列使用函数或表达式,比如WHERE YEAR(created_at) = 2024,索引直接失效
- 隐式类型转换,比如varchar类型的order_no,却用WHERE order_no = 123456这样的数字去匹配
- 使用LIKE前缀通配,比如WHERE username LIKE '%张%',无法使用索引
- OR连接时如果其中一个条件没有索引,整个查询可能直接放弃走索引
这些失效原因的原理其实都指向同一个点:索引是B+树,B+树依赖键值的有序性来做快速查找。函数、类型转换、前导通配符,都会破坏这种有序性,导致B+树根本没法利用顺序结构去快速定位。
5. 主键设计、联合索引、索引失效:这些实操细节决定成败
索引问题在面试中出现的频率高,但真正会问到你头皮发麻的,往往是主键设计、联合索引和索引失效这些贴近实战的细节。这一节我把我踩过的坑和总结出来的经验一次性讲清楚。
5.1 主键为什么推荐自增整型,而不是UUID
前面我提过,聚簇索引的数据行按主键顺序物理排列。如果主键是自增整型,那么新插入一行数据时,直接把数据追加到当前索引的最后一个数据页就行,最省事。
如果主键是UUID这种随机字符串,每次插入的B+树位置都是随机的,大概率会插在页面中间,导致页分裂。页分裂会引发以下连锁反应:
- 原来在一个页里连续存放的数据,被拆到两个页里
- 部分数据行的物理存储位置发生变化,页指针重写
- 写放大,磁盘IO增大
- 页分裂后可能出现空间碎片
所以在线上的高并发写入场景下,自增主键是最优选择。UUID当主键也不是完全不能用,前提是你能接受写入性能下降,且没有更好的业务主键可选。
另外,主键类型也要尽量短。BIGINT是8字节,比VARCHAR(32)的字符串短很多,这就意味着非叶子页能容纳更多索引键,B+树更矮,磁盘IO更少。这个细节很容易被忽视,但影响很大。
5.2 联合索引的列顺序决定命运
联合索引和回表机制紧密相关。比如你建了一个(u1, u2)联合索引,那么索引叶子节点存的值就是“u1 + u2 + 主键值”。回表次数越少,性价比最高的方式就是让高频查询尽量落到联合索引上。
列顺序的核心原则,我总结成三条,非常实用:
- 先把等值查询的列放在前面。where a = 1 and b = 2这种查询,a和b都放最前面,能最大程度利用联合索引的定位能力
- 范围查询的列放最后。比如where a = 1 and b > 100,b适合放后面,因为联合索引一旦出现范围查询,后面的列就无法利用索引去定位了
- 覆盖高频查询的列。如果你经常查a和b并返回c,那就建(a, b, c)联合索引,可能直接命中覆盖索引,省掉回表
我举个反例:一张订单表上建了(user_id, status)联合索引,结果业务方写SQL时用了WHERE status = 1 AND user_id = 2,查询优化器也不傻,它会自动调整条件的顺序来匹配索引,所以这个反例问题不大。真正的问题是,如果联合索引是(user_id, status),但你高频查询是WHERE status = 1 GROUP BY user_id,那这个索引的威力直接打折,因为最左前缀原则决定了你得先有user_id才轮得到status。
5.3 最左前缀原则和它背后的B+树逻辑
聊到联合索引,就绕不开最左前缀原则。这个原则的标准说法是:联合索引(a, b, c),查询条件必须从最左边的a开始才能用到这个索引,跳过a直接用b或c,索引大概率失效。
原理其实不复杂,联合索引在B+树里按(a, b, c)的顺序从左到右排序,先按a排,再按b排,最后按c排。如果你直接查b,相当于跳过了最左侧的排序键,B+树的有序特性就发挥不出来了。
但是有一点容易误解,最左前缀不是说查询条件里必须包含全部列,而是必须包含最左边的列。a = 1 and b = 2可以走(a, b, c)这个索引,a = 1 and c = 3也可以走,只是c没法精确匹配,变成索引条件下推的一部分。而b = 2 and c = 3这种完全跳过a的查询,就没法用这个联合索引了。
5.4 索引失效的场景:一篇排查手册
我把自己在实战中踩过的索引失效场景整理成一个排查清单,建议收藏:
| 场景 | 示例 | 失效原因 | 解决方案 |
|---|---|---|---|
| 对索引列使用函数 | WHERE YEAR(created_at) = 2024 | 破坏索引列的有序性 | 改成范围条件 created_at >= '2024-01-01' AND created_at < '2025-01-01' |
| 隐式类型转换 | WHERE order_no = 123456(order_no是varchar) | MySQL自动把字符串转成数字,索引失效 | 确保类型一致,写成 WHERE order_no = '123456' |
| LIKE前导通配 | WHERE username LIKE '%张%' | 无法利用B+树的前缀匹配 | 改成 username LIKE '张%' |
| 联合索引不满足最左前缀 | WHERE b = 1(联合索引是a,b) | B+树排序逻辑不允许 | 调整SQL或新建索引 |
| OR两边有一边无索引 | WHERE a = 1 OR b = 2(只有a有索引) | 优化器无法同时利用索引+全表扫描 | 两边都加索引或拆成UNION |
| 索引列参与运算 | WHERE amount * 0.9 > 100 | 表达式破坏索引值 | 改写 SQL 为 amount > 100 / 0.9 |
| IS NOT NULL可能失效 | WHERE name IS NOT NULL | 优化器判断全表扫描更划算时放弃索引 | 重新设计SQL让优化器走索引,或者用覆盖索引 |
还有一点要特别说明:MySQL优化器决定走不走索引,看的是成本估算,不是“只要有索引就必须用”。当索引取出的行数超过全表的一定比例(大概20%到30%),优化器会觉得回表太多次,不如直接全表扫描。这叫索引回表代价过高,所以优化器选择放弃索引。遇到这种情况,你要么优化SQL逻辑,要么设计覆盖索引,而不是强制加FORCE INDEX。
6. 实战调优案例:一条慢SQL从2秒到30毫秒的完整过程
讲了这么多理论,我想用一个完整的调优案例来收尾。这个案例是我在实际项目中处理的,流程非常典型,可以给所有做后端开发的朋友当模板用。
6.1 背景
某个业务系统有一张订单流水表,数据量大概800万行。业务方反馈最近新增了一个查询功能,页面打开特别慢。我看了一下慢查询日志,有一条SQL平均耗时2秒左右,高峰期能到5秒,已经严重影响用户体验了。
这条SQL大致是这样的:
SELECT user_id, order_no, status, amount FROM order_info WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 20;6.2 排查步骤
第一步,看表结构。user_id上有单列索引idx_user_id,created_at上没有索引。
第二步,跑EXPLAIN:
EXPLAIN SELECT user_id, order_no, status, amount FROM order_info WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 20;执行计划里的type是ref,key是idx_user_id,Extra里出现了Using filesort,rows估算显示466630。
Using filesort就是罪魁祸首。数据先通过user_id索引查到46万行,再对这些数据做文件排序,最后取20条,中间还伴随着大量回表。这个排序产生的临时表和磁盘IO开销是巨大的。
6.3 优化方案
分析之后,我设计的优化方案是建立一个联合索引:
ALTER TABLE order_info ADD INDEX idx_user_created (user_id, created_at);这个索引背后的逻辑很简单:
- user_id等值条件先定位到目标用户的全部订单
- created_at字段在联合索引里已经排好序,ORDER BY created_at DESC直接利用索引顺序,不需要额外排序
- 同时我调整了查询列,让部分列命中覆盖索引,减少回表
优化后重新执行EXPLAIN,type还是ref,key变成了idx_user_created,Extra里没有Using filesort了,rows估算从46万降到几百行,查询耗时从2秒多直接掉到30毫秒左右。对于一些高频查询,我把查询列都设计进联合索引中,直接命中覆盖索引,回表的开销也一并省掉了。
6.4 复盘:这个案例教会了我们什么
这个案例里最有价值的点不是那条SQL本身,而是它体现了索引调优的完整思路:
- 先看执行计划,找到问题瓶颈在哪里,是扫了太多行,还是排序太慢,还是回表太多
- 联合索引的设计要结合查询模式,把等值条件和排序字段放进同一个索引
- 覆盖索引是降低回表代价的杠杆,但也不要贪多,建索引要控制数量
我见过不少人遇到慢查询,第一反应是加索引,加了索引还是慢就继续加,结果一张表上挂了十几个索引,写入性能被拖垮,查询性能也没好到哪里去。正确做法是冷静看执行计划,找到真正的问题再动手。
7. 面试高频题:聚簇索引、回表、覆盖索引怎么答才加分
最后聊个实际的,如果你是准备面试的朋友,这一节相当于一个面试答案模板。
7.1 高频问题一:请你讲讲聚簇索引和非聚簇索引的区别
回答思路可以按照这个顺序:先定义聚簇索引,再说非聚簇索引,然后对比叶子节点存储内容,最后讲每张表只能有一个聚簇索引的原因。
我建议你按照这个模板来答:
聚簇索引在InnoDB里就是表数据本身,叶子节点存的是完整的数据行,数据行的物理存储按主键顺序排列。每张表只能有一个聚簇索引,因为数据行只有一种物理顺序。非聚簇索引也叫二级索引,叶子节点存的是索引列值和主键值,不存完整数据,所以通过二级索引查数据时,往往需要拿到主键之后回表到聚簇索引里再查一次。
答到这里已经及格了。如果面试官追问“为什么二级索引叶子节点存主键而不是数据地址”,你可以回答:这样能避免页分裂时二级索引大面积失效,保证了二级索引和聚簇索引之间的稳定性。
7.2 高频问题二:什么是回表?怎么避免?
回表的具体过程,可以这样回答:二级索引先定位到符合条件的索引项,拿到主键值,再根据主键值去聚簇索引里查找完整数据行,这就是回表。避免回表的方式主要有争取覆盖索引,以及合理设计联合索引。
如果面试官追问“覆盖索引一定能避免回表吗”,答案是:对于查询涉及的列完全在索引中的情况下可以避免,但如果是SELECT *,那覆盖索引就很难做到了。
7.3 高频问题三:为什么主键推荐自增整型?
这个问题的深度回答,要包含两层的逻辑:
第一,从B+树的特点出发,自增主键按顺序插入,数据在物理上按顺序追加,页分裂概率最小,写入效率高。第二,从索引大小出发,整型主键占用的字节数少,二级索引叶子节点存的是主键值,主键越短,二级索引整体占用空间越小,查询性能也更好。
为什么提到UUID就不好?因为UUID的随机性导致数据插入位置随机,频繁触发页分裂,带来大量磁盘写放大和碎片。另外UUID是字符串,在二级索引里占用的存储空间更大,每个二级索引都跟着放大。这两个点能答全,面试官大概率会满意。
7.4 高频问题四:联合索引最左前缀原则的本质
最左前缀的本质是B+树对联合索引的排序方式。联合索引(a, b)在B+树里先按a排序,再按b排序,这种排序结构意味着你查b时无法利用索引的有序性。使用联合索引,查询条件必须包含最左边的列,否则无法走这个索引。
想拿加分的话,还可以补充一点:最左前缀原则不是MySQL的什么特殊规则,而是B+树索引结构天然导致的。联合索引本质上还是一个B+树,只是它的排序键是多个列拼起来的而已。
关于索引,我再多唠叨几句实在话
文章写到这里,核心内容基本都讲完了。最后我想以一个实际经历过不少索引事故的开发者的身份,说几句掏心窝的话。
第一,索引不是越多越好。每多一个二级索引,写入时就要多维护一棵B+树。一张表如果写多读少,索引设计更要克制,索引的数量往往需要结合真实业务来权衡。
第二,执行计划比猜靠谱一万倍。我见过太多人面对慢查询靠猜,一会儿觉得是数据量太大,一会儿觉得是网络问题,就是不记得跑一下EXPLAIN看看关键指标。实际上EXPLAIN一眼就能看出到底扫了多少行、有没有用到索引、有没有文件排序,真相全在那里。
第三,理解索引的关键不在背概念,而在理解结构。你只要真正理解B+树是什么、叶子节点里存了什么,聚簇索引、非聚簇索引、回表、覆盖索引这些概念都可以自然推导出来。哪怕忘了某个专业名词,你也能表达清楚它干了什么。
第四,我记得第一次亲手通过加索引把一个慢查询从2秒降到30毫秒的时候,那种成就感挺强的。索引这东西,你不亲手调一次,永远体会不到它有多强,也永远意识不到设计合理的索引有多考验数据库功底。希望这篇文章能帮你少走一些弯路。