前面我们讲到了 WAL、事务、MVCC 和锁。这次进入数据库的另一个核心问题:数据库发生崩溃后,如何从磁盘上可能不完整的数据状态中恢复,并重新提供服务?
理解这个问题,需要先区分两个概念:Checkpoint(检查点) 和 Crash Recovery(崩溃恢复)。它们密切相关,但并不是同一件事。
一、为什么有了 WAL,还需要 Checkpoint?
假设数据库执行了下面的操作:
- 读取磁盘上的 Page A。
- 修改 Page A,将内存中的数据从
100改为200。 - 把对应的修改记录写入 WAL。
- 将事务提交,向客户端返回成功。
- 由于 Buffer Pool 中的 Page A 是脏页,暂时没有写回数据文件。
此时,数据库可能处于这样的状态:
磁盘数据文件
css
Page A:旧值 100
内存 Buffer Pool
css
Page A:新值 200,处于 Dirty 状态
磁盘 WAL
css
已经持久化了将 Page A 修改为 200 的必要日志信息
如果数据库此时崩溃,内存中的 Page A 就会丢失,但磁盘上的 WAL 仍然可以帮助数据库恢复已经提交的修改。
这就是 WAL 的核心价值:
数据页尚未落盘,不代表已提交事务的数据无法恢复。
但接下来会出现一个问题。
假设数据库连续运行了几个月,积累了数百 GB 的 WAL。崩溃后,恢复程序是否需要从最早的日志开始,把所有记录重新检查一遍?
如果每次启动都必须扫描和重放全部历史日志,恢复成本就会越来越高。
因此,数据库需要一种机制,记录恢复工作已经推进到了什么位置。这就是 Checkpoint 要解决的核心问题。
二、Checkpoint 到底是什么?
Checkpoint ,通常翻译为"检查点",是数据库在运行期间建立的一个恢复进度边界。
它的核心作用是:
让崩溃恢复不必每次都从最早的 WAL 开始处理。
这里需要特别注意:
Checkpoint 并不简单等于"把所有内存数据刷进磁盘"。
不同数据库的检查点机制不同;有的采用模糊检查点(Fuzzy Checkpoint),允许检查点期间继续修改数据页,不需要冻结整个数据库,也不要求所有脏页在同一时刻完成写回。
可以先把 Checkpoint 理解为三个相互关联的动作:
- 记录恢复所需的关键状态。
- 推进数据页持久化和恢复进度。
- 让数据库判断哪些旧日志仍然需要保留。
1. 没有 Checkpoint 时
假设 WAL 中存在以下日志:
css
LSN 100 修改 Page A
LSN 200 修改 Page B
LSN 300 修改 Page A
LSN 400 修改 Page C
LSN 500 修改 Page B
如果没有可用的恢复边界,恢复程序可能需要检查大量历史日志,才能确定哪些操作需要重放。
2. 有 Checkpoint 时
假设在 LSN 400 附近建立了一个有效检查点,并且恢复所需的相关状态已正确记录。
恢复程序就可能从检查点指定的恢复位置开始,而不必重新处理全部历史日志。
不过,不能简单地认为检查点之前的所有日志都能立即删除。具体还取决于:
- 相关数据页是否已经持久化。
- 检查点记录了什么状态。
- 是否存在尚未完成的事务。
- 是否还有其他恢复、复制或备份需求。
Checkpoint 记录的恢复位置也不一定就是检查点日志记录本身的 LSN。真正的起点取决于数据库的恢复协议。
三、Checkpoint 的关键数据结构
从存储引擎的实现视角看,理解下面三个概念就足够建立第一层模型。
1. LSN:日志序列号
LSN(Log Sequence Number) 用于标识 WAL 中的日志位置。
可以把它想象成日志流中的位置编号:
cpp
using LSN = uint64_t;
struct LogRecord {
LSN lsn;
uint64_t page_id;
// 日志类型、事务 ID、修改内容等
};
这里的结构只是概念示例。真实数据库的日志格式通常更加复杂,LSN 的编码方式也不一定是简单的连续整数。
LSN 的重要价值在于:
数据库可以比较日志位置,判断某个修改是否早于另一个修改,以及某个数据页已经应用到什么日志位置。
2. Page LSN:数据页已经应用到哪里
一个数据页通常可以记录最近一次已经应用到该页的日志位置,称为 Page LSN。
cpp
struct PageHeader {
uint64_t page_id;
LSN page_lsn;
// 其他页元数据
};
例如:
| 对象 | LSN |
|---|---|
| 修改 Page A 的日志 | 100 |
| 再次修改 Page A 的日志 | 300 |
| 当前 Page A 的 Page LSN | 300 |
| 当前 WAL 尾部位置 | 500 |
这表示 Page A 当前记录的内容已经应用到 LSN 300 对应的修改。
如果恢复时发现 WAL 中还有该页的后续修改,就需要根据恢复规则判断是否应用。Page LSN 是判断依据之一,但不能脱离具体日志格式、页写入原子性和恢复协议单独使用。
3. Checkpoint Record:检查点记录
概念上,一个检查点可能包含:
cpp
struct CheckpointInfo {
LSN checkpoint_lsn;
LSN redo_start_lsn;
// 活跃事务状态
// 脏页状态或其他恢复所需元数据
};
这里的 redo_start_lsn 表示恢复时需要考虑的重做起始位置。
真实系统可能把检查点信息分散在多条 WAL 记录中,也可能把必要状态保存在其他结构中。这个结构只是帮助我们理解检查点需要回答的问题,并不是 PostgreSQL、InnoDB 或 SQLite 的真实结构定义。
四、Checkpoint 是如何执行的?
先看一个简化的执行流程:
-
确定检查点边界
记录当前恢复所需的日志位置和相关状态。
-
处理脏页
按数据库的刷盘策略,将需要持久化的页面写入数据文件。
-
确保恢复信息可靠
持久化必要的 WAL 和检查点元数据,遵守 WAL-before-data 等规则。
-
发布有效检查点
让崩溃恢复能够使用这个检查点作为恢复依据。
这个顺序是帮助理解的抽象流程,不是所有数据库严格采用的时间顺序。特别是在模糊检查点中,页面刷写、日志追加、状态记录和检查点完成可以交错进行。
需要保证的不是"所有动作必须串行",而是:
即使在任意一个中间步骤崩溃,重启后的数据库仍然能够找到一个有效的恢复路径。
举个例子:
假设 Page A 的修改日志位于 LSN 300,而数据库计划把 Page A 写回磁盘。
在遵守 WAL 协议的系统中,必须先确保恢复这个数据页所需的日志已经持久化,然后才能安全地持久化该数据页。否则可能出现一种危险情况:
- 数据页已经写入新值。
- 但对应的 WAL 尚未可靠持久化。
- 随后发生崩溃。
如果数据页本身也发生了不完整写入或损坏,恢复程序就可能缺少重建正确页面所需的信息。
这就是 WAL 与 Checkpoint 必须共同设计的原因。
五、崩溃后如何恢复?
有了检查点,数据库启动时就可以执行恢复流程。
-
定位有效检查点
从可靠的检查点元数据中找到恢复起点,并确认所需日志仍然可用。
-
分析日志
检查相关 WAL,识别日志涉及的页面、事务及其状态。
-
执行 Redo
根据恢复规则,重放需要恢复的修改,使数据库重新达到一致状态。
-
处理未完成事务
依据数据库的恢复设计,对崩溃时未提交的事务执行必要的回滚或其他恢复处理。
-
恢复正常服务
确认必要的恢复工作完成后,数据库才按照自身协议重新开放正常访问。
注意:这篇文章侧重点学习 Checkpoint,所以只对 Redo 和未提交事务的处理做概览。不同数据库的 Undo、Redo 和事务恢复机制并不完全相同,下次会专门拆开分析。
六、真实数据库如何处理?
PostgreSQL
PostgreSQL 使用 WAL 支持崩溃恢复,并通过检查点控制恢复所需的日志范围。检查点与后台写回机制协作;在检查点期间,不需要停止所有正常事务。恢复过程会从适当的 WAL 位置重放需要的记录。
InnoDB
InnoDB 通过 Redo Log、脏页刷写、检查点和崩溃恢复协作。它需要考虑尚未刷入数据文件的修改,也需要结合事务状态和 Undo 信息处理崩溃时未完成的事务。
SQLite
SQLite 的恢复机制与其事务日志模式有关。Rollback Journal 模式和 WAL 模式的工作方式不同,不能直接套用服务端数据库的检查点流程。SQLite WAL 模式使用 checkpoint 将 WAL 中的内容回写到主数据库文件。
这些实现的共同目标是缩短恢复时间并保证持久性,但具体日志格式、检查点边界和恢复步骤各不相同。
七、最重要的设计权衡:检查点越频繁越好吗?
并不是。
| 检查点频率 | 优点 | 缺点 |
|---|---|---|
| 较高 | 通常有助于限制崩溃恢复需要处理的日志范围 | 更频繁的刷页和检查点工作可能增加 I/O 压力 |
| 较低 | 减少检查点工作频率,可能降低部分运行时开销 | 恢复时可能需要处理更多日志,WAL 保留压力也可能增大 |
实际数据库还会根据脏页数量、日志增长、后台写入能力等因素调整检查点行为。
这里有一个容易忽略的细节:
检查点不仅影响恢复时间,还会影响正常运行时的 I/O 延迟。
例如,检查点期间大量脏页集中刷盘,可能与前台查询、事务提交争用磁盘带宽,导致延迟上升。因此,数据库通常还会采用渐进刷页、后台写回等机制,避免把所有压力集中到某一个时刻。
八、如果自己实现一个简化存储引擎,应该怎么验证?
不需要立刻实现完整的 Checkpoint。可以先设计一个最小实验,明确它要保证什么。
假设只有一个数据页,页内存储一个整数 value,并使用 WAL 记录修改。
- 将
value从 100 修改为 200。 - 持久化对应的 WAL。
- 提交事务,但暂时不刷写数据页。
- 模拟进程崩溃并重新启动。
- 执行恢复,确认已提交的值能够恢复为 200。
然后逐步增加场景:
- 检查点之前的数据页已刷盘:恢复程序仍然要正确判断哪些日志需要处理。
- 检查点执行中崩溃:不能依赖一个尚未完整建立的检查点。
- 数据页已经写回,但后续日志未全部应用:需要根据 WAL 和 Page LSN 等信息正确恢复。
- 事务修改后未提交便崩溃:不能把未提交的事务错误地当成已提交事务对外可见。
这些测试验证的是恢复正确性,不是单纯比较启动速度。
对实现而言,最关键的原则是:
先定义崩溃时必须成立的不变量,再设计刷盘顺序和检查点协议。
小结
要记住四点:
- WAL 让数据库在数据页尚未写回时,仍然能够恢复已提交的修改。
- Checkpoint 为恢复提供可靠的进度边界,减少恢复时需要处理的历史日志。
- Page LSN 用于描述数据页应用到的日志位置,是恢复判断的重要依据之一。
- 检查点必须正确协调 WAL、数据页写回和恢复元数据,不能只把它理解为一次普通的批量写盘。