数据库恢复处理的不是“怎样保证机器永不出错”,而是机器、进程或存储已经出错以后,怎样仍然兑现事务的原子性与持久性。系统在正常运行期间必须提前留下足够证据;故障发生后,再依据这些证据把数据库带回一个可解释、可继续服务的状态。
贯穿本文的案例是“云帆票务”平台。一次购票事务 T17 要完成三项修改:余票从 12 张减到 11 张,用户余额从 500 元减到 380 元,再创建金额为 120 元的订单。业务不变量可以写成:
如果系统只把余票写回磁盘便崩溃,数据库就留下了“票少了、订单却不存在”的半成品。恢复系统的任务,是根据事务最终是否提交,选择完成全部效果或消除全部效果,而不是猜测故障前执行到了哪一行代码。

恢复不是故障后的临时补救。日志、缓冲策略、检查点和备份都在正常运行时共同准备恢复所需的证据。
恢复正确性有两个时间方向:向后撤销未完成事务,守住原子性;向前重做已完成事务,守住持久性。只讨论其中一个方向,无法覆盖真实系统中的崩溃状态。
面对告警时,先问“哪些信息还可靠”,比先问“服务为什么停了”更有利于选择恢复路径。事务失败、系统崩溃和持久介质损坏的损失边界不同,不能使用同一套处理动作。
事务可能因为两类原因无法继续。逻辑错误来自事务自身,例如场次不存在、余额不足、输入溢出或超过资源上限。系统已经知道这次业务不应完成,因此中止 T17 并撤销它已经造成的修改。
系统错误来自当前执行环境,例如死锁检测器选择 T17 为牺牲者。事务本身的输入未必有错,只是此刻无法继续;回滚释放资源后,它可以稍后重新执行。两类错误都通常不要求重启整个数据库。
电源中断、操作系统故障、数据库进程缺陷都可能让内存中的日志缓冲和数据页消失,同时终止所有正在执行的事务。恢复算法通常采用 fail-stop 假设:错误会让系统停止,但不会悄悄篡改非易失存储中原本正确的内容。硬件校验、页校验和内部断言都在帮助系统尽早停止,而不是带着未知损坏继续写入。
这个假设不是说磁盘永远可靠,而是把“内存丢失、磁盘仍可信”的崩溃恢复,与“磁盘内容也丢失”的介质恢复分开处理。
磁头损坏、闪存故障或传输中的部分写可能破坏一个或多个持久块。此时本机数据库文件不能再被视为完整事实,需要从镜像、归档备份或远程副本恢复。若只是少数页损坏,可以只还原受损页再重放与这些页相关的日志;若整个设备丢失,则要先恢复完整备份,再重放备份之后的日志。

判断依据是仍然可信的存储层:单事务失败通常保留全部存储,系统崩溃丢失内存,介质故障连数据库文件也可能丢失。
恢复协议依赖三类存储。易失存储以主存为代表,速度快,但断电即失;日志缓冲、数据库缓冲页和事务工作区都在这里。非易失存储能跨重启保留内容,例如 SSD 和磁盘,但设备自身仍可能损坏。稳定存储是一种可靠性目标:通过相互独立的多个非易失副本和受控写入,让信息丢失的概率降到工程上可接受的程度。
仅把块复制两份还不够。一次块传输可能成功,也可能在中途失败并留下校验不通过的部分写,还可能在真正覆盖目标块前就失败。为了让一个逻辑块 B 的更新表现为“全部成功或完全不变”,系统可按顺序执行:
先把新内容写入物理副本 B1,等待设备确认写入完成,并通过校验和验证。
再把同一内容写入具有独立故障模式的物理副本 B2,同样等待完成。
只有第二次写入也成功,逻辑 output(B) 才向上层返回成功。若中途故障,恢复程序比较两份内容与校验结果,用有效副本修复无效副本。
如果两份副本校验都通过却内容不同,协议允许选择其中一个完整版本统一副本;关键是不能把部分写的混合内容当成新状态。系统还可以在少量非易失内存中记录“正在写哪些块”,重启时只核对这些块,无须扫描全部数据。
磁盘上的物理块通过 input(B) 进入内存中的缓冲块;output(B) 再把缓冲块写回对应物理块。事务第一次读取数据项 X 时,若其所在块 B_X 不在缓冲池,系统先调入整页,再把 X 复制到事务私有变量。事务执行 write(X) 时,通常只是把私有变量的新值写入缓冲页,不代表该页已经落盘。
因此,下面三件事不能混为一谈:事务代码算出了新余额、缓冲页中的余额已改变、磁盘页中的余额已改变。崩溃可能发生在任意两步之间,日志正是用来弥合这些时间差。

read 和 write 面向数据项,input 和 output 面向整个块;写入缓冲页不等于写入磁盘。
数据库状态本身无法告诉我们某个事务在崩溃前“本来打算做什么”。恢复系统需要另一条顺序历史:日志。对 T17 而言,一组典型记录可以写成:
〈T17, start〉
〈T17, 余票, 12, 11〉
〈T17, 余额, 500, 380〉
〈T17, 订单O9001, 不存在, 金额120〉
〈T17, commit〉更新日志至少包含事务标识、数据项标识、旧值和新值。旧值让系统能够撤销,恢复未提交事务执行前的状态;新值让系统能够重做,把已经提交但尚未落盘的效果补回数据库。start、commit 与 abort 则标记事务生命周期。
立即修改允许事务提交前就改动缓冲页,甚至把含有未提交修改的页写到磁盘,所以既可能需要 UNDO,也可能需要 REDO。延迟修改在事务提交前不改数据库,把新值保留在私有区域或日志中;它可以免去未提交数据落盘后的物理撤销,但要管理本地副本,并在事务读取自己写过的数据时返回正确版本。
本文后续算法按更一般的立即修改来说明。它同样可以支持延迟修改,只是后者能够省去部分旧值和撤销工作。
日志不是唯一能实现原子提交的结构。影子复制会保留一份不再改动的旧数据库,事务在新副本上工作;事务中止时丢弃新副本,提交时先确保新副本完整落盘,再原子切换一个持久指针。只要指针完全位于设备保证原子写的扇区内,崩溃后它会指向旧版本或新版本,不会指向半个版本。
完整复制大型数据库代价太高,影子页把粒度缩小:复制页表与被更新的页,未修改页仍由新页表指向原页,提交时原子切换页表根指针。这种方法直观,但页表复制、空间回收和并发事务合并都很棘手,因此通用数据库更常使用日志恢复。它在小型单写场景仍然有价值,也帮助我们理解“先构造完整新版本,再切换唯一入口”这类原子更新模式。
日志先行写入(WAL)有两条直接的安全边界:
commit 记录必须已经持久化。第一条保证未提交修改即使被偷写到磁盘,也有旧值可撤销。第二条保证一旦客户端收到“提交成功”,即使数据页还在内存,也有新值可重做。由此可见,事务真正提交的时刻不是“所有数据页都落盘”,而是提交记录随此前日志安全进入稳定日志的时刻。
WAL 不是“先在内存里生成日志,再更新数据页”。日志只留在易失的日志缓冲中仍会随崩溃一起丢失。安全边界要求相关日志先到达稳定存储,随后数据页才可以落盘。
REDO 使用新值,按日志原顺序向前应用更新;UNDO 使用旧值,按事务操作的逆序向后撤销。方向不能交换:若同一数据项被连续改为 12 → 11 → 10,正向重做得到 10;反向撤销则必须先把 10 恢复成 11,再把 11 恢复成 12。
回滚 T17 时,系统逆向扫描它的记录。每恢复一个旧值,都写一条只用于重做的补偿记录,例如 〈T17, 余额, 500〉。这种记录不再包含“如何撤销本次撤销”,因为恢复系统不会把撤销操作再次撤销;若回滚途中又崩溃,重启恢复只需重做已经完成的补偿,再继续余下撤销。回滚走到 start 后写入 abort,事务才算完整结束。
这也解释了一个容易困惑的规则:带有 abort 的事务在“重复历史”阶段仍可能被重做。重做的是它原来的更新以及后续补偿记录,最终效果仍然是已经撤销后的状态。
没有检查点时,重启可能要从日志开头判断每个事务的命运。一个简单检查点会先持久化内存中的日志,再把全部脏页写盘,最后写入 〈checkpoint L〉,其中 L 是当时仍活跃的事务集合。检查点之前已结束事务的修改已经落盘,恢复可以忽略它们。
不过,集合 L 中的事务可能在检查点之前就开始,它们的早期日志仍可能用于撤销。因此,能够回收的日志边界要早于 L 中最早的事务起点,不能简单删除检查点之前的所有记录。
下面的实验展示同一条购票日志在三个崩溃位置的处理。拖动滑块后,观察哪些事务必须重做、哪些必须撤销,以及最终的余票与余额。
精确的恢复算法把正常回滚和重启恢复连接起来。正常回滚只关注一个事务;重启恢复面对许多已提交、已中止和未完成事务,通常分成 REDO 与 UNDO 两个阶段。
事务 T17 主动中止时,系统从日志尾部向前寻找它的更新记录。每找到一条,就把旧值写回数据项,并追加一条 redo-only 补偿记录。扫描到 〈T17,start〉 后写 〈T17,abort〉。严格并发控制会保证其他事务没有在 T17 提交前覆盖同一个数据项,否则简单恢复旧值可能误删别人的更新。
系统从最近检查点开始正向扫描,初始 undo-list 等于检查点的活跃事务集合。扫描期间:
start 就把它加入 undo-list;commit 或 abort 就把它移出 undo-list。这个阶段故意把崩溃前已经执行过的动作再放回同一顺序,包括未完成事务的更新和回滚动作。它先恢复到“最后一条稳定日志出现时”的历史状态,再处理未完成事务,逻辑更统一,也更容易应对恢复期间的再次崩溃。
REDO 结束后,undo-list 中只剩崩溃时未完成的事务。系统从日志尾部逆向扫描,只撤销这些事务的更新;每次撤销继续写补偿记录。遇到某事务的 start 后,为它写 abort 并从列表删除。当列表为空时,数据库才可以恢复正常事务处理。
每个事务单独把提交记录强制写盘,会让提交吞吐受存储延迟限制。组提交让多个刚完成的事务在一个很短的时间窗内共享一次日志写入。设单次稳定日志写入耗时为 ,每个块平均容纳 个事务的提交记录,不考虑其他瓶颈时,提交吞吐上界可近似为:
组太小,摊薄效果有限;等待窗口太长,单事务延迟上升。系统要在块利用率和最大等待时间之间设界限。应用批量导入时,把多条插入放进一个合理大小的事务,也能减少同类提交开销。

REDO 正向重复历史并构造未完成事务集合,UNDO 再逆向清理这个集合;恢复期间产生的新日志让流程可重入。
日志缓冲把多条小记录凑成块后批量写入,数据库缓冲则让多次更新先在内存页聚合。两种缓冲都提高吞吐,但也制造了“日志、内存页、磁盘页到达时间不同”的问题。
force 要求事务提交时把它修改的所有数据页立即写盘。提交后数据效果已经在磁盘上,持久性不依赖 REDO,但一次事务可能触发许多随机写。
no-force 允许事务仅持久化日志后就提交,数据页稍后再写。它让多个事务对同一页的更新合并成一次 I/O,提交更快;代价是崩溃后必须 REDO 已提交但未落盘的更新。实际系统通常选择 no-force。
steal 允许缓冲管理器把含未提交修改的页写盘,以便腾出帧给其他页。它支持更新量超过缓冲池的大事务,但崩溃或事务失败后必须 UNDO 已经落盘的未提交效果。
no-steal 禁止这类页在事务结束前写盘,可以免去磁盘上的未提交效果,却可能让长事务占满缓冲池而无法继续。实际系统通常选择 steal,并依靠 WAL 保证可撤销。
因此,常见的 steal + no-force 组合同时需要 UNDO 和 REDO。它把 I/O 调度自由交给缓冲管理器,再由日志恢复承担正确性。
缓冲页准备写盘时,系统用短期 latch 阻止该页同时被更新,然后把与该页有关的日志推进到满足 WAL 的位置,最后输出数据页并释放 latch。latch 保护内存数据结构和 I/O 临界区,持有时间很短;它不同于保证事务隔离的逻辑锁,不要求遵循两阶段加锁。
数据库自管一块内存,可以准确控制 WAL 与页写出顺序,但预留过多会挤压其他进程,预留过少又无法使用闲置内存。把数据库缓冲放在普通虚拟内存中,操作系统可能先把页换到交换区,数据库之后又要把它读回并写入真正的数据文件,形成额外 I/O;更麻烦的是,操作系统不了解 WAL 边界。数据库仍需掌握数据库文件的写出时机。
简单检查点要求暂停更新并刷完全部脏页。模糊检查点先记录当时的脏页集合,允许其他页继续更新,再在后台刷页。磁盘上固定位置的 last-checkpoint 只在这一轮所需页面处理完成后指向新检查点;如果中途崩溃,恢复仍从上一个完整检查点开始。单个页面输出期间仍必须加 latch,并遵守 WAL。

横轴决定提交时是否强制写数据页,纵轴决定未提交页能否被写出。右下角的 steal + no-force 需要同时支持 UNDO 与 REDO。
选择任意组合,观察它对提交延迟、长事务、UNDO 和 REDO 的影响。实验中的结论假设日志协议正确,并不表示较少恢复动作的组合一定更快。
崩溃恢复假设数据库文件仍在;介质恢复不能这样假设。系统需要周期性把数据库完整内容转储到稳定存储,形成可恢复基线。一次最保守的归档转储会暂停事务,依次持久化日志、刷出缓冲页、复制数据库,再写入 〈dump〉 记录。
全盘损失后的恢复顺序是:
找到最近一次可验证的完整转储,把数据库文件恢复到转储时的状态。校验转储清单、页校验和与加密密钥可用性,避免从损坏基线开始恢复。
从该转储对应的日志位置向前重做,直到目标恢复点。因为转储提供的是一个一致基线,介质恢复阶段不必撤销转储之前的事务。
若只有少数页损坏,只恢复这些页,再筛选或重放会影响它们的日志,避免复制整个数据库。
物理转储保留页布局,适合快速恢复到同类实例。SQL 转储写出 DDL 与插入语句,重建速度通常更慢,却适合迁移到不同物理布局或软件版本。大型系统还会使用模糊转储,让事务在复制期间继续运行;这要求额外记录复制窗口内的变化,确保最终能拼成一致状态。
恢复点目标可以描述为转储时刻 加上其后的日志区间:

转储提供可信基线,连续日志把基线推进到目标时刻;只有备份而没有可用日志,恢复点只能停在备份时刻。
远程备份系统在主站处理更新,同时把日志持续发送到物理隔离的备站。主站不可用时,备站用自己的数据库副本和已经收到的日志完成恢复,再接管新事务。它解决的不只是“数据能否找回”,还包括“服务多久能够重新接受请求”。
备站看不到主站心跳,原因可能是主机故障,也可能只是通信链路断开。系统可以使用故障模式相互独立的多条链路、仲裁服务或人工确认减少误判。最危险的状态是主备都认为自己拥有写权限,形成双主分裂;控制权转移必须与隔离旧主、租约或仲裁绑定。
备站接管前先完成恢复,然后让客户端连接到新主。可通过漂移地址、高可用代理或服务发现更新路由。原主修复后,不能直接恢复写入;它必须先获得故障期间新主产生的日志并追平,再成为备站。若业务要切回,还需执行一次受控角色转换。
冷备只接收日志,接管时再集中重放,恢复时间随待处理日志量增长。热备持续应用收到的 REDO,故障后通常只需处理尾部日志并撤销未完成事务。备站也可以承担读查询,但要提供事务一致快照,不能让只读负载阻塞日志应用。
远程复制可以位于数据库日志层,也可以位于文件系统或存储阵列层。下层块复制虽然不理解事务,仍必须保留数据库要求的先后关系:若主站先强制写出日志再写数据页,备站也不能让对应数据页先于日志稳定下来。否则主站故障后,备站可能看到未提交数据,却缺少撤销证据。
高可用还要覆盖数据库之外的请求入口。应用可以运行在多个服务节点,由负载均衡器把请求路由到健康节点;会话信息要共享或外置,单个应用节点失败时用户才不必重新登录。数据库备站接管与应用流量切换是两条相关但不同的链路,演练时应分别验证。

高可用链路不止复制数据,还要定义谁有写权限、何时确认提交、怎样把客户端安全切到新主。
严格两阶段加锁让更新者持有排他锁直到事务结束,物理 UNDO 才能放心恢复旧值。但 B+ 树页、空闲空间表和页分配表被频繁访问,如果低层锁一直持有到事务提交,许多原本互不冲突的操作会被迫串行。
假设 T17 在订单索引页插入键 O9001,完成页内移动后立刻释放页 latch。随后 T18 在同一页插入 O9002 并提交。若 T17 失败后把整页恢复为自己插入前的旧字节,T18 的合法记录也会被抹掉。正确做法是执行“删除键 O9001”这个反向逻辑操作,让 O9002 保留下来。
一次索引插入可短暂锁住具体页,操作完成就释放;事务仍需持有键值级或更高层的逻辑锁,直到满足两阶段规则,阻止其他事务执行会让该插入无法安全撤销的冲突动作。低层锁提供并发,高层锁保护可撤销性。
逻辑操作开始前写 〈T17,O1,operation-begin〉。执行期间,每个物理页更新照常记录旧值与新值;若操作中途失败,这些物理记录可以把半完成的数据结构恢复一致。操作完成后写 〈T17,O1,operation-end,U〉,其中 U 描述逻辑反操作,例如“从订单索引删除 O9001”。
回滚扫描遇到完整的 operation-end 时,执行 U,记录逻辑撤销产生的物理更新,再写 operation-abort。之后跳过该操作原先的物理日志直到 operation-begin,避免既做逻辑撤销又恢复旧字节。若操作没有 operation-end,说明它未完成,系统改用内部物理日志清除半成品。
崩溃时,磁盘上的 B+ 树可能只包含一次操作的部分页更新,结构尚不满足逻辑操作的前置条件。REDO 必须先使用可定位页面的物理或生理记录“重复历史”,把数据结构带回操作一致状态;逻辑 UNDO 才能随后执行。物理设值天然幂等,而索引插入可能重复产生两条记录,因此恢复还要避免重复执行已经完成的非幂等逻辑操作。

T17 完成插入后释放页级保护,T18 可以在同页继续操作;T17 回滚时删除自己的键,不能把整页恢复成旧副本。
基础算法从检查点后重放每条日志,正确但可能做很多无用 I/O。ARIES 用日志序列号、页上的进度标记和脏页表缩小 REDO 范围,同时仍坚持“分析、重复历史、撤销失败事务”的主线。
每条日志有单调递增的 LSN。工程中它常由日志文件号与文件内偏移组成,既能比较先后,也能定位记录。每个数据页保存 PageLSN,表示最后一次已经反映在该页上的更新日志。若待重做记录满足:
说明该效果已经在页上,不能再次执行非幂等的生理操作。每条事务日志还保存 PrevLSN,直接链接到同一事务的上一条记录,UNDO 无须扫描与该事务无关的整段日志。
生理日志在页面外是物理的:明确指出页号;在页面内是逻辑的:例如“删除槽位中的某条记录”,无须记录页内所有字节移动。它比整页字节日志小,但重复执行可能改变结果,所以 PageLSN 检查不可缺少。页面更新未完成时不能刷盘,系统用 latch 保护页面,完成更新并生成日志后才释放。
页面第一次在缓冲池中变脏时进入 DirtyPageTable,其 RecLSN 记录“磁盘版本可能缺失的最早更新”。页面成功刷盘后可以从表中移除。检查点记录脏页表和活跃事务表;后者为每个事务保存 LastLSN。检查点不强制刷完所有脏页,因此开销小,也解释了为什么 REDO 起点可能早于检查点记录本身。
分析从最后一个完整检查点向前扫描,重建事务表与脏页表。待撤销集合包含没有结束记录的事务。REDO 起点取脏页表中最小的 RecLSN:
若脏页表为空,可以从检查点 LSN 开始。扫描检查点之后的新更新时,若页面还不在脏页表,就以该更新的 LSN 建立 RecLSN。
从 RedoLSN 正向扫描更新日志。页面不在脏页表,或日志 LSN 早于该页 RecLSN,说明磁盘页不可能缺这项更新,可以不读页直接跳过。其他情况才读取页面;若 PageLSN 小于日志 LSN,执行生理 REDO 并推进 PageLSN,否则再次跳过。
UNDO 从所有失败事务的 LastLSN 中选择当前最大的待处理 LSN,沿 PrevLSN 逆向工作。每次撤销写入一条 CLR,并把 UndoNextLSN 指向该事务下一条还需撤销的日志。CLR 只重做、不撤销;若恢复期间再次崩溃,分析与 REDO 会重放已完成的 CLR,UNDO 读取 UndoNextLSN 跳过已处理区间。
保存点记录事务内部的回退位置。死锁处理或应用发现可局部修复的错误时,事务可以只回滚到某个保存点,释放相关资源后继续执行,而不必放弃此前全部工作。
嵌套顶层动作用于“事务失败也不应撤销”的系统操作。例如事务为一个关系分配了新页,其他事务随后已在该页存放记录,原事务回滚时再收回整页会伤及别人的数据。ARIES 可以写一条虚拟 CLR,让 UndoNextLSN 跨过这段日志,相当于声明这项系统操作的逻辑撤销为空。
ARIES 还允许部分页面独立恢复,未受损页面可提前开放;细粒度索引锁让不同键上的操作保持并发。REDO 期间可以依据脏页表预取页面,也可以在某页仍等待 I/O 时继续处理其他日志,等页面到达后再补做该页更新。这些优化不改变三阶段语义,只改变 I/O 排程与可用时间。

RecLSN 缩小可能需要重做的日志区间,PageLSN 判断具体页面是否已经包含某项效果,CLR 则标记撤销已经推进到哪里。
依次点击分析、REDO、UNDO。示例中页 P8 的磁盘 PageLSN 已经覆盖 LSN 104,页 P9 仍缺少 LSN 106;事务 T31 已提交,T32 在崩溃时未结束。
内存数据库把主要关系放在主存,随机访问很快,但进程退出或断电会让整个工作集消失。它仍需把必要日志写入稳定存储,并周期性生成持久检查点。重启时先加载检查点,再重放后续日志;此时恢复速度直接决定服务何时可用,因为数据库未装载完成前很难处理正常请求。
关系数据恢复到内存后,许多索引能以并行扫描快速重建。系统可以不为索引更新记录 REDO,减少正常运行时的日志量;事务正常回滚仍可能需要内存中的 UNDO 信息。这里的取舍是用重启时的计算换取更低的持续写放大。
一些内存数据库只持久化 REDO。检查点必须保证未提交数据不进入持久映像,或使用多版本记录,把未提交版本与可见版本分开。恢复时装载检查点、重做已提交日志,再回收失败事务遗留的版本。若检查点混入未提交效果却没有 UNDO,系统将无法恢复原子性。
为了避免单线程重放成为瓶颈,系统可以按数据分区组织日志,使一个分区的日志只影响对应数据。多个核心各自加载并恢复一个分区,在满足跨分区依赖的前提下并行推进。快速恢复不只依靠更快存储,也依靠日志布局在正常运行时就支持并行回放。
存储级内存兼具持久性与接近内存的随机访问方式,部分场景可以省去传统 REDO,但系统仍要处理缓存刷新顺序、写入原子粒度和事务中止。介质更快不会自动把多个写操作变成一个原子动作,恢复协议只是换了更细的持久化边界。
完整的恢复方案可以沿着“故障边界—持久证据—恢复算法—可用性”逐层检查。首先明确事务失败、系统崩溃和介质损失分别会丢什么;随后确认日志与数据页遵守 WAL,提交确认点可被审计;再验证检查点、备份和日志保留区间能够覆盖目标恢复点;最后演练主备切换与恢复期间再次崩溃。
对“云帆票务”的 T17,我们应能回答这些具体问题:
T17 提交前能否被写盘?若能,旧值日志何时持久化?commit 与此前更新日志是否已到达承诺的副本数?恢复能力只有经过故障注入、备份还原和主备切换演练才算得到验证。日志“存在”、备份任务“成功”和备站“在线”都只是条件;真正的结果是能在目标时间内恢复到明确的事务边界,并解释每一笔已确认事务为何保留或为何丢失。