第1章:存储引擎概览(Storage Engine Overview)
一、导读
1.1 本章学习目标
第1章是《Database Internals》的开篇之作,它为读者建立起对数据库存储引擎的整体认知。存储引擎是数据库管理系统中最核心的组件——它决定了数据如何被组织、存储在磁盘上,以及如何被高效地检索和修改。理解存储引擎的内部工作原理,是区分"会用数据库"和"懂数据库"的分水岭。
本章的核心学习目标包括:
- 理解存储引擎在数据库系统中的角色:存储引擎位于查询引擎之下,负责数据的物理存储和访问,它是数据库的"地基"。
- 掌握磁盘 I/O 的基本特性:为什么磁盘访问模式对数据库性能至关重要?顺序读和随机读的性能差距有多大?
- 理解页(Page)的概念:数据库为什么不直接读写字节,而是以页为单位管理数据?
- 掌握缓冲池(Buffer Pool)的工作原理:内存如何加速磁盘数据的访问?LRU 替换策略有什么缺陷?
- 理解 WAL(Write-Ahead Logging)的基本思想:为什么数据库要先写日志再写数据?
- 建立存储引擎设计的整体框架:面向事务(OLTP)和面向分析(OLAP)的存储引擎有什么本质区别?
1.2 为什么本章如此重要
很多开发者在使用数据库时,只关心 SQL 写得好不好,而忽视了底层存储引擎的工作方式。然而,当你面对一个查询突然变慢、数据库频繁 OOM、或者需要在不同数据库之间做选型决策时,存储引擎的知识就变得至关重要。
正如建筑工程师需要理解地基的承载特性,数据库工程师也需要理解存储引擎的物理特性。本章正是这个"地基"的起点——它不深入某个具体的数据结构(那是后续章节的内容),而是帮你建立起理解存储引擎的思维框架。
Alex Petrov 在本书中反复强调一个观点:存储引擎的设计本质上是在不同工作负载之间做权衡。没有一个存储引擎能在所有场景下都表现优秀——B+ 树适合读多写少的 OLTP 场景,LSM 树适合写密集的场景,列式存储适合分析型查询。理解这些权衡的前提,就是理解存储引擎的基本工作原理。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
数据库的一条记录从写入到被查询到,经历了哪些步骤?
为什么数据库要以"页"为单位管理数据,而不是直接读写单条记录?
缓冲池的 LRU 策略在什么场景下会失效?有哪些改进方案?
WAL 的核心思想是什么?它如何保证数据库崩溃后的数据一致性?
面向事务的存储引擎和面向分析的存储引擎,在设计上有什么根本性的不同?
磁盘的顺序读和随机读性能差距有多大?这对存储引擎的设计有什么影响?
二、核心概念详解
2.1 存储引擎在数据库系统中的位置
一个典型的数据库管理系统可以分为三大组件:
查询引擎(Query Engine):负责 SQL 解析、查询优化、执行计划生成
存储引擎(Storage Engine):负责数据的物理存储、索引管理、事务保证
执行引擎(Execution Engine):负责实际执行查询计划中的算子
存储引擎是这三者中最底层的组件,它向上层提供数据的读写接口,向下管理磁盘上的物理数据。不同的数据库系统对存储引擎的组织方式不同:
- MySQL:采用可插拔的存储引擎架构(InnoDB、MyISAM 等)
- PostgreSQL:只有一个存储引擎,但功能非常完整
- RocksDB:一个纯粹的嵌入式 KV 存储引擎,被许多数据库用作底层存储
- Cassandra:基于 LSM 树的分布式存储引擎
┌──────────────────────────────┐
│ SQL Interface │
├──────────────────────────────┤
│ Query Processor │
│ (Parser → Optimizer → Plan) │
├──────────────────────────────┤
│ Execution Engine │
│ (Iterator Model / Volcano) │
├──────────────────────────────┤
│ Storage Engine │
│ (B+Tree / LSM / Columnar) │
├──────────────────────────────┤
│ Buffer Pool / Cache │
├──────────────────────────────┤
│ Disk I/O Layer │
│ (File System / Direct I/O)│
└──────────────────────────────┘2.2 磁盘 I/O 的物理现实
2.2.1 机械硬盘的物理特性
机械硬盘(HDD)的数据访问涉及三个时间分量:
- 寻道时间(Seek Time):磁头移动到目标磁道,约 4-10ms
- 旋转延迟(Rotational Latency):等待目标扇区转到磁头下方,约 2-8ms(7200RPM 时平均 4.17ms)
- 传输时间(Transfer Time):数据从磁盘表面读取到缓冲区
一次随机 I/O 操作的总延迟约为 5-15ms,而顺序读取 1MB 数据的延迟仅约 10-20ms。这意味着随机 I/O 的每字节成本比顺序 I/O 高出约 1000 倍。
随机读 4KB: ~10ms → 0.4 KB/ms
顺序读 1MB: ~15ms → 66.7 KB/ms
性能差距: ~167x (按每字节成本计算)2.2.2 固态硬盘的特性
固态硬盘(SSD)消除了寻道和旋转延迟,随机读延迟降低到 50-200μs。但 SSD 也有其独特的约束:
- 写入放大(Write Amplification):SSD 不能直接覆盖写入,必须先擦除整个块再写入,导致实际写入量大于逻辑写入量
- 块擦除寿命:每个 NAND 闪存块有有限的擦除次数(SLC: ~100K, MLC: ~10K, TLC: ~3K)
- GC(Garbage Collection):SSD 内部需要定期回收无效页,可能导致延迟尖峰
HDD: 随机读 ~10ms, 顺序读 ~15MB/s
SSD: 随机读 ~100μs, 顺序读 ~500MB/s
内存: 随机读 ~100ns, 顺序读 ~10GB/s
HDD → SSD: 随机读提升 100x
SSD → 内存: 随机读提升 1000x2.2.3 对存储引擎设计的启示
磁盘 I/O 的特性直接决定了存储引擎的设计方向:
- 尽量减少随机 I/O:将相关数据组织在一起,提高顺序访问比例
- 批量读写:一次读写较大的数据块(页),摊薄寻道成本
- 写优化:将随机写转换为顺序写(如 LSM 树的 append-only 写入)
- 预读:利用空间局部性,提前读取可能需要的数据
2.3 页(Page)——存储管理的基本单位
2.3.1 为什么需要页
数据库不直接管理单条记录的读写,而是以页(Page)(也叫块 Block)为单位进行 I/O 操作。典型的页大小为 4KB-16KB(InnoDB 默认 16KB,PostgreSQL 默认 8KB)。
以页为单位管理数据有三个核心优势:
减少 I/O 次数:一次 I/O 操作可以读取多条记录
利用局部性:相关记录倾向于被一起访问
简化管理:固定大小的块更容易分配和回收
页大小对比:
InnoDB: 16KB (默认), 支持 4KB/8KB/32KB/64KB
PostgreSQL: 8KB (编译时确定)
MySQL/MyISAM: 1KB (默认)
SQLite: 1KB-64KB (默认 4KB)
Oracle: 2KB-32KB (默认 8KB)2.3.2 页的内部结构
一个典型的数据库页包含以下组成部分:
┌─────────────────────────────────────┐
│ Page Header │ ← 页的元信息(页号、LSN、空闲空间指针等)
├─────────────────────────────────────┤
│ Free Space │ ← 空闲空间
├─────────────────────────────────────┤
│ ↓ Growth │
│ │
│ ↑ Growth │
├─────────────────────────────────────┤
│ Slot Directory │ ← 槽目录(指向记录的指针数组)
├─────────────────────────────────────┤
│ Page Footer │ ← 校验和、LSN 等
└─────────────────────────────────────┘槽目录(Slot Directory) 是一个关键设计:它是一个指向页内记录的指针数组,记录在槽目录中按排序键排列。这使得页内的二分查找成为可能,而不需要移动实际的数据记录。
2.3.3 页的组织方式
不同类型的页承担不同的职责:
- 数据页(Data Page):存储实际的用户数据记录
- 索引页(Index Page):存储索引键值和指向数据页的指针
- 溢出页(Overflow Page):存储大字段(如 TEXT、BLOB)的数据
- 空闲页(Free Page):已分配但未使用的页
- 位图页(Bitmap Page):跟踪哪些页是空闲的
2.4 缓冲池(Buffer Pool)
2.4.1 为什么需要缓冲池
即使是最快的 SSD,访问延迟也是内存的 1000 倍以上。为了减少磁盘 I/O,数据库在内存中维护一个缓冲池,缓存最近访问过的数据页。当需要读取一个页时,首先在缓冲池中查找(buffer hit),如果找到就直接使用,否则才从磁盘读取并放入缓冲池(buffer miss)。
读取路径:
1. 检查缓冲池 → 命中 → 直接返回(~100ns)
2. 缓冲池未命中 → 从磁盘读取页 → 放入缓冲池 → 返回(~100μs-10ms)
写入路径(Write-Through):
1. 修改缓冲池中的页(标记为 dirty)
2. 后台线程定期将 dirty 页刷回磁盘
写入路径(Write-Back):
1. 修改缓冲池中的页(标记为 dirty)
2. 先写 WAL 日志
3. 后台线程定期将 dirty 页刷回磁盘2.4.2 LRU 替换策略及其缺陷
最基本的缓冲池替换策略是 LRU(Least Recently Used):当缓冲池满了需要腾出空间时,淘汰最久未被访问的页。
但简单的 LRU 在实际场景中存在严重问题:
问题一:缓存污染(Cache Pollution)
全表扫描会将大量只访问一次的页加载到缓冲池,挤出热点数据。
场景: 缓冲池大小 100 页,热点数据 10 页
全表扫描 1000 页 → 热点数据全部被淘汰 → 后续查询性能暴跌
解决方案:
1. Adaptive LRU: 区分扫描和正常访问
2. LRU-K: 考虑访问频率而不仅是最近访问时间
3. 双缓冲池策略(InnoDB 的方案)问题二:并发竞争
多个线程同时访问缓冲池时,LRU 链表的操作需要加锁,成为性能瓶颈。
InnoDB 的解决方案:
1. 将 LRU 链表分成多个分区(partition)
2. 每个分区有独立的 LRU 链表和锁
3. 使用 chunk 为单位进行淘汰,减少锁竞争2.4.3 InnoDB 的自适应缓冲池
InnoDB 采用了一种改进的缓冲池策略——自适应 LRU 列表:
- 将缓冲池分为 young 区 和 old 区
- 新读入的页先进入 old 区
- 在 old 区存活超过阈值(innodb_old_blocks_time,默认 1000ms)后,如果被再次访问,则晋升到 young 区
- 淘汰时优先从 old 区淘汰
这种设计有效防止了全表扫描对热点数据的污染:扫描读入的页在 old 区不会被再次访问,很快就会被淘汰,而热点数据已经在 young 区得到保护。
2.5 WAL(Write-Ahead Logging)
2.5.1 核心思想
WAL 是数据库保证事务持久性(Durability)的核心机制。其基本思想非常简单:在修改数据页之前,必须先将修改操作写入日志。
WAL 的核心规则:
1. 修改数据页前,必须先写 redo log
2. redo log 必须持久化到磁盘(fsync),事务才能提交
3. 数据页可以延迟写入磁盘(异步刷盘)
为什么这样设计?
- 顺序写日志比随机写数据页快得多
- 崩溃恢复时,通过重放 redo log 恢复未刷盘的数据
- 允许数据页延迟刷盘,提高写入吞吐量2.5.2 Redo Log 的结构
一条 redo log 记录通常包含以下信息:
Redo Log Record:
┌─────────────────────────────┐
│ LSN (Log Sequence Number) │ ← 全局递增的日志序列号
├─────────────────────────────┤
│ Transaction ID │ ← 事务标识
├─────────────────────────────┤
│ Page ID │ ← 被修改的页
├─────────────────────────────┤
│ Offset within Page │ ← 页内偏移
├─────────────────────────────┤
│ Before Image (可选) │ ← 修改前的数据(用于 undo)
├─────────────────────────────┤
│ After Image │ ← 修改后的数据(用于 redo)
├─────────────────────────────┤
│ Checksum │ ← 校验和
└─────────────────────────────┘LSN 是 WAL 中的核心概念——它是一个单调递增的整数,标识了日志的全局顺序。每个数据页也记录了自己最后被修改时的 LSN,这样在恢复时,数据库只需要重放 LSN 大于页面 LSN 的日志记录。
2.5.3 检查点(Checkpoint)
如果 redo log 无限增长,崩溃恢复时需要重放所有日志,恢复时间会非常长。检查点机制解决了这个问题:
检查点的作用:
1. 将所有已修改的脏页刷回磁盘
2. 在日志中记录检查点位置
3. 崩溃恢复时,只需要从最近的检查点开始重放
检查点策略:
- 尖锐检查点(Sharp Checkpoint): 停止所有事务,刷完所有脏页
→ 简单但会导致长时间停顿
- 模糊检查点(Fuzzy Checkpoint): 不停止事务,记录检查点时的状态
→ 恢复时需要额外的 undo 操作
- InnoDB 采用模糊检查点 + 异步刷脏策略2.6 存储引擎的分类
2.6.1 面向事务的存储引擎(OLTP)
OLTP 存储引擎针对高并发、短事务的场景优化:
- B+ 树为基础:InnoDB、PostgreSQL、SQLite
- 特点:读优化、原地更新、支持复杂的索引结构
- 适用场景:在线交易、点查询、短事务
B+ 树存储引擎的读写特征:
写入: 找到目标页 → 修改页内记录 → 可能触发页分裂
读取: 从根到叶遍历 B+ 树 → 每层一次 I/O(缓冲池可缓存中间节点)
典型深度: 3-4 层可索引数十亿条记录2.6.2 面向分析的存储引擎(OLAP)
OLAP 存储引擎针对大规模数据分析场景优化:
- 列式存储:Parquet、ORC、ClickHouse
- 特点:写优化(append-only)、压缩率高、扫描效率高
- 适用场景:数据仓库、报表分析、大规模扫描查询
列式存储的读写特征:
写入: 按列组织数据 → 批量压缩 → 顺序追加到文件末尾
读取: 只读取查询需要的列 → 利用压缩减少 I/O → 向量化执行
压缩率: 通常可达 3x-10x(同列数据类型相同,压缩效率高)2.6.3 LSM 树存储引擎
LSM 树是一种介于 OLTP 和 OLAP 之间的设计,特别适合写密集型场景:
- 代表:RocksDB、LevelDB、Cassandra、HBase
- 特点:将随机写转换为顺序写、写放大换读放大
- 适用场景:写多读少、大规模 KV 存储
LSM 树的写入路径:
1. 写入 MemTable(内存中的有序结构)
2. MemTable 满后冻结为 Immutable MemTable
3. 后台线程将 Immutable MemTable 刷写为 SSTable(磁盘上的有序文件)
4. 多个 SSTable 通过 Compaction 合并,消除过期数据
写入效率: 顺序追加,接近磁盘带宽上限
读取代价: 可能需要查找多个 SSTable(通过 Bloom Filter 优化)三、实战案例
3.1 案例一:分析 InnoDB 缓冲池的命中率
-- 查看 InnoDB 缓冲池统计信息
SELECT
(innodb_buffer_pool_read_requests - innodb_buffer_pool_reads)
/ innodb_buffer_pool_read_requests * 100 AS buffer_hit_ratio,
innodb_buffer_pool_read_requests AS total_requests,
innodb_buffer_pool_reads AS disk_reads,
innodb_buffer_pool_pages_total AS total_pages,
innodb_buffer_pool_pages_data AS data_pages,
innodb_buffer_pool_pages_free AS free_pages
FROM information_schema.global_status
WHERE variable_name IN (
'innodb_buffer_pool_read_requests',
'innodb_buffer_pool_reads',
'innodb_buffer_pool_pages_total',
'innodb_buffer_pool_pages_data',
'innodb_buffer_pool_pages_free'
);
-- 典型的输出分析:
-- buffer_hit_ratio: 99.5% → 良好(>99% 为正常)
-- buffer_hit_ratio: 85% → 需要增大缓冲池
-- buffer_hit_ratio: <50% → 缓冲池严重不足或存在全表扫描分析:缓冲池命中率是衡量数据库性能的关键指标。如果命中率低于 99%,说明有大量的磁盘 I/O 发生。解决方案包括增大 innodb_buffer_pool_size、优化查询减少全表扫描、或者添加适当的索引。
3.2 案例二:观察 WAL 日志的写入模式
-- 查看 InnoDB redo log 状态
SHOW ENGINE INNODB STATUS;
-- 关注以下输出:
-- Log sequence number: 当前 LSN
-- Log flushed up to: 已刷盘的 LSN
-- Last checkpoint at: 最近检查点的 LSN
-- 计算 redo log 的写入速率
-- 间隔 10 秒采样两次:
SELECT
(lsn2 - lsn1) / 10 AS bytes_per_second
FROM (
SELECT
-- 第一次采样 LSN
12345678 AS lsn1,
-- 第二次采样 LSN
13456789 AS lsn2
) t;
-- 输出: 1111111 bytes/sec ≈ 1.1 MB/s
-- 这意味着每秒产生约 1.1MB 的 redo log分析:通过监控 LSN 的增长速率,可以了解数据库的写入负载。如果 redo log 写入速率持续接近磁盘 I/O 带宽,可能需要考虑使用更快的存储设备,或者调整 innodb_log_file_size 和 innodb_log_buffer_size。
3.3 案例三:对比不同页大小对性能的影响
import os
import time
import struct
def benchmark_page_size(page_size, total_data_size, read_pattern='random'):
"""模拟不同页大小对随机读性能的影响"""
# 创建测试文件
data = os.urandom(total_data_size)
filename = f"test_{page_size}.dat"
with open(filename, 'wb') as f:
f.write(data)
# 计算页数量
num_pages = total_data_size // page_size
start = time.time()
with open(filename, 'rb') as f:
if read_pattern == 'random':
import random
for _ in range(1000):
page_no = random.randint(0, num_pages - 1)
f.seek(page_no * page_size)
f.read(page_size)
elif read_pattern == 'sequential':
for i in range(min(1000, num_pages)):
f.seek(i * page_size)
f.read(page_size)
elapsed = time.time() - start
os.remove(filename)
return elapsed
# 测试结果(示意):
# Page Size | Random Read (1000 ops) | Sequential Read (1000 ops)
# 4KB | 2.3s | 0.8s
# 8KB | 2.5s | 0.9s
# 16KB | 2.8s | 1.0s
# 32KB | 3.2s | 1.1s
# 分析:
# - 页越大,每次 I/O 传输的数据越多,但随机读的开销也越大
# - 对于 OLTP 场景(大量随机读),较小的页(8KB-16KB)更合适
# - 对于 OLAP 场景(大量顺序扫描),较大的页(32KB-64KB)更合适四、本章小结
本章从存储引擎的物理基础出发,建立了理解数据库存储系统的完整框架:
磁盘 I/O 特性决定了存储引擎的基本设计方向——尽量减少随机 I/O,将随机操作转换为顺序操作。
页是存储管理的基本单位,典型的页大小为 4KB-16KB,页内通过槽目录实现有序组织。
缓冲池通过在内存中缓存数据页来减少磁盘 I/O,但简单的 LRU 策略存在缓存污染问题,需要自适应策略来改进。
WAL 通过将随机写转换为顺序写日志,在保证事务持久性的同时大幅提高了写入性能。检查点机制限制了崩溃恢复时需要重放的日志量。
存储引擎的分类:面向事务的 B+ 树引擎(读优化)、面向分析的列式引擎(扫描优化)、LSM 树引擎(写优化),各有其适用场景。
理解这些基础概念后,后续章节将深入讲解每种存储结构的具体实现:B+ 树如何组织索引和数据、LSM 树如何通过 Compaction 管理数据版本、列式存储如何利用编码和压缩提高效率。
五、思考与练习
思考题
如果一个数据库系统的缓冲池命中率长期低于 90%,可能的原因有哪些?分别如何解决?
WAL 机制中,如果 redo log 的写入速度跟不上事务的提交速度,会发生什么?如何优化?
为什么 InnoDB 选择 16KB 作为默认页大小?如果将页大小改为 4KB,会对哪些操作产生影响?
LSM 树的 Compaction 策略有哪些?Leveled Compaction 和 Tiered Compaction 各有什么优缺点?
编程练习
实现一个简单的 LRU 缓冲池模拟器,支持页的读取、写入和淘汰操作,统计不同工作负载下的命中率。
实现一个基于 WAL 的简单恢复系统:支持写入 redo log、模拟崩溃、从日志恢复数据。
对比不同页大小(4KB、8KB、16KB、32KB)在顺序读和随机读场景下的 I/O 性能差异。