一、B + 树 两条最核心规则
B + 树分为两种节点:叶子节点、非叶子节点
- ✅ 叶子节点 :存放【key + 真实数据 data】
- 所有我们要查的真实数据,全部只存在叶子节点
- ✅ 非叶子节点(上层、中间、根节点都属于这类) :只存 key,不存真实 data!
- 这里的 key 仅仅是分界路标,用来告诉你:要找的值该往哪个子节点走。
👉 重点:所有出现在上层的 key,一定会在叶子节点里面再次出现一遍
额外一条 B + 树独有规则:
✅ 所有叶子节点,按 key 从小到大排成一条双向链表
找到一个叶子之后,可以顺着链表向右 / 向左遍历,适合范围查询。
二、从零搭建一棵树
前提:
什么是「m 阶树」
假设:4 阶 B + 树(m=4)
m 阶:代表一个节点最多可以有 m 个子节点(分支)
4 阶 → 最多 4 个分支(子节点)
key 数量 = 分支数量 - 1
👉 最多 4 分支 → 节点里最多放
4-1=3个key👉key:就是用来排序、用来查找的关键字(比如 id=13,这个 13 就是 key)
具体示例:
4 阶 B + 树 :阶数 m=4
规则:
- 每个节点最多 4 个 (m)分支,最多存放 3 个key(m
-1)- 非叶子:最多 4 个分支指针,最多 3 个 key;普通非叶子 key 数量:1~3
- 叶子:最多存 3 条记录,普通叶子最少 2 条记录;叶子存完整数据记录,叶子之间链表相连
为什么非叶子节点:key 数量 = 分支指针数 − 1key = 分界值;分支指针 = 区间入口
举例:2个分支,1 个 key
节点里 key:
20
[ ptr0 | 20 | ptr1 ]
- ptr0:指向所有 < 20 的子树
- ptr1:指向所有 ≥ 20 的子树 👉 1 个 key,切成 2 个区间,2 个分支指针
key数 = 1,分支指针=2→key = 指针 -13个分支,2 个 key
key:
20, 50
[ptr0 | 20 | ptr1 | 50 | ptr2]
- ptr0:<20
- ptr1:≥20 且 <50
- ptr2:≥50 👉 2 个 key,3 个区间,3 个分支指针
key=2,指针=3
【根页(非叶子)】
[ 17 , 62 ]
/ \ \
【中间页A】 【中间页B】 【中间页C】
[3, 8] [17, 41, 53] [62,75,88]
/ \ \ / | | \ / | | \
[3,5] ↔ [8,12] ↔ [14,16] ↔ [17,29] ↔ [41,47] ↔ [53,58] ↔ [62,69] ↔ [75,81] ↔ [88,94]
解释(标准 B + 树)
根节点:[P0, 20, P1, 50, P2]
P0:指向子树全部 < 20(中间 A,叶子最大 key 是 15)P1:指向子树**≥ 20 并且 <50** (中间 B,叶子最小 key 就是 20!20 是叶子里真实存在的 key)P2:指向子树**≥ 50** (中间 C,叶子最小 key 就是 50!50 是叶子真实存在的 key)
B + 树的作用?
计算机在磁盘上存大量数据。磁盘读取很慢。
我们希望:尽量少读磁盘,就能找到想要的数据。
每读取一个节点 = 一次磁盘 IO。(节点 = 磁盘页Page,默认 16KB)
所以设计 B + 树的目标:树的高度尽量矮。
假设:
我们最终要的叶子数据:3,7,13,17,25,30,每个都附带 data。 叶子节点最多放 3 条记录,所以叶子分成 3 块: 叶子 A:(3,data)、(7,data) 叶子 B:(13,data)、(17,data) 叶子 C:(25,data)、(30,data)
并且叶子连成双向链表:A ↔ B ↔ C
链表含义:从小到大,3→7→13→17→25→30
现在我们需要上层路标节点,帮助快速定位到 A/B/C。
上层节点只存分界 key,不带 data
每个分界 key 代表:后面这棵子树里面最小的 key
- B 叶子最小 key 是 13
- C 叶子最小 key 是 25
所以根节点存放两个分界 key:[13, 25] 根节点有 2 个 key,对应 3 个向下指针:
[ 13 , 25 ]
/ | \
叶子A 叶子B 叶子C
(3,7) (13,17) (25,30)
指针含义:
- 第一个指针:指向所有 key 小于 13 的节点(叶子 A:3、7)
- 第二个指针:指向 key ≥13 并且 小于 25 的节点(叶子 B:13、17)
- 第三个指针:指向 key ≥25 的节点(叶子 C:25、30)
👉 根节点就是这个 13,25 的节点! 根节点就是树最顶上唯一的那个节点。根属于非叶子节点,只有路标,没有真实数据。
分界 key 到底是怎么选出来的?
非叶子节点的分界 key = 它右边子树的最小 key
举例子: 根里面第一个 key=13,它右边的子树是叶子 B,叶子 B 最小就是 13。 根里面第二个 key=25,它右边的子树是叶子 C,叶子 C 最小就是 25。
三、实操:在这棵树上查找,一步步演示
案例 1:查找 key=17
- 来到根节点
[13,25]- 拿 17 和 13 对比:17 ≥ 13
- 拿 17 和 25 对比:17 < 25 → 走中间指针,去到叶子 B
- 叶子 B 是
(13,data),(17,data),找到 17,取出 data。 ✅ 查找结束。
案例 2:范围查询:找 7 ~ 25 的所有数据
- 根节点比较 7:7<13,走到叶子 A
- 在叶子 A 找到 7,取出 7 的数据
- B + 树叶子有链表!直接顺着链表跳到下一个叶子 B,取出 13、17
- 继续跳到叶子 C,取出 25
- 下一个是 30,超过范围,停止。
这就是 B + 树做范围查询很强的原因,不用反复回到上层节点。
四、对比 B 树
- B 树:所有节点,不管上层还是叶子,都存 key+data
- B + 树:只有叶子节点存 data;上层只有路标 key
- B 树叶子没有链表;B + 树叶子串成有序链表
MySQL 的 InnoDB 索引,就是 B + 树。
好处:
- 非叶子节点不存 data,一页能放下更多 key → 树更矮,磁盘 IO 更少
- 范围查询直接遍历叶子链表,速度快
五、插入简单演示(4 阶 B + 树,最多 3 条记录在叶子)
现有叶子:A (3,7) B (13,17) C (25,30) 现在插入 22:
- 找位置:根对比,13 ≤22 <25 →去叶子 B
- 叶子 B 现在是 13,17;加上 22 →
13,17,22,刚好 3 条,没超过上限,直接放进去。 叶子 B 变成:(13,data),(17,data),(22,data)
继续插入 24:叶子 B 变成13,17,22,24,一共 4 条,超过最多 3 条的上限,必须分裂! 分裂规则:把中间值提上去到上层。 叶子 B 分裂成两个叶子:
-
B1:13,17
-
B2:22,24 B2 最小 key=22,把 22 插入到根节点。 原来根
[13,25]→ 插入 22 后变成[13,22,25],3 个 key,刚好没超限。 树变成:[13, 22, 25] / | | \ 叶子A B1 B2 C 3,7 13,17 22,24 25,30
如果继续插入,根节点 key 超过 3 个,根节点就要分裂,树高度 + 1。
基础小结
- B + 树节点分两类:叶子(存 key + 真实数据)、非叶子(只存分界 key,路标)
- 4 阶 B + 树:一个节点最多 4 个子节点,最多 3 个 key
- 非叶子的分界 key = 对应右侧子树的最小 key,用来划分区间
- 根节点就是最顶层节点,可以是非叶子,也可以是叶子(只有一层数据的时候)
- 所有叶子节点连成有序双向链表,擅长范围查询
扩展:
树高度越高,查询需要读取的节点越多,磁盘 IO 次数越多,查询越慢;
树的高度,由「每个节点最多能存多少 key(分支数)」和「总数据量」共同决定。
记住前提:一次读取一个节点 = 一次磁盘 IO(磁盘 IO 非常慢,是数据库最大瓶颈)
继续用我们的 4 阶 B + 树(每个节点最多 3 个 key,最多 4 个子指针) 举例。
4 阶:一个非叶子节点最多 4 个分支(4 个子节点)
1. 树高度是什么?
B + 树高度 = 从根节点 → 叶子节点,一共要访问几层节点
注意:高度的定义有两种写法,有的教材根算第 0 层,有的算第 1 层。我们用数据库常用:根是第 1 层。
例子,我们当前这棵树:
[13 , 25] ← 第1层(根,非叶子)
/ | \
L1 L2 L3 ← 第2层(叶子层)
👉 树高度 = 2。 查询任意一条数据:最多2 次磁盘 IO(读根节点一次 + 读叶子节点一次)。
2. 高度和节点数量的关系(重点)
B + 树是多叉树,不是二叉树! 二叉树每个节点最多 2 个分支;B + 树阶数 m 很大(InnoDB 一页 16KB,可以存上千个 key,分支上千)。
m 阶 B + 树:每个非叶子节点,最多有 m 个子节点。 每往下一层,节点数量最多可以 ×m。
4 阶例子(m=4,最多 4 个子节点)
- 高度 = 1:只有根节点,根同时是叶子。最多 3 条记录。节点总数 = 1
- 高度 = 2:根(1 个),叶子最多 4 个。总节点最多
1+4=5。叶子最多 4×3=12 条记录 - 高度 = 3:根 (1) + 第二层最多 4 个非叶子 + 第三层叶子最多 4×4=16 个。总节点最多
1+4+16=21。叶子最多 16×3=48 条记录 - 高度 = 4:叶子最多 4×4×4=64 个叶子节点,最多存 64×3=192 条记录
✅ 规律: 高度每增加 1 层,能容纳的最大叶子节点数量,扩大 m 倍 。 反过来:数据总量越大,需要的树高度越高。
但是!阶数 m 越大(一个节点能放下越多 key),同样数据量下,树高度就越低。 这就是 B + 树设计核心:尽量让单个节点存更多分界 key,减少树高。
3. 树高直接决定:一次查询最多多少次磁盘 IO
高度 = 2:最多 2 次 IO(读根 + 读叶子) 高度 = 3:最多 3 次 IO(根→中间层→叶子) 高度 = 4:最多 4 次 IO
磁盘 IO 是毫秒级别,内存读取是纳秒级别。多一次磁盘 IO,性能差很多。
这就是为什么 InnoDB 索引要尽量压低树高度。 实际生产:16KB 一页,一个非叶子页可以存几千个主键。几百万甚至几千万条数据,B + 树高度一般也就 3~4。
4. 区分两个概念(很多人混淆)
- 树高度:层数,决定 IO 次数
- 节点总数:整棵树一共有多少个节点,由总数据量和每个节点容量决定
关系:
- 同样阶数(m 不变):数据越多 → 需要更多节点 → 树高度可能变大
- 同样数据量:节点能存的 key 越多(m 越大)→ 需要的节点越少,树高度越低
举对比: 同样 100 条记录
- 4 阶 B + 树:高度 3
- 如果是二叉树(每个节点最多 2 分支):高度接近 7 二叉树查询最多 7 次 IO,B + 树最多 3 次 IO,差距巨大。
这就是为什么数据库不用二叉查找树,要用 B + 多叉树。
5. 结合我们之前 UUID / 雪花 ID 的知识点串联
雪花 ID:追加写入,很少分裂节点,节点数量平稳增长,树高度增长缓慢。 UUID 随机插入:频繁分裂叶子节点,会更早产生大量节点,树更容易长高,同时带来索引碎片。
概括
- B + 树高度 = 根到叶子的层数;查询一条数据,最多要进行【树高度】次磁盘 IO。
- 在阶数固定时,总数据量变大,节点数量变多,树高度会随之增加。
- 阶数越大(单个节点存放更多索引 key),同等数据量下节点更少,树高度越低,IO 越少。
- B + 树设计目标:利用多叉特性,压低树高度,减少磁盘 IO。
为什么 InnoDB 默认页大小是 16KB,页大小如何影响 B + 树高度
先记住:
InnoDB 的「页 (page)」= B + 树的一个节点。
磁盘和内存交换数据的最小单位就是页。
一次磁盘 IO,就是读取一整个页(一个节点),不能只读页里面一小部分数据。
1. 一页里面装了什么?
一页 16KB,里面包含:
- 页头信息(固定占用一点空间,几百字节)
- 索引 key + 子节点指针(非叶子节点,上层索引节点)
- 或者:主键 + 行数据(叶子节点,聚簇索引的真实数据)
重点:
非叶子节点不存行数据!只存主键 key + 页指针
所以非叶子页,可以放下非常多的 key。
叶子节点要存完整行数据,一页能放下的记录数量就少很多。
举个真实估算例子(方便理解)
假设主键是 bigint 雪花 ID:8 字节,页指针 6 字节。
一条索引条目:8字节key +6字节指针 =14字节 一页 16KB(16384 字节),扣除页头,大概能放下 1000 多个 key。
也就是:非叶子节点,一个节点可以有 1000 多个分支!
这就是真实 MySQL 的 B + 树,不是我们之前教学用的 4 阶(最多 4 分支)。
估算树高
- 高度 1:只有根(叶子),最多一千多条记录
- 高度 2:根节点 1 个,根可以分出 1000 多个叶子节点。可以存 1000 * 一页叶子行数(假设一页叶子存 100 条)≈ 10 万条
- 高度 3:根 → 1 层非叶子 → 叶子。容量:1000 × 1000 ×100 = 1 亿条记录
✅ 一亿条数据,B + 树高度才 3!最多 3 次磁盘 IO。
2. 如果修改页大小,会发生什么?
场景 A:调大页,比如 32KB
一页空间更大:
- 单个非叶子节点,能存放更多 key,分支数量变多
- 同样数据量,树高度会更低,查询 IO 次数更少 缺点:
- 一次磁盘 IO 读取的数据变多。很多时候我们只需要一条记录,却读取 32KB 到内存,内存浪费更大。
- 刷脏页的时候,单次要写更多数据,刷页压力变大。
场景 B:调小页,比如 4KB
一页变小:
- 一页能放下的 key 变少,每个节点分支变少
- 同样数据量,树高度变高,查询需要更多磁盘 IO,查询变慢 优点: 单次 IO 读取的数据量小,内存占用小,适合大量随机读写、单行小查询场景。
3. 为什么 MySQL 默认选 16KB?
是折中方案:
- 磁盘本身块大小一般是 4KB/8KB,16KB 对齐比较友好。
- 兼顾:树高度不要太高,同时单次 IO 加载的数据量不要太大,内存开销可控。
生产环境一般不建议随便修改页大小,需要重新编译 MySQL,改动成本极高。
4. 把「页大小」和 UUID / 雪花 ID 串起来
页大小决定一个节点最多能存多少记录:
- 叶子页装满了 → 触发节点分裂。
- 雪花 ID:永远往最后一个叶子页追加,只有最后一页满了才分裂。
- UUID 随机插入:会随机在任意叶子页插入,很容易把中间页塞满,频繁分裂。 分裂会新增很多叶子节点,节点变多,索引占用空间变大,严重的时候会让树高度提前上涨。
5. 总结
- InnoDB 的页就是 B + 树节点,磁盘 IO 最小单位是页;一次 IO 读取一整个页。
- 页越大,单节点能存放的索引 key 越多,分支越多,同等数据量下树高度越低;但单次 IO 读写的数据量大,内存消耗更高。
- 页越小,单节点容纳 key 越少,树越高,IO 次数变多,查询性能下降;内存占用小。
- InnoDB 默认 16KB,是磁盘 IO、内存开销、B + 树高度三者之间的平衡。
- 非叶子节点不存行数据,一页能存上千个索引 key,所以千万甚至上亿数据,B + 树高度通常只有 3~4 层。
同样 16KB 页,主键越长(比如 varchar (36) 的 UUID 字符串主键),非叶子节点一页能放下的 key 数量会怎么变?对 B + 树高度有什么影响?
答案:主键越长,一页能放下的 key 越少,B + 树高度会变高
前提:页固定 16KB,非叶子节点存放:
主键key + 页指针
举例对比
案例 1:雪花 ID,bigint,8 字节,页指针固定 6 字节 一条索引条目 = 8+6 =14 字节,一页扣除页头,能放下约 1000 个 key。 也就是一个非叶子节点可以分出约 1000 个子分支。
案例 2:UUID 字符串 varchar (36),36 字节,页指针 6 字节 一条索引条目 =36+6=42 字节。 同样 16KB 页,能存放的 key 数量大幅下降,大概只有 300 多个 key。 单个节点分支数从 1000 → 300。
容量估算
- 雪花 ID(8 字节):3 层树,可以承载上亿数据
- UUID 字符串(36 字节):同样 3 层,能承载的数据量就小很多;
如果数据量很大,UUID 主键的 B + 树会更早到达第 4 层。 树高 + 1,查询就多一次磁盘 IO,查询变慢。
两层负面影响(两点,面试常考)
- 非叶子节点能存的 key 变少 → 节点分支变少 → 树更高 同样数据量,分支少,树的层数增加,IO 次数变多。
- 索引占用磁盘空间变大 主键变长,叶子节点里面每一行记录的主键也变大,整张表、二级索引都会变大。
InnoDB 二级索引会保存主键作为回表查找的依据! 这个是很多人容易漏掉的大坑:主键变长,所有二级索引全部跟着变大。
举例子:一张表有 3 个二级索引。主键从 8 字节改成 36 字节 UUID。 聚簇索引 + 3 个二级索引,4 套索引全部膨胀,磁盘占用暴涨,缓冲池能缓存的索引页变少,更多磁盘 IO。
串联前面全部知识点,完整链路
主键变长 → 单条索引条目变大 → 一页存放key数量下降
→ 单个节点分支变少 → 同等数据量B+树高度增加
→ 查询需要更多磁盘IO → 性能下降
同时:所有二级索引占用空间增加,缓冲池能缓存的索引页更少,进一步加重IO压力。
补充:这是 UUID 的第二个缺点(第一个是随机插入导致频繁分裂)
UUID 两大问题:
- 无序:随机位置插入,频繁叶子节点分裂,索引碎片,写入性能差。
- 体积大:36 字节字符串,主键太长,索引膨胀,树更容易变高,查询性能变差。
UUIDv7 虽然解决了无序问题(有序),但是它依然是字符串(36 字节),第二个缺点依然存在,索引还是比 bigint 雪花 ID 更大。 所以 UUIDv7 比 v4 好,但依然不如 bigint 雪花 ID。
二级索引的 B + 树结构是什么样?和聚簇索引 B + 树有什么区别?
二级索引(非聚簇索引)B + 树
前提回顾:InnoDB 只有聚簇索引 (主键索引),其余所有索引都叫二级索引(辅助索引),二级索引本身也是一棵独立的 B + 树。
核心区别一句话
- ✅ 聚簇索引 B + 树:叶子节点 = 完整的一行数据(主键 + 所有列)
- ✅ 二级索引 B + 树 :叶子节点 =【索引列的值 + 主键 】,不存完整行数据
重点!二级索引叶子里面,存的不是行的磁盘地址,存的是主键 。 如果通过二级索引查到记录,想要拿到其他列数据,需要拿着这个主键,再去主键聚簇索引 B + 树查一遍 ,这个动作就叫回表。
用我们之前 4 阶 B + 树模型举例
表结构:
id bigint(主键,雪花ID),name varchar(20)
数据:
| id(主键) | name |
|---|---|
| 3 | Alice |
| 7 | Bob |
| 13 | Tom |
| 17 | Jack |
| 25 | Lily |
| 30 | Lucy |
我们新建二级索引:name 列,也就是按 name 建立 B + 树。 二级索引的 B + 树,排序依据是 name 字段。
二级索引叶子节点存储:(索引key:name, 主键id) 叶子记录: (Alice,3)、(Bob,7)、(Jack,17)、(Lily,25)、(Lucy,30)、(Tom,13)
叶子链表按照 name 有序排列。 二级索引的 B + 树(4 阶):
[Bob, Lily]
/ | \
L1 L2 L3
(Alice,3) (Bob,7) | (Jack,17)(Lucy,30) | (Lily,25)(Tom,13)
非叶子节点:只存 name 的值(分界 key)+ 子指针,没有完整数据。
查找演示:select * from table where name='Tom'
- 去name 二级索引 B + 树查找 Tom
- 定位到叶子节点,找到
(Tom,13),拿到主键id=13 - 回表 :拿着 id=13,去主键聚簇索引 B + 树,查询 id=13,取出完整一行所有字段。 👉 一共遍历两棵 B + 树。
覆盖索引(避免回表,面试高频)
如果 SQL 只查询索引列和主键:
select name,id from table where name='Tom'
只需要二级索引 B + 树里面的数据,不需要回表 ,这就是覆盖索引,性能更好。
原理:二级索引叶子已经有 name+id,不需要访问聚簇索引。
聚簇索引 vs 二级索引对比表
| 项目 | 聚簇索引(主键索引) | 二级索引(辅助索引) |
|---|---|---|
| B + 树排序依据 | 主键 | 建立索引的字段 |
| 叶子节点存储内容 | 整行全部数据 | 索引字段的值 + 主键 |
| 查到后是否需要回表 | 不需要,叶子直接拿到全部数据 | 查询非索引字段时,需要回表拿完整数据 |
| 索引数量 | 一张表只有 1 个 | 一张表可以建立多个,每个二级索引都是独立 B + 树 |
结合前面的知识点串联
还记得前面说:主键越长,所有二级索引都会变大 ,现在就能理解原因: 二级索引叶子节点,每一条记录都要带上主键。主键从 8 字节变成 36 字节 UUID,每一条二级索引叶子记录都增加 28 字节。 一页 16KB 能存放的记录变少 → 二级索引 B + 树节点变多,树高度变高,IO 变多。
二级索引插入的 B + 树行为
二级索引 B + 树的排序规则是索引列,不是主键。 举例子:name 作为二级索引,插入新数据,是按 name 的顺序放到 B + 树。
哪怕主键是有序雪花 ID,name 是随机字符串,那么二级索引 B + 树插入就是随机插入,会频繁分裂叶子节点,和 UUID 主键的问题一模一样!
重点:二级索引的有序性,看的是索引字段,不是主键!
面试精简背诵
- InnoDB 二级索引是独立 B + 树,叶子节点存放【索引列值 + 主键】,不存储完整行数据。
- 使用二级索引查询,若需要不在索引里的字段,会拿着主键去聚簇索引查询,这个过程叫回表。
- 覆盖索引:查询的所有字段都在二级索引叶子节点,不需要回表,性能更高。
- 主键长度会影响所有二级索引大小:主键越长,二级索引占用空间越大,B + 树越高。
- 二级索引 B + 树的插入是否会频繁分裂,取决于索引字段是否有序,和主键是否有序无关。
什么是索引最左前缀原则?为什么 B + 树会有这个特性?
索引最左前缀原则(基于 B + 树原理讲解)
前提:联合索引(复合索引) ,就是多个字段一起建立的二级索引,例如
index(a,b,c)。 联合索引的 B + 树,排序规则是先按第一个字段排序;第一个字段相同,再按第二个;第二个相同,再按第三个 。 叶子节点存储:(a的值,b的值,c的值,主键id)
总结
最左前缀原则:联合索引 B + 树,索引的排序是从左到右依次排序。查询条件必须匹配索引最左边连续的字段,才能利用索引;跳过左边字段,后面的字段就无法走索引。
我们举例子:联合索引 idx_name_age (name, age) 表数据:
| id (主键) | name | age |
|---|---|---|
| 3 | Alice | 18 |
| 7 | Bob | 20 |
| 13 | Bob | 22 |
| 17 | Jack | 19 |
| 25 | Lily | 21 |
| 30 | Lucy | 20 |
联合索引 B + 树排序规则:
- 优先按
name排序; - name 相同的行,再按
age排序。
叶子节点顺序: (Alice,18,3) → (Bob,20,7) → (Bob,22,13) → (Jack,19,17) → (Lily,21,25) → (Lucy,20,30)
这棵 B + 树的叶子链表,是先排 name,后排 age。
✅ 能走索引的情况
where name='Bob'匹配最左第一个字段 name,可以走索引。找到所有 name=Bob 的连续区间。where name='Bob' and age=20匹配最左连续两列 name+age,可以走索引。
❌ 不能走索引(或只能部分走)
where age=20直接跳过最左的name,只用 age 查询。 B + 树叶子是按 name 排序,age 是name 内部才有序;全局看 age 是乱序的。 没办法快速定位 age=20 的连续区间,只能全索引扫描。where name like '%Bob'左边模糊匹配,破坏前缀,无法利用 B + 树有序性,不能走索引。
但是
name like 'Bob%'可以,前缀固定,B + 树能找到连续区间。
为什么 B + 树天然就有这个特性?
B + 树叶子节点是有序链表 ,联合索引的排序是从左到右依次排序。
叶子链表上,只有最左边字段是全局有序;后面字段只有在前面字段相等的时候,才局部有序。
拿我们例子看: 全局看 age:18,20,22,19,21,20 → age 整体是乱的! 只有 name 相同的行,age 才有序:Bob 的两条记录 age:20、22。
简单理解: 联合索引的叶子链表,就像字典。字典先按首字母排序,首字母相同再看第二个字母。 你查单词,如果不知道首字母,直接查第二个字母,就没法快速定位。这就是最左前缀。
补充:范围查询会截断后面的索引
where name='Bob' and age>20 name 是等值,age 是范围。age 后面的索引列失效 。 如果索引是(name,age,gender),age 做范围查询,gender 无法使用索引。 原理:age 一旦是范围,age 后面 gender 不再是连续有序区间。
覆盖索引也遵循最左前缀
idx(name,age) select name,age from t where name='Bob' ✔ 覆盖索引 select age from t where age=20 ❌ 不走索引
总结
- 联合索引 B + 树叶子链表,从左到右依次排序:先第一列,第一列相同再第二列,以此类推。
- 最左前缀原则:查询条件需要匹配索引最左侧连续字段,才能利用索引快速定位。
- 前面字段做范围查询(> < between like 前缀模糊除外),后面的索引字段无法使用索引。
- 原理:联合索引中,后面字段仅在前面字段值相等时才有序,全局无序。
什么是索引失效?列举几个常见索引失效场景,结合 B + 树原理解释。
索引失效:结合 B + 树底层原理讲解
核心本质:B + 树之所以能快速查找,依赖叶子节点是有序链表。一旦查询条件无法利用索引的有序性,数据库就不能通过 B + 树快速定位区间,只能全索引扫描 / 全表扫描,这就是索引失效。
注意:失效不是索引不存在了,是优化器放弃使用 B + 树索引查找,改用扫描。
我们继续沿用联合索引 idx(name, age) 的例子。
一、常见失效场景 + B + 树原理拆解
1. 对索引列做函数运算、表达式计算
示例:
WHERE UPPER(name) = 'BOB';
WHERE age + 1 = 20;
原理:
索引 B + 树叶子里面存的是原始字段值。
索引里存的是name="Bob",不是UPPER(name)="BOB";存的是age=19,不是age+1=20。
B + 树是按原始值排序,无法在索引树上找到函数计算结果对应的连续区间。
MySQL 不能直接在索引树上匹配,只能取出每一行索引数据,再计算判断,变成全索引扫描,索引失效。
对比:
where name = 'Bob',直接拿原始值,可以在 B + 树快速定位连续区间。
2. 隐式类型转换
-- name是varchar字符串类型,但是用数字去匹配
WHERE name = 123;
原理:
MySQL 会把字段做函数转换 :WHERE CAST(name AS SIGNED) = 123。
和上面场景一样:索引存的是原始字符串,转换之后的值不在 B + 树排序体系里,无法走 B + 树区间查找,索引失效。
口诀:索引列不要做自动转换,转换发生在索引字段上,索引失效。
3. 联合索引,不满足最左前缀
索引 idx(name,age)
WHERE age = 20;
原理:
B + 树叶子链表全局按 name 排序,age 只有 name 相同才局部有序。
全局 age 无序,找不到 age=20 的连续区间,只能扫描全部索引叶子。
4. 模糊查询以 % 开头 like '%Bob'
WHERE name LIKE '%Bob';
原理:
B + 树叶子按 name 字符串有序排列。
name like 'Bob%':前缀固定,B + 树可以找到Bob开头的连续区间 ✅ 可以走索引。
name like '%Bob':前缀不确定,无法定位连续范围,逐个遍历叶子节点,索引失效 ❌。
5. 使用 OR,一端字段不在索引内
WHERE name = 'Bob' OR create_time = '2026-01-01';
假设只有idx(name,age),create_time 没有索引。
原理:OR 代表满足任意一个条件。
一部分数据需要走 name 索引,另一部分需要全表扫描。
MySQL 优化器评估后,直接选择全表扫描,放弃索引。
小技巧:OR 两边字段都要有独立索引,才有可能走索引。
6. 范围查询后面的索引列失效(最左前缀延伸)
索引 idx(name,age,gender)
WHERE name='Bob' AND age>20 AND gender='male';
原理:
name 等值,age 是范围>20。
在 B + 树叶子链表中,name 固定,age 一旦进入范围,age 后面的 gender 不再是连续有序。
所以只能用到 name+age,gender 无法使用索引。
7. MySQL 优化器判断:走索引不如全表扫描(伪失效,很容易被忽略)
比如查询条件匹配表中大部分数据(比如查询返回 20% 以上的数据)。
示例:一张表 100 万行,
where age>0,查出 80 万行。原理:
使用二级索引需要:扫描二级索引叶子 + 大量回表。
大量回表意味着大量随机磁盘 IO。
优化器评估:直接全表顺序读取聚簇索引叶子,顺序 IO 更快,于是放弃二级索引,看起来索引失效。
重点:索引本身没问题,是优化器主动选择不使用索引。
二、快速区分:真失效 vs 优化器选择不走索引
- 真失效:SQL 写法破坏 B + 树有序性,根本无法利用索引树(函数、左模糊、最左前缀不满足)
- 优化器选择:索引技术上可以用,但成本太高,主动放弃。
总结
- 索引本质依赖 B + 树叶子节点有序链表,无法定位连续区间时索引失效。
- 索引列做函数运算、隐式类型转换,会破坏索引原始值,无法利用 B + 树有序性。
- 联合索引不满足最左前缀、like 以 % 开头,无法定位连续区间,索引失效。
- 联合索引中,范围查询之后的列,无法使用索引。
- OR 条件一侧无索引、查询结果集占比过大,优化器可能放弃索引。
常见问题
1. B + 树和 B 树的核心区别?InnoDB 为什么选 B + 树
区别
- B 树:所有节点都存数据;B + 树仅叶子存完整数据,非叶子只存索引 key + 指针。
- B 树无叶子链表;B + 树叶子节点连成有序双向链表。
- B 树查询可能在非叶子节点命中;B + 树任何查询都必须走到叶子。
InnoDB 选 B + 树原因
- 非叶子节点体积小,单页能放更多索引 key,树更矮,磁盘 IO 更少。
- 叶子链表,范围查询极强,数据库大量范围查询场景适配更好。
2. B + 树的非叶子节点为什么不存储真实行数据?
非叶子节点只做索引导航,不需要完整行数据。
去掉行数据,单个节点可以存放更多索引 key,分支数变多,降低树高度,减少磁盘 IO。
3. B + 树叶子节点为什么要做成双向链表?
叶子按主键有序串联。
找到起点后,直接顺着链表向后遍历,不用回到上层索引节点,大幅提升范围查询性能;
双向链表还支持向前遍历。
4. 一张表可以没有主键吗?如果没有主键,InnoDB 怎么建立聚簇索引?
可以没有主键。 InnoDB 会自动找唯一非空索引当作聚簇索引;
如果连这种索引都没有,内部自动生成一个6 字节的隐藏 row_id作为聚簇索引主键,构建聚簇 B + 树。
5. 聚簇索引,插入大量有序数据,会不会产生索引碎片?
几乎不会。
有序插入只在最右侧叶子追加,仅满页时才分裂,很少产生碎片。
碎片大多来自随机插入、中间位置删除更新。
6. 主键能不能更新?更新主键会发生什么?结合 B + 树解释
可以更新,但强烈不建议。
主键是聚簇索引 B + 树的排序 key;
修改主键等价于:删除旧主键对应的记录,再插入新主键记录。
- 聚簇索引要删除旧位置、在新位置插入,可能触发叶子分裂;
- 所有二级索引叶子存的主键值,全部要同步修改,大量索引维护,IO 开销巨大。
7. 什么是索引下推(ICP)
索引下推:
在遍历二级索引叶子节点时,把索引里包含的条件,直接在索引页过滤,不先回表。
原理:
联合索引叶子存了索引列,不需要拿到主键就先过滤不满足条件的数据。
减少开销:
减少不必要的回表次数。
8. 什么是索引合并
索引合并:
一条 SQL,使用多个独立二级索引,分别查询后合并结果(交集 / 并集)。
触发场景:
where 条件用到多个单列索引。
局限:优化器评估代价高,性能一般不如联合索引,有一定开销,不是总能触发。
9. 回表是随机 IO 还是顺序 IO?为什么大量回表性能差?
回表是随机磁盘 IO。
原因:
二级索引查到的主键是无序的,拿着这些主键去聚簇索引查找,访问的叶子页在磁盘上分散。
大量随机 IO,磁盘寻道耗时很高,性能暴跌。
回表:随机 IO,场景 + 示例
先回顾核心结论
InnoDB 二级索引叶子节点存的是「索引列 + 主键」;
聚簇索引叶子节点才是完整行数据。
回表:通过二级索引找到主键,再拿着主键去聚簇索引查找完整行的过程。
✅ 回表是随机 IO
原因:
二级索引筛选出来的主键值通常不是连续有序的,对应聚簇索引的数据页在磁盘上物理位置分散,每次读取都要磁盘寻道。
场景举例(MySQL InnoDB)
建表:
sqlCREATE TABLE user ( id INT PRIMARY KEY, -- 聚簇索引,id有序,数据行存在id对应的叶子页 name VARCHAR(32), age INT, KEY idx_age(age) -- 二级索引:叶子节点保存 age + id );执行 SQL
sqlSELECT * FROM user WHERE age = 20;执行流程:
- 走二级索引
idx_age,找到所有age=20的记录,拿到对应的一批主键id。 注意:二级索引里age是有序的,但age 相同的 id 不一定连续 。 比如查到 id 列表:10,105,230,789- 拿着这些 id,去聚簇索引查找完整行数据(
SELECT *需要全部字段),这个动作就是回表。IO 分析
- 二级索引这一步:读取二级索引页,属于顺序 IO(索引页物理连续)。
- 回表这一步:
id=10、id=105、id=230、id=789对应的聚簇索引叶子页,在磁盘上物理位置相隔很远。 每一个 id,大概率要去读不同的数据页 。磁盘磁头需要不断移动到不同位置,也就是随机 IO。磁盘(机械盘 HDD)最大痛点:寻道很慢。
顺序读可以连续批量拉数据;随机读每次都要磁头移动、等待盘片转到对应扇区,耗时比顺序 IO 高几十上百倍。
为什么大量回表性能很差?
- 主键离散 → 数据页分散 筛选出来的主键乱序,每次回表访问不同的数据页,触发大量独立的磁盘寻道。 如果命中的数据页不在缓冲池,每次都要落盘随机 IO。
- 缓冲池命中率快速下降 大量离散的数据页被加载进内存,很快占满缓冲池;很多页只用一次就不再访问,马上被淘汰,下次查询又要重新读盘。
- 对比:不回表(覆盖索引)
sqlSELECT id,age FROM user WHERE age=20;只需要二级索引里的
age+id,不需要回表,全程顺序扫描二级索引页,性能高很多。一个直观对比
假设机械硬盘:
- 顺序 IO:约 200MB/s
- 随机 IO:大约只有 100~200 IOPS,一秒只能一百多次磁盘读取。
如果回表返回 1000 条离散主键,最坏情况要触发上千次随机 IO,查询直接卡慢。
总结
回表是随机 IO。
二级索引检索得到的主键通常无序,去聚簇索引读取完整行时,需要访问磁盘上分散的数据页,机械磁盘每次访问离散页都需要磁盘寻道;
当回表行数很大时,会产生大量随机磁盘 IO,寻道开销巨大,所以性能急剧下降。
10. 索引碎片是什么?有哪两种碎片?碎片对 B + 树查询、写入有什么影响?
碎片:B + 树叶子页在磁盘上逻辑有序,但物理存储不连续,或页内存在大量空闲空间。
两类:内部碎片(页内空洞) 、外部碎片(页之间物理不连续)。
影响:范围查询变成更多随机 IO;节点分裂更容易发生,写入性能下降。
1)内部碎片(Intra-page fragmentation,页内碎片)
定义:
同一个数据页里面,记录之间有空洞、空闲空间。 B + 树一页默认 16KB,一页里存放多条索引记录。
产生原因:
- 记录删除:delete 删除一行,InnoDB 不会立刻把磁盘空间还给操作系统,只是标记这条记录为已删除,形成空洞。
- 更新导致行变长:比如 varchar 字段从短字符串改成很长,原来的位置放不下,这条记录会迁移到别的页,原地留下空洞。
- B + 树页分裂:页满了,拆分新页,原页会预留一部分空闲空间,也会带来少量内部碎片。
特点:
- 空洞在同一个页内部,页本身物理位置没问题。
- 页还是完整的 16KB 存在磁盘,只是里面有效数据变少,浪费页内空间。
- 影响:读取这个页的时候,虽然只需要一次 IO,但有效数据密度低,同样的数据要加载更多页;缓冲池内存被无效空洞占用。
举例:一页 16KB,本来存 100 条索引记录。删除了 60 条,页内只剩下 40 条,剩下的空间是空标记。这就是内部碎片。
2)外部碎片(Inter-page fragmentation,页间碎片,也叫索引碎片)
定义:
B + 树逻辑上连续的数据页,在磁盘上物理位置分散、不连续。
B + 树叶子节点是双向链表,逻辑上按索引顺序是连续的页;但是磁盘上这些页东一块西一块,不挨在一起。
产生原因:
- 频繁页分裂:插入数据时页满了,新建一个页。新页在磁盘上不一定紧跟原来的页,物理位置随机分配。
- 删除大量数据,页回收后,新写入的页无法填充原来连续的位置。
- 大量随机更新、随机插入(比如非自增主键插入)。
特点:
- 空洞不是在页内,而是页与页之间物理不连续。
- 逻辑连续的索引记录,分散在磁盘不同位置的页。
- 影响:顺序扫描索引(range 查询、全索引扫描)的时候,本来应该顺序 IO,变成大量随机 IO! 这是最致命的。
举例:
idx_age 索引叶子页逻辑顺序:page1 → page2 → page3 → page4
磁盘物理分布:page1 在磁盘扇区 100,page2 在扇区 2000,page3 在扇区 500,page4 在扇区 9000。
逻辑连续,物理散落,这就是外部碎片。
当执行
select * from user where age between 10 and 50,顺着索引链表依次读 page1、page2、page3、page4,磁头来回跳,大量随机 IO。碎片对查询的影响区分
- 内部碎片为主:主要浪费内存 / 磁盘空间,IO 恶化相对轻。
- 外部碎片为主:范围查询、索引扫描性能下降非常明显,因为顺序扫描退化成随机 IO。
11. delete 删除一条记录,B + 树节点会立刻合并吗?为什么?
不会立刻合并。
原因:
合并节点成本高(磁盘写、修改上层索引);
MySQL 会预留空间,避免少量删除、新增又反复分裂合并,等页空闲比例达到阈值才会考虑合并。
12. 为什么不建议建立过多索引?多索引对写入(insert/update/delete)有什么代价?
每张表的每个索引都是独立 B + 树。
写入数据时,所有相关索引都要同步维护(插入 / 删除 / 修改索引 B + 树节点),会增加磁盘 IO,拖慢写操作。
索引越多,写开销越大。