Skip to content

第4章:B+树与存储引擎


元数据卡

维度
难度(进阶)
前置第1章 SQL 基础,了解二叉搜索树概念
关键词B+树、索引、Buffer Pool、WAL、ARIES、PostgreSQL、InnoDB
代码语言SQL / 伪代码

你的进度

老陈带你走进数据堡垒的地下室。一排排巨大的铁质文件柜从地面延伸到天花板,每个柜门上标着数字标签。'这就是索引。'老陈拍了拍最近的一个柜子——20万页。'没有索引,查找就是翻遍每一页。'B+树就是这个文件柜系统的骨架。

破局 · 溯源

为什么需要索引——全表扫描的代价

先看一个问题。你手头有一份 1 亿条的 quests 表:

sql
SELECT * FROM quests WHERE adventurer_id = 42;

没有索引时,数据库只能做 顺序扫描(Sequential Scan):从第一页读到最后一页,逐行检查 adventurer_id 是否等于 42。

假设每行 200 字节,一个 4KB 的页能装 20 行。1 亿行就是 500 万页。

pseudo
// 全表扫描伪代码
page_id = 0
while page_id < total_pages:
 page = read_page_from_disk(page_id) // 一次磁盘 I/O
 for each row in page:
 if row.adventurer_id == 42:
 add_to_result(row)
 page_id++

// 500 万次磁盘 I/O——这就是查询慢的原因

机械硬盘一次随机 I/O 大约 10ms——500 万次是 5 万秒(13.8 小时)。SSD 一次 0.1ms,500 万次也是 500 秒。这还算快的,但也不是"几毫秒"能解决的量级。

索引的目标:把 O(n) 的扫描变成 O(log n) 的查找。

B+树结构——数据堡垒的核心骨架

老陈带你走到最近的一个文件柜前,拉开抽屉,里面是密密麻麻的标签卡。'这不是普通的柜子,'他说,'你看着。'

他指向柜子最顶层的目录卡——上面写着数字范围,从 0 到 1 亿。目录卡不存文件,只告诉你往哪个抽屉走。每个抽屉里又有一张子目录卡,继续引导你。最底层的抽屉里才是真正的文件。

这就是 B+树——数据库索引最主流的骨架。

它和 B-树很像,但有一个关键区别:

  • B-树:每个节点既能存键也能存值,内节点和叶节点都存数据
  • B+树:只有叶节点存值;内节点只存键(用于导航)

'就像我只看文件柜上的标签就能找到你想要的档案,不用打开每个抽屉。'老陈拍了拍柜子。

B+树的一个节点对应一个数据库页(通常 4KB-16KB),由海量节点组成一棵平衡的多叉树。

 
 内节点 
 50 | 150 
 
 
 
 25 100 
 25|75 100|...
 
 
 
 叶节点 叶节点 叶节点 
 1..25 26..75 76..100
 ⇄ ⇄ ⇄

看这个图,你能发现三个设计上的特别之处:

  1. 扇出(Fan-out)极高。一个 4KB 的页如果存 8 字节的键,能存大约 500 个键。这意味着三层 B+树就能处理 500³ ≈ 1.25 亿条记录。大部分 B+树是 3-4 层。
  2. 叶节点通过链表连接。这是 B+树相比 B-树的关键优势——范围查询不用回溯上层节点,顺着链表走就行。
  3. 所有叶节点在同一层。从根到任何叶节点的路径深度相同,保证查找延迟稳定。

插入、查找、范围查询——三个最常用操作

'知道结构了,现在你来试试。'老陈递给你一张纸条,上面写着一个 quest 编号。

查找(Point Lookup)——找到这个编号对应的记录:

pseudo
function btree_search(tree key):
 node = tree.root
 while node is not leaf:
 i = binary_search(node.keys key)
 node = node.children[i]
 // node is now a leaf
 i = binary_search(node.keys key)
 if node.keys[i] == key:
 return node.values[i]
 else:
 return NOT_FOUND

你从根节点开始,每层用二分查找决定往哪个子节点走。3 层 B+树只用读 3 个页——3 次磁盘 I/O,从 500 万次砍到 3 次,几十毫秒搞定。

'如果是一个范围呢?'你追问,'比如找出 40 到 60 级之间的所有冒险者。'

老陈笑了笑,走到柜子侧面,指了指叶节点之间的横向连接。'这才是 B+树比 B-树聪明的地方。'

范围查询(Range Scan)——找到起点,顺着走:

sql
SELECT * FROM quests WHERE adventurer_id BETWEEN 40 AND 60;

找到 adventurer_id=40 的叶节点后,顺着叶节点的链表向右遍历,直到超过 60——完全不回溯上层。

pseudo
function btree_range_scan(tree lower upper):
 leaf = find_leaf(tree lower)
 result = []
 while leaf is not null:
 for each (key value) in leaf:
 if key > upper:
 return result
 if key >= lower:
 result.append(value)
 leaf = leaf.next_sibling
 return result

'那往柜子里加新档案呢?'你问。

老陈拉开了柜子底层的一个抽屉——已经塞满了。'你看,这就是麻烦所在。'

插入——当抽屉满了的时候:

pseudo
function btree_insert(tree key value):
 leaf = find_leaf(tree key)
 insert_into_leaf(leaf key value)
 if leaf.is_full(): // 节点溢出
 split_leaf(leaf)
 // 将中间键提升到父节点
 propagate_split(leaf.parent)

插入会导致节点溢出(超过一个页能装的键数)。这时需要分裂:把一半键移到新节点,把中间键提升到父节点。如果父节点也满了,递归分裂。最坏情况下分裂到根——树长高一层。这就是 B+树由下往上生长的方式。

'树不是从根往下长的,是从叶子往上长的。'老陈拍了拍书架,'跟真的树反着来。'

Buffer Pool——为什么不能每次都读磁盘

你准备测试一下索引的速度,老陈摆了摆手。'别急着跑地下室,你腿不累,磁盘会累。'

如果每查一页都走磁盘 I/O,数据库的吞吐量会非常难看。即使索引把查询从 500 万次 I/O 降到 3 次,对于百万 QPS 的场景,3 次也是天文数字。

老陈带你到柜子旁边的一个大桌子前。桌上堆着今天被翻阅过的文件。'这是 Buffer Pool,'他说,'你查过的文件放在桌上,下次还查它就不用跑地下室了。'

Buffer Pool(也叫缓存池)是数据库在内存中维护的页缓存。所有读写都优先经过 Buffer Pool:

查询请求
 
 
 命中(hit) 
 Buffer Pool 返回数据 
 (内存页缓存) 
 
 LRU 链表 未命中(miss)
 ... 
 磁盘读取 
 (page_id) 
 
 
 
 
 读入 Buffer 
 Pool 返回数据

Buffer Pool 的大小通常是物理内存的一定比例。PostgreSQL 的 shared_buffers 默认 128MB,生产环境通常设为内存的 25%。MySQL InnoDB 的 innodb_buffer_pool_size 甚至默认是物理内存的 70-80%。

淘汰策略:当 Buffer Pool 满了,需要腾出空间放新页。最基础的是 LRU(Least Recently Used):淘汰最长时间没被访问的页。

但 LRU 在数据库场景有一个问题——顺序扫描污染。一个全表扫描会把大量页读入 Buffer Pool,把真正的"热页"挤出去。

sql
-- 这个大查询会污染 Buffer Pool
SELECT * FROM vault_items WHERE value > 5000;
-- 之后,真正热门的 quests 索引页被挤出去了

LRU-K 改进:传统 LRU 只记录最近一次访问。LRU-K 记录最近 K 次访问的时间戳。一个页只有在被访问 K 次以上才被认为是"热的"。顺序扫描的页只出现一次,不会被提升为热页,很快被淘汰。

PostgreSQL 使用的一个变体叫 clock sweep(时钟扫描),也是一种近似 LRU,但更轻量——不需要维护精细的 LRU 链表。

WAL——先写日志,再写数据

你往数据库里插了一行数据。它现在在 Buffer Pool 里——在内存中,还没落盘。

如果这时候断电了,数据就丢了。

Write-Ahead Logging(WAL) 的核心原则:在修改任何数据页之前,先把修改记录写到日志文件(WAL 文件)中,并且保证日志已经安全落盘。

INSERT INTO vault_items VALUES (1 '龙骨盾' '武器' 5000);
 
 
 
 1. 写 WAL 日志到磁盘 ← 必须先做
 <1 '龙骨盾' '武器' 5000 INSERT> 
 
 
 
 
 2. 修改 Buffer Pool ← 可以稍后落盘
 中的对应页 
 
 
 
 
 3. (后台) 脏页刷盘 ← checkpoint

为什么这能保证不丢数据?

  • 数据库崩溃重启后,读取 WAL 日志
  • REDO:把崩溃前已提交但未写入数据文件的操作重放
  • UNDO:把崩溃前未提交的操作回滚

WAL 日志的结构(简化版):

[LSN: 100] BEGIN T1
[LSN: 120] T1: INSERT INTO vault_items (item_id=1 name='龙骨盾' item_type='武器' value=5000) page_id=42 offset=128
[LSN: 150] COMMIT T1
[LSN: 160] T2: UPDATE vault_items SET value=6000 WHERE item_id=1 page_id=42 offset=128
[LSN: 180] CHECKPOINT

LSN(Log Sequence Number)是日志中的单调递增序号,也是日志在文件中的偏移量。每个数据页上会记录它被修改时的最大 LSN(page_lsn),恢复时就知道哪些页需要 REDO。

ARIES 恢复算法——如果堡垒断电了

'记了这么多日志,但如果真断电了怎么恢复呢?'你问。

老陈从抽屉里拿出一本操作手册,封面写着 'ARIES'。'1992 年提出来的算法,到现在还是 PostgreSQL 和 InnoDB 的恢复基础。你只需要记住三个阶段。'

阶段 1:分析(Analysis)——从最近的 checkpoint 往前扫描 WAL,确定哪些事务在崩溃时是活跃的(既没提交也没回滚)。老陈管这叫'清点损失'。

阶段 2:REDO——从 checkpoint 开始,对所有已记录的修改重放。对于每个日志记录,检查对应数据页的 page_lsn:如果小于日志的 LSN,说明页需要重做;否则跳过。

阶段 3:UNDO——对分析阶段确定的活跃事务,反向回滚它们的修改。ARIES 用 CLR(Compensation Log Record) 来记录回滚操作,这样如果回滚过程中又崩溃了,可以从中断处继续。

pseudo
// ARIES 恢复(简化)
function aries_recovery():
 // 1. 分析
 dirty_pages loser_txns = analysis_pass()

 // 2. REDO
 for each log_record from oldest_dirty_page_lsn:
 page = read_page(log_record.page_id)
 if page.page_lsn < log_record.lsn:
 redo_modification(page log_record)
 write_page(page)

 // 3. UNDO
 for each txn in loser_txns (reverse order):
 for each log_record in txn.logs (reverse order):
 if not already_compensated(log_record):
 clr = create_CLR(txn log_record)
 undo_modification(page log_record)
 write_clr(clr)

ARIES 真正重要的洞察:WAL 日志不需要记录完整的前后镜像(在 CMU 课程中这被称为 physiological logging)——只要记录"在 page_id=42 的偏移 128 处,把旧值改成新值"就够了。这样 REDO 是幂等的,不会因为你重放两次就出问题。


深入冒险

B+树在磁盘上的物理布局

'理论说完了,看看真实代码里长什么样。'老陈带你走进档案室,打开一个数据库的页文件。'你之前看的图是概念,现在是真实的比特排列。'

磁盘是个复杂的块设备——它按扇区(512B)读写,但文件系统按块(4KB)组织。数据库需要在这中间做一层映射。

PostgreSQL 的页面布局:

 
 PageHeaderData (24 bytes) ← LSN、校验和、空闲空间起始、特殊数据
 
 ItemIdData 数组 ← 每个 ItemId 4 字节(偏移+长度)
 (指向实际元组的指针) 
 
 ← 空闲空间 → 
 
 实际元组数据(从底部往上长) 
 (HeapTupleHeader + 数据)

PostgreSQL 的页是 8KB(编译时可调)。每个页头部包含页的校验和、空闲空间、LSN 等元数据。实际数据从页底部往上生长,ItemId 数组从头部往下生长——这样不需要移动数据就能回收空闲空间。

'注意这个设计:数据从下往上,索引从上往下,中间是空闲空间——两边向中间挤,空间就回收了。'老陈用手指比划了一下。

聚簇索引 vs 非聚簇索引——叶子到底存什么

'回到核心问题:B+树的叶节点存什么?'老陈敲了敲柜子。'这决定你的表是'数据跟着索引走'还是'索引指向数据'。'

这取决于索引类型:

  • 聚簇索引(Clustered Index):叶节点存完整行数据。表数据本身按索引顺序物理存储。一张表只有一个聚簇索引。

  • MySQL InnoDB 的 PRIMARY KEY 就是聚簇索引

  • 没有主键时,InnoDB 会自动生成一个隐式的 ROW_ID

  • 非聚簇索引(Secondary Index):叶节点存主键值(InnoDB)或行指针(PostgreSQL)

  • 通过二级索引查询时,需要先找到主键值,再回表查聚簇索引(这叫"回表查询")

sql
-- InnoDB 示例
CREATE TABLE vault_items (
 item_id INT PRIMARY KEY -- 聚簇索引
 item_type VARCHAR(20)
 name VARCHAR(100)
 value INT
);
CREATE INDEX idx_type ON vault_items(item_type); -- 二级索引

-- 这个查询走二级索引找到 item_id,再回表查完整行
SELECT * FROM vault_items WHERE item_type = '武器';

-- 这个查询直接走聚簇索引,不需要回表
SELECT * FROM vault_items WHERE item_id = 42;

PostgreSQL 的处理不同:它的所有索引都是"二级索引"(非聚簇),表数据以堆的形式存储在单独的页面中,索引叶节点指向 (page_id offset)CTID(Tuple ID)。

sql
-- PostgreSQL 行定位
SELECT ctid * FROM vault_items WHERE item_id = 42;
-- ctid 的格式是 (page_id tuple_index)
-- 例如 (42 5) 表示第 42 页的第 5 个元组

这有什么影响?InnoDB 的聚簇索引插入顺序很重要——按主键顺序插入最快(不用频繁页分裂)。UUID 做主键会让 InnoDB 很痛苦(随机插入导致大量页分裂)。PostgreSQL 的堆组织方式对插入顺序不那么敏感,但代价是二级索引需要额外的 CTID 解析。

PostgreSQL vs MySQL InnoDB:引擎哲学对比

'不同的数据库选择不同。'老陈把两张对比表推到你的面前。'这两种主流实现代表了不同的工程哲学——就像文件柜和书架的区别。'

特性PostgreSQL (Heap)MySQL InnoDB (聚簇索引)
数据组织堆表 + 独立索引聚簇索引(数据在索引叶节点)
页大小固定 8KB默认 16KB(1KB-64KB 可调)
索引叶节点内容CTID (page_id offset)主键值
回表CTID 直接定位通过主键回查聚簇索引
VACUUM需要(MVCC 死元组)不需要(UNDO 日志分离)
并发控制SSI / MVCCMVCC + Gap Lock
缓存shared_buffersinnodb_buffer_pool

'那 VACUUM 是怎么回事?'你问。

老陈从柜子里拿出一本标着旧日期的记录册。'PostgreSQL 的 MVCC 有一个特点:更新一行时,不覆盖原行,而是插入一个新版本。旧版本保留在页中,直到被 VACUUM 清理。如果表经常更新,死元组会膨胀。'

PostgreSQL 的堆组织为什么需要 VACUUM?

PostgreSQL 的 MVCC 实现方式是:更新一行时,不覆盖原行,而是插入一个新版本。旧版本保留在页中,直到被 VACUUM 清理。如果表经常更新,死元组会膨胀。

sql
-- 查看膨胀情况
SELECT n_dead_tup n_live_tup last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'quests';

-- 手动 VACUUM
VACUUM quests; -- 清理死元组(不锁表,可在线执行)
VACUUM FULL quests; -- 重建表(锁表,需要停服窗口)

InnoDB 的处理方式不同:它的 UNDO 日志存在独立的表空间中,更新是在原地修改数据页(通过 MVCC 实现并发控制)。这意味着 InnoDB 不需要像 PostgreSQL 一样频繁地 VACUUM。

'各有代价,'老陈总结,'PG 的更新快,但要定期打扫;InnoDB 不用打扫,但 UNDO 表空间一样会膨胀。'


常见陷阱

1. 索引不是越多越好

每个索引就是一棵 B+树。写操作(INSERT/UPDATE/DELETE)需要维护所有索引。如果一张表有 5 个索引,写入一条数据要写 1 个聚簇索引 + 5 个二级索引——6 棵 B+树。

sql
-- 过度索引
CREATE INDEX idx_type ON vault_items(item_type);
CREATE INDEX idx_val ON vault_items(value);
CREATE INDEX idx_name ON vault_items(name);

-- 合理索引
CREATE INDEX idx_type_val ON vault_items(item_type value); -- 覆盖 (item_type) 和 (item_type value) 的查询

2. 复合索引的列顺序很重要

sql
-- 建了 (item_type value name) 的复合索引
CREATE INDEX idx_tvn ON vault_items(item_type value name);

-- 生效
SELECT * FROM vault_items WHERE item_type = '武器'; -- 走索引
SELECT * FROM vault_items WHERE item_type = '武器' AND value > 500; -- 走索引
SELECT * FROM vault_items WHERE item_type = '武器' AND name = '魔剑'; -- 只能用到 item_type 条件

-- 失效(最左前缀原则)
SELECT * FROM vault_items WHERE value > 500; -- 不走索引
SELECT * FROM vault_items WHERE name = '魔剑'; -- 不走索引

B+树的复合索引按 (a b c) 排序。查找时先比较 a,相等才比较 b。如果没有 a 的条件,B+树无法确定从哪个分支往下走。

3. Buffer Pool 太小导致性能雪崩

Buffer Pool 小 → 频繁淘汰热页 → 频繁读磁盘 → 查询变慢 → 连接堆积 → 数据库 OOM。

监控指标:Buffer Pool 命中率(Hit Ratio)。PostgreSQL 可以通过 pg_buffercache 扩展查看,InnoDB 通过 SHOW ENGINE INNODB STATUS

sql
-- InnoDB 查看 Buffer Pool 命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read_requests'; -- 总读取请求数
SHOW STATUS LIKE 'Innodb_buffer_pool_reads'; -- 实际磁盘读取数
-- 命中率 = (requests - reads) / requests * 100%
-- 目标:99% 以上

4. WAL 配置不当

WAL 写入是顺序 I/O,非常快。但如果 WAL 和数据文件在同一块磁盘上,当数据文件的随机 I/O 和 WAL 的顺序 I/O 争抢磁盘时,性能会大幅下降。

最佳实践:把 WAL 放在单独的 SSD 上。PostgreSQL 中由 pg_wal 目录管理,InnoDB 由 innodb_log_group_home_dir 配置。


通关挑战

** 热身(5 分钟,必做)**

  1. 在你的 PostgreSQL 或 MySQL 实例上创建一张 100 万行的表:
sql
CREATE TABLE test_vault (
 id SERIAL PRIMARY KEY
 item_value INTEGER NOT NULL
 item_name VARCHAR(100)
);

-- 插入 100 万行
INSERT INTO test_vault (item_value item_name)
SELECT floor(random() * 1000000) '宝物'
FROM generate_series(1 1000000);
  1. 运行以下查询并对比执行时间+执行计划:
sql
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE item_value = 42;
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE id = 42;
  1. value 列上建索引后再次运行:
sql
CREATE INDEX idx_val ON test_vault(item_value);
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE item_value = 42;

** 挑战(30 分钟,选做)**

用 Python 模拟一个最小 B+树:

python
# min_btree.py - 仅实现最核心的查找和插入
class BPlusTreeNode:
 def __init__(self is_leaf=False max_keys=4):
 self.is_leaf = is_leaf
 self.keys = []
 self.children = [] # 内节点用
 self.values = [] # 叶节点用
 self.next = None # 叶节点链表
 self.max_keys = max_keys

class BPlusTree:
 def __init__(self max_keys=4):
 self.root = BPlusTreeNode(is_leaf=True max_keys=max_keys)

 def search(self key):
 """查找单个键"""
 # 你的实现在这里
 pass

 def insert(self key value):
 """插入键值对"""
 # 你的实现在这里
 pass

 def range_query(self low high):
 """范围查询"""
 # 你的实现在这里
 pass

要求

  • search(42) 返回正确的 valueNone
  • range_query(10 20) 返回区间内的所有 (key value)
  • 节点分裂后树自动增长

** 观察**:用 strace 观察 PostgreSQL 的磁盘 I/O 模式:

bash
# 追踪一条 SELECT 的 I/O 操作
strace -e trace=pread64pwrite64 -p $(pgrep -n postgres) 2>&1 | head -20

或者在 MySQL 中查看 I/O 状态:

sql
SHOW ENGINE INNODB STATUS\G
-- 查看 "Page operations" 部分

验收标准

读完本章后,你应该能:

  • [x] 解释 B+树相比 B 树和二叉搜索树的优势(扇出、叶节点链表、平衡深度)
  • [x] 描述 B+树的查找、插入、范围查询的伪代码流程
  • [x] 解释 Buffer Pool 的作用和 LRU-K 为什么比 LRU 更适合数据库
  • [x] 解释 WAL 为什么先写日志再写数据——以及这为什么能保证崩溃恢复
  • [x] 说出 ARIES 恢复的三个阶段(Analysis → REDO → UNDO)
  • [x] 对比 PostgreSQL(堆表+CTID)和 InnoDB(聚簇索引)的存储组织差异
  • [x] 解释为什么复合索引的最左前缀原则成立
  • [x] 诊断 Buffer Pool 命中率过低的问题

常见卡点

卡点原因解决
"为什么 B+树不用数组?数组 BFS 更快"数组对插入/删除代价太大,B+树的分裂设计保证写操作局部化B+树的写操作平均 O(log n),数组插入 O(n)
"为什么 WAL 不直接写数据文件,还要多一层日志?"数据文件是随机写入(更新各个页);WAL 是顺序追加,快得多顺序 I/O vs 随机 I/O 的差距可达 100 倍
"Buffer Pool 和 OS Page Cache 是什么关系?"大部分数据库用 O_DIRECT 跳过 OS Cache,自己管理双缓存浪费内存,自管理能更精准控制淘汰策略
"为什么 PostgreSQL 的 VACUUM 不自动运行?"自动运行的(autovacuum),但配置不当可能跟不上更新速度监控 n_dead_tup,调大 autovacuum_vacuum_scale_factor

现在不需要理解

  • B+树并发控制(锁耦合/乐观锁):第11-13章会讲事务和并发
  • B+树批量加载(Bottom-up Bulk Loading):适用于初始化加载大量数据
  • T树(内存数据库索引):第14章内存数据库会涉及
  • B-link Tree(B+树的并发变体):CMU 15-445 的高级话题

旅人笔记

数据库索引不是魔法——它是一棵精心设计的树。B+树用内节点导航(只存键)、叶节点存储数据并通过链表连接,把一个 O(n) 的全表扫描变成了 O(log n) 的索引查找。而 WAL 和 Buffer Pool 让你在"写入快慢"和"崩溃后数据不丢"之间找到了工程平衡。

下一站预告

Built with VitePress | Software Systems Atlas