1Database Internals

存储引擎概览

数据库的基石

存储引擎磁盘I/O缓冲池页管理

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 → 内存: 随机读提升 1000x

2.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 缓冲池的命中率

sql
-- 查看 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 日志的写入模式

sql
-- 查看 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 案例三:对比不同页大小对性能的影响

python
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 性能差异。


1/7存储引擎
0%