数据库系统要求事务具备ACID特性,其中原子性和持久性直接关系到数据是否可信。一个事务可能包含多条更新语句,执行过程中系统随时可能断电或崩溃,内存中的脏页和日志缓冲区都会丢失。为了在重启后恢复到一个一致的状态,SQL数据库普遍采用Redo日志和Undo日志协同工作。Redo日志记录了数据页的物理变更内容,Undo日志记录了行数据被修改前的旧版本,二者分别对应持久性和原子性,但只有配合起来才能完成完整的崩溃恢复。

一、Redo与Undo解决的是两个不同维度的问题
事务的持久性要求一旦提交,修改就不能丢失;原子性要求事务要么全部生效,要么全部不生效。数据库引擎的缓冲池机制导致数据页不会在每次修改后立即写入磁盘,通常是异步刷盘。这就产生一个矛盾:已提交的数据可能还在内存中尚未落盘,而某些未提交事务的脏页反而因为缓冲区压力被提前刷到磁盘。如果没有日志记录,重启后根本无法区分哪些修改应该保留、哪些应该丢弃。
Redo日志正是为了应对第一种情况。它记录的是数据页的变更本身,例如某个表空间的某个页在某个偏移量上从旧值变成了新值,或者更粗略地记录完整页映像。只要Redo日志在提交前写入磁盘,即使数据页没有刷盘,恢复时也能根据日志重新执行这些修改。Undo日志则记录修改前的旧值,当事务回滚时,系统根据Undo把数据恢复到修改前状态。对于MVCC多版本控制,Undo还承担了为其他事务提供历史版本的任务。
把两者对立起来看并不准确。Redo保证已提交事务的修改不会因为延迟刷盘而丢失,Undo保证未提交事务的修改不会因为提前刷盘而污染数据。两种日志的写入时机、刷盘策略和清理规则不同,需要在一个事务生命周期内紧密配合。
二、事务执行阶段:Redo与Undo的写入顺序与WAL约束
事务执行过程中,修改数据会先在缓冲池中进行,不会同步落盘。为了在崩溃后能恢复,数据库必须先把日志写入磁盘或至少保证日志在提交前落盘,这就是Write-Ahead Logging(WAL)原则。WAL要求数据页的刷盘不能早于对应的Redo日志;而Undo日志作为维护原子性的数据,它本身也被Redo日志保护,即Undo页的修改同样会产生Redo记录。
具体到一次更新操作,流程大致如下:事务先向Undo日志写入旧版本记录,确保回滚时有依据;然后修改内存中的缓冲池页;接着生成一条Redo日志记录,描述这次物理变更。提交时,数据库根据配置将Redo日志刷到磁盘,Undo日志也通过WAL机制得到持久化保障。不少引擎在提交时仅仅写入日志,数据页可以由后台线程定期刷盘,因此事务提交延迟主要取决于日志刷盘速度。
下面用伪代码展示一条UPDATE语句在日志层面的基本顺序:
事务开始
分配事务ID:trx_id = 1001
定位目标行记录,锁定该行
生成Undo日志:记录修改前的旧值 old_value
写入Undo日志缓冲区
修改缓冲池中的行数据:new_value 替换 old_value
生成Redo日志:记录页号、偏移量、新值 new_value
写入Redo日志缓冲区
事务提交:
将Redo日志缓冲区刷盘
标记Undo日志为已提交
释放锁
这个顺序中,关键点在于Undo写入一定发生在数据修改之前,而Redo写入发生在数据修改之后或者同时,但提交前必须刷盘。如果Redo刷盘失败,事务不会提交成功;如果Undo写入失败,修改不会发生。这样可以保证恢复阶段有完整的信息链。
值得注意的是,Undo日志自身也存储在表空间或文件里,修改Undo页同样会产生Redo日志。这样即使Undo页因为断电没有落盘,恢复时也可以通过Redo重建Undo内容。对于MySQL InnoDB,redo log采用循环写文件组,undo log则存储在独立的undo表空间中,二者在物理结构上分离。
三、崩溃恢复流程:分析、重做、回滚三个步骤
数据库异常重启后,需要根据日志把系统恢复到崩溃前的等价一致状态。恢复通常分为三个阶段:分析、重做(Redo)和回滚(Undo)。分析阶段扫描Redo日志,确定哪些事务已经提交、哪些事务尚未提交,并找到所有需要处理的日志记录起点。重做阶段从检查点开始,将所有Redo日志重新执行一遍,让数据页内容追上日志的最新状态。回滚阶段则对分析阶段标记为未提交的事务,利用Undo日志将其修改撤销。
这里有一个容易误解的地方:重做阶段为什么要连未提交事务的Redo也重做?因为缓冲池的脏页可能在崩溃前被部分刷盘,已提交和未提交的修改都可能已经落盘。如果不重做所有日志,数据页可能处于一个不完整的状态,后续回滚就缺少正确的基础。重做全部日志让数据页达到崩溃瞬间的最新值,然后通过Undo撤销未提交的部分,最终得到只包含已提交事务修改的干净状态。
下面是恢复算法的简化描述:
恢复开始
读取最近的检查点LSN
从检查点开始扫描Redo日志:
构建事务状态表:
遇到事务开始,标记为活跃
遇到提交记录,标记为已提交
遇到回滚记录,标记为已回滚
重做阶段:
从检查点LSN开始,按顺序应用所有Redo日志到数据页
即使对应事务未提交,也先全部重做
回滚阶段:
遍历事务状态表中所有未提交事务
按事务的Undo链逆向执行回滚
生成新的Redo日志记录回滚操作本身
完成后标记事务为已回滚
数据库对外提供服务
检查点在恢复中的作用是减少需要处理日志的数量。检查点会记录当前所有活跃事务、对应的Undo位置以及数据页刷盘进度,恢复时从检查点开始而不是从日志开头开始。如果检查点过于陈旧,恢复时间会变长;如果检查点过于频繁,正常运行时的I/O开销又会增加。
四、典型实现中的协同细节与调优注意事项
以InnoDB为例,redo log通过组提交机制将多个事务的日志一起刷盘,降低fsync的代价。Undo日志则用于事务回滚和一致性读。当事务提交后,Undo日志并不会立即删除,因为其他事务可能还在通过MVCC读取旧版本。InnoDB使用后台purge线程根据undo log的可见性判断,逐步清理不再需要的旧版本,这个过程中也会产生对应的Redo日志。
长事务会导致Undo日志持续增长,因为事务未提交,其所有旧版本都不能被清理。这可能会拖慢回滚速度,甚至撑大undo表空间。另一方面,redo log配置过小会造成频繁检查点和日志切换,影响写入吞吐;配置过大则恢复时间可能变长。实际调优时,需要关注事务平均大小、并发写入量和恢复时间目标。
例如在MySQL中,参数innodb_flush_log_at_trx_commit控制redo日志刷盘策略。设置为1时每次提交都刷盘,最安全但性能较低;设置为2时每秒刷盘,操作系统崩溃可能丢失一秒数据;设置为0时交给后台刷盘,性能最高但会丢失更多已提交事务。对应的innodb_undo_tablespaces和innodb_max_undo_log_size则用于管理Undo表空间数量与大小。这些参数需要结合Redo和Undo的协同关系来调整,不能只看单一指标。
如果发现事务回滚异常缓慢,通常是因为回滚过程中需要逆向读取大量Undo记录,并且回滚本身也会写Redo日志。这种场景下,避免在单个事务中执行过多更新、及时提交、拆分批次往往会比直接调大日志文件更有效。理解Redo与Undo的协同机制,能够帮助开发者在设计事务边界、排查恢复问题以及配置日志参数时做出更合理的判断。