当你执行一条 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 树、哈希表、跳表
本章是系列中最”底层”的章节之一。如果你主要关注应用层优化,可以先跳到 索引原理,需要时再回来理解存储引擎。
一、存储引擎概述
1.1 DBMS 的架构分层
一个典型的数据库管理系统可以分为四层:
- 传输层(Transport):管理客户端连接、认证、通信协议
- 查询处理器(Query Processor):SQL 解析→语义分析→查询优化→执行计划生成
- 执行引擎(Execution Engine):按执行计划调度算子,处理排序、聚合、连接等
- 存储引擎(Storage Engine):数据的”管家”——负责数据在磁盘和内存之间的搬移、索引维护、事务日志管理
存储引擎是数据库中与硬件最接近的一层。它的设计决策——用 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 倍 |
顺序 I/O 和随机 I/O 的性能差距是存储引擎设计的第一性原理。B 树通过将相关数据集中在页中来减少随机 I/O 次数;LSM 树通过将随机写转化为顺序写来提升写入吞吐。两者都在用自己的方式”对抗”随机 I/O。
2.2 磁盘寻道时间
传统机械硬盘(HDD)的随机访问延迟由三部分组成:
- 寻道时间(Seek Time):磁头移动到目标磁道,约 3-5 ms
- 旋转延迟(Rotational Latency):等待目标扇区转到磁头下方,7200 RPM 约为 4.16 ms
- 传输时间(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使用 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+ 树 |
|---|---|---|
| 数据存储位置 | 所有节点都存数据 | 只有叶子节点存数据 |
| 内部节点内容 | 键 + 数据 + 子指针 | 仅键 + 子指针 |
| 叶子节点连接 | 无 | 链表连接(范围扫描利器) |
| 查找路径 | 可能在中间节点命中 | 必须走到叶子节点 |
| 空间利用率 | 中间节点存数据,键少 | 中间节点只存键,分支因子更大 |
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(这是 B+ 树唯一长高的方式)
B+ 树的分裂策略影响空间利用率。50/50 分裂简单但浪费空间;InnoDB 使用”插入位置感知”的分裂策略——如果插入在页面末尾,只移动少量记录到新页,保持原页较满。
3.4 B+ 树的删除与合并
删除比插入更复杂,因为节点下溢(键数低于最小值)时需要合并或借用:
- 借用(Redistribution):从兄弟节点借一个键,通过父节点中转
- 合并(Merge):将两个节点合并为一个,从父节点删除分隔键
实际生产中,很多 B+ 树实现(包括 InnoDB)不立即合并,而是标记删除、留出空间给后续插入使用。这避免了频繁的合并/分裂抖动。
四、LSM 树
4.1 写入优化:从随机写到顺序写
LSM 树(Log-Structured Merge Tree)的核心思想:将随机写转化为顺序写。
B 树的写入是”就地更新”——找到页、修改、写回,这是随机 I/O。LSM 树的写入是”追加”——新数据直接写入内存,满了之后顺序刷盘,永远不修改已有文件。
LSM 树写入流程:
- 写入 WAL:先将操作追加到预写日志(保证持久性)
- 写入 MemTable:在内存中的有序数据结构(通常是跳表或红黑树)插入数据
- MemTable 满时冻结:标记为 Immutable MemTable,不再接受新写入
- 后台刷盘:Immutable MemTable 被顺序写入磁盘,成为 SSTable 文件
- 新 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 合并为一层 | 写放大低 | 空间放大高(旧版本数据占空间) |
| Leveled | L0→L1→L2…逐层合并,每层 key 范围不重叠 | 空间放大低 | 写放大高(同一数据多次重写) |
| FIFO | 直接丢弃最旧的 SSTable | 写放大最低 | 不支持更新/删除 |
Size-Tiered Compaction(Cassandra 默认):
L0: [SST1] [SST2] [SST3] [SST4] ← 4 个相似大小的 SSTable ↓ 合并L1: [SST_big] ← 1 个合并后的大 SSTableLeveled Compaction(RocksDB 默认):
L0: [SST1] [SST2] ← 允许范围重叠L1: [SST_a] [SST_b] [SST_c] ← key 范围不重叠,总大小 ≤ 10MBL2: [SST_1] ... [SST_10] ← 总大小 ≤ 100MBL3: [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 (估算)写放大的数字看似 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 | 淘汰最近最少使用的页 | 实现简单 | 全表扫描污染缓存 |
| Clock | LRU 的近似,用引用位环形扫描 | 开销低,无需维护链表 | 精度不如 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),需要写回磁盘。写回策略影响崩溃恢复和性能:
关键规则:WAL 先于数据页写盘(Write-Ahead Log)。脏页写回前,对应的 Redo Log 必须已经持久化。这保证了崩溃后可以通过 Redo Log 恢复数据。
InnoDB 的刷脏策略由 innodb_io_capacity 和 innodb_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 = 10000innodb_io_capacity_max = 20000innodb_max_dirty_pages_pct = 75.0innodb_max_dirty_pages_pct_lwm = 10.0七、WAL 与检查点
7.1 Write-Ahead Log 原理
WAL(Write-Ahead Log)是数据库持久性的基石。核心规则:数据页写盘前,对应的日志必须先写盘。
WAL 的关键优势:
- 顺序写:日志追加写入,每次事务提交只需一次顺序 I/O
- 组提交:多个事务的日志可以合并为一次 fsync
- 延迟刷脏:数据页可以延迟写盘,由后台线程异步完成
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 ← 检查点 LSN7.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. 撤销阶段 | 回滚所有未提交事务 | 保证原子性 |
ARIES 的关键设计:Redo 所有操作(包括未提交事务的),再 Undo 未提交事务。这看似浪费,但简化了恢复逻辑——Redo 阶段不需要判断事务是否提交,只需按 LSN 顺序重放。Undo 阶段才需要关心事务状态。将在事务与并发控制中更深入地讨论事务恢复的细节。
八、InnoDB vs RocksDB 对比
8.1 架构对比
8.2 性能特征对比
| 维度 | InnoDB | RocksDB |
|---|---|---|
| 点查延迟 | 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 上优势更明显)
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;SQL1|Alice|alice@example.com2|Bob|bob@example.com3|Charlie|charlie@example.com4|Dave|dave@example.com5|Eve|eve@example.com2. 查看页结构信息
sqlite3 /tmp/test.db "PRAGMA page_size;"# 4096
sqlite3 /tmp/test.db "PRAGMA page_count;"# 2
sqlite3 /tmp/test.db "PRAGMA journal_mode;"# deleteSQLite 默认页大小为 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.dbWAL(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|2rootpage=2 表示 users 表的 B 树根节点在第 2 页。第 1 页是 sqlite_master 系统表。
注意:实验结束后清理:
rm /tmp/test.db /tmp/test.db-wal /tmp/test.db-shm
九、总结
本章从磁盘 I/O 的物理特性出发,深入剖析了存储引擎的两大范式:
核心脉络:
- 磁盘特性决定设计:随机 I/O vs 顺序 I/O 的巨大差距,是 B 树和 LSM 树分道扬镳的根本原因
- B+ 树为读优化:矮胖的树结构减少随机 I/O,叶子链表加速范围查询,就地更新天然支持事务
- LSM 树为写优化:追加写入将随机写变顺序写,Compaction 回收空间,Bloom Filter 缓解读放大
- Buffer Pool 桥接内存与磁盘:改良 LRU 抗扫描污染,WAL 保证持久性,检查点加速恢复
- 没有银弹:InnoDB 和 RocksDB 的选择,本质是读性能与写性能的取舍
与后续章节的联系:
- B+ 树的索引结构在索引原理中会更深入地分析——覆盖索引、索引下推、最左前缀等实战技巧
- WAL 和 Undo Log 是事务与并发控制的基础——MVCC、2PL、隔离级别都建立在这些机制之上
- InnoDB 的具体实现细节将在MySQL 深入中展开——行格式、Gap Lock、Change Buffer、Doublewrite Buffer
存储引擎是数据库的地基。理解了 B 树与 LSM 树的设计取舍,你就能理解为什么不同数据库在性能上表现迥异——这不是实现优劣,而是设计选择。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






