要理解 B + 树,首先要明确它的核心定位:一种为磁盘等外存设备优化的多路平衡查找树。它的设计初衷是解决 “磁盘 IO 效率低” 的痛点 —— 由于磁盘读写速度远慢于内存,B + 树通过 “降低树高、集中存储数据、优化范围查询”,最大限度减少磁盘 IO 次数,成为数据库索引、文件系统索引的核心数据结构。
在讲解原理前,我们先铺垫两个关键背景:
- 磁盘 IO 的特点:磁盘读写以 “块” 为单位(比如一个块 4KB),每次 IO 会读取一个完整的块到内存。树的每个节点恰好对应一个磁盘块,因此 “树的高度” 直接等于 “查询时的 IO 次数”。
- 与二叉树的本质区别:二叉树是 “二路” 结构,树高会随数据量呈对数增长(如 100 万数据约 20 层),导致 20 次 IO;B + 树是 “多路” 结构,一层节点可对应几十个甚至上百个子节点,树高通常仅 3-4 层(1000 万数据也只需 3-4 次 IO)。
一、B + 树的核心概念与结构
1. 关键概念:“阶”(Order)
“阶” 是 B + 树的 “容量单位”,定义了节点的最大子节点数。通常我们说 “m 阶 B + 树”,需满足两个规则:
- 非叶子节点:最多存储
m-1个 “索引键”,最多有m个子节点(因为每个键对应一个子节点的分割点)。 - 叶子节点:最多存储
m-1个 “数据键 + 数据地址”(或直接存数据),无子女。 - 最小容量限制(平衡的关键):为保证树的 “平衡”,所有非根节点的键数不能少于
⌈m/2⌉ - 1(⌈⌉表示向上取整),子节点数不能少于⌈m/2⌉。
例子:3 阶 B + 树(m=3)
- 非叶子节点:最多 2 个键、3 个子节点;最少 1 个键、2 个子节点。
- 叶子节点:最多 2 个键 + 数据;最少 1 个键 + 数据。
2. 节点的两种类型
B + 树的节点分为 “非叶子节点” 和 “叶子节点”,两者功能完全不同,这是 B + 树与 B 树的核心区别之一。
| 节点类型 | 存储内容 | 功能 |
|---|---|---|
| 非叶子节点 | 仅存 “索引键”(无数据),每个键对应一个子节点的 “范围边界” | 引导查找方向,类似 “目录的目录” |
| 叶子节点 | 存 “数据键 + 数据地址”(或直接存数据),所有叶子节点通过 “双向链表” 连接 | 实际存储数据,支持单值查找和范围查找 |
结构示意图(以 3 阶 B + 树为例):
3. B + 树的 5 个核心特性
这些特性是理解其效率的关键,必须牢记:
- 平衡特性:所有叶子节点在同一层,保证任何查询的 IO 次数相同(查找效率稳定)。
- 非叶子仅索引:非叶子节点只存索引键,不存数据 —— 这意味着同样大小的磁盘块,能存更多键,子节点数更多,树更矮(IO 更少)。
- 叶子存全量数据:所有数据(或数据地址)都集中在叶子节点,非叶子节点的键是叶子节点键的 “副本”(比如根节点的 3,是叶子节点中 3 的副本)。
- 叶子有序链表:所有叶子节点按键的顺序用双向链表连接,这是 “范围查询高效” 的核心(无需回溯树结构,直接遍历链表)。
- 键有序性:每个节点内的键都按升序(或降序)排列,保证查找时能通过二分法快速定位。
二、B + 树的关键操作(附 3 阶实例)
B + 树的操作围绕 “保持平衡” 展开,核心是插入时的 “分裂” 和删除时的 “合并 / 借键” 。我们以最典型的 3 阶 B + 树为例,拆解查找、插入、删除过程。
1. 查找操作:单值查找 + 范围查找
查找是 B + 树最常用的操作,分为 “单值查找” 和 “范围查找”,两者都依赖于 “键有序” 和 “叶子链表”。
我们还是以这个数据为示例
(1)单值查找:找 “键 = 5”
步骤:
- 从根节点开始:根节点键为 [5],5 等于 5,如果小于 5,则进入左边节点,如果大于等于 5 ,则进入右边节点,因此进入右边子节点 [7](非叶子)。
- 中间节点 [7]:5 小于 7,进入其对应的左侧子节点 [6](中间节点)。
- 中间节点 [6]:5 小于 6,进入其对应的左侧子节点 [5](叶子节点)。
- 叶子节点 [5]:二分法找到键 = 5,直接获取对应数据。
IO 次数:4 次(根→中间非叶子→中间非叶子→叶子),对应树高 4。
上面的示例有些过于简单,接下来展示一个相对复杂的示例
请看下面的数据
(1)单值查找:找 “键 = 12”
步骤:
- 从根节点开始:根节点键为 [5,7],如果查找的数值小于5,则进入左子节点,如果在5-7之间,则进入中间子节点,如果大于7,则进入右子节点。我们可以看到 12 大于 7,进入右子节点(中间节点)。
- 中间节点 [8,12]:查找值 12 等于索引值 12,进入右子节点(叶子节点)。
- 叶子节点 [12,14]:二分法找到键 = 12,直接获取对应数据。
IO 次数:3 次(根→中间非叶子→叶子),对应树高 3。
(2)范围查找:找 “键≥5 且≤9”
步骤:
- 先按单值查找找到 “键 = 5” 所在的叶子节点 [5]。
- 利用叶子节点的双向链表,依次遍历后续叶子节点:[5] → [6] → [7] → [8,9] 。
- 从这些叶子中提取键在 5-9 之间的数据(5、6、7、8、9)。
优势:范围查找无需回溯树,仅需 “1 次定位 + 链表遍历”,效率远高于 B 树(B 树需回溯树结构找相邻节点)。
2. 插入操作:核心是 “节点满时分裂”
核心原则
在插入任何新数据之前,必须牢记 B+ 树的几个核心特征,插入操作就是为了维护这些特征:
- 有序性:所有节点(内部节点的键和叶子节点的数据)必须保持有序。
- 平衡性:所有叶子节点必须位于同一层。
- 节点容量:每个节点最多包含
m个键(或数据项),最少包含⌈m/2⌉个键(根节点除外)。
插入过程可以概括为:寻找正确的叶子节点 -> 插入数据 -> 如果节点“溢出”则分裂 -> 递归调整父节点。
详细步骤
假设我们有一个阶数为 m 的 B+ 树(即每个节点最多有 m 个孩子,或 m-1 个键)。
第 1 步:查找正确的叶子节点
从根节点开始,自上而下遍历树,根据待插入的键 K 与节点内键的比较结果,选择正确的分支,直到找到应该包含 K 的那个叶子节点(Leaf Node)。
第 2 步:将键值对插入叶子节点
在找到的正确叶子节点中,将新的键 K 和其对应的数据(或指针)按顺序插入。这意味着你需要移动已有的键来保持节点内的有序性。
第 3 步:判断叶子节点是否需要分裂
插入新键后,检查该叶子节点是否已满(即键的数量是否超过了 m-1)。
- 如果未满(键数
<= m-1):插入操作结束,树依然保持平衡。 - 如果已满(键数
== m):需要执行分裂操作(Splitting)。
第 4 步:分裂叶子节点
-
创建新节点:创建一个新的叶子节点。
-
重新分配数据:将原已满的叶子节点中的
m个键值对平均分配。通常的做法是:- 原节点保留前
⌈m/2⌉个键值对。 - 新节点接收后
⌊m/2⌋个键值对。
- 原节点保留前
-
设置链表指针:维护叶子节点之间的双向链表结构。
- 将新节点的
next指针指向原节点的下一个叶子节点。 - 将原节点的
next指针指向新节点。 - 更新前一个叶子节点(如果存在)的
next指针。
- 将新节点的
-
向上冒泡键:将新节点的第一个键(即最小的键)复制到父节点中。这个键将成为父节点中用于导航到新节点的索引。
关键点:分裂叶子节点时,复制最小键到父节点。
第 5 步:递归调整父节点
将冒泡上来的新键插入到父节点中。这个插入过程可能又会导致父节点溢出。
-
在父节点中插入:将第 4 步中冒泡上来的键,以及指向新子节点(刚分裂出来的叶子节点)的指针,按顺序插入到父节点中。
-
检查父节点是否溢出:
- 如果未满:操作结束。
- 如果已满:父节点也需要进行分裂。
第 6 步:分裂内部节点(如果父节点溢出)
分裂内部节点的过程与分裂叶子节点类似,但有一个关键区别:
-
创建新节点:创建一个新的内部节点。
-
重新分配键和指针:
- 原父节点保留前
⌈m/2⌉个键和对应的指针。 - 中间位置的键(即第
⌈m/2⌉个键)不会被分配到新节点,而是被提升(Move Up) 到更上一级的父节点。 - 新内部节点接收剩下的
⌊m/2⌋个键和对应的指针。
- 原父节点保留前
-
向上冒泡键:将中间位置的键移动(不是复制)到更上一级的父节点中。
关键点:分裂内部节点时,移动中间键到父节点。
第 7 步:处理根节点分裂
如果分裂一直向上传递到根节点,并且根节点也溢出了,那么:
- 按照第 6 步的规则分裂根节点。
- 被提升的中间键会成为一个新的根节点。
- 树的高度因此增加 1。
总结与要点
| 操作阶段 | 关键动作 | 与 B 树的区别 |
|---|---|---|
| 查找 | 找到正确的叶子节点 | 仅在叶子节点插入数据 |
| 插入 | 在叶子节点中按序插入 | 同 B 树 |
| 分裂叶子 | 复制新节点的最小键到父节点 | B 树是移动中间键到父节点 |
| 分裂内部 | 移动中间键到父节点 | 同 B 树 |
| 根分裂 | 树高增加,产生新根 | 同 B 树 |
核心逻辑流程
实例:插入 1→3→5→7(逐步观察)
插入 1:叶子节点为空,直接存入 [1](未满,无需操作)。
插入 3:叶子节点 [1]→[1,3](满,仍无需分裂,因为未超 2)。
插入 5:叶子节点 [1,3] 已满(最大 2 个键),需分裂:
- 步骤 1:将节点键排序后,取中间键(3)作为 “提升键”,向上插入父节点。
- 步骤 2:原节点分裂为两个新叶子节点:[1] 和 [3,5]。
- 步骤 3:创建父节点(根),存入提升键 [3],子节点指向 [1] 和 [3,5]。
此时树结构:
插入 7:叶子节点 [3,5] 已满,需要分裂。
- 原先的叶子节点 [3,5] 变成 [3] →[5,7]
- 将 5 提到 [3] 里面,变成 [3,5],无需分裂,不再进行向上查找
此时树结构:
分裂的核心逻辑:
- 叶子节点分裂:中间键提升到父节点,原节点拆为 “左半键” 和 “右半键” 两个节点。
- 非叶子节点分裂:逻辑同上(但无数据,仅索引键),分裂后父节点若满,递归分裂,直到根节点(根节点分裂会让树高 + 1)。
3. 删除操作:核心是 “节点不足时合并 / 借键”
核心原则
删除操作必须始终维护以下特性:
- 节点容量:除了根节点,每个节点(内部节点和叶子节点)必须包含至少
⌈m/2⌉ - 1个键(或⌈m/2⌉个指针)。对于最常见的偶数阶(如 m=4, 5),可以简单理解为最少要有m/2(向上取整)个键。 - 平衡性:所有叶子节点位于同一层。
- 有序性:所有键保持有序。
删除过程可以概括为:找到并删除数据 -> 检查节点是否“欠载” -> 通过“借用”或“合并”来修复 -> 递归调整父节点。
详细步骤
假设我们有一个阶数为 m 的 B+ 树。
第 1 步:查找并删除
从根节点开始,找到包含待删除键 K 的叶子节点。在该叶子节点中删除键 K 及其对应的数据(或指针),并保持节点内键的有序性。
关键:数据只存在于叶子节点,所以删除操作总是从叶子节点开始。
第 2 步:判断叶子节点是否“欠载”
删除键 K 后,检查该叶子节点是否仍然满足最小容量要求(即当前键数 >= ⌈m/2⌉ - 1)。
-
如果未欠载(键数
>= ⌈m/2⌉ - 1):- 如果
K是当前节点的第一个键,并且它也存在于父节点中作为索引,则需要用当前节点新的第一个键去更新父节点中对应的索引。 - 否则,删除操作结束。
- 如果
-
如果欠载(键数
< ⌈m/2⌉ - 1):需要执行再平衡操作(Rebalancing)。
第 3 步:叶子节点的再平衡(借用或合并)
当一个叶子节点 L 欠载时,首先看它的直接兄弟节点(通常是左兄弟,也可以是右兄弟)是否可以借出一个键。
-
情况一:借用(Borrowing)
-
条件:相邻兄弟节点有多余的键(即键数
> ⌈m/2⌉ - 1)。 -
操作:
-
从兄弟节点那里借来一个键(从左兄弟借最大的,从右兄弟借最小的)。
-
将借来的键插入到欠载节点 L 中,并保持有序。
-
更新父节点:这个操作会改变兄弟节点和 L 节点的第一个键。因此,必须更新父节点中对应的索引键,使其正确反映子节点新的最小值。
-
示例:假设 L 节点为
[5](欠载),其左兄弟为[1, 3, 4](m=4,最小键数为1,它有3个,可借)。我们借走左兄弟最大的键4。- 左兄弟变为
[1, 3] - L 节点变为
[4, 5] - 必须将父节点中指向 L 节点的旧索引(很可能是5)更新为新的最小值4。
- 左兄弟变为
-
-
-
情况二:合并(Merging)
-
条件:相邻的兄弟节点也刚好满足最小容量要求(即键数
== ⌈m/2⌉ - 1),无法借用。 -
操作:
-
将欠载节点 L 和它的一个兄弟节点合并成一个新的节点。
-
删除父节点中用于分隔这两个兄弟节点的索引键。
-
维护叶子节点之间的链表指针。
-
合并后的节点键数刚好为
(⌈m/2⌉ - 1) * 2 + 1,对于m=4就是(1 + 1 + 1) = 3,是合法的。 -
示例:假设 L 节点为
[5],其左兄弟为[3](m=4,最小键数为1,都刚好满足)。我们将它们合并。- 新节点为
[3, 5] - 在父节点中,删除指向
[3]和[5]的两个指针以及它们之间的分隔键(比如4)。
- 新节点为
-
-
第 4 步:递归调整父节点
在第 3 步的合并操作中,我们在父节点中删除了一個键和一个指针。这个删除操作可能会导致父节点本身欠载。
-
判断父节点是否欠载:检查父节点在删除键后是否满足最小容量要求。
- 如果未欠载:操作结束。
- 如果欠载:父节点(现在是一个内部节点)也需要进行再平衡。
第 5 步:内部节点的再平衡
内部节点的再平衡逻辑与叶子节点完全类似,也是先尝试借用,不行再合并。但有一些细微差别:
-
借用(Borrowing) :
-
从兄弟节点借一个键和一个指针。
-
“旋转”操作:借用的键并不是直接拿来用。需要父节点参与。
- 从父节点“降下”一个合适的键到欠载节点。
- 用兄弟节点的一个键去“填补”父节点降下来的位置。
-
示例:假设内部节点 P 为
[..., parent_key, ...],其欠载子节点 C 为[c1, c2],C 的右兄弟 S 为[s1, s2, s3](富余)。- 将父节点中的
parent_key(它位于 C 和 S 之间) 降下到 C 节点,变成C: [c1, c2, parent_key]。 - 将兄弟节点 S 的第一个键
s1提升到父节点中parent_key原来的位置。 - 将 S 节点的第一个指针转移给 C 节点。
- 将父节点中的
-
-
合并(Merging) :
- 将欠载的内部节点 C 与一个兄弟节点 S 以及它们父节点中的分隔键合并成一个节点。
- 这个操作会在父节点中删除一个键和一个指针,可能导致父节点继续欠载,需要继续递归向上处理。
第 6 步:处理根节点
如果合并操作一直向上传递到根节点,并且根节点只剩下一个孩子(即根节点只剩一个指针,没有键了),那么:
- 将这个唯一的子节点设置为新的根节点。
- 树的高度因此减少 1。
实例:(阶数 m=4)
初始树结构:
删除 7
操作过程如下:
- 删除 7:在叶子节点
[5,7,9]中删除7,变为[5,9]。键数为2,未欠载(2 >= 1)。但5不再是第一个键,第一个键仍是5,所以父节点索引无需更新。操作结束。
结果如下:
删除 9
- 删除 9:在叶子节点
[5,9]中删除9,变为[5]。键数为1,未欠载(1 >= 1)。操作结束。
结果如下:
删除 5 (最复杂的情况)
-
删除 5:在叶子节点
[5]中删除5,变为[]。键数为0,欠载(0 < 1)。 -
再平衡:检查其左兄弟
[1,3],它有2个键(2 > 1),可以借用。 -
借用:
- 从左兄弟借最大的键
3。 - 左兄弟变为
[1]。 - 欠载节点变为
[3]。 - 更新父节点:这个借用的操作改变了右叶子节点的最小值(从
5变成了3)。因此,必须将父节点中的索引键从5更新为3。
- 从左兄弟借最大的键
结果如下:
总结与要点
| 操作阶段 | 关键动作 | 注意事项 |
|---|---|---|
| 查找删除 | 在叶子节点中找到并删除键 | 数据只存在于叶子节点 |
| 检查欠载 | 检查键数是否 < ⌈m/2⌉ - 1 | 根节点除外 |
| 再平衡 | 先尝试借用,再考虑合并 | |
| 叶子节点借用 | 从兄弟节点借一个键 | 必须更新父节点中的索引 |
| 叶子节点合并 | 与兄弟节点合并 | 在父节点中删除一个键,可能导致递归 |
| 内部节点操作 | 借用涉及父键“旋转”,合并涉及父键“降下” | 比叶子节点更复杂 |
| 根节点处理 | 若根节点无键只剩一个孩子,则树高减1 |
核心流程逻辑
三、B + 树的优势(对比 B 树、二叉查找树)
为什么数据库索引首选 B + 树?核心是它完美适配磁盘 IO 特性,优势体现在三方面:
| 对比维度 | B + 树 | B 树 | 二叉查找树 |
|---|---|---|---|
| 数据存储位置 | 仅叶子节点存数据,非叶子仅索引键 | 所有节点都存数据 | 所有节点存数据 |
| 树高与 IO 次数 | 多路结构,树高极低(3-4 层),IO 次数少 | 多路结构,但非叶子存数据,键数少,树高略高 | 二路结构,树高极高(20 层 +),IO 次数多 |
| 范围查询效率 | 叶子链表直接遍历,效率极高 | 需回溯树结构找相邻节点,效率低 | 需中序遍历,效率极低 |
| 查询稳定性 | 所有查找都到叶子节点,IO 次数固定,稳定 | 查找可能在非叶子节点结束,效率不稳定 | 极端情况退化为链表,效率极低 |
四、B + 树的实际应用
B + 树的设计完全为 “外存优化” 服务,因此在需要高效索引的场景中无处不在:
-
数据库索引:MySQL 的 InnoDB、MyISAM 引擎,PostgreSQL 的默认索引都是 B + 树。其中:
- 聚簇索引:叶子节点直接存完整数据行。
- 非聚簇索引:叶子节点存主键地址,需二次查找(回表)。
-
文件系统:Linux 的 Ext3/Ext4、Windows 的 NTFS 文件系统,用 B + 树管理文件目录(目录项为索引键,文件地址为数据)。
总结
B + 树的本质是 “多路平衡查找树的外存优化版”,核心设计思路可概括为三句话:
- 用 “多路” 降低树高,减少磁盘 IO;
- 用 “非叶子仅索引” 提升节点键密度,进一步降低树高;
- 用 “叶子链表” 优化范围查询,适配实际业务需求。
由于B+树在实际开发中,基本上不会用到,所以在这里就不再展示代码