本节摘要:先写日志是数据库最硬的一条铁律——数据页被改之前,对应日志必须先落盘,这是原子性与持久性的共同根基。崩溃后,ARIES 算法用分析、重做、回滚三步,从残缺的页面和日志里精确重建出"最后一个一致且已提交事务全部生效"的状态;检查点则把恢复窗口从扫全量日志压到从最近锚点开始。读完你能讲清 redo 与 undo 各自救什么,以及一条日志如何在断电后把半成品数据原样找回来。
阅读完本节,你应当能够:
很多人以为"写进磁盘"就等于"不会丢"。这个直觉在现代存储栈面前已经过时了。一次写调用返回成功,字节可能还躺在操作系统缓存里;就算刷到了盘,SSD 内部的映射表、闪存页的编程时序,每一层都可能成为灰色地带。真实世界里,"已提交却蒸发"的事故,根子几乎都在这条写入因果链的某一环偷了懒。
比"丢"更麻烦的是"乱"。崩溃发生的那一瞬,数据库里什么都有:有的数据页已经刷盘,有的还在内存;有的日志写了一半,有的提交记录还没落盘。半成品和成品混在一起,没人能靠肉眼分出来。故障恢复要解决的不是"把数据找回来"这么笼统的事,而是"在没有内存、没有完整日志的前提下,精确判断哪些修改必须留下、哪些必须抹掉"。
这个判断的锚点,只能落在一样东西上:日志。它是数据库里唯一被赋予"先于数据"地位的存在。理解了它,就理解了为什么数据库敢在断电面前拍胸脯。
先写日志的规则一句话就能说清:任何数据页在改动并写回磁盘之前,对应的日志记录必须先持久化。 这条铁律看似拖慢了写入,实则是把昂贵的随机写(改数据页)转化成了便宜的顺序写(追加日志),再把压力集中到一条可优化的通道上。
它建立了一条因果依赖:如果某条日志已落盘,而它保护的数据页还没写,崩溃后可以重放日志把页重建出来;反过来,如果数据页先写了、日志却没落盘,这笔修改就成了"幽灵写入",原子性当场破产。所以日志必须先走。
一条典型的日志记录,装着这么几样东西:全局唯一的序号,用来定位;归属事务的标识;目标数据页和偏移;修改前的原始字节,给回滚用;修改后的新字节,给重放用。前后两个镜像都在,日志就有了双重可执行性——既能正着重放,也能倒着撤销。这个设计是整个恢复体系的地基。
顺序写快,但"刷盘"这个动作本身有固定成本,如果每个事务都单独刷一次,高并发下磁盘会被刷盘调用拖垮。于是几乎所有数据库都做了组提交:把一小段时间里多个事务的日志攒成一批,一次刷盘全部落盘。代价是单个事务的提交延迟略微拉长,换来的是整体吞吐成倍上升。这里的取舍很典型——数据库从不追求单个请求最快,而是追求"在满足持久性承诺的前提下,让每秒提交数最高"。
崩溃之后,内存灰飞烟灭,缓冲池一片狼藉。ARIES 算法登场的方式很朴素:它不赌崩溃发生在哪一刻,只认日志,然后把重建拆成三个逻辑严密的阶段——分析、重做、回滚。每一步只干一件事,干完留下一个明确的中间状态。
| 阶段 | 做什么 | 关键产物 | 顺序要求 |
|---|---|---|---|
| 分析 | 只扫日志不改数据 | 活跃事务表、脏页表 | 必须最先 |
| 重做 | 回放所有够新的修改 | 页面回到崩溃时最新状态 | 紧随分析 |
| 回滚 | 撤销未提交事务的修改 | 补偿日志记录 | 必须最后 |
分析阶段像考古现场勘查。它不碰任何数据,只扫日志,建两张表:一张记录每个事务是活跃、已提交还是已中止,另一张记录每个脏页最早是从哪条日志开始被改的。这第二张表最关键,因为它回答了"这个页面上最早的、可能没落盘的修改始于何处"——它就是重做的起点坐标。
重做阶段从那个最早的坐标开始,顺序扫日志,凡是够新的修改,不管它属于哪个事务、有没有提交,一律把修改后的镜像应用到对应页面上。这一步的目的很单纯:把磁盘上所有页都推进到崩溃那一刻的最新状态。已提交事务的效果被补全,未提交但已刷盘的脏页也被还原到位,为下一步铺平道路。
回滚阶段开始外科手术。此时所有页都是"崩溃时刻最新状态",它遍历所有还没结束的事务,按日志序号的逆序执行修改前的镜像,把未提交的改动一个个抹掉。逆序是关键——先撤销后来的改动,再撤销早先的,避免中间状态打架。每回滚一条,就写一条补偿日志,标记这次撤销已经做完,让回滚本身也能被恢复,形成幂等保障。
这条补偿日志是 ARIES 里一个不起眼却极关键的设计。回滚过程中,如果系统在回滚到一半时又崩了,下一次恢复靠什么知道"这一条我已经撤过了、不用再撤"?答案就是这条补偿记录——它把"撤销"这个动作也记进了日志,让回滚变成可重放、可中断、可续做的过程。没有它,重复恢复时可能会把同一条修改撤两遍,反而制造出新的不一致。正是这种"连恢复动作本身也要可恢复"的执念,让 ARIES 能在最恶劣的崩溃序列里依然站稳。
如果每次崩溃都从日志文件的开头扫起,那跑了好几个月的数据库,恢复一次可能要几分钟甚至几十分钟,直接击穿可用性承诺。检查点的作用,就是把恢复的起点从"日志第一行"往前推到"最近的某个已知安全点"。
最朴素的检查点是停服式的:暂停所有事务,把全部脏页刷盘,再写一条包含活跃事务和最新序号的检查点记录。简单,但代价是短暂停摆。现代系统几乎都用模糊检查点——事务在检查点过程中照常跑,检查点进程先记一条"开始",异步刷脏页,刷完再记一条"结束"。恢复时只要找到最后一条"结束"记录,就能判断:比它记录的某条界线更早的页面一定已经落盘,真正要重放的只剩界线之后的那一小段。
检查点本质是时空权衡:用少量额外磁盘空间和分散的刷盘压力,换取恢复时间数量级的下降。一个设计得当的检查点策略,能让恢复时间从"和日志总量成正比"降到"和最近一个检查点间隔成正比"。这重新定义了可靠——可靠不是永不崩溃,而是崩溃后总能在业务能承受的时间内活过来。
再往前走一步,还有非阻塞检查点。它连"刷完一批"这个阶段性目标都拆散了,后台进程周期性地只刷"自上次以来新变脏"的那一小批页,每次刷完都在日志里记下涉及的页范围和对应序号。这样一来,检查点的压力被均匀摊到日常运行的每一秒里,而不是集中爆发在某个时间点。恢复时,分析阶段只需要读最近几次增量检查点的记录和对应的日志段,恢复窗口被压到秒级。代价是元数据更复杂、日志归档和回收要配合得更紧,但换来的"永不卡顿"对在线业务来说太值了。

先写日志的坑,多半不在协议本身,而在"落盘"这两个字的实现。操作系统缓存、块设备的写缓存、文件系统对刷盘调用的理解,任何一层含糊,都会让"已提交"变成一句空话。所以生产系统里,日志落盘的语义必须一路追到介质层,别只满足于应用层返回成功。
另一个常被低估的成本是日志量。顺序写快,但它会涨,尤其是高频更新负载下,日志增长速度可能比数据页还猛。这时候检查点策略和日志归档、回收就得配合起来,既保证恢复窗口可控,又别让日志把磁盘吃光。
还要盯住一个容易被忽略的指标:恢复时间目标。它决定了检查点间隔、日志归档频率、脏页刷盘速率这三者怎么配。想要崩溃后一分钟内恢复,就得把检查点打得密一点、把刷盘压得勤一点,代价是日常吞吐被分走一部分;想榨干峰值吞吐,就得容忍恢复慢一点。这个三角没有免费的午餐,只能在业务能接受的恢复时间内,尽量把刷盘成本摊薄。
⚠️ 常见坑:只保证数据页刷盘、不保证日志刷盘。这会把"先写日志"倒过来,崩溃后日志缺了关键一条,重做做不动、回滚回不去,恢复直接失败。
💡 关键直觉:日志是数据库给自己买的保险。保费是顺序写的那点带宽,赔付的是崩溃后"已提交不丢、未提交不留"的确定性。这笔交易,几乎永远是划算的。
到这里,单机事务的闭环讲完了:模型定规矩,并发控制守规矩,日志兜底。第五章我们会把这套闭环搬到多节点上,看分布式事务如何用两阶段提交和共识协议重新解决同样的三个问题。