mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5735 字
16 分钟
存储引擎:从磁盘到 B 树与 LSM 树
2024-05-18

当你执行一条 SELECT * FROM users WHERE id = 42,从敲下回车到拿到结果,中间发生了什么?上一章鸟瞰了数据库的全景图,知道了数据库大致分为查询处理器、执行引擎和存储引擎几层。但数据究竟怎么存在磁盘上?为什么有的数据库读快写慢,有的写快读慢?B 树和 LSM 树到底在争什么?

本章深入存储引擎——数据库最底层的核心。理解了存储引擎,你就理解了数据库性能的物理边界。

存储引擎的演进是一部”读与写的永恒权衡”史。1970 年代,IBM System R 使用 ISAM(Indexed Sequential Access Method)——一种静态的索引结构,简单但不支持动态插入。B 树(Bayer & McCreight,1970)解决了动态插入问题,成为关系数据库的标准索引结构。2000 年代,Google 的 Bigtable 论文(2006)引入了 LSM 树(Log-Structured Merge Tree)——写操作先追加到内存表,再异步合并到磁盘,写性能远超 B 树,但读性能需要多级查找。RocksDB(Facebook,2012)将 LSM 树推向了生产级,成为 LevelDB 的后继者。今天,B 树和 LSM 树是存储引擎的两大阵营:MySQL InnoDB、PostgreSQL 用 B 树,Cassandra、HBase、RocksDB 用 LSM 树。理解这段历史,你就会明白为什么”没有最好的存储引擎,只有最适合读写模式的存储引擎”。

前置知识#

  • Ch01 数据库全景:理解数据库的分层架构,存储引擎位于最底层
  • 操作系统基础:磁盘 I/O(顺序读写 vs 随机读写)、内存管理(页缓存)、文件系统
  • 数据结构基础:B 树、哈希表、跳表
Note

本章是系列中最”底层”的章节之一。如果你主要关注应用层优化,可以先跳到 索引原理,需要时再回来理解存储引擎。

一、存储引擎概述#

1.1 DBMS 的架构分层#

一个典型的数据库管理系统可以分为四层:

graph TB subgraph DBMS["数据库管理系统"] CLIENT["客户端连接"] --> TRANSPORT["传输层<br/>连接管理/认证/协议"] TRANSPORT --> QP["查询处理器<br/>解析/优化/计划"] QP --> EXEC["执行引擎<br/>算子执行/并行/排序"] EXEC --> SE["存储引擎<br/>缓冲/索引/日志/存储"] SE --> DISK["磁盘"] end style SE fill:#4CAF50,color:#fff style DISK fill:#607D8B,color:#fff
  • 传输层(Transport):管理客户端连接、认证、通信协议
  • 查询处理器(Query Processor):SQL 解析→语义分析→查询优化→执行计划生成
  • 执行引擎(Execution Engine):按执行计划调度算子,处理排序、聚合、连接等
  • 存储引擎(Storage Engine):数据的”管家”——负责数据在磁盘和内存之间的搬移、索引维护、事务日志管理
Note

存储引擎是数据库中与硬件最接近的一层。它的设计决策——用 B 树还是 LSM 树、页多大、怎么缓存——直接决定了数据库的读写性能特征。同一个查询处理器可以搭配不同的存储引擎,比如 MySQL 就支持 InnoDB、MyISAM 等多种引擎。

1.2 数据文件与索引文件#

存储引擎管理两类核心文件:

文件类型职责组织方式
数据文件存储实际的数据记录按页(Page)组织,InnoDB 16KB/页
索引文件加速数据查找的辅助结构B+ 树节点或 SSTable 文件

在某些存储引擎中(如 InnoDB),数据和索引是聚簇的——主键索引的叶子节点直接包含完整行数据,二级索引的叶子节点存储主键值。在另一些引擎中(如 MyISAM),数据和索引是分离的——索引指向数据文件中的偏移量。

// InnoDB 页结构简化示意
typedef struct page_t {
page_header_t header; // 页头信息
page_dir_t dir; // 页目录(槽位)
record_t records[]; // 用户记录(从低地址向高地址增长)
free_space_t free; // 空闲空间
} page_t;
typedef struct page_header_t {
uint32_t page_id; // 页编号
uint16_t n_records; // 记录数
uint16_t first_free; // 第一条空闲记录偏移
page_type_t type; // 页类型(索引页/数据页/LOB页等)
} page_header_t;

二、磁盘与内存#

2.1 随机 I/O vs 顺序 I/O#

理解存储引擎的一切设计,都要从磁盘 I/O 的特性出发。磁盘有两种截然不同的访问模式:

特性随机 I/O顺序 I/O
寻址开销需要寻道+旋转等待一次寻址后连续读写
HDD 吞吐~100 IOPS(~0.4 MB/s)~100-200 MB/s
SSD 吞吐~10,000-100,000 IOPS~500-3000 MB/s
性能差距基准线比随机快 50-500 倍
Important

顺序 I/O 和随机 I/O 的性能差距是存储引擎设计的第一性原理。B 树通过将相关数据集中在页中来减少随机 I/O 次数;LSM 树通过将随机写转化为顺序写来提升写入吞吐。两者都在用自己的方式”对抗”随机 I/O。

2.2 磁盘寻道时间#

传统机械硬盘(HDD)的随机访问延迟由三部分组成:

  1. 寻道时间(Seek Time):磁头移动到目标磁道,约 3-5 ms
  2. 旋转延迟(Rotational Latency):等待目标扇区转到磁头下方,7200 RPM 约为 4.16 ms
  3. 传输时间(Transfer Time):读取数据本身,通常可忽略

一次随机 I/O 约 5-10 ms,而顺序读取同样数据量可能只需 0.01 ms。这就是为什么数据库要竭尽全力减少随机 I/O。

2.3 SSD 的特性#

固态硬盘(SSD)没有机械部件,随机访问性能大幅提升,但仍有关键特性影响存储引擎设计:

SSD 特性对存储引擎的影响
随机读快B 树的随机读性能在 SSD 上显著改善
随机写仍慢于顺序写LSM 树的写入优势在 SSD 上依然存在
擦除块(Erase Block)4KB 写入需要先擦除整个块(128KB-2MB),引发写放大
磨损均衡SSD 需要均匀使用所有块,频繁更新同一页会加速磨损
FTL(闪存转换层)SSD 内部有映射表,随机写会导致 GC 开销

2.4 为什么数据库需要自己的 Buffer Pool#

操作系统已经有页缓存(Page Cache),为什么数据库还要自己管理缓冲区?

# 操作系统 Page Cache 的问题
# 1. 淘汰策略不可控:OS 使用通用 LRU,不知道哪些页对数据库更重要
# 2. 预读策略不匹配:OS 的预读是通用的,数据库知道自己的访问模式
# 3. 刷脏策略不可控:OS 可能突然刷大量脏页,造成 I/O 尖刺
# 4. 重复缓存:如果 DB 自己也缓存,同一页可能被缓存两次(浪费内存)
# 数据库 Buffer Pool 的优势
class BufferPoolAdvantage:
"""数据库自管 Buffer Pool 的核心优势"""
def controlled_eviction(self):
"""可控的淘汰策略"""
# 数据库知道哪些是索引页(热点)、哪些是数据页
# 可以实现 LRU-K、Clock 等更适合数据库的策略
pass
def smart_prefetch(self):
"""智能预读"""
# 索引范围扫描时,数据库知道接下来要读哪些页
# 可以提前异步加载,而不是等 OS 的顺序检测
pass
def group_flush(self):
"""分组刷脏"""
# 将相邻页的脏数据合并为一次顺序写
# 避免操作系统随机刷脏造成的 I/O 尖刺
pass
Warning

使用 Direct I/O(绕过 OS Page Cache)是数据库的常见做法。MySQL InnoDB 默认使用 O_DIRECT 标志打开数据文件,跳过操作系统缓存,完全依赖自己的 Buffer Pool。如果同时使用 OS 缓存和 Buffer Pool,不仅浪费内存,还可能产生一致性问题。

三、B 树家族#

3.1 从二叉搜索树到 B 树#

为什么数据库不用二叉搜索树?核心原因是磁盘访问的单位是页,不是字节

一棵高度为 3 的平衡二叉树存储 100 万条记录,查找一个键需要 20 次随机 I/O(log₂10⁶⁺ ≈ 20)。而一棵 3 阶 B 树(每页存几百个键),同样 100 万条记录只需要 3 次随机 I/O。

树类型分支因子100 万记录的树高磁盘 I/O 次数
二叉搜索树2~20~20
B 树(阶 200)200~3~3
B+ 树(阶 200)200~3~3

B 树的核心思想:让每个节点尽可能大,填满一个磁盘页。一个 16KB 的页可以存储几百个键值对,从而将树的高度压缩到 3-4 层。

3.2 B+ 树#

B+ 树是 B 树最常用的变体,几乎所有主流关系数据库的索引都基于 B+ 树。它与 B 树的关键区别:

特性B 树B+ 树
数据存储位置所有节点都存数据只有叶子节点存数据
内部节点内容键 + 数据 + 子指针仅键 + 子指针
叶子节点连接链表连接(范围扫描利器)
查找路径可能在中间节点命中必须走到叶子节点
空间利用率中间节点存数据,键少中间节点只存键,分支因子更大
graph TB subgraph B+树结构 ROOT["根节点<br/>[10 | 20 | 30]"] L1A["内部节点<br/>[5 | 10]"] L1B["内部节点<br/>[15 | 20]"] L1C["内部节点<br/>[25 | 30]"] ROOT --> L1A ROOT --> L1B ROOT --> L1C LEAF1["叶子<br/>[1|2|3|5]"] LEAF2["叶子<br/>[6|8|10]"] LEAF3["叶子<br/>[12|15|18]"] LEAF4["叶子<br/>[20|22]"] LEAF5["叶子<br/>[25|28]"] LEAF6["叶子<br/>[30|35]"] L1A --> LEAF1 L1A --> LEAF2 L1B --> LEAF3 L1B --> LEAF4 L1C --> LEAF5 L1C --> LEAF6 LEAF1 -->|"→"| LEAF2 LEAF2 -->|"→"| LEAF3 LEAF3 -->|"→"| LEAF4 LEAF4 -->|"→"| LEAF5 LEAF5 -->|"→"| LEAF6 end style ROOT fill:#1565c0,color:#fff style L1A fill:#42a5f5,color:#fff style L1B fill:#42a5f5,color:#fff style L1C fill:#42a5f5,color:#fff style LEAF1 fill:#66bb6a,color:#fff style LEAF2 fill:#66bb6a,color:#fff style LEAF3 fill:#66bb6a,color:#fff style LEAF4 fill:#66bb6a,color:#fff style LEAF5 fill:#66bb6a,color:#fff style LEAF6 fill:#66bb6a,color:#fff

B+ 树叶子节点的链表连接是范围查询的关键。执行 WHERE id BETWEEN 10 AND 25,只需找到起始键 10,然后沿链表顺序读取直到 25,无需回溯上层节点。

3.3 B+ 树的插入与分裂#

B+ 树的插入遵循”找到位置→放入→溢出则分裂”的逻辑:

class BPlusTreeNode:
"""B+ 树节点简化实现"""
def __init__(self, order, is_leaf=False):
self.keys = []
self.children = [] # 内部节点:子节点指针;叶子节点:数据
self.is_leaf = is_leaf
self.order = order # 阶数
self.next_leaf = None # 叶子节点链表指针
def insert(self, key, value):
if self.is_leaf:
return self._insert_leaf(key, value)
else:
# 找到合适的子节点,递归插入
idx = self._find_child(key)
result = self.children[idx].insert(key, value)
if result: # 子节点分裂,需要提升中间键
mid_key, new_node = result
self._promote(mid_key, new_node)
if len(self.keys) >= self.order:
return self._split()
return None
def _insert_leaf(self, key, value):
"""叶子节点插入,溢出时分裂"""
idx = self._find_position(key)
self.keys.insert(idx, key)
self.children.insert(idx, value)
if len(self.keys) >= self.order:
return self._split_leaf()
return None
def _split_leaf(self):
"""叶子节点分裂:左半留下,右半新建,中间键提升"""
mid = len(self.keys) // 2
new_node = BPlusTreeNode(self.order, is_leaf=True)
new_node.keys = self.keys[mid:]
new_node.children = self.children[mid:]
new_node.next_leaf = self.next_leaf
self.keys = self.keys[:mid]
self.children = self.children[:mid]
self.next_leaf = new_node
# 返回提升的键和新节点
return (new_node.keys[0], new_node)

分裂过程的关键点:

  1. 叶子节点分裂:左半留在原节点,右半移到新节点,新节点的最小键复制提升到父节点
  2. 内部节点分裂:中间键移动提升到父节点(不保留在原节点)
  3. 根节点分裂:创建新根,树高 +1(这是 B+ 树唯一长高的方式)
Note

B+ 树的分裂策略影响空间利用率。50/50 分裂简单但浪费空间;InnoDB 使用”插入位置感知”的分裂策略——如果插入在页面末尾,只移动少量记录到新页,保持原页较满。

3.4 B+ 树的删除与合并#

删除比插入更复杂,因为节点下溢(键数低于最小值)时需要合并借用

  • 借用(Redistribution):从兄弟节点借一个键,通过父节点中转
  • 合并(Merge):将两个节点合并为一个,从父节点删除分隔键

实际生产中,很多 B+ 树实现(包括 InnoDB)不立即合并,而是标记删除、留出空间给后续插入使用。这避免了频繁的合并/分裂抖动。

四、LSM 树#

4.1 写入优化:从随机写到顺序写#

LSM 树(Log-Structured Merge Tree)的核心思想:将随机写转化为顺序写

B 树的写入是”就地更新”——找到页、修改、写回,这是随机 I/O。LSM 树的写入是”追加”——新数据直接写入内存,满了之后顺序刷盘,永远不修改已有文件。

graph LR subgraph LSM写入流程 WRITE["写入请求"] --> WAL["WAL 日志<br/>(顺序写)"] WRITE --> MEM["MemTable<br/>(内存排序树)"] MEM -->|"满"| IMMEM["Immutable MemTable<br/>(冻结,等待刷盘)"] IMMEM -->|"后台刷盘"| SST["SSTable<br/>(磁盘有序文件)"] end style WRITE fill:#e3f2fd,stroke:#1565c0 style WAL fill:#fff3e0,stroke:#e65100 style MEM fill:#e8f5e9,stroke:#2e7d32 style IMMEM fill:#f3e5f5,stroke:#6a1b9a style SST fill:#fce4ec,stroke:#c62828

LSM 树写入流程:

  1. 写入 WAL:先将操作追加到预写日志(保证持久性)
  2. 写入 MemTable:在内存中的有序数据结构(通常是跳表或红黑树)插入数据
  3. MemTable 满时冻结:标记为 Immutable MemTable,不再接受新写入
  4. 后台刷盘:Immutable MemTable 被顺序写入磁盘,成为 SSTable 文件
  5. 新 MemTable 接管:创建新的空 MemTable 继续接收写入
// SSTable 文件结构简化
typedef struct sstable_t {
data_block_t blocks[]; // 数据块(按 key 有序)
index_block_t index; // 索引块(每个数据块的 min_key)
filter_block_t filter; // 布隆过滤器块
footer_t footer; // 尾部:指向 index 和 filter 的偏移
} sstable_t;
typedef struct data_block_t {
entry_t entries[]; // key-value 条目
uint32_t restarts[]; // 重启点(前缀压缩的断点)
uint32_t num_restarts; // 重启点数量
} data_block_t;

4.2 Compaction 策略#

SSTable 文件会越来越多,不同层之间可能存在相同的 key(更新/删除)。Compaction 的作用是合并 SSTable、消除冗余、回收空间

策略原理优点缺点
Size-Tiered相似大小的 SSTable 合并为一层写放大低空间放大高(旧版本数据占空间)
LeveledL0→L1→L2…逐层合并,每层 key 范围不重叠空间放大低写放大高(同一数据多次重写)
FIFO直接丢弃最旧的 SSTable写放大最低不支持更新/删除

Size-Tiered Compaction(Cassandra 默认):

L0: [SST1] [SST2] [SST3] [SST4] ← 4 个相似大小的 SSTable
↓ 合并
L1: [SST_big] ← 1 个合并后的大 SSTable

Leveled Compaction(RocksDB 默认):

L0: [SST1] [SST2] ← 允许范围重叠
L1: [SST_a] [SST_b] [SST_c] ← key 范围不重叠,总大小 ≤ 10MB
L2: [SST_1] ... [SST_10] ← 总大小 ≤ 100MB
L3: [SST_1] ... [SST_100] ← 总大小 ≤ 1000MB
每层大小是上层的 10 倍
# RocksDB 中查看 Compaction 统计
# 通过 rocksdb.compaction.stats 指标
# Level Files Size(MB) Read(MB) Write(MB) Write Amp
# --------------------------------------------------------
# L1 12 10.1 8.2 18.3 2.2
# L2 45 100.5 18.3 118.8 6.5
# L3 210 1002.0 118.8 1120.8 9.4
# --------------------------------------------------------

4.3 读放大与 Bloom Filter#

LSM 树的读操作需要检查多个层:MemTable → Immutable MemTable → L0 SSTable → L1 SSTable → … → Ln SSTable。这就是读放大问题。

Bloom Filter 是解决读放大的利器——一个空间效率极高的概率数据结构,可以快速判断某个 key 一定不存在于某个 SSTable 中:

import mmh3 # MurmurHash3
class BloomFilter:
"""布隆过滤器简化实现"""
def __init__(self, capacity, error_rate=0.01):
# 计算需要的位数和哈希函数数量
self.size = self._optimal_size(capacity, error_rate)
self.num_hashes = self._optimal_hashes(self.size, capacity)
self.bit_array = [False] * self.size
def add(self, key):
for i in range(self.num_hashes):
idx = mmh3.hash(str(key), i) % self.size
self.bit_array[idx] = True
def might_contain(self, key):
"""可能存在(有假阳性)或一定不存在(无假阴性)"""
for i in range(self.num_hashes):
idx = mmh3.hash(str(key), i) % self.size
if not self.bit_array[idx]:
return False # 一定不存在
return True # 可能存在(1% 假阳性率)
@staticmethod
def _optimal_size(n, p):
"""计算最优位数组大小:m = -(n * ln(p)) / (ln2)^2"""
import math
return int(-(n * math.log(p)) / (math.log(2) ** 2))
@staticmethod
def _optimal_hashes(m, n):
"""计算最优哈希函数数量:k = (m/n) * ln2"""
import math
return int((m / n) * math.log(2))

Bloom Filter 的关键特性:

特性说明
无假阴性如果 Filter 说”不存在”,则一定不存在
有假阳性如果 Filter 说”可能存在”,实际可能不存在(典型 1%)
空间效率1% 假阳性率下,每个 key 仅需 ~10 bit
不可删除标准布隆过滤器不支持删除(可用 Counting Bloom Filter)

五、B 树 vs LSM 树#

5.1 详细对比#

维度B+ 树LSM 树
读性能优秀:O(log N) 定位,1-3 次随机读较差:需检查多层,读放大
写性能较差:需读-改-写整页,随机 I/O优秀:纯追加写入,顺序 I/O
空间放大较高:页分裂导致 50% 空间浪费中等:Compaction 回收旧版本
写放大中等:修改一行需重写整页(16KB)较高:Leveled Compaction 多次重写
压缩效率较差:页内碎片多,压缩率低优秀:SSTable 不可变,可高效压缩
事务支持天然友好:就地更新,锁粒度细需额外机制:多版本合并复杂
范围查询优秀:叶子链表顺序扫描需合并多层结果
后台开销低:无 Compaction高:Compaction 消耗 CPU/IO

5.2 写放大深度分析#

写放大是衡量存储引擎效率的关键指标——实际写入磁盘的数据量与用户写入量的比值:

# B+ 树写放大估算
def btree_write_amplification(page_size=16*1024, record_size=100):
"""B+ 树修改一条记录需要重写整个页"""
return page_size / record_size # ≈ 163 倍
# LSM 树 Leveled Compaction 写放大估算
def lsm_write_amplification(num_levels=7, size_ratio=10):
"""每层都会重写数据,写放大 ≈ 层数 × 大小比率"""
return num_levels * size_ratio # ≈ 10-30 倍(实际更复杂)
# 实际测量
print(f"B+ 树写放大: {btree_write_amplification():.0f}x")
print(f"LSM 树写放大: {lsm_write_amplification():.0f}x (估算)")
# 输出:
# B+ 树写放大: 163x
# LSM 树写放大: 70x (估算)
Important

写放大的数字看似 B+ 树更差,但要注意:B+ 树的写放大是”一次性的”(只重写一次),而 LSM 树的写放大是”累积的”(同一数据在每层 Compaction 时都被重写)。对于写多读少的场景,LSM 树的总体 I/O 吞吐仍然更优,因为每次写入都是顺序的。

5.3 各自适用场景#

B+ 树更适合

  • 读多写少的 OLTP 场景(用户系统、订单查询)
  • 需要强事务保证的场景(金融、库存)
  • 范围查询频繁的场景(时间序列、区间统计)
  • 对延迟敏感的场景(P99 延迟要求稳定)

LSM 树更适合

  • 写多读少的场景(日志、监控数据、IoT 传感器)
  • 高吞吐写入场景(消息队列、事件流)
  • 数据量远大于内存的场景(LSM 的压缩更高效)
  • 时间序列数据(天然追加写入模式)

5.4 为什么 InnoDB 选 B+ 树而 RocksDB 选 LSM 树#

InnoDB 选择 B+ 树:MySQL 的定位是通用 OLTP 数据库,读性能和事务一致性是首要目标。B+ 树提供稳定的读延迟、天然的事务锁支持、高效的范围查询——这些都是 OLTP 场景的核心需求。

RocksDB 选择 LSM 树:RocksDB 起源于 Facebook 的 MyRocks 项目,目标是解决写入密集场景下的 I/O 瓶颈。LSM 树的顺序写入在 SSD 上尤其高效,配合 Bloom Filter 和 Block Cache,读性能也可以接受。

六、Buffer Pool#

6.1 替换策略#

Buffer Pool 是数据库在内存中的缓存层,存储从磁盘读入的数据页。当 Buffer Pool 满了,需要淘汰某些页来腾出空间。替换策略决定了淘汰谁:

策略原理优点缺点
LRU淘汰最近最少使用的页实现简单全表扫描污染缓存
ClockLRU 的近似,用引用位环形扫描开销低,无需维护链表精度不如 LRU
LRU-K记录最近 K 次访问时间,淘汰第 K 次最远的抗全表扫描污染实现复杂,需维护历史
InnoDB 改良 LRU分 Young 区和 Old 区,新页先入 Old 区抗扫描污染,保护热数据参数需调优
// InnoDB 改良 LRU 结构简化
typedef struct buf_pool_t {
// LRU 链表分为两段
ut_list_base_t LRU; // 完整 LRU 链表
ulint LRU_old_ratio; // Old 区比例(默认 3/8)
// 关键指针
buf_block_t* LRU_old; // Old 区的起始位置
buf_block_t* LRU_new; // Young 区的起始位置(MRU 端)
// 统计信息
ulint n_pages_read; // 读入页数
ulint n_pages_written; // 写出页数
ulint n_pages_made_young; // 从 Old 晋升到 Young 的次数
} buf_pool_t;
// 页访问时的晋升逻辑
void buf_page_accessed(buf_block_t* block) {
if (block->in_old_zone) {
// 在 Old 区中,被访问后是否晋升?
// 需要满足"停留时间"条件,防止全表扫描污染 Young 区
if (block->old_time > BUF_LRU_OLD_MIN_AGE) {
buf_LRU_make_young(block); // 移到 Young 区 MRU 端
}
} else {
// 在 Young 区,移到 MRU 端
buf_LRU_make_young(block);
}
}

6.2 脏页写回#

Buffer Pool 中的页被修改后成为脏页(Dirty Page),需要写回磁盘。写回策略影响崩溃恢复和性能:

graph LR subgraph Buffer Pool 脏页写回 READ["从磁盘读页"] --> BP["Buffer Pool<br/>(干净页)"] BP -->|"修改"| DIRTY["脏页"] DIRTY -->|"后台刷脏"| DISK["写回磁盘"] DIRTY -->|"WAL 先写"| REDO["Redo Log<br/>(保证持久性)"] end style DIRTY fill:#ff9800,color:#fff style REDO fill:#4caf50,color:#fff

关键规则:WAL 先于数据页写盘(Write-Ahead Log)。脏页写回前,对应的 Redo Log 必须已经持久化。这保证了崩溃后可以通过 Redo Log 恢复数据。

InnoDB 的刷脏策略由 innodb_io_capacityinnodb_io_capacity_max 控制:

-- 查看当前 Buffer Pool 状态
SHOW ENGINE INNODB STATUS\G
-- 关键指标:
-- Buffer pool size: 8192 pages (128MB)
-- Free buffers: 1024
-- Database pages: 7000
-- Old database pages: 2625
-- Modified db pages: 500 ← 脏页数量
-- Pages made young: 15000
-- Pages not made young: 3000
-- 调整刷脏速度(SSD 推荐)
SET GLOBAL innodb_io_capacity = 10000;
SET GLOBAL innodb_io_capacity_max = 20000;

6.3 预读#

预读(Prefetch)是 Buffer Pool 的重要优化——在用户请求之前提前加载可能需要的页:

预读类型触发条件场景
线性预读同一区(Extent)中连续页被顺序访问全表扫描、索引范围扫描
随机预读同一区中大量页被随机访问索引随机查找
B+ 树预读范围查询时预读叶子节点WHERE id BETWEEN 100 AND 200

6.4 InnoDB Buffer Pool 实例配置#

# my.cnf 中 Buffer Pool 关键参数
# Buffer Pool 大小(建议物理内存的 50-70%)
innodb_buffer_pool_size = 8G
# Buffer Pool 实例数(多实例减少锁竞争)
# 每个 instance 至少 1GB,总大小 / 1G ≈ 实例数
innodb_buffer_pool_instances = 8
# Old 区比例(默认 37%,即 3/8)
innodb_old_blocks_pct = 37
# Old 区停留时间(默认 1000ms)
# 全表扫描的页在 Old 区至少停留 1 秒才可能晋升到 Young 区
innodb_old_blocks_time = 1000
# 刷脏策略
innodb_io_capacity = 10000
innodb_io_capacity_max = 20000
innodb_max_dirty_pages_pct = 75.0
innodb_max_dirty_pages_pct_lwm = 10.0

七、WAL 与检查点#

7.1 Write-Ahead Log 原理#

WAL(Write-Ahead Log)是数据库持久性的基石。核心规则:数据页写盘前,对应的日志必须先写盘

graph TB subgraph WAL写入流程 TXN["事务提交"] --> LOG_BUF["Log Buffer<br/>(内存)"] LOG_BUF -->|"commit 时刷盘"| LOG_FILE["Redo Log File<br/>(磁盘,顺序写)"] LOG_FILE -->|"后台异步"| DATA_FILE["数据文件<br/>(磁盘,随机写)"] end subgraph 崩溃恢复 CRASH["崩溃发生"] --> SCAN["扫描 Redo Log"] SCAN --> REDO["重做(Redo)<br/>已提交但未落盘的事务"] SCAN --> UNDO["撤销(Undo)<br/>未提交的事务"] end style LOG_FILE fill:#4caf50,color:#fff style DATA_FILE fill:#607d8b,color:#fff

WAL 的关键优势:

  1. 顺序写:日志追加写入,每次事务提交只需一次顺序 I/O
  2. 组提交:多个事务的日志可以合并为一次 fsync
  3. 延迟刷脏:数据页可以延迟写盘,由后台线程异步完成

7.2 Redo Log 与 Undo Log#

数据库维护两种关键日志:

日志类型作用写入时机内容
Redo Log保证持久性:崩溃后重做已提交事务事务提交时修改后的值(after-image)
Undo Log保证原子性:回滚未提交事务、实现 MVCC修改数据前修改前的值(before-image)
-- InnoDB 中 Redo Log 的查看
SHOW VARIABLES LIKE 'innodb_log_file%';
-- innodb_log_file_size: 1GB ← 单个日志文件大小
-- innodb_log_files_in_group: 2 ← 日志文件数量
-- Redo Log 空间 = 1GB × 2 = 2GB
-- 如果写入密集,可能需要增大
-- 查看日志写入频率
SHOW ENGINE INNODB STATUS\G
-- Log sequence number: 1234567890 ← 当前 LSN
-- Log flushed up to: 1234567800 ← 已刷盘的 LSN
-- Pages flushed up to: 1234567000 ← 数据页刷到的 LSN
-- Last checkpoint at: 1234565000 ← 检查点 LSN

7.3 检查点加速恢复#

如果没有检查点,崩溃恢复需要从头扫描整个 Redo Log——可能几十 GB。检查点的作用是记录一个”安全点”,此点之前的数据页已经保证写盘,恢复时只需从此点开始扫描。

检查点类型触发条件特点
Sharp Checkpoint正常关闭数据库所有脏页写盘,恢复最快
Fuzzy Checkpoint后台周期性执行只记录 LSN,不强制所有脏页写盘

InnoDB 使用 Fuzzy Checkpoint,由后台线程异步刷脏,避免一次性大量 I/O。

7.4 ARIES 恢复算法简介#

ARIES(Algorithm for Recovery and Isolation Exploiting Semantics)是数据库恢复的经典算法,被 InnoDB、SQL Server、DB2 等广泛采用:

阶段操作目的
1. 分析阶段从最后一个检查点向前扫描 Redo Log确定脏页集合和活跃事务集合
2. 重做阶段从最早未落盘的 LSN 开始重做所有操作恢复到崩溃前的状态
3. 撤销阶段回滚所有未提交事务保证原子性
Note

ARIES 的关键设计:Redo 所有操作(包括未提交事务的),再 Undo 未提交事务。这看似浪费,但简化了恢复逻辑——Redo 阶段不需要判断事务是否提交,只需按 LSN 顺序重放。Undo 阶段才需要关心事务状态。将在事务与并发控制中更深入地讨论事务恢复的细节。

八、InnoDB vs RocksDB 对比#

8.1 架构对比#

graph TB subgraph InnoDB["InnoDB 架构"] direction TB I_QP["查询接口"] --> I_BP["Buffer Pool<br/>(改良 LRU)"] I_BP --> I_BT["B+ 树索引<br/>(聚簇索引)"] I_BT --> I_PAGE["数据页<br/>(16KB 就地更新)"] I_BP --> I_REDO["Redo Log<br/>(WAL 顺序写)"] I_BP --> I_UNDO["Undo Log<br/>(MVCC 回滚段)"] end subgraph RocksDB["RocksDB 架构"] direction TB R_QP["查询接口"] --> I_BC["Block Cache<br/>(LRU)"] R_QP --> I_BF["Bloom Filter"] I_BC --> R_MEM["MemTable<br/>(跳表)"] R_MEM --> R_IMM["Immutable MemTable"] R_IMM --> R_L0["L0 SSTable"] R_L0 --> R_L1["L1 SSTable"] R_L1 --> R_LN["... Ln SSTable"] R_MEM --> R_WAL["WAL"] end style InnoDB fill:#e3f2fd,stroke:#1565c0 style RocksDB fill:#fce4ec,stroke:#c62828

8.2 性能特征对比#

维度InnoDBRocksDB
点查延迟1-3 次随机读,稳定需查多层 + Bloom Filter,波动大
范围查询叶子链表顺序扫描,极快需合并多层迭代器,较慢
写入吞吐受随机 I/O 限制顺序写,吞吐高 5-10 倍
压缩率页内碎片多,压缩率低SSTable 不可变,压缩率高 30-50%
P99 延迟稳定Compaction 期间可能抖动
内存效率Buffer Pool 缓存热页Block Cache + Bloom Filter
事务支持完整 ACID + MVCC基础事务 + WriteBatch

8.3 适用场景#

选 InnoDB 的场景

  • 通用 OLTP 业务(电商、CRM、ERP)
  • 读写均衡或读多写少
  • 需要复杂事务和强一致性
  • 大量范围查询
  • 对 P99 延迟敏感

选 RocksDB 的场景

  • 写入密集型工作负载(日志、监控、IoT)
  • 作为嵌入式存储引擎(MyRocks、TiKV、CockroachDB)
  • 存储成本敏感(压缩率高,节省磁盘)
  • SSD 环境(LSM 的顺序写在 SSD 上优势更明显)
Tip

RocksDB 常被用作其他数据库的底层存储引擎——TiKV 用它存储键值数据,CockroachDB 用它存储 MVCC 数据,Kafka Streams 用它做本地状态存储。这种”存储引擎的引擎”定位,正是 LSM 树写入优势的体现。

·附、实践:SQLite 页结构与 WAL 模式#

本节用 SQLite 观察存储引擎的页结构和 WAL 模式。SQLite 是最轻量的 B 树存储引擎实现,适合学习。

1. 创建数据库并插入数据#

sqlite3 /tmp/test.db << 'SQL'
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
INSERT INTO users VALUES (2, 'Bob', 'bob@example.com');
INSERT INTO users VALUES (3, 'Charlie', 'charlie@example.com');
INSERT INTO users VALUES (4, 'Dave', 'dave@example.com');
INSERT INTO users VALUES (5, 'Eve', 'eve@example.com');
SELECT * FROM users;
SQL
1|Alice|alice@example.com
2|Bob|bob@example.com
3|Charlie|charlie@example.com
4|Dave|dave@example.com
5|Eve|eve@example.com

2. 查看页结构信息#

sqlite3 /tmp/test.db "PRAGMA page_size;"
# 4096
sqlite3 /tmp/test.db "PRAGMA page_count;"
# 2
sqlite3 /tmp/test.db "PRAGMA journal_mode;"
# delete

SQLite 默认页大小为 4096 字节(4KB),当前数据库只有 2 页(1 页文件头 + 1 页数据)。默认日志模式为 delete——事务回滚时用回滚日志恢复。

3. 切换到 WAL 模式#

sqlite3 /tmp/test.db "PRAGMA journal_mode=WAL;"
# wal
ls -la /tmp/test.db*
# -rw-r--r-- 1 root root 8192 May 8 11:12 /tmp/test.db

WAL(Write-Ahead Log)模式是 SQLite 的高性能日志方案——写操作追加到 WAL 文件,读操作同时读取数据文件和 WAL,两者互不阻塞。这与 PostgreSQL 的 WAL 和 MySQL InnoDB 的 Redo Log 原理相同。

4. B-tree 页面分析#

sqlite3 /tmp/test.db "SELECT name, rootpage FROM sqlite_master WHERE type='table';"
users|2

rootpage=2 表示 users 表的 B 树根节点在第 2 页。第 1 页是 sqlite_master 系统表。

注意:实验结束后清理:rm /tmp/test.db /tmp/test.db-wal /tmp/test.db-shm

九、总结#

本章从磁盘 I/O 的物理特性出发,深入剖析了存储引擎的两大范式:

核心脉络

  1. 磁盘特性决定设计:随机 I/O vs 顺序 I/O 的巨大差距,是 B 树和 LSM 树分道扬镳的根本原因
  2. B+ 树为读优化:矮胖的树结构减少随机 I/O,叶子链表加速范围查询,就地更新天然支持事务
  3. LSM 树为写优化:追加写入将随机写变顺序写,Compaction 回收空间,Bloom Filter 缓解读放大
  4. Buffer Pool 桥接内存与磁盘:改良 LRU 抗扫描污染,WAL 保证持久性,检查点加速恢复
  5. 没有银弹:InnoDB 和 RocksDB 的选择,本质是读性能与写性能的取舍

与后续章节的联系

  • B+ 树的索引结构在索引原理中会更深入地分析——覆盖索引、索引下推、最左前缀等实战技巧
  • WAL 和 Undo Log 是事务与并发控制的基础——MVCC、2PL、隔离级别都建立在这些机制之上
  • InnoDB 的具体实现细节将在MySQL 深入中展开——行格式、Gap Lock、Change Buffer、Doublewrite Buffer

存储引擎是数据库的地基。理解了 B 树与 LSM 树的设计取舍,你就能理解为什么不同数据库在性能上表现迥异——这不是实现优劣,而是设计选择。

支持与分享

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

存储引擎:从磁盘到 B 树与 LSM 树
https://blog.souloss.com/posts/database/storage-engine/
作者
Souloss
发布于
2024-05-18
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时