B+ 树是数据库索引最常用、最高效的数据结构之一,它是在 B 树基础上优化而来的。理解其结构和原理,以及为什么它比 B 树更适合数据库,关键在于其设计如何针对磁盘存储和数据库查询模式进行了优化。
一、 B+ 树的结构与原理
-
核心特征:
- 多路平衡搜索树: 和 B 树一样,每个节点可以有多个子节点(称为"阶"或"度",记为
m
),这显著降低了树的高度。 - 所有数据记录存储在叶子节点: 这是与 B 树最本质的区别。非叶子节点(内部节点)仅存储键(Key) 和指向子节点的指针。这些键充当路由信息,用于在树中导航。
- 叶子节点包含所有键: 叶子节点不仅存储实际的数据记录(或指向数据记录的指针),还存储了对应的键,并且这些键是按顺序排列的。
- 叶子节点通过指针串联成有序链表: 所有叶子节点通过双向(或单向)指针连接起来,形成一个按键值排序的有序链表。这是 B+ 树实现高效范围查询的关键。
- 树的高度平衡: 插入和删除操作会遵循严格的规则(如节点分裂、合并、借键),保证从根节点到任意叶子节点的路径长度都相同(所有叶子节点都在同一层),确保操作效率的稳定性(O(log n))。
- 节点填充因子: 通常要求节点(除了根节点)的键数量至少达到
ceil(m/2) - 1
,最多为m - 1
(有时定义略有差异,但核心是控制最小填充度)。这保证了空间利用率和树结构的紧凑性。
- 多路平衡搜索树: 和 B 树一样,每个节点可以有多个子节点(称为"阶"或"度",记为
-
工作原理:
- 查找:
- 从根节点开始。
- 在当前节点中找到第一个大于或等于目标键的键(通过顺序扫描或二分查找)。
- 根据该键对应的指针(或小于该键的指针)进入相应的子节点。
- 重复步骤 2-3,直到到达叶子节点。
- 在叶子节点中顺序扫描(或二分查找)找到目标键。
- 如果找到,则获取键关联的数据记录(或指针);如果没找到,则记录不存在。
- 插入:
- 按照查找的路径定位到目标键应该插入的叶子节点。
- 将键(以及对应的数据记录/指针)按顺序插入到该叶子节点。
- 如果插入后叶子节点键数超过上限
m-1
,则进行节点分裂 :- 将该节点分裂成两个节点(通常是均分)。
- 将分裂后新节点的最小键(或第一个键)复制到父节点中(作为新的分隔键),并添加指向新节点的指针。
- 如果父节点也因此溢出,则递归向上分裂,可能最终导致树的高度增加。
- 如果根节点分裂,会创建一个新的根节点。
- 删除:
- 按照查找的路径定位到包含目标键的叶子节点。
- 从叶子节点中删除该键及其关联的数据记录/指针。
- 如果删除后叶子节点的键数低于下限
ceil(m/2) - 1
:- 尝试借键: 检查相邻的兄弟节点(左或右)。如果某个兄弟节点有富余的键(>
ceil(m/2) - 1
),则从父节点借一个分隔键下来,并从兄弟节点移一个键(及相应指针)过来,同时更新父节点的分隔键。 - 节点合并: 如果兄弟节点也没有富余键,则将该节点、一个兄弟节点以及父节点中它们之间的分隔键合并成一个新节点(或直接合并到兄弟节点)。删除父节点中的分隔键。
- 尝试借键: 检查相邻的兄弟节点(左或右)。如果某个兄弟节点有富余的键(>
- 合并操作可能导致父节点下溢,需要递归向上进行借键或合并操作,可能最终导致树的高度降低。
- 范围查询:
- 通过查找操作定位到范围起始键所在的叶子节点。
- 读取该叶子节点上所有满足范围的记录。
- 沿着叶子节点的链表指针(通常是向右)遍历后续叶子节点,读取并筛选记录,直到遇到超出范围的键。
- 查找:
二、 为什么 B+ 树比 B 树更适合数据库索引?
B+ 树的设计在以下几个方面针对数据库(尤其是基于磁盘的系统)进行了优化,使其相比 B 树具有显著优势:
-
更高的扇出,更低的树高:
- 由于非叶子节点只存储键和指针,不存储数据记录,所以一个非叶子节点可以容纳更多的键(键比数据记录小得多)。
- 更高的扇出(一个节点能指向的子节点数)意味着对于相同数量的数据记录,B+ 树的高度通常比 B 树更低。
- 意义: 更低的树高意味着查找、插入、删除操作需要访问的磁盘 I/O 次数更少。磁盘 I/O 是数据库操作中最耗时的部分,减少 I/O 是提升性能的关键。即使数据量巨大,B+ 树也能保持较少的层级访问。
-
更稳定的查询性能(所有查询都到叶子节点):
- 在 B+ 树中,任何查询(精确查找、范围查找)都必须遍历到叶子节点才能找到数据。无论键在树中何处出现(可能在非叶子节点出现多次),数据只在叶子节点。
- 在 B 树中,数据记录可能存储在任何节点(非叶子或叶子)。这意味着精确查找可能在非叶子节点就找到结果并提前返回。
- 意义: B+ 树的查询路径长度总是等于树高,非常稳定和可预测(O(h))。B 树的查询路径长度则可能小于树高(提前找到),但波动性较大。对于数据库系统来说,稳定和可预测的性能非常重要,尤其是在高并发和复杂查询场景下。
-
无与伦比的范围查询效率:
- 叶子节点间的有序链表是 B+ 树的核心优势之一。
- 执行范围查询(如
SELECT * FROM table WHERE key BETWEEN 10 AND 100
)时:- B+ 树:找到起始键 (10) 所在的叶子节点后,只需顺序扫描该节点和链表连接的后续叶子节点即可高效获取所有范围内的记录。这最大限度地利用了磁盘的顺序读取特性(远快于随机读取)。
- B 树:没有叶子链表。找到起始键后,要获取后续记录,必须不断地回溯到父节点,再定位到下一个子节点(可能在不同的磁盘页),进行中序遍历。这会产生大量的随机磁盘 I/O,性能远低于 B+ 树的顺序扫描。
- 意义: 范围查询是数据库中最常见、最重要的操作之一(如按时间范围筛选、分页查询)。B+ 树对此类查询的优化是革命性的。
-
更少的空间占用(非叶子节点):
- 非叶子节点不存储实际数据,只存储键和指针,通常比存储完整数据记录的 B 树非叶子节点小得多。
- 意义:
- 更多的非叶子节点可以缓存在宝贵的内存中(Buffer Pool),进一步减少磁盘 I/O。
- 即使需要从磁盘读取非叶子节点,更小的节点意味着一次 I/O 可以读取更多的路由信息(键和指针),间接提升了扇出和降低了树高。
-
全表扫描更高效:
- 如果需要对整个表进行扫描(如
SELECT * FROM table
,无 WHERE 条件),B+ 树只需遍历叶子节点的链表即可顺序访问所有记录。 - B 树进行全表扫描也需要进行树的中序遍历(递归或栈),效率低于顺序扫描链表。
- 如果需要对整个表进行扫描(如
总结对比表
特性 | B+ 树 | B 树 | 对数据库的意义 |
---|---|---|---|
数据存储位置 | 仅在叶子节点 | 所有节点(叶子 + 非叶子)都可能存储数据 | B+ 树非叶节点更小,扇出更高 |
非叶子节点内容 | 仅键 + 指针(路由信息) | 键 + 指针 + 可能的数据记录 | B+ 树非叶节点更小,扇出更高 |
叶子节点连接 | 通过指针形成有序链表 | 无显式链表连接 | B+ 树范围查询高效(顺序 I/O) |
查找性能稳定性 | 稳定 (总是到叶子节点,路径长=树高) | 不稳定 (可能中途找到,路径长 <= 树高) | B+ 树性能可预测性更好 |
范围查询效率 | 极高 (顺序遍历叶子链表) | 较低 (需中序遍历,随机 I/O 多) | B+ 树更适合常见数据库操作 (BETWEEN, >, <) |
等值查询 I/O 次数 | 通常更少 (树高更低) | 可能更少或更多 (树高可能更高) | B+ 树平均 I/O 更少 |
全表扫描效率 | 高 (顺序遍历叶子链表) | 中 (中序遍历) | B+ 树更高效 |
非叶子节点空间占用 | 更小 (只存键+指针) | 更大 (可能存数据) | B+ 树缓存更有效,间接提升 I/O |
结论:
B+ 树通过将数据集中存储在叶子节点 并用链表连接叶子节点 的核心设计,完美适配了数据库系统的主要需求:减少昂贵的磁盘 I/O 次数 (尤其是通过更高的扇出降低树高)和高效支持范围查询 (通过叶子链表实现顺序访问)。虽然精确查找在 B 树中有时可能更快(提前返回),但这种优势在数据库庞大的数据量和频繁的范围查询面前显得微不足道。B+ 树在稳定性、整体性能(特别是范围查询)和磁盘 I/O 优化方面的综合优势,使其成为数据库索引事实上的标准结构。几乎所有主流的关系型数据库(如 MySQL InnoDB, PostgreSQL, Oracle, SQL Server)以及许多 NoSQL 数据库都使用 B+ 树或其变种作为主要的索引实现方式。