mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1600 字
4 分钟
B 树深入
2025-09-19

哈希表查找是 O(1),B+ 树查找是 O(log N)——数据库为什么不用更快的哈希表?因为 SELECT * FROM orders WHERE id BETWEEN 1000 AND 5000 这条范围查询,哈希表只能逐条查找,B+ 树沿叶子链表顺序扫描一遍就完成。1 亿条记录,B+ 树只需 3–4 次磁盘 I/O 就能定位到任何一条数据。

B+ 树是读优化存储结构的基石。这一章拆解它的页面组织、分裂合并算法、Latch 并发控制与前缀压缩——理解数据库为什么把赌注押在这棵树上。

一、为什么选择 B+ 树#

1.1 从二叉搜索树到 B+ 树#

graph TB subgraph 二叉搜索树["二叉搜索树<br/>高度 O(log₂N)"] BST["1M 记录 → 高度 20<br/>20 次磁盘 I/O"] end subgraph B树["B 树(阶数 m=100)<br/>高度 O(log_m N)"] BTREE["1M 记录 → 高度 3<br/>3 次磁盘 I/O"] end subgraph B+树["B+ 树(阶数 m=100)<br/>叶子链表 + 内部节点只存键"] BPTREE["1M 记录 → 高度 3<br/>3 次磁盘 I/O<br/>范围扫描高效"] end BST --> BTREE --> BPTREE style BST fill:#ffcdd2,stroke:#c62828 style BTREE fill:#fff9c4,stroke:#f9a825 style BPTREE fill:#c8e6c9,stroke:#2e7d32
树类型高度(1M 记录)磁盘 I/O范围扫描空间利用
二叉搜索树~2020
B 树(m=100)~33一般
B+ 树(m=100)~33优秀
Important

B+ 树的核心优势:矮胖。每个内部节点存储大量键(InnoDB 一个 16KB 页面可存 ~1000 个键),使树的高度极低。1 亿条记录只需要 3–4 层,即 3–4 次磁盘 I/O 就能找到任何一条记录。

1.2 B+ 树 vs B 树#

维度B 树B+ 树
内部节点存储键 + 数据只存储键(路由信息)
叶子节点部分数据在内部节点所有数据都在叶子节点
叶子链表有(双向链表)
范围扫描需要中序遍历沿叶子链表顺序扫描
扇出较小(内部节点存数据)较大(内部节点只存键)
查询稳定性不稳定(数据可能在任何层)稳定(都要到叶子层)

二、B+ 树页面组织#

2.1 InnoDB 页面结构#

// InnoDB 16KB 页面结构(简化)
struct Page {
// 页头(38 字节)
struct PageHeader {
uint32_t page_id; // 页编号
uint16_t n_dir_slots; // 目录槽数
uint16_t heap_top; // 堆顶指针
uint16_t n_heap; // 堆中记录数
uint16_t first_free; // 第一条空闲记录
uint16_t garbage; // 已删除记录空间
page_type_t type; // 页类型
} header;
// 系统记录(INFIMUM + SUPREMUM)
Record infimum; // 最小记录
Record supremum; // 最大记录
// 用户记录(从低地址向高地址增长)
Record records[]; // 用户记录
// 空闲空间
byte free_space[]; // 可用空间
// 页目录(从高地址向低地址增长)
Slot dir_slots[]; // 目录槽(二分查找用)
};
// 记录头(5 字节)
struct RecordHeader {
uint8_t n_owned; // 该槽拥有的记录数
uint16_t heap_no; // 堆中序号
uint8_t record_type; // 记录类型
uint16_t next; // 下一条记录偏移(链表)
};

2.2 页目录:二分查找加速#

graph LR subgraph 页目录["页目录(Slot Array)"] S0["Slot 0<br/>→ INFIMUM"] S1["Slot 1<br/>→ Record 4"] S2["Slot 2<br/>→ Record 8"] S3["Slot 3<br/>→ Record 12"] S4["Slot 4<br/>→ SUPREMUM"] end subgraph 记录链表["记录链表(有序)"] INF["INFIMUM"] --> R1["R1"] --> R2["R2"] --> R3["R3"] R3 --> R4["R4"] --> R5["R5"] --> R6["R6"] --> R7["R7"] R7 --> R8["R8"] --> R9["R9"] --> R10["R10"] --> R11["R11"] R11 --> R12["R12"] --> SUP["SUPREMUM"] end S0 -.-> INF S1 -.-> R4 S2 -.-> R8 S3 -.-> R12 S4 -.-> SUP style 页目录 fill:#e3f2fd,stroke:#1565c0 style 记录链表 fill:#c8e6c9,stroke:#2e7d32

页目录将记录分组,每组 4–8 条,通过二分查找快速定位:

  1. 在 Slot 数组中二分查找,确定目标记录在哪个组
  2. 在组内线性遍历记录链表
步骤操作复杂度
1. 二分查找 SlotO(log S),S 为 Slot 数~4–6 次比较
2. 组内遍历O(K),K 为每组记录数~4–8 次比较
总计~10 次比较

2.3 B+ 树的扇出计算#

# InnoDB B+ 树扇出计算
def calculate_fanout(page_size=16384, key_size=8, ptr_size=6,
record_size=100):
"""计算 B+ 树的扇出"""
# 内部节点:只存储键 + 子节点指针
# 非叶子页可用空间 ≈ page_size - 页头 - 目录
internal_usable = page_size - 100 # 约 16284 字节
internal_entry_size = key_size + ptr_size # 14 字节
fanout = internal_usable // internal_entry_size
print(f"内部节点扇出: ~{fanout} 个子节点")
# 叶子节点:存储完整记录
leaf_usable = page_size - 100
records_per_leaf = leaf_usable // record_size
print(f"叶子节点记录数: ~{records_per_leaf} 条")
# 树高度计算
for height in range(1, 6):
capacity = fanout ** (height - 1) * records_per_leaf
print(f"高度 {height}: 可存储 {capacity:,} 条记录")
return fanout
calculate_fanout()
# 内部节点扇出: ~1163 个子节点
# 叶子节点记录数: ~162 条
# 高度 1: 162 条记录
# 高度 2: 188,406 条记录
# 高度 3: 219,144,378 条记录(2.19 亿)
# 高度 4: 254,876,866,014 条记录

三、页面分裂与合并#

3.1 页面分裂#

当页面空间不足时,需要分裂:

graph TB subgraph 分裂前["分裂前:页面已满"] P1["Page A<br/>[1,3,5,7,9,11,13,15]<br/>已满"] end subgraph 分裂后["分裂后:中间记录上移"] P2["Page A<br/>[1,3,5,7]"] P3["Page B<br/>[9,11,13,15]"] PARENT["父节点<br/>...9..."] PARENT --> P2 PARENT --> P3 end P1 -->|"插入 8<br/>触发分裂"| P2 P1 --> P3 style P1 fill:#ffcdd2,stroke:#c62828 style P2 fill:#c8e6c9,stroke:#2e7d32 style P3 fill:#e3f2fd,stroke:#1565c0
// B+ 树页面分裂算法(简化)
void btree_split_page(Page *page, Key new_key, Value new_value) {
// 1. 分配新页面
Page *new_page = allocate_page();
// 2. 确定分裂点(中间位置)
int mid = page->n_records / 2;
Key mid_key = page->records[mid].key;
// 3. 将上半部分移到新页面
for (int i = mid; i < page->n_records; i++) {
new_page->records[i - mid] = page->records[i];
}
new_page->n_records = page->n_records - mid;
page->n_records = mid;
// 4. 插入新记录到正确的页面
if (new_key < mid_key) {
insert_into_page(page, new_key, new_value);
} else {
insert_into_page(new_page, new_key, new_value);
}
// 5. 更新父节点
insert_into_parent(page->parent, mid_key, new_page);
// 6. 更新叶子链表
new_page->next = page->next;
page->next = new_page;
new_page->prev = page;
}

3.2 分裂策略对比#

策略分裂点选择优点缺点
中间分裂50% 位置简单,空间均匀不考虑插入模式
左倾分裂新记录在左页适合递增插入右页可能很空
右倾分裂新记录在右页适合递增插入左页可能很空
批量加载预先排序后填充最优空间利用只适合初始加载
Note

InnoDB 使用自适应分裂:对于递增主键的插入,倾向于将新记录单独放在新页面(右倾分裂),避免浪费空间。这就是为什么自增主键的 B+ 树页面利用率通常接近 100%。

3.3 页面合并#

当页面中的记录过少时(低于阈值),需要与兄弟页面合并:

# 页面合并条件
def should_merge(page, min_records=2):
"""判断是否需要合并"""
# 页面记录数低于最小值
if page.n_records < min_records:
# 尝试与兄弟页面合并
sibling = page.prev or page.next
if sibling and (page.n_records + sibling.n_records) <= max_records:
return True
# 否则尝试从兄弟页面借记录( redistribution)
elif sibling and sibling.n_records > min_records + 1:
redistribute(page, sibling)
return False
return False

四、Latch 并发控制#

4.1 Latch vs Lock#

维度Latch(闩锁)Lock(锁)
保护对象内部数据结构(页面、B+ 树)逻辑数据(行、表)
持有时间微秒级毫秒–秒级
死锁检测无(使用 try-lock)有(wait-for graph)
获取顺序严格的自顶向下无严格顺序
实现方式自旋锁 + 信号量锁管理器
回滚不支持支持

4.2 B+ 树的 Latch 协议#

graph TB subgraph Latch耦合["Latch Coupling(蟹行协议)"] ROOT["根节点<br/>获取 Read Latch"] --> L1["Level 1<br/>获取 Read Latch"] L1 -->|"释放根节点 Latch"| L2["Level 2<br/>获取 Read Latch"] L2 -->|"释放 L1 Latch"| LEAF["叶子节点<br/>获取 Read Latch"] end subgraph 乐观插入["乐观插入协议"] ROOT2["根节点<br/>Read Latch"] --> SEARCH["搜索到叶子<br/>Read Latch"] SEARCH -->|"空间足够"| INSERT["直接插入<br/>升级为 Write Latch"] SEARCH -->|"空间不足"| PESSIMISTIC["悲观路径<br/>释放所有 Latch<br/>重新搜索(Write Latch)"] end style Latch耦合 fill:#e3f2fd,stroke:#1565c0 style 乐观插入 fill:#c8e6c9,stroke:#2e7d32

蟹行协议(Crabbing Protocol) 的规则:

  1. 搜索:从根开始,获取子节点的 Read Latch 后释放父节点的 Latch
  2. 插入/删除
    • 乐观路径:先以 Read Latch 搜索到叶子,如果叶子有空间,升级为 Write Latch
    • 悲观路径:如果叶子需要分裂,释放所有 Latch,从根开始以 Write Latch 重新搜索

4.3 InnoDB 的 Latch 实现#

// InnoDB rw-lock 实现(简化)
struct rw_lock_t {
os_event_t event; // 等待事件
volatile ulint lock_word; // 锁字
// lock_word > 0: 读锁数量
// lock_word == 0: 写锁
// lock_word == -1: 独占写锁
volatile ulint waiters; // 等待者标志
UT_LIST_BASE_NODE_T(rw_lock_t) list; // 全局锁列表
};
// 获取读锁(共享锁)
void rw_lock_s_lock(rw_lock_t *lock) {
// 自旋尝试
for (int i = 0; i < spin_rounds; i++) {
if (atomic_compare_and_swap(&lock->lock_word,
expected, expected - 1)) {
return; // 成功获取读锁
}
cpu_relax(); // CPU 暂停
}
// 自旋失败,进入等待
os_event_wait(lock->event);
}
// 获取写锁(排他锁)
void rw_lock_x_lock(rw_lock_t *lock) {
// 自旋尝试
for (int i = 0; i < spin_rounds; i++) {
if (atomic_compare_and_swap(&lock->lock_word, 0, -1)) {
return; // 成功获取写锁
}
cpu_relax();
}
// 自旋失败,进入等待
os_event_wait(lock->event);
}

五、前缀压缩与批量加载#

5.1 前缀压缩#

B+ 树内部节点可以只存储区分键的最短前缀:

# 前缀压缩示例
# 假设子节点 1 的最大键是 "apple_100"
# 子节点 2 的最小键是 "banana_200"
# 父节点中只需要存储 "b" 即可区分
keys_without_compression = ["apple_100", "banana_200", "cherry_300"]
keys_with_compression = ["a", "b", "c"] # 只需首字母即可路由
# InnoDB 的键压缩
# 对于 (a, b, c) 联合索引,如果 a 相同,只存储 b, c 的差异部分
压缩方式说明节省空间
前缀截断内部节点只存区分子节点的最短前缀10–50%
键压缩同一页面内相邻键只存差异20–60%
无压缩存储完整键0%

5.2 批量加载#

对于已知排序的数据,批量加载比逐条插入高效得多:

// B+ 树批量加载算法
void btree_bulk_load(Key *sorted_keys, Value *sorted_values, int n) {
// 1. 从左到右依次填满叶子页面
Page *current_leaf = allocate_page();
for (int i = 0; i < n; i++) {
if (!page_has_space(current_leaf, sorted_keys[i])) {
// 当前页面已满,分配新页面
Page *new_leaf = allocate_page();
current_leaf->next = new_leaf;
new_leaf->prev = current_leaf;
current_leaf = new_leaf;
}
append_record(current_leaf, sorted_keys[i], sorted_values[i]);
}
// 2. 自底向上构建内部节点
// 叶子页面的第一个键作为父节点的路由键
build_internal_levels();
// 优势:
// - 页面利用率接近 100%(无分裂开销)
// - 顺序写入,I/O 效率最高
// - 无并发冲突
}
方式页面利用率插入速度顺序性
逐条插入50–70%慢(分裂开销)
排序后批量加载95–100%快 10–100x

六、B+ 树性能分析#

6.1 读写放大#

# B+ 树的读写放大分析
def btree_amplification(n_records, page_size=16384, record_size=100):
"""分析 B+ 树的读写放大"""
fanout = page_size // (8 + 6) # ~1163
records_per_leaf = page_size // record_size # ~162
# 树高度
height = 1
capacity = records_per_leaf
while capacity < n_records:
height += 1
capacity *= fanout
# 读放大:点查需要读取 height 个页面
read_amp = height
print(f"树高度: {height}")
print(f"读放大(点查): {read_amp}x")
# 写放大:插入一条记录需要
# - 写入 WAL(1 次)
# - 读取目标页面(1 次)
# - 修改并写回目标页面(1 次)
# 如果触发分裂,还需要写入新页面和父页面
write_amp_normal = 3 # WAL + 读 + 写
write_amp_split = write_amp_normal + 2 # + 新页面 + 父页面
print(f"写放大(无分裂): {write_amp_normal}x")
print(f"写放大(有分裂): {write_amp_split}x")
# 空间放大:页面平均利用率
space_amp = 1 / 0.69 # 平均 69% 利用率
print(f"空间放大: {space_amp:.2f}x")
btree_amplification(100_000_000)
# 树高度: 3
# 读放大(点查): 3x
# 写放大(无分裂): 3x
# 写放大(有分裂): 5x
# 空间放大: 1.45x

6.2 B+ 树的适用场景#

场景适合原因
OLTP 点查非常适合O(log N) 查找,3–4 次 I/O
范围扫描适合叶子链表顺序扫描
随机写入一般原地更新,页面分裂开销
顺序写入一般需要找到插入位置
大批量写入不适合频繁分裂,写放大高

七、实战:观察 B+ 树行为#

7.1 InnoDB B+ 树观察#

-- 查看索引树的高度
SELECT
table_name,
index_name,
stat_value AS pages,
stat_description
FROM mysql.innodb_index_stats
WHERE database_name = 'testdb'
AND stat_name = 'n_leaf_pages'
ORDER BY table_name, index_name;
-- 查看索引统计
ANALYZE TABLE storage_test;
SHOW INDEX FROM storage_test;
-- 观察页面分裂
-- 创建自增主键表 vs 随机主键表
CREATE TABLE auto_inc (id INT AUTO_INCREMENT PRIMARY KEY, data VARCHAR(100));
CREATE TABLE random_pk (id CHAR(36) PRIMARY KEY, data VARCHAR(100));
-- 插入 10 万条记录后比较
SELECT table_name, data_length, index_length
FROM information_schema.tables
WHERE table_schema = 'testdb';

7.2 PostgreSQL B+ 树观察#

-- 查看索引大小和页面数
SELECT
relname AS index_name,
relpages,
reltuples,
pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relkind = 'i' AND relname LIKE '%storage_test%';
-- 使用 pageinspect 扩展观察页面
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 查看索引的元页面
SELECT * FROM bt_metap('storage_test_pkey');
-- 查看索引的叶子页面
SELECT * FROM bt_page_items('storage_test_pkey', 1) LIMIT 10;
Tip

B+ 树的页面大小选择直接影响性能:更大的页面(如 32KB/64KB)减少树高度但增加写放大,更小的页面(如 4KB/8KB)降低写放大但增加随机 I/O。OLTP 场景推荐 8KB-16KB,OLAP 场景推荐 16KB-64KB。

八、总结#

主题核心要点关键词
为什么 B+ 树矮胖结构使 1 亿记录只需 3–4 次 I/O,叶子链表支持高效范围扫描树高, 范围扫描
页面组织页目录加速页内查找,记录链表维护有序性页目录, 记录链表
分裂与合并中间分裂是默认策略,InnoDB 自适应分裂优化递增插入中间分裂, 自适应
Latch 协议蟹行协议保证并发安全,乐观插入减少 Write Latch 开销蟹行协议, 乐观锁
前缀压缩内部节点只存区分前缀,提升扇出前缀压缩, 扇出
批量加载排序后顺序填充,页面利用率接近 100%顺序填充, 高利用率

支持与分享

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

B 树深入
https://blog.souloss.com/posts/storage/storage-b-tree-deep-dive/
作者
Souloss
发布于
2025-09-19
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时