上一篇,我们在物理大地上算了一笔磁盘 I/O 的经济账,终于明白了为什么 MySQL 会把表的数据全部挂在"主键 B+ 树"的叶子节点上。
此时,很多刚学完 B+ 树的同学会产生一种错觉:既然有了无敌的 B+ 树,那只要我在查询的字段上建了索引,查询速度就一定会像坐火箭一样快。
如果你带着这种错觉去改线上代码,随手写下一句 SELECT * FROM users WHERE name = '张三',并在 name 字段上建了索引。第二天,DBA(数据库管理员)大概率会拿着大刀来找你:"你写的破 SQL 把库拖垮了!"
你委屈地反驳:"我明明建了索引啊!"
今天,我们就来扒开非主键查询的底裤,看看在这一句看似无辜的 SELECT * 背后,MySQL 到底付出了怎样惨痛的性能代价。
一、 案发现场:底层世界的"双树联动"
在 InnoDB 的世界里,索引并不是只有一棵树。我们继续用"新华字典"来打比方。
上一篇我们说过,以主键(ID)为目录、底层挂着完整数据的 B+ 树,叫聚簇索引(拼音目录)。整本字典的纸张,就是按拼音顺序装订的。
但业务是不讲理的,用户不知道 ID,他就是要按名字(name)查。 为了满足这个需求,MySQL 只能在旁边再建一棵 B+ 树,这棵树的名字叫二级索引 / 非聚簇索引(偏旁部首目录)。
这棵"二级索引树"的结构是怎样的?
-
非叶子节点(树枝): 存的是
name的值。 -
叶子节点(底层): 注意!这里绝对没有 完整的一行数据(总不能把几十个字段的数据再复制一份,太浪费硬盘了)。它的叶子节点里,只存了当前的
name值,以及它对应的【主键 ID】。
(高阶插曲:为什么只存主键 ID,不存这行数据的物理磁盘地址?因为 InnoDB 会发生页分裂,数据搬家是常态。如果存绝对地址,数据一挪窝,所有的二级索引全得跟着改,简直是灾难。存主键 ID,就是最完美的解耦!)
二、 性能天坑:臭名昭著的"回表(Bookmark Lookup)"
了解了"双树联动",我们再来回放一下 SELECT * FROM users WHERE name = '张三' 这句代码的执行死刑现场。
当你敲下回车,底层发生了什么?
-
第一步(查副树): MySQL 跑到
name的二级索引 B+ 树上,极其丝滑地经历了 3 次 I/O,在叶子节点找到了"张三"。 -
第二步(拿线索): MySQL 翻开叶子节点一看,里面只有
name='张三'和它的主键ID = 10。 -
第三步(致命发现): MySQL 怒了:"你代码里写的是
SELECT *啊!你要张三的年龄、手机号、家庭住址!但我这棵树上根本没有这些资料!" -
第四步(原路折返): 无奈之下,MySQL 只能拿着
ID = 10这个线索,重新跑回"主键 B+ 树"里,再从上往下查一遍,最终在主键树的叶子节点把张三的全部信息捞出来交给你。
这"拿着主键 ID,再跑去主键树查一遍"的折返跑过程,在数据库界有一个极其臭名昭著的名字------回表(Bookmark Lookup)。
带着泥土气息的经济账: 如果表里只有 1 个张三,回表 1 次,你根本感觉不到慢。 但如果表里有 1 万个叫张三的人呢? 二级索引树上找这 1 万个张三很快(因为他们挨在一起,顺序 I/O)。但是!这 1 万个张三的主键 ID 是杂乱无章的(比如 ID 分别是 5, 999, 10240)。 MySQL 拿着这 1 万个乱序的 ID 去主键树里回表,就会触发 1 万次极其昂贵的随机磁盘 I/O!此时的查询速度,甚至可能比直接不走索引、无脑全表扫描还要慢!
结论:SELECT * 最大的罪过,就是它会极其贪婪地索要那些索引里没有的字段,从而 100% 触发回表,把磁盘 I/O 瞬间打满。
三、 究极防御:高级研发的"索引覆盖(Covering Index)"
既然知道了痛点,怎么破局?高级后端工程师在写 SQL 时,心里永远有一道防线。
假设业务其实只需要展示张三的 ID 和名字,不需要看他的家庭住址。 你把 SQL 改写成:SELECT id, name FROM users WHERE name = '张三';
我们再来推演一遍底层的奇妙反应:
-
MySQL 跑到
name的二级索引 B+ 树上,找到了"张三"。 -
翻开叶子节点,里面有
name,还有主键id。 -
优化器一看:"诶?哥们你只要
id和name啊?我手里刚好全都有,资料凑齐了!" -
MySQL 直接拿着手里的数据原地返回!绝对不回表!
这种在非聚簇索引树上,就能直接收集齐查询所需所有字段的绝妙现象,就叫做索引覆盖(Covering Index)。
如果你用 EXPLAIN 去看这条 SQL 的执行计划,会在 Extra 字段里看到一行让人如沐春风的字:Using index。这代表你完美避开了回表这个性能黑洞。
四、 架构演进:为了"覆盖",联合索引闪亮登场
这时候业务又作妖了。产品经理说:"光看名字不行,列表上必须展示用户的年龄(age)!"
此时你的 SQL 变成了:SELECT id, name, age FROM users WHERE name = '张三'; 完了,name 的二级索引树上只有 id 和 name,没有 age。难道又要被迫回表了吗?
这时候,就轮到联合索引(Composite Index)登场救主了。 既然一棵树上缺个字段,那我们在建索引的时候,直接把两个字段绑在一起建树不就行了吗! 你执行:ALTER TABLE users ADD INDEX idx_name_age(name, age);
这棵全新的联合索引 B+ 树长什么样?
-
非叶子节点: 存着
name和age两个维度的目录。 -
叶子节点: 存着
name、age以及对应的【主键 ID】。
现在,再执行上面那句需要查 age 的 SQL,你会发现,资料在二级索引树上又双叒凑齐了!完美触发"索引覆盖",再次暴杀回表!
💡 为下一篇埋下的天坑
通过这篇文章,我们扒开了非主键查询的底裤,明白了 "按需 SELECT 字段 + 构建联合索引" 是干掉"回表"的无敌连招。
但是,联合索引这把武器极其锋利,也很容易伤到自己。 既然我们建了一棵包含了 name 和 age 的联合索引树,底层是把这两个字段绑在一起排序的。
灵魂拷问来了: 如果此时,业务传来一句变态的查询:"我不知道用户的名字,我只想查所有 age = 25 的人!" 执行 SELECT * FROM users WHERE age = 25;
这棵辛辛苦苦建好的 idx_name_age 联合索引树,还能帮我们快速找到 25 岁的人吗? (提示:想象一下,如果一份多级目录是先按省份排,再按城市排。你不告诉我省份,直接让我找"杭州市",目录还能起作用吗?)
下一篇,我们将直面实际开发中最让人崩溃的场景,图解多字段排序的空间法则,手撕"最左前缀法则"与三大失效天坑!