Checkpoint 与 Crash Recovery:数据库恢复机制入门

前面我们讲到了 WAL、事务、MVCC 和锁。这次进入数据库的另一个核心问题:数据库发生崩溃后,如何从磁盘上可能不完整的数据状态中恢复,并重新提供服务?

理解这个问题,需要先区分两个概念:Checkpoint(检查点) 和 Crash Recovery(崩溃恢复)。它们密切相关,但并不是同一件事。


一、为什么有了 WAL,还需要 Checkpoint?

假设数据库执行了下面的操作:

  1. 读取磁盘上的 Page A。
  2. 修改 Page A,将内存中的数据从 100 改为 200。
  3. 把对应的修改记录写入 WAL。
  4. 将事务提交,向客户端返回成功。
  5. 由于 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 是如何执行的?

先看一个简化的执行流程:

  1. 确定检查点边界

    记录当前恢复所需的日志位置和相关状态。

  2. 处理脏页

    按数据库的刷盘策略,将需要持久化的页面写入数据文件。

  3. 确保恢复信息可靠

    持久化必要的 WAL 和检查点元数据,遵守 WAL-before-data 等规则。

  4. 发布有效检查点

    让崩溃恢复能够使用这个检查点作为恢复依据。

这个顺序是帮助理解的抽象流程,不是所有数据库严格采用的时间顺序。特别是在模糊检查点中,页面刷写、日志追加、状态记录和检查点完成可以交错进行。

需要保证的不是"所有动作必须串行",而是:

即使在任意一个中间步骤崩溃,重启后的数据库仍然能够找到一个有效的恢复路径。

举个例子:

假设 Page A 的修改日志位于 LSN 300,而数据库计划把 Page A 写回磁盘。

在遵守 WAL 协议的系统中,必须先确保恢复这个数据页所需的日志已经持久化,然后才能安全地持久化该数据页。否则可能出现一种危险情况:

  • 数据页已经写入新值。
  • 但对应的 WAL 尚未可靠持久化。
  • 随后发生崩溃。

如果数据页本身也发生了不完整写入或损坏,恢复程序就可能缺少重建正确页面所需的信息。

这就是 WAL 与 Checkpoint 必须共同设计的原因。


五、崩溃后如何恢复?

有了检查点,数据库启动时就可以执行恢复流程。

  1. 定位有效检查点

    从可靠的检查点元数据中找到恢复起点,并确认所需日志仍然可用。

  2. 分析日志

    检查相关 WAL,识别日志涉及的页面、事务及其状态。

  3. 执行 Redo

    根据恢复规则,重放需要恢复的修改,使数据库重新达到一致状态。

  4. 处理未完成事务

    依据数据库的恢复设计,对崩溃时未提交的事务执行必要的回滚或其他恢复处理。

  5. 恢复正常服务

    确认必要的恢复工作完成后,数据库才按照自身协议重新开放正常访问。

注意:这篇文章侧重点学习 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 记录修改。

  1. 将 value 从 100 修改为 200。
  2. 持久化对应的 WAL。
  3. 提交事务,但暂时不刷写数据页。
  4. 模拟进程崩溃并重新启动。
  5. 执行恢复,确认已提交的值能够恢复为 200。

然后逐步增加场景:

  • 检查点之前的数据页已刷盘:恢复程序仍然要正确判断哪些日志需要处理。
  • 检查点执行中崩溃:不能依赖一个尚未完整建立的检查点。
  • 数据页已经写回,但后续日志未全部应用:需要根据 WAL 和 Page LSN 等信息正确恢复。
  • 事务修改后未提交便崩溃:不能把未提交的事务错误地当成已提交事务对外可见。

这些测试验证的是恢复正确性,不是单纯比较启动速度。

对实现而言,最关键的原则是:

先定义崩溃时必须成立的不变量,再设计刷盘顺序和检查点协议。


小结

要记住四点:

  1. WAL 让数据库在数据页尚未写回时,仍然能够恢复已提交的修改。
  2. Checkpoint 为恢复提供可靠的进度边界,减少恢复时需要处理的历史日志。
  3. Page LSN 用于描述数据页应用到的日志位置,是恢复判断的重要依据之一。
  4. 检查点必须正确协调 WAL、数据页写回和恢复元数据,不能只把它理解为一次普通的批量写盘。
相关推荐
2301_786756601 小时前
中科院分区表停发后,期刊评价的技术逻辑:从期刊超越指数到多指标选刊方案
java·数据库·windows
闲云野鹤在人间1 小时前
Ubuntu 24.04 安装过程
服务器·数据库·ubuntu
duxingzhe03112 小时前
我的一些书
java·数据库·tomcat·数据库开发·etl工程师
2603_965148112 小时前
商品价格监控 API 接口:从原理到实战的完整指南
java·前端·数据库·python·生活
AllData公司负责人2 小时前
AllData搭建AI中台,集成开源项目LabelStudio/CubeStudio,建设数据标注平台/机器学习平台
数据库·人工智能·机器学习·labelstudio·cubestudio
2601_962071002 小时前
Kimi K3 vs DeepSeek V4-Pro实测对比:2026年同一入口十道题,耗时与token消耗差在哪
android·java·数据库
mengzhi啊2 小时前
把数据存入sql数据库。qt,c++。
数据库·sql·qt
ManageEngineITSM3 小时前
工单优先级怎么定?影响×紧急度矩阵的设计与落地方法
java·大数据·运维·前端·数据库·变更管理