mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2313 字
6 分钟
B+ 树(B+ Tree)
2026-06-13

一、为什么需要 B+ 树#

假设你在设计一个数据库,表里有 10 亿条记录,索引键是用户 ID。数据存在磁盘上,每次读取一个磁盘页大约 10ms。如果用二叉搜索树做索引,树高大约 30 层(log₂ 10⁹),一次查询需要 30 次磁盘读取,也就是 300ms——用户点一个搜索按钮要等半秒,不可接受。

红黑树或 AVL 树能保证平衡,但每个节点只存一个键和两个子指针,一个磁盘页(通常 4KB 或 8KB)只能装下一个节点,太浪费了。一次磁盘读取换来一个键的比较,性价比极低。

问题的核心是:磁盘 I/O 是按页计费的,不是按字节计费的。读取 1 字节和读取 4KB 花的时间几乎一样。所以你希望每次读一页,能比较尽可能多的键。这就是 B+ 树的设计动机——让每个节点装下几百个键,把树压扁,减少层数,从而减少磁盘读取次数。

二、现实类比#

图书馆的卡片目录柜。顶层是一排大抽屉,每个抽屉上贴着一个范围标签:「A-Da」「Db-Ha」「Hb-Ma」。你根据标签找到对应的大抽屉,打开后发现里面不是书,而是更小的抽屉,每个小抽屉又贴着更细的范围:「A-An」「Ao-Da」。最后一层的小抽屉里才是真正的书卡——上面写着书名、作者和书架位置,而且这些书卡按字母顺序串在一起。

关键点:大抽屉和小抽屉只负责「指路」,不存放真正的书卡。只有最底层的抽屉才存放数据,并且相邻抽屉之间用链条串起来。这样,找一本书只需要开几个抽屉,而要找某个范围内的所有书,只需要在底层沿着链条顺序扫描。

三、核心思想#

B+ 树是一种平衡多路搜索树。内部节点只存键和子指针,不存数据——纯路由角色。叶子节点存键值对,且所有叶子通过链表串联,支持高效的范围扫描。每个节点对应一个磁盘页,高扇出(fanout)让树变得很矮。

flowchart TD subgraph 内部节点——只存键,负责路由 I1["5 | 12 | 20"] end subgraph 叶子节点——存键值对 + 链表 L1["1→v1 | 3→v3 | 5→v5"]:::leaf L2["7→v7 | 10→v10 | 12→v12"]:::leaf L3["15→v15 | 18→v18 | 20→v20"]:::leaf end I1 -->|"≤5"| L1 I1 -->|"6-12"| L2 I1 -->|"≥13"| L3 L1 <-->|"链表"| L2 L2 <-->|"链表"| L3 classDef leaf fill:#e8f5e9,stroke:#4caf50

高扇出 = 矮树 = 少读磁盘。一个阶数(order)为 100 的 B+ 树,每个内部节点最多 100 个键、101 个子指针。3 层就能存 100 万个键(100 × 100 × 100),4 层存 1 亿,5 层存 100 亿。也就是说,在 10 亿条记录中查找,只需要 5 次磁盘读取——从 300ms 降到 50ms。

操作时间复杂度说明
点查询O(logₘ N)M 为阶数,实际约 3-5 次磁盘 I/O
范围查询O(logₘ N + K)K 为结果数量,叶子链表顺序扫描
插入O(logₘ N)可能触发节点分裂,向上传播
删除O(logₘ N)可能触发节点合并或借用

四、变体与对比#

结构内部节点存数据叶子链表适合场景
B+ 树否(只存键)数据库索引、文件系统、范围查询
B 树是(键值都在节点)早期数据库、嵌入式存储
LSM 树N/A(写内存,合并到磁盘)N/A写多读少、追加写入
哈希索引N/A(O(1) 直接定位)N/A纯等值查询、内存缓存

为什么数据库选 B+ 树而不是 B 树?两个核心原因:

  1. 扇出更高。B+ 树内部节点不存值,同样大小的磁盘页能放更多键,树更矮,磁盘 I/O 更少。
  2. 范围扫描更简单。B+ 树的叶子链表让范围查询变成链表的顺序遍历;B 树做范围查询需要反复回溯父节点,实现复杂且 I/O 多。

B 树的优势在哪?虽然 B+ 树是数据库索引的主流选择,B 树在特定场景下反而更优。B 树的关键区别在于每个节点都存储键值对——内部节点也不例外。这意味着点查询可能在中途就能命中数据返回,不必一路走到叶子,在某些情况下能减少 1 次磁盘 I/O。但代价也很明显:内部节点存了值,同样大小的磁盘页能放的键更少,扇出更低,树更高,范围查询也更麻烦。SQLite 是一个典型的例子——它的表数据用 B 树而非 B+ 树存储。原因在于 SQLite 的典型工作负载是嵌入式设备的点查询(比如手机上的通讯录查找),而不是服务端的大范围扫描。点查询场景下,B 树提前命中数据带来的 I/O 节省更有价值,而 B+ 树叶子链表对范围扫描的优势用不上。

B-link 树(PostgreSQL 使用的变体):在每个节点增加指向右兄弟的链接。当节点分裂时,即使父节点还没更新,其他线程也能通过右兄弟链接找到正确位置,实现并发访问而不需要全局锁。这是 Lehman-Yao 算法的核心思路。

Note

联合索引的最左前缀规则:索引 (status, region) 可以高效查询 statusstatus + region,但无法高效查询单独的 region——因为 B+ 树按左到右的列顺序组织键。

五、多语言实现#

5.1 Go 实现——搜索与插入#

package bplustree
import "sort"
// BPlusTree 的节点
type Node struct {
keys []int // 键列表
children []*Node // 子指针(内部节点用)
values []string // 值列表(叶子节点用)
next *Node // 叶子链表右指针
isLeaf bool
}
type BPlusTree struct {
root *Node
order int // 每个节点最多 order 个键
}
func New(order int) *BPlusTree {
return &BPlusTree{
root: &Node{isLeaf: true},
order: order,
}
}
// Search 在树中查找 key 对应的值
func (t *BPlusTree) Search(key int) (string, bool) {
leaf := t.findLeaf(key)
idx := sort.SearchInts(leaf.keys, key)
if idx < len(leaf.keys) && leaf.keys[idx] == key {
return leaf.values[idx], true
}
return "", false
}
// findLeaf 沿内部节点一路向下,找到 key 所属的叶子
func (t *BPlusTree) findLeaf(key int) *Node {
node := t.root
for !node.isLeaf {
// 找到第一个大于 key 的位置,进入对应子树
idx := sort.SearchInts(node.keys, key)
node = node.children[idx]
}
return node
}
// Insert 插入键值对,必要时分裂节点
func (t *BPlusTree) Insert(key int, value string) {
leaf := t.findLeaf(key)
t.insertIntoLeaf(leaf, key, value)
// 键数超过上限,需要分裂
if len(leaf.keys) >= t.order {
t.splitLeaf(leaf)
}
}
func (t *BPlusTree) insertIntoLeaf(leaf *Node, key int, value string) {
idx := sort.SearchInts(leaf.keys, key)
leaf.keys = append(leaf.keys, 0)
leaf.values = append(leaf.values, "")
// 向后移动腾出位置
copy(leaf.keys[idx+1:], leaf.keys[idx:])
copy(leaf.values[idx+1:], leaf.values[idx:])
leaf.keys[idx] = key
leaf.values[idx] = value
}
// splitLeaf 将叶子节点一分为二,把中间键提升到父节点
func (t *BPlusTree) splitLeaf(leaf *Node) {
mid := len(leaf.keys) / 2
newLeaf := &Node{
keys: append([]int{}, leaf.keys[mid:]...),
values: append([]string{}, leaf.values[mid:]...),
isLeaf: true,
next: leaf.next,
}
leaf.keys = leaf.keys[:mid]
leaf.values = leaf.values[:mid]
leaf.next = newLeaf // 维护叶子链表
// 将新叶子的最小键提升到父节点
t.insertIntoParent(leaf, newLeaf.keys[0], newLeaf)
}
// insertIntoParent 递归向上插入分裂产生的键和子节点
func (t *BPlusTree) insertIntoParent(left *Node, key int, right *Node) {
if left == t.root {
// 根节点分裂,创建新根
t.root = &Node{
keys: []int{key},
children: []*Node{left, right},
}
return
}
// 简化:实际需要回溯查找父节点并处理内部节点分裂
// 生产实现通常在搜索路径上保存父节点栈
}

Go 实现展示了 B+ 树的核心操作流程:搜索时沿内部节点逐层下降到叶子;插入时先在叶子中找到位置,键数超限则分裂,分裂后的中间键向上传播。

5.2 TypeScript 实现——搜索与范围查询#

interface LeafNode {
isLeaf: true;
keys: number[];
values: string[];
next: LeafNode | null;
}
interface InternalNode {
isLeaf: false;
keys: number[];
children: BPlusNode[];
}
type BPlusNode = LeafNode | InternalNode;
class BPlusTree {
root: BPlusNode;
order: number;
constructor(order: number) {
this.root = { isLeaf: true, keys: [], values: [], next: null };
this.order = order;
}
// 点查询:沿内部节点下降到叶子
search(key: number): string | undefined {
let node = this.root;
while (!node.isLeaf) {
let i = 0;
// 找到第一个大于 key 的位置
while (i < node.keys.length && key >= node.keys[i]) i++;
node = node.children[i];
}
const idx = node.keys.indexOf(key);
return idx !== -1 ? node.values[idx] : undefined;
}
// 范围查询:找到起点后沿叶子链表顺序扫描
rangeSearch(start: number, end: number): Map<number, string> {
const result = new Map<number, string>();
let node: BPlusNode = this.root;
// 先下降到起始叶子
while (!node.isLeaf) {
let i = 0;
while (i < node.keys.length && start >= node.keys[i]) i++;
node = node.children[i];
}
// 沿叶子链表扫描直到超出范围
let leaf = node as LeafNode;
while (leaf !== null) {
for (let i = 0; i < leaf.keys.length; i++) {
if (leaf.keys[i] > end) return result; // 超出上界,直接返回
if (leaf.keys[i] >= start) {
result.set(leaf.keys[i], leaf.values[i]);
}
}
leaf = leaf.next;
}
return result;
}
}

TypeScript 实现重点展示了 B+ 树最独特的优势——范围查询。先定位到起始叶子,然后沿 next 指针顺序扫描,无需回溯父节点。这正是 B+ 树成为数据库索引标配的核心原因。

六、生产验证#

PostgreSQL——nbtinsert.c——PostgreSQL 的 B+ 树索引(文档中称 B-tree,实际是 B-link 树/Lehman-Yao 变体)。文件头注释明确写明 “Item insertion in Lehman and Yao btrees”。每个节点带有右兄弟链接(right sibling link),分裂时无需全局锁,其他并发线程可以通过右链接继续搜索。_bt_stepright_bt_split_bt_insert_parent 等函数协同完成无锁并发插入。

SQLite——btreeInt.h——SQLite 中所有表和索引都由 B+ 树支撑。BtShared 结构体管理共享的页面缓存和 B+ 树元信息,pageSizemaxLocal/minLocalmaxLeaf/minLeaf 等字段控制每个页面的有效载荷上限,超过时溢出到溢出页。数据库文件就是一组磁盘页的集合,每个页是一棵 B+ 树的节点。

InnoDB(MySQL)——MySQL 的 InnoDB 存储引擎使用 B+ 树组织聚簇索引和二级索引。聚簇索引的叶子节点直接包含行数据,二级索引的叶子节点存主键值,查询时可能需要回表。page size 默认 16KB,非叶子页可容纳约 1170 个键,3 层 B+ 树可索引约 2000 万行。

七、小结#

适合使用 B+ 树的场景

  • 数据库索引——点查和范围查都高效,几乎所有关系型数据库的默认选择
  • 文件系统目录——NTFS、ext4、Btrfs 用 B+ 树管理文件名到磁盘块的映射
  • 需要范围查询的有序数据——叶子链表让范围扫描变成顺序读取
  • 磁盘存储——高扇出降低树高,把磁盘 I/O 次数压到个位数

不适合使用 B+ 树的场景

  • 小规模内存数据——数据能全部放内存时,哈希表或 BST 更简单,不需要考虑页对齐
  • 写多读少——频繁插入导致页分裂,LSM 树(写内存、后台合并)更适合
  • 纯等值查询——哈希索引 O(1) 比 B+ 树 O(log N) 快,且实现更轻
  • 追加写入为主的场景——页分裂开销大,LSM 树或日志结构存储更合适

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

B+ 树(B+ Tree)
https://blog.souloss.com/posts/programming/data-structures/data-structures-b-plus-tree/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时