Undo、Redo 与事务回滚

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。

因此,恢复过程在逻辑上需要完成两件事:

  1. Redo T1:确保已提交事务的修改能够体现在恢复后的数据库中。
  2. 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

输入:一条修改日志和目标数据页。

基本逻辑:

  1. 检查日志是否适用于该页面。
  2. 判断页面当前的 LSN 与日志 LSN 的关系。
  3. 如果该修改尚未应用,执行 Redo。
  4. 更新页面的恢复元数据,并将页面标记为脏页。

注意,只有在日志和页面的持久化协议正确时,这个流程才具有崩溃恢复保证。

第三步:实现 Undo

输入:一条可以撤销的修改日志。

基本逻辑:

  1. 找到该事务尚未撤销的修改。
  2. 按正确的撤销顺序恢复修改前的状态。
  3. 记录必要的回滚进度。
  4. 完成后更新事务状态。

如果暂时不实现 CLR,可以先实现一个不支持回滚中再次崩溃的教学版本。但要明确:这只是阶段性实现,不能直接视为可靠的生产级恢复系统。

第四步:实现崩溃恢复

建议先构造下面这个测试:

测试:提交一个事务,再让另一个事务中途崩溃

  1. 初始化数据页:value = 100。
  2. T1 修改为 150,持久化日志并提交。
  3. T2 修改为 200,写入修改日志但不提交。
  4. 模拟崩溃,丢弃内存中的 Buffer Pool。
  5. 重新启动,读取 WAL 并执行恢复。
  6. 验证最终 value = 150。

测试时还要注意两个边界:

  • 如果 T1 已经提交,但数据页尚未落盘,恢复后必须保留 150。
  • 如果 T2 已经把 200 写进数据文件,但事务没有提交,恢复后仍然必须得到 150。

第二个场景尤其重要,因为它证明恢复系统不仅能重做修改,还必须能够正确处理未提交事务。

本课小结

以上的知识可以归纳为四个层次:

  1. Redo:重新应用需要恢复的修改,解决已记录修改没有可靠反映到数据页的问题。
  2. Undo:撤销不应保留的事务修改,保证未完成事务不会留下错误结果。
  3. 事务回滚:运行期间主动撤销事务;崩溃恢复:重启后根据持久化信息恢复状态,两者会共享部分机制。
  4. 恢复算法:需要考虑日志顺序、页面状态、事务状态,以及恢复中途再次崩溃的情况。
相关推荐
FluxDB1 小时前
用 Rust + GPUI 重做数据库客户端
数据库·mysql
烬羽1 小时前
PostgreSQL:AI 时代原生的"隐型"向量数据库 — 用 pgvector 给消息表加上长期记忆
数据库·人工智能·程序员
Yyyyyy~1 小时前
【Mysql】增删查改操作
数据库·mysql
钱栈up2 小时前
前端改了三页、后端也动了,用户说「完全没变化」——我在 116 库上找到的三个真凶
css·数据库
这个DBA有点耶2 小时前
索引建了查询却更慢——优化器选错索引与Index Merge的隐藏代价
数据库·架构·代码规范
云贝贝贝2 小时前
删了却没变小:PostgreSQL 死元组与表膨胀排查
数据库·postgresql
依晨照旧2 小时前
Langfuse 打爆两台机器:一条分位数 SQL,在 400G 表上扫了 8 分钟
数据库
笃行3503 小时前
列存储这条线,我顺着 KingbaseES 的文档摸了一遍
数据库
Navicat中国4 小时前
Navicat 18 支持 Azure OpenAI 接入,合规 AI 赋能 SQL 开发
数据库·人工智能·microsoft·ai·azure·navicat·数据