1600 字
4 分钟
B 树深入
哈希表查找是 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 | 范围扫描 | 空间利用 |
|---|---|---|---|---|
| 二叉搜索树 | ~20 | 20 | 差 | 低 |
| B 树(m=100) | ~3 | 3 | 一般 | 中 |
| B+ 树(m=100) | ~3 | 3 | 优秀 | 高 |
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 条,通过二分查找快速定位:
- 在 Slot 数组中二分查找,确定目标记录在哪个组
- 在组内线性遍历记录链表
| 步骤 | 操作 | 复杂度 |
|---|---|---|
| 1. 二分查找 Slot | O(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) 的规则:
- 搜索:从根开始,获取子节点的 Read Latch 后释放父节点的 Latch
- 插入/删除:
- 乐观路径:先以 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.45x6.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_descriptionFROM mysql.innodb_index_statsWHERE 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_lengthFROM information_schema.tablesWHERE table_schema = 'testdb';7.2 PostgreSQL B+ 树观察
-- 查看索引大小和页面数SELECT relname AS index_name, relpages, reltuples, pg_size_pretty(pg_relation_size(oid)) AS sizeFROM pg_classWHERE 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% | 顺序填充, 高利用率 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
LSM 树深入
存储 深入 LSM 树——MemTable 与 SSTable 的组织、Leveled/Tiered/FIFO Compaction 策略、写放大分析、布隆过滤器与分区索引,理解写优化存储结构的设计精髓。
2
磁盘与 SSD 物理结构
存储 深入磁盘与 SSD 物理结构——HDD 的磁道/柱面/扇区、寻道与旋转延迟,SSD 的 NAND 闪存页/块/擦除、FTL 映射、写入放大与磨损均衡,理解存储系统的物理约束。
3
读路径
存储 深入存储引擎的读路径——从读取请求到数据的完整流程——B+ 树遍历、LSM 树多层级查找、Bloom Filter 过滤、Block Cache 缓存、预取策略,对比 InnoDB 与 RocksDB 的读路径差异。
4
MVCC 与版本管理
存储 深入 MVCC 与版本管理——Undo Log MVCC(InnoDB)、Append-Only MVCC(PostgreSQL)、VACUUM 清理、快照隔离、版本链,理解如何实现无锁并发读。
5
对象存储
存储 深入对象存储——MinIO/S3 架构、纠删码实现、多地域复制、生命周期管理,理解如何存储海量非结构化数据。






