第4章:B+树与存储引擎
元数据卡
维度 值 难度 (进阶) 前置 第1章 SQL 基础,了解二叉搜索树概念 关键词 B+树、索引、Buffer Pool、WAL、ARIES、PostgreSQL、InnoDB 代码语言 SQL / 伪代码
你的进度
老陈带你走进数据堡垒的地下室。一排排巨大的铁质文件柜从地面延伸到天花板,每个柜门上标着数字标签。'这就是索引。'老陈拍了拍最近的一个柜子——20万页。'没有索引,查找就是翻遍每一页。'B+树就是这个文件柜系统的骨架。
破局 · 溯源
为什么需要索引——全表扫描的代价
先看一个问题。你手头有一份 1 亿条的 quests 表:
SELECT * FROM quests WHERE adventurer_id = 42;没有索引时,数据库只能做 顺序扫描(Sequential Scan):从第一页读到最后一页,逐行检查 adventurer_id 是否等于 42。
假设每行 200 字节,一个 4KB 的页能装 20 行。1 亿行就是 500 万页。
// 全表扫描伪代码
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
⇄ ⇄ ⇄看这个图,你能发现三个设计上的特别之处:
- 扇出(Fan-out)极高。一个 4KB 的页如果存 8 字节的键,能存大约 500 个键。这意味着三层 B+树就能处理 500³ ≈ 1.25 亿条记录。大部分 B+树是 3-4 层。
- 叶节点通过链表连接。这是 B+树相比 B-树的关键优势——范围查询不用回溯上层节点,顺着链表走就行。
- 所有叶节点在同一层。从根到任何叶节点的路径深度相同,保证查找延迟稳定。
插入、查找、范围查询——三个最常用操作
'知道结构了,现在你来试试。'老陈递给你一张纸条,上面写着一个 quest 编号。
查找(Point Lookup)——找到这个编号对应的记录:
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)——找到起点,顺着走:
SELECT * FROM quests WHERE adventurer_id BETWEEN 40 AND 60;找到 adventurer_id=40 的叶节点后,顺着叶节点的链表向右遍历,直到超过 60——完全不回溯上层。
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'那往柜子里加新档案呢?'你问。
老陈拉开了柜子底层的一个抽屉——已经塞满了。'你看,这就是麻烦所在。'
插入——当抽屉满了的时候:
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,把真正的"热页"挤出去。
-- 这个大查询会污染 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] CHECKPOINTLSN(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) 来记录回滚操作,这样如果回滚过程中又崩溃了,可以从中断处继续。
// 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)
通过二级索引查询时,需要先找到主键值,再回表查聚簇索引(这叫"回表查询")
-- 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)。
-- 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 / MVCC | MVCC + Gap Lock |
| 缓存 | shared_buffers | innodb_buffer_pool |
'那 VACUUM 是怎么回事?'你问。
老陈从柜子里拿出一本标着旧日期的记录册。'PostgreSQL 的 MVCC 有一个特点:更新一行时,不覆盖原行,而是插入一个新版本。旧版本保留在页中,直到被 VACUUM 清理。如果表经常更新,死元组会膨胀。'
PostgreSQL 的堆组织为什么需要 VACUUM?
PostgreSQL 的 MVCC 实现方式是:更新一行时,不覆盖原行,而是插入一个新版本。旧版本保留在页中,直到被 VACUUM 清理。如果表经常更新,死元组会膨胀。
-- 查看膨胀情况
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+树。
-- 过度索引
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. 复合索引的列顺序很重要
-- 建了 (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。
-- 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 分钟,必做)**
- 在你的 PostgreSQL 或 MySQL 实例上创建一张 100 万行的表:
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);- 运行以下查询并对比执行时间+执行计划:
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE item_value = 42;
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE id = 42;- 在
value列上建索引后再次运行:
CREATE INDEX idx_val ON test_vault(item_value);
EXPLAIN ANALYZE SELECT * FROM test_vault WHERE item_value = 42;** 挑战(30 分钟,选做)**
用 Python 模拟一个最小 B+树:
# 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)返回正确的value或Nonerange_query(10 20)返回区间内的所有(key value)对- 节点分裂后树自动增长
** 观察**:用 strace 观察 PostgreSQL 的磁盘 I/O 模式:
# 追踪一条 SELECT 的 I/O 操作
strace -e trace=pread64pwrite64 -p $(pgrep -n postgres) 2>&1 | head -20或者在 MySQL 中查看 I/O 状态:
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 让你在"写入快慢"和"崩溃后数据不丢"之间找到了工程平衡。