Checkpoint 与 Crash Recovery 讲到数据库崩溃后,如何借助 WAL 恢复到一致状态。
今天继续深入一个核心问题:
数据库崩溃后,为什么有些操作需要重新执行(Redo),有些操作却需要撤销(Undo)?
这两个动作看起来相反,却并不矛盾。数据库需要同时保证两件事:
- 已经提交的事务,其结果不能因为崩溃而丢失。
- 尚未提交的事务,不能在恢复后被错误地当成已提交的数据。
Undo 和 Redo 正是围绕这两个目标建立的机制。
一、先用一个例子理解 Undo 和 Redo
假设账户余额初始为 100,数据库依次执行两个事务。
事务 T1:已提交
把余额从 100 修改为 150。
修改已写入 WAL,事务提交成功,但对应的数据页还没有刷入数据文件。
事务 T2:未提交
把余额从 150 修改为 200。
修改已经发生,甚至可能已经写入 WAL,但事务尚未提交。
此时数据库突然崩溃。
假设磁盘数据文件仍然保存着旧值 100,而 WAL 中记录了上述修改。
数据库重启后,应该得到什么结果?
- T1 已经提交,余额必须恢复为 150。
- T2 没有提交,余额不能保留为 200,最终应当是 150。
因此,恢复过程在逻辑上需要完成两件事:
- Redo T1:确保已提交事务的修改能够体现在恢复后的数据库中。
- Undo T2:撤销未提交事务留下的修改。
最终结果为 150。
注意,真实数据库并不一定严格按照"先把所有已提交事务 Redo,再把所有未提交事务 Undo"的简单顺序执行。具体顺序取决于数据库的恢复算法。这里先用逻辑结果建立直觉。
二、Redo:为什么需要重做?
Redo 的含义是:根据日志重新应用修改,使数据库恢复到应有的状态。
它解决的核心问题是:事务已经提交,但数据页尚未可靠地写入数据文件。
例如:
text
磁盘数据页:balance = 100
WAL:
LSN 100:将 balance 修改为 150
LSN 120:T1 COMMIT
如果数据库在提交后立即崩溃,内存中的新页面会丢失,磁盘上仍然可能是 100。
恢复程序需要根据 WAL,把修改重新应用到数据页中。
Redo 的关键点:重做不等于重复计算
假设一条日志记录的是:
text
balance: 100 -> 150
恢复时可以根据数据库的日志格式和恢复规则,将相应修改应用到页面。
但如果同一条日志被处理两次,会不会把余额变成 200?
这取决于日志语义和重做操作的设计。可靠的恢复机制必须处理重复应用的问题。
一种常见思路是使用 Page LSN:
cpp
if (page.page_lsn < log_record.lsn) {
ApplyRedo(page, log_record);
page.page_lsn = log_record.lsn;
}
这段代码是简化的示意,并不是完整的生产级恢复实现。真实系统还需要处理日志类型、页面是否存在、页面校验、写入原子性,以及特定恢复协议的规则。
其基本思想是:如果页面已经应用到这条日志或更靠后的位置,就不应该盲目重复应用同一个修改。
Redo 的重要特征
Redo 通常以日志中记录的修改为依据,而不是重新执行用户原来的 SQL。
例如,恢复程序通常不会重新执行原来的 UPDATE 查询并重新计算业务逻辑。它需要依据持久化的恢复信息重建数据库状态,否则查询条件、并发环境或其他数据可能已经发生变化。
三、Undo:为什么不能只做 Redo?
Redo 能够重建已经记录在日志中的修改,但它本身并不能保证未提交事务不会留下错误结果。
继续看 T2:
text
T1: balance 100 -> 150,已提交
T2: balance 150 -> 200,未提交
假设数据库已经把 T2 的修改写入了数据文件。随后数据库崩溃。
这时,磁盘上可能已经是 200。
如果恢复程序只执行 Redo,那么余额就可能错误地停留在 200。因为 T2 没有提交,数据库必须撤销它的影响。
Undo 的含义是:利用保存的恢复信息,撤销事务尚未完成的修改,使其效果不再存在。
1. Undo 需要什么信息?
如果数据库只记录:
text
将 balance 修改为 200
那么恢复程序可能不知道原来的值是什么。
因此,支持 Undo 的日志机制通常需要保留足够的信息,以便恢复旧状态。例如:
text
T2 修改日志:
page_id = 7
old_value = 150
new_value = 200
这样,撤销 T2 时,就能够把余额恢复为 150。
这里的 old_value 和 new_value 是概念示例。真实数据库可能记录字节级差异、页面偏移、字段变化,或其他能够完成恢复的信息,而不一定保存完整的旧值和新值。
2. Undo 不一定是简单地恢复旧值
考虑下面的情况:
text
T1:余额 100 -> 150,已提交
T2:余额 150 -> 200,未提交
T3:余额 200 -> 250,未提交
如果 T2 和 T3 都影响了同一条记录,那么恢复时不能随意撤销,也不能让较早的修改覆盖掉较晚修改应有的恢复过程。
数据库需要根据事务之间的依赖关系、日志顺序和恢复协议,确定正确的撤销顺序。
对于简单的同一记录更新,通常可以理解为逆着修改顺序撤销:
text
250 -> 200 撤销 T3
200 -> 150 撤销 T2
最终保留 T1 已提交的结果 150。
真实系统还需要处理多页面修改、事务状态变化、恢复期间再次崩溃等更复杂的情况。
四、Undo 和 Redo 的本质区别
| 比较维度 | Redo | Undo |
|---|---|---|
| 目的 | 重建需要保留的修改 | 撤销不应保留的修改 |
| 方向 | 将状态推进到修改后的状态 | 将状态恢复到修改前的状态 |
| 典型需求 | 已提交修改尚未写入数据文件 | 未提交事务已经影响数据页 |
| 常用信息 | 足以重新应用修改的日志 | 足以撤销修改的恢复信息 |
| 恢复后的目标 | 保留应当存在的修改 | 去除不应保留的事务效果 |
需要注意一个关键点:Undo 和 Redo 描述的是恢复操作的目的,不完全等同于两种固定的日志文件。
有的数据库将 Redo Log 和 Undo Log 分开组织;有的恢复协议则使用同时携带前向和反向信息的日志记录。
此外,Undo 也不只用于崩溃恢复。在运行期间,事务执行 ROLLBACK 时,同样需要撤销事务此前做出的修改。
五、事务回滚与崩溃恢复有什么区别?
这两个场景都会涉及 Undo,但触发条件和处理方式不同。
场景 A:正常事务回滚
事务还在运行,客户端或数据库决定放弃它。
sql
BEGIN
修改 Page A
修改 Page B
ROLLBACK
数据库知道事务正在运行,也知道它修改过哪些数据。
因此,它可以利用 Undo 信息撤销这些修改,释放相应的锁,更新事务状态,并完成回滚。
场景 B:数据库崩溃后恢复
数据库突然停止运行,内存中的事务状态可能丢失。
sql
BEGIN
修改 Page A
修改 Page B
-- 崩溃,未执行 COMMIT
重新启动后,数据库必须通过持久化的 WAL、事务状态和其他恢复信息,判断这个事务是否完成提交,并恢复到正确状态。
两者的共同点是都需要恢复信息;区别在于,崩溃恢复必须先从持久化信息重建足够的状态,才能处理未完成事务。
为什么不能只依靠内存记录?
因为进程崩溃后,普通内存中的事务列表、修改历史和指针都会丢失。
如果 Undo 信息只存在内存里,那么崩溃之后数据库可能知道页面的状态不对,却不知道该怎样恢复。
所以,恢复机制必须把关键的恢复信息以满足持久性要求的方式记录下来。
六、数据库如何组织 Undo 信息?
在实现层面,一个事务可能修改很多页面。数据库需要知道:某个事务做过哪些修改,以及撤销时应该按什么顺序处理。
一种常见的设计是通过日志链记录同一事务的修改顺序。
例如:
text
事务 T2
|
v
日志 LSN 300:修改 Page A
|
v
日志 LSN 350:修改 Page B
|
v
日志 LSN 400:修改 Page A
概念上,事务可以保存最近一条日志的 LSN,每条日志再指向该事务的上一条日志。
cpp
struct LogRecord {
uint64_t lsn;
uint64_t transaction_id;
uint64_t prev_lsn;
// 操作类型、页面位置、恢复所需的数据等
};
这个 prev_lsn 让恢复程序可以沿着事务自己的日志链向前查找修改历史。
这里要区分两个顺序:
- 全局日志顺序:所有事务的日志按照日志序列组织。
- 事务内部顺序:同一事务的日志通过链式关系连接。
它们并不相同。多个事务可以交错执行,但每个事务仍然需要维护自己的修改历史。
回滚期间为什么还需要写日志?
假设 T2 的修改已经持久化到 WAL。数据库开始撤销 T2,将余额从 200 恢复到 150。
如果恢复过程中再次崩溃,数据库怎样知道刚才的撤销是否完成?
这意味着,撤销操作本身也需要被恢复机制跟踪。
在采用 ARIES 类恢复算法的系统中,会使用 Compensation Log Record(CLR,补偿日志记录)记录已经执行的撤销动作。CLR 还包含用于继续恢复的相关信息,例如 Undo Next LSN。
CLR 的作用不是简单地再记录一遍原始修改,而是让恢复程序能够识别:哪些撤销已经执行,以及下一步还需要撤销什么。
这样,如果数据库在回滚中途再次崩溃,就能继续恢复,而不必把已经完成的撤销当成普通修改重新处理。
这也是为什么成熟数据库的崩溃恢复不能只靠"读取日志,然后把旧值写回去"这样简单的逻辑。
七、ARIES:把恢复过程组织成三个阶段
ARIES 是数据库恢复领域的一种经典算法。它的设计特别适合用来理解 WAL、Redo、Undo 和 Checkpoint 之间的关系。
ARIES 的经典恢复流程分为三个阶段:
1. Analysis:分析
从适当的检查点开始分析日志,重建事务表和脏页表等恢复所需状态,并判断哪些事务在崩溃时仍未完成。
2. Redo:重做
从计算得到的 Redo 起点开始,按照算法规则重放需要重做的日志,使页面状态恢复到崩溃前已记录的状态,包括部分未提交事务的修改。
3. Undo:撤销
识别崩溃时尚未完成的事务,撤销其修改,并记录必要的补偿信息,直到这些事务完成恢复处理。
这里有一个容易误解的地方:
ARIES 的 Redo 阶段可能会重做未提交事务的修改。
为什么?
因为 Redo 阶段首先要重建崩溃前数据库实际达到的页面状态,而不是立即决定每个事务最终应该提交还是回滚。
例如,未提交的 T2 已经把余额改为 200,并且这次修改已经写入磁盘。Redo 阶段可能先把这个修改重建出来,随后 Undo 阶段再将它撤销为 150。
这并不意味着数据库把 T2 当成了已提交事务。Redo 和事务提交是两个不同的概念。
需要补充的是,ARIES 是一种经典恢复算法,并非所有数据库都原样采用它的三个阶段。不同数据库会依据自身的存储架构、日志组织和事务实现,采用不同的恢复策略。
八、PostgreSQL、InnoDB 和 SQLite 有什么区别?
这三个系统很适合用来理解:同样要保证事务正确性,数据库可以采用不同的恢复设计。
| 系统 | 恢复机制的主要特点 |
|---|---|
| PostgreSQL | 使用 WAL 重放恢复数据页;MVCC 旧版本通常保留在 Heap 中,未提交事务的可见性及清理由事务状态、MVCC 和 VACUUM 等机制协同处理。 |
| InnoDB | 使用 Redo Log 恢复数据页,使用 Undo 信息支持事务回滚、MVCC 和历史版本管理;崩溃恢复会处理未完成事务。 |
| SQLite | Rollback Journal 模式主要依靠回滚日志恢复;WAL 模式则使用 WAL 重放,并通过 checkpoint 将内容回写到主数据库文件。 |
最值得关注的是 PostgreSQL 与 InnoDB 的差异。
PostgreSQL:不要把 WAL Redo 和事务回滚混为一谈
PostgreSQL 使用 WAL 进行崩溃恢复。对于 Heap Tuple,数据库还会通过事务元数据判断版本是否对当前事务可见。
因此,恢复页面的物理状态与判断事务结果是否可见,是相互关联但不同的工作。
不能简单地认为:只要发现某条事务修改没有提交,PostgreSQL 就一定通过 Redo Log 中保存的旧值来直接恢复整条记录。它的版本管理、事务状态和清理机制共同决定最终行为。
InnoDB:Undo 不仅仅用于崩溃回滚
InnoDB 的 Undo 信息有两个重要用途:
- 事务回滚:撤销事务执行的修改。
- MVCC:为读取旧版本数据提供必要的历史信息。
这意味着 Undo 信息可能需要保留一段时间,即使对应事务已经提交,也不一定能够立即删除。
例如,较早启动的读事务可能仍然需要读取历史版本。Purge 机制会在不再需要相关历史信息时清理它们。
这也解释了为什么 Undo、MVCC 和事务生命周期必须一起理解,而不能把 Undo 当成只在数据库崩溃时才使用的临时机制。
九、项目导向:如何实现一个最小的 Undo/Redo 系统?
如果以后要用 C++ 构建简化存储引擎,建议按照下面的顺序实现,而不是一开始就照搬成熟数据库的完整恢复算法。
第一步:定义日志格式
先定义一条修改日志至少需要包含哪些信息。
cpp
using LSN = uint64_t;
using TxnId = uint64_t;
using PageId = uint64_t;
struct UpdateLog {
LSN lsn;
TxnId txn_id;
PageId page_id;
uint32_t offset;
uint32_t length;
// 概念示例:修改前和修改后的字节
std::vector<std::byte> before_image;
std::vector<std::byte> after_image;
};
这里保存前后两份数据,是为了清晰地演示 Undo 和 Redo。生产级系统通常会根据数据类型、日志量和恢复要求采用更精细的编码。
第二步:实现 Redo
输入:一条修改日志和目标数据页。
基本逻辑:
- 检查日志是否适用于该页面。
- 判断页面当前的 LSN 与日志 LSN 的关系。
- 如果该修改尚未应用,执行 Redo。
- 更新页面的恢复元数据,并将页面标记为脏页。
注意,只有在日志和页面的持久化协议正确时,这个流程才具有崩溃恢复保证。
第三步:实现 Undo
输入:一条可以撤销的修改日志。
基本逻辑:
- 找到该事务尚未撤销的修改。
- 按正确的撤销顺序恢复修改前的状态。
- 记录必要的回滚进度。
- 完成后更新事务状态。
如果暂时不实现 CLR,可以先实现一个不支持回滚中再次崩溃的教学版本。但要明确:这只是阶段性实现,不能直接视为可靠的生产级恢复系统。
第四步:实现崩溃恢复
建议先构造下面这个测试:
测试:提交一个事务,再让另一个事务中途崩溃
- 初始化数据页:
value = 100。 - T1 修改为 150,持久化日志并提交。
- T2 修改为 200,写入修改日志但不提交。
- 模拟崩溃,丢弃内存中的 Buffer Pool。
- 重新启动,读取 WAL 并执行恢复。
- 验证最终
value = 150。
测试时还要注意两个边界:
- 如果 T1 已经提交,但数据页尚未落盘,恢复后必须保留 150。
- 如果 T2 已经把 200 写进数据文件,但事务没有提交,恢复后仍然必须得到 150。
第二个场景尤其重要,因为它证明恢复系统不仅能重做修改,还必须能够正确处理未提交事务。
本课小结
以上的知识可以归纳为四个层次:
- Redo:重新应用需要恢复的修改,解决已记录修改没有可靠反映到数据页的问题。
- Undo:撤销不应保留的事务修改,保证未完成事务不会留下错误结果。
- 事务回滚:运行期间主动撤销事务;崩溃恢复:重启后根据持久化信息恢复状态,两者会共享部分机制。
- 恢复算法:需要考虑日志顺序、页面状态、事务状态,以及恢复中途再次崩溃的情况。