一文详解B+树原理(附场景+示例)

一、B + 树 两条最核心规则

B + 树分为两种节点:叶子节点、非叶子节点

  1. ✅ 叶子节点 :存放【key + 真实数据 data】
    • 所有我们要查的真实数据,全部只存在叶子节点
  2. ✅ 非叶子节点(上层、中间、根节点都属于这类) :只存 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

规则:

  1. 每个节点最多 4 个 (m)分支,最多存放 3 个key(m-1)
  2. 非叶子:最多 4 个分支指针,最多 3 个 key;普通非叶子 key 数量:1~3
  3. 叶子:最多存 3 条记录,普通叶子最少 2 条记录;叶子存完整数据记录,叶子之间链表相连
    为什么非叶子节点:key 数量 = 分支指针数 − 1

key = 分界值;分支指针 = 区间入口

举例:2个分支,1 个 key

节点里 key:20

复制代码
[ ptr0 | 20 | ptr1 ]
  • ptr0:指向所有 < 20 的子树
  • ptr1:指向所有 ≥ 20 的子树 👉 1 个 key,切成 2 个区间,2 个分支指针 key数 = 1,分支指针=2 → key = 指针 -1

3个分支,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]

  1. P0:指向子树全部 < 20(中间 A,叶子最大 key 是 15)
  2. P1:指向子树**≥ 20 并且 <50** (中间 B,叶子最小 key 就是 20!20 是叶子里真实存在的 key)
  3. 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)

指针含义:

  1. 第一个指针:指向所有 key 小于 13 的节点(叶子 A:3、7)
  2. 第二个指针:指向 key ≥13 并且 小于 25 的节点(叶子 B:13、17)
  3. 第三个指针:指向 key ≥25 的节点(叶子 C:25、30)

👉 根节点就是这个 13,25 的节点! 根节点就是树最顶上唯一的那个节点。根属于非叶子节点,只有路标,没有真实数据。

分界 key 到底是怎么选出来的?

非叶子节点的分界 key = 它右边子树的最小 key

举例子: 根里面第一个 key=13,它右边的子树是叶子 B,叶子 B 最小就是 13。 根里面第二个 key=25,它右边的子树是叶子 C,叶子 C 最小就是 25。


三、实操:在这棵树上查找,一步步演示

案例 1:查找 key=17

  1. 来到根节点 [13,25]
    • 拿 17 和 13 对比:17 ≥ 13
    • 拿 17 和 25 对比:17 < 25 → 走中间指针,去到叶子 B
  2. 叶子 B 是(13,data),(17,data),找到 17,取出 data。 ✅ 查找结束。

案例 2:范围查询:找 7 ~ 25 的所有数据

  1. 根节点比较 7:7<13,走到叶子 A
  2. 在叶子 A 找到 7,取出 7 的数据
  3. B + 树叶子有链表!直接顺着链表跳到下一个叶子 B,取出 13、17
  4. 继续跳到叶子 C,取出 25
  5. 下一个是 30,超过范围,停止。

这就是 B + 树做范围查询很强的原因,不用反复回到上层节点。


四、对比 B 树

  • B 树:所有节点,不管上层还是叶子,都存 key+data
  • B + 树:只有叶子节点存 data;上层只有路标 key
  • B 树叶子没有链表;B + 树叶子串成有序链表

MySQL 的 InnoDB 索引,就是 B + 树。

好处:

  1. 非叶子节点不存 data,一页能放下更多 key → 树更矮,磁盘 IO 更少
  2. 范围查询直接遍历叶子链表,速度快

五、插入简单演示(4 阶 B + 树,最多 3 条记录在叶子)

现有叶子:A (3,7) B (13,17) C (25,30) 现在插入 22:

  1. 找位置:根对比,13 ≤22 <25 →去叶子 B
  2. 叶子 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。


基础小结

  1. B + 树节点分两类:叶子(存 key + 真实数据)、非叶子(只存分界 key,路标)
  2. 4 阶 B + 树:一个节点最多 4 个子节点,最多 3 个 key
  3. 非叶子的分界 key = 对应右侧子树的最小 key,用来划分区间
  4. 根节点就是最顶层节点,可以是非叶子,也可以是叶子(只有一层数据的时候)
  5. 所有叶子节点连成有序双向链表,擅长范围查询

扩展:

树高度越高,查询需要读取的节点越多,磁盘 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. 区分两个概念(很多人混淆)

  1. 树高度:层数,决定 IO 次数
  2. 节点总数:整棵树一共有多少个节点,由总数据量和每个节点容量决定

关系:

  • 同样阶数(m 不变):数据越多 → 需要更多节点 → 树高度可能变大
  • 同样数据量:节点能存的 key 越多(m 越大)→ 需要的节点越少,树高度越低

举对比: 同样 100 条记录

  • 4 阶 B + 树:高度 3
  • 如果是二叉树(每个节点最多 2 分支):高度接近 7 二叉树查询最多 7 次 IO,B + 树最多 3 次 IO,差距巨大。

这就是为什么数据库不用二叉查找树,要用 B + 多叉树。

5. 结合我们之前 UUID / 雪花 ID 的知识点串联

雪花 ID:追加写入,很少分裂节点,节点数量平稳增长,树高度增长缓慢。 UUID 随机插入:频繁分裂叶子节点,会更早产生大量节点,树更容易长高,同时带来索引碎片。

概括

  1. B + 树高度 = 根到叶子的层数;查询一条数据,最多要进行【树高度】次磁盘 IO。
  2. 在阶数固定时,总数据量变大,节点数量变多,树高度会随之增加。
  3. 阶数越大(单个节点存放更多索引 key),同等数据量下节点更少,树高度越低,IO 越少。
  4. 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 次数更少 缺点:
  1. 一次磁盘 IO 读取的数据变多。很多时候我们只需要一条记录,却读取 32KB 到内存,内存浪费更大。
  2. 刷脏页的时候,单次要写更多数据,刷页压力变大。

场景 B:调小页,比如 4KB

一页变小:

  • 一页能放下的 key 变少,每个节点分支变少
  • 同样数据量,树高度变高,查询需要更多磁盘 IO,查询变慢 优点: 单次 IO 读取的数据量小,内存占用小,适合大量随机读写、单行小查询场景。

3. 为什么 MySQL 默认选 16KB?

是折中方案:

  1. 磁盘本身块大小一般是 4KB/8KB,16KB 对齐比较友好。
  2. 兼顾:树高度不要太高,同时单次 IO 加载的数据量不要太大,内存开销可控。

生产环境一般不建议随便修改页大小,需要重新编译 MySQL,改动成本极高。

4. 把「页大小」和 UUID / 雪花 ID 串起来

页大小决定一个节点最多能存多少记录:

  • 叶子页装满了 → 触发节点分裂。
  • 雪花 ID:永远往最后一个叶子页追加,只有最后一页满了才分裂。
  • UUID 随机插入:会随机在任意叶子页插入,很容易把中间页塞满,频繁分裂。 分裂会新增很多叶子节点,节点变多,索引占用空间变大,严重的时候会让树高度提前上涨。

5. 总结

  1. InnoDB 的页就是 B + 树节点,磁盘 IO 最小单位是页;一次 IO 读取一整个页。
  2. 页越大,单节点能存放的索引 key 越多,分支越多,同等数据量下树高度越低;但单次 IO 读写的数据量大,内存消耗更高。
  3. 页越小,单节点容纳 key 越少,树越高,IO 次数变多,查询性能下降;内存占用小。
  4. InnoDB 默认 16KB,是磁盘 IO、内存开销、B + 树高度三者之间的平衡。
  5. 非叶子节点不存行数据,一页能存上千个索引 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,查询变慢。

两层负面影响(两点,面试常考)

  1. 非叶子节点能存的 key 变少 → 节点分支变少 → 树更高 同样数据量,分支少,树的层数增加,IO 次数变多。
  2. 索引占用磁盘空间变大 主键变长,叶子节点里面每一行记录的主键也变大,整张表、二级索引都会变大。

InnoDB 二级索引会保存主键作为回表查找的依据! 这个是很多人容易漏掉的大坑:主键变长,所有二级索引全部跟着变大。
举例子:一张表有 3 个二级索引。主键从 8 字节改成 36 字节 UUID。 聚簇索引 + 3 个二级索引,4 套索引全部膨胀,磁盘占用暴涨,缓冲池能缓存的索引页变少,更多磁盘 IO。

串联前面全部知识点,完整链路

复制代码
主键变长 → 单条索引条目变大 → 一页存放key数量下降
→ 单个节点分支变少 → 同等数据量B+树高度增加
→ 查询需要更多磁盘IO → 性能下降
同时:所有二级索引占用空间增加,缓冲池能缓存的索引页更少,进一步加重IO压力。

补充:这是 UUID 的第二个缺点(第一个是随机插入导致频繁分裂)

UUID 两大问题:

  1. 无序:随机位置插入,频繁叶子节点分裂,索引碎片,写入性能差。
  2. 体积大: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'

  1. 去name 二级索引 B + 树查找 Tom
  2. 定位到叶子节点,找到(Tom,13),拿到主键 id=13
  3. 回表 :拿着 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 主键的问题一模一样!
重点:二级索引的有序性,看的是索引字段,不是主键!

面试精简背诵

  1. InnoDB 二级索引是独立 B + 树,叶子节点存放【索引列值 + 主键】,不存储完整行数据。
  2. 使用二级索引查询,若需要不在索引里的字段,会拿着主键去聚簇索引查询,这个过程叫回表。
  3. 覆盖索引:查询的所有字段都在二级索引叶子节点,不需要回表,性能更高。
  4. 主键长度会影响所有二级索引大小:主键越长,二级索引占用空间越大,B + 树越高。
  5. 二级索引 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 + 树排序规则:

  1. 优先按name排序;
  2. name 相同的行,再按age排序。

叶子节点顺序: (Alice,18,3) → (Bob,20,7) → (Bob,22,13) → (Jack,19,17) → (Lily,21,25) → (Lucy,20,30)

这棵 B + 树的叶子链表,是先排 name,后排 age。

✅ 能走索引的情况

  1. where name='Bob' 匹配最左第一个字段 name,可以走索引。找到所有 name=Bob 的连续区间。
  2. where name='Bob' and age=20 匹配最左连续两列 name+age,可以走索引。

❌ 不能走索引(或只能部分走)

  1. where age=20 直接跳过最左的name,只用 age 查询。 B + 树叶子是按 name 排序,age 是name 内部才有序;全局看 age 是乱序的。 没办法快速定位 age=20 的连续区间,只能全索引扫描。
  2. 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 ❌ 不走索引

总结

  1. 联合索引 B + 树叶子链表,从左到右依次排序:先第一列,第一列相同再第二列,以此类推。
  2. 最左前缀原则:查询条件需要匹配索引最左侧连续字段,才能利用索引快速定位。
  3. 前面字段做范围查询(> < between like 前缀模糊除外),后面的索引字段无法使用索引。
  4. 原理:联合索引中,后面字段仅在前面字段值相等时才有序,全局无序。

什么是索引失效?列举几个常见索引失效场景,结合 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 + 树有序性,根本无法利用索引树(函数、左模糊、最左前缀不满足)
  • 优化器选择:索引技术上可以用,但成本太高,主动放弃。

总结

  1. 索引本质依赖 B + 树叶子节点有序链表,无法定位连续区间时索引失效。
  2. 索引列做函数运算、隐式类型转换,会破坏索引原始值,无法利用 B + 树有序性。
  3. 联合索引不满足最左前缀、like 以 % 开头,无法定位连续区间,索引失效。
  4. 联合索引中,范围查询之后的列,无法使用索引。
  5. OR 条件一侧无索引、查询结果集占比过大,优化器可能放弃索引。

常见问题

1. B + 树和 B 树的核心区别?InnoDB 为什么选 B + 树

区别

  1. B 树:所有节点都存数据;B + 树仅叶子存完整数据,非叶子只存索引 key + 指针。
  2. B 树无叶子链表;B + 树叶子节点连成有序双向链表。
  3. B 树查询可能在非叶子节点命中;B + 树任何查询都必须走到叶子。

InnoDB 选 B + 树原因

  • 非叶子节点体积小,单页能放更多索引 key,树更矮,磁盘 IO 更少。
  • 叶子链表,范围查询极强,数据库大量范围查询场景适配更好。

2. B + 树的非叶子节点为什么不存储真实行数据?

非叶子节点只做索引导航,不需要完整行数据。

去掉行数据,单个节点可以存放更多索引 key,分支数变多,降低树高度,减少磁盘 IO。

3. B + 树叶子节点为什么要做成双向链表?

叶子按主键有序串联。

找到起点后,直接顺着链表向后遍历,不用回到上层索引节点,大幅提升范围查询性能;

双向链表还支持向前遍历。

4. 一张表可以没有主键吗?如果没有主键,InnoDB 怎么建立聚簇索引?

可以没有主键。 InnoDB 会自动找唯一非空索引当作聚簇索引;

如果连这种索引都没有,内部自动生成一个6 字节的隐藏 row_id作为聚簇索引主键,构建聚簇 B + 树。

5. 聚簇索引,插入大量有序数据,会不会产生索引碎片?

几乎不会。

有序插入只在最右侧叶子追加,仅满页时才分裂,很少产生碎片。

碎片大多来自随机插入、中间位置删除更新。

6. 主键能不能更新?更新主键会发生什么?结合 B + 树解释

可以更新,但强烈不建议。

主键是聚簇索引 B + 树的排序 key;

修改主键等价于:删除旧主键对应的记录,再插入新主键记录。

  1. 聚簇索引要删除旧位置、在新位置插入,可能触发叶子分裂;
  2. 所有二级索引叶子存的主键值,全部要同步修改,大量索引维护,IO 开销巨大。

7. 什么是索引下推(ICP)

索引下推:

在遍历二级索引叶子节点时,把索引里包含的条件,直接在索引页过滤,不先回表。

原理:

联合索引叶子存了索引列,不需要拿到主键就先过滤不满足条件的数据。

减少开销:

减少不必要的回表次数。

8. 什么是索引合并

索引合并:

一条 SQL,使用多个独立二级索引,分别查询后合并结果(交集 / 并集)。

触发场景:

where 条件用到多个单列索引。

局限:优化器评估代价高,性能一般不如联合索引,有一定开销,不是总能触发。

9. 回表是随机 IO 还是顺序 IO?为什么大量回表性能差?

回表是随机磁盘 IO。

原因:

二级索引查到的主键是无序的,拿着这些主键去聚簇索引查找,访问的叶子页在磁盘上分散。

大量随机 IO,磁盘寻道耗时很高,性能暴跌。

回表:随机 IO,场景 + 示例

先回顾核心结论

InnoDB 二级索引叶子节点存的是「索引列 + 主键」;

聚簇索引叶子节点才是完整行数据。

回表:通过二级索引找到主键,再拿着主键去聚簇索引查找完整行的过程。

✅ 回表是随机 IO

原因:

二级索引筛选出来的主键值通常不是连续有序的,对应聚簇索引的数据页在磁盘上物理位置分散,每次读取都要磁盘寻道。


场景举例(MySQL InnoDB)

建表:

sql 复制代码
CREATE TABLE user (
  id INT PRIMARY KEY, -- 聚簇索引,id有序,数据行存在id对应的叶子页
  name VARCHAR(32),
  age INT,
  KEY idx_age(age) -- 二级索引:叶子节点保存 age + id
);

执行 SQL

sql 复制代码
SELECT * FROM user WHERE age = 20;

执行流程:

  1. 走二级索引 idx_age,找到所有 age=20 的记录,拿到对应的一批主键 id。 注意:二级索引里age是有序的,但age 相同的 id 不一定连续 。 比如查到 id 列表:10,105,230,789
  2. 拿着这些 id,去聚簇索引查找完整行数据(SELECT *需要全部字段),这个动作就是回表。

IO 分析

  • 二级索引这一步:读取二级索引页,属于顺序 IO(索引页物理连续)。
  • 回表这一步:id=10、id=105、id=230、id=789 对应的聚簇索引叶子页,在磁盘上物理位置相隔很远。 每一个 id,大概率要去读不同的数据页 。磁盘磁头需要不断移动到不同位置,也就是随机 IO。

磁盘(机械盘 HDD)最大痛点:寻道很慢。

顺序读可以连续批量拉数据;随机读每次都要磁头移动、等待盘片转到对应扇区,耗时比顺序 IO 高几十上百倍。

为什么大量回表性能很差?

  1. 主键离散 → 数据页分散 筛选出来的主键乱序,每次回表访问不同的数据页,触发大量独立的磁盘寻道。 如果命中的数据页不在缓冲池,每次都要落盘随机 IO。
  2. 缓冲池命中率快速下降 大量离散的数据页被加载进内存,很快占满缓冲池;很多页只用一次就不再访问,马上被淘汰,下次查询又要重新读盘。
  3. 对比:不回表(覆盖索引)
sql 复制代码
SELECT 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,一页里存放多条索引记录。

产生原因:

  1. 记录删除:delete 删除一行,InnoDB 不会立刻把磁盘空间还给操作系统,只是标记这条记录为已删除,形成空洞。
  2. 更新导致行变长:比如 varchar 字段从短字符串改成很长,原来的位置放不下,这条记录会迁移到别的页,原地留下空洞。
  3. B + 树页分裂:页满了,拆分新页,原页会预留一部分空闲空间,也会带来少量内部碎片。

特点:

  • 空洞在同一个页内部,页本身物理位置没问题。
  • 页还是完整的 16KB 存在磁盘,只是里面有效数据变少,浪费页内空间。
  • 影响:读取这个页的时候,虽然只需要一次 IO,但有效数据密度低,同样的数据要加载更多页;缓冲池内存被无效空洞占用。

举例:一页 16KB,本来存 100 条索引记录。删除了 60 条,页内只剩下 40 条,剩下的空间是空标记。这就是内部碎片。


2)外部碎片(Inter-page fragmentation,页间碎片,也叫索引碎片)

定义:

B + 树逻辑上连续的数据页,在磁盘上物理位置分散、不连续。

B + 树叶子节点是双向链表,逻辑上按索引顺序是连续的页;但是磁盘上这些页东一块西一块,不挨在一起。

产生原因:

  1. 频繁页分裂:插入数据时页满了,新建一个页。新页在磁盘上不一定紧跟原来的页,物理位置随机分配。
  2. 删除大量数据,页回收后,新写入的页无法填充原来连续的位置。
  3. 大量随机更新、随机插入(比如非自增主键插入)。

特点:

  • 空洞不是在页内,而是页与页之间物理不连续。
  • 逻辑连续的索引记录,分散在磁盘不同位置的页。
  • 影响:顺序扫描索引(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。

碎片对查询的影响区分

  1. 内部碎片为主:主要浪费内存 / 磁盘空间,IO 恶化相对轻。
  2. 外部碎片为主:范围查询、索引扫描性能下降非常明显,因为顺序扫描退化成随机 IO。

11. delete 删除一条记录,B + 树节点会立刻合并吗?为什么?

不会立刻合并。

原因:

合并节点成本高(磁盘写、修改上层索引);

MySQL 会预留空间,避免少量删除、新增又反复分裂合并,等页空闲比例达到阈值才会考虑合并。

12. 为什么不建议建立过多索引?多索引对写入(insert/update/delete)有什么代价?

每张表的每个索引都是独立 B + 树。

写入数据时,所有相关索引都要同步维护(插入 / 删除 / 修改索引 B + 树节点),会增加磁盘 IO,拖慢写操作。

索引越多,写开销越大。

相关推荐
All for pursuit.4 小时前
【位运算-1】338.比特位计数
数据结构·c++·算法·leetcode
xxwl5856 小时前
数据结构知识点和代码实现总结(C语言实现)
c语言·开发语言·数据结构
mokongJ11 小时前
17 份异构销售数据的合并清洗方案:30 万行处理耗时 9.64 秒
数据结构
蛋蛋的就会顺顺的12 小时前
hot100——二叉树
java·数据结构·算法·leetcode·力扣
List<String> error_P14 小时前
freertos数据结构--链表
数据结构·链表
longlongzihan15 小时前
LeetCode 128. 最长连续序列 —— 从排序到哈希集合
数据结构·c++·算法·leetcode·排序算法·哈希表
mmmmath_315 小时前
二叉树の一些知识
数据结构·算法
程序员清风15 小时前
数据类型转换与日期时间处理
数据结构·pandas
Logic10117 小时前
C语言/数据结构位运算题解:异或XOR找出英雄队伍中的“独特战斗力“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质