IT 论文精读 · PAPER 60
Mohan, Haderle, Lindsay, Pirahesh, Schwarz · IBM Almaden · ACM TODS 1992
你点「确认转账」,屏幕回了「成功」。假如下一秒机房断电,这笔钱会不会扣了一半、另一半没到账?不会——这份底气很大程度上来自 1992 年 IBM 一队人发表的 ARIES:它把「崩溃之后怎么把账收拾干净」从各家各法的土办法,变成一套能讲清楚、能照着抄的标准做法。今天绝大多数数据库用的还是它的骨架。
数据库改数据不是改一笔落一笔盘——太慢。它先在内存里改,攒一攒才整块写回磁盘。于是断电那一瞬,磁盘上是一锅夹生饭:有的事务已经跟用户说了「成功」,改动却还在内存里(丢了);有的只做了一半、还没提交,弄脏的内存却被顺手刷了盘(脏了)。两种还交织在同一页上。
ARIES 的地基是一条近乎无聊的规矩:动账本之前,先把「我要干什么」写进日记,并保证这笔日记先落到磁盘上。日记只往后加、绝不涂改——「我把第 7 页那个数从 3 改成了 5」。有了它,夹生饭就有救:日记里写着、账本上没有的补做;日记里属于半截事务的痕迹倒推撤掉。而日记是顺序写的,比在账本上东改一处西改一处快得多。
一、给每条日记编号,给每页账本盖印章。页眉上盖着一行小字:「我最后一次被改,对应日记第 42 条」。拿日记第 57 条来对:印章是 42,早于 57,说明这笔还没落在这页上,补做;印章若已是 90,说明早做过了,跳过。于是补做十遍和一遍结果一样——这点至关重要,因为恢复途中还可能再断电、再来一遍。
二、先原样重演历史,再回头撤销。它不挑好人坏人,把日记从头到尾原样重演一遍,连注定要撤销的半截事务的改动也照做,先把数据库复原成「断电前最后一瞬」的模样,再把半截事务干净撤掉。为什么多这一步?因为撤销是有前提的:要抹掉一条记录,得先知道它在哪儿、周围长什么样。只挑着补做,数据库会停在一个从未真实存在过的怪状态。像刑侦:先按照片把现场摆回原样,再一步步倒推。
三、撤销本身也写进日记。每撤一步就补记一笔「刚才那笔撤了,下一个该撤第几条」。撤到一半又断电,重启读到最后那笔补记就知道从哪儿接着撤——撤过的整段跳过,绝不重撤。这正是「反复崩溃也一定收敛」的保证。
有了这套,数据库才敢把内存当内存用:脏数据想什么时候刷盘就什么时候刷(哪怕事务还没提交),提交时也不必傻等数据落盘、只等日记落盘。快,且不怕断电。代价也实打实:每笔改动都要写两遍,这本日记成了全系统最拥挤的一条路,到了几十核的内存数据库时代反成新瓶颈。
动数据前先把改动写进一本只追加的日记并落盘;崩溃后靠每页上的「最后改动编号」印章,把补做变成做几遍都一样的安全动作,先原样重演全部历史复原现场,再回头撤销半截事务,每一步撤销也记进日记——于是无论哪一刻再断电,恢复都能接着跑并收拾干净。
想看三趟恢复的流程图、CLR 指针链和 steal / no-force 的取舍? → 切到精读版
ARIES 用一套建在预写日志(WAL)与日志序号(LSN)之上的三趟恢复——分析 → 重做 → 撤销——收拾崩溃现场:重做阶段原样重演全部历史(连注定回滚的事务也照做)把状态复原到崩溃瞬间,再倒着撤销未提交事务,并把每一步撤销也写成「只重做、永不被撤销」的补偿日志(CLR)。它第一次同时满足了高性能缓冲策略、记录级锁、逻辑撤销、部分回滚,以及恢复途中再崩也收敛且工作量有界。
作者是 C. Mohan、Don Haderle、Bruce Lindsay、Hamid Pirahesh、Peter Schwarz,出自 IBM Almaden 研究中心;1989 年先以研究报告在圈内流传,1992 年正式发表于 ACM TODS,正文六十余页。它上承 System R 的影子分页(改页时不覆盖原页、另写副本,提交时切换页表指针)与 Jim Gray 一系的事务日志理论,下启专攻索引加锁的姊妹论文(ARIES/KVL、ARIES/IM),并成为此后商用关系库恢复子系统的默认蓝本。关系模型(Codd)给了数据库理论的根,这一篇给的是工程的根。
恢复之难全部来自一件事:为了快,数据页在内存与磁盘之间的流动不能与事务的起止对齐。两个策略维度:
要性能就得选 steal + no-force,代价是重做与撤销一个都不能少。这是整个问题的源头。
真正让前人方案失效的是记录级锁。加锁单位从整页降到单条记录后,同一页上同时躺着多个事务的改动:回滚时不能把整页囫囵退回旧版本(那会连别人已提交的改动一起抹掉);而 B 树分裂会让记录换页搬家,等到要撤销一条插入时它可能已不在原处,「把第 7 页第 3 槽改回去」这种物理撤销直接失效——撤销必须能逻辑地说「把 key=42 那条删掉」。
前人方案各有裂缝:影子分页破坏物理聚簇、并发差;一些 WAL 方案只重做「赢家」、或要求回滚时页面处于某种约定状态,与细粒度锁直接打架;还有的在回滚途中再次崩溃时会重复撤销,日志没有上界。真问题于是是:有没有一套算法,既允许 steal / no-force,又支持记录级锁与逻辑撤销,还能在恢复中任意时刻再崩、依旧收敛且工作量有界?
规矩只有一句:描述某次改动的日志记录必须先落到稳定存储,被改的那页才允许写回磁盘;提交只需保证自己的日志已落盘。WAL 不是 ARIES 首创,但 ARIES 把建在它上面的一切做对了。
每条日志记录有一个单调递增的编号 LSN。关键动作是:每个数据页的页头存一个 page_LSN,值为最后一次改动这页的那条日志的 LSN。重做的判据于是只有一句:page_LSN < 该日志的 LSN 才做,做完把 page_LSN 更新成它。它在算什么?——「这一页到底做没做过这一笔」。页自带的印章把一个原本靠猜的问题变成一次数值比较,重做由此幂等:恢复跑到一半又崩、从头再来也不会把同一笔加两次。ARIES 后面所有机制的可靠性,都压在这个小小的页头字段上。
最反直觉、也最本质的决定。重做阶段不区分赢家(已提交)与输家(未提交),把所有可重做的改动按顺序统统补做一遍——包括马上要被撤销的输家的改动——让数据库精确回到崩溃那一瞬,才回头撤销输家。
为什么绕这一圈?因为在细粒度锁 + 逻辑撤销的世界里,撤销是有前提的动作:「把 key=42 那条删掉」需要页面与索引处在它当初被记录时的样子才有意义。若只挑赢家重做,数据库会停在一个从未真实存在过的混合状态,撤销面对的是它没见过的现场。这一决定把恢复的正确性论证压成一句统一的不变式:重做结束时,数据库状态 = 崩溃瞬间的状态。
撤销一条改动时,ARIES 不是悄悄改回去了事,而是补写一条补偿日志记录(CLR,compensation log record)说明这次撤销做了什么。每条 CLR 带一个指针 UndoNxtLSN,指向刚被撤销那条记录的前一条,即下一个还该撤销的位置;而 CLR 是 redo-only 的——只会被重做,永不被撤销。
三个收益:① 撤销工作量有界——回滚途中崩溃,重启后顺着最后一条 CLR 的 UndoNxtLSN 接着撤,撤过的整段跳过,不会「撤销的撤销」,日志也不会因反复崩溃而无限膨胀(前人方案最常见的裂缝就在这);② 部分回滚免费得到——回滚到事务中途的存档点(savepoint)用同一套指针;③ 撤销受重做保护——撤销动作既在日志里就同样能被重做,恢复因此可在任意一点被打断。
① 分析:从最近一次检查点(日志里周期性记下的「此刻系统大致如此」)扫到日志末尾,重建脏页表(每个脏页记一个 recLSN:自上次落盘后第一次把它弄脏的那条日志的 LSN)与事务表(崩溃时仍活着的事务及其最后一条日志的 LSN),取脏页表里最小的 recLSN 作为重做起点 RedoLSN。这一趟不碰数据,只摸清「从哪开始、谁是输家」。
② 重做:从 RedoLSN 向前扫,按图 2 的判据决定做还是跳过。脏页表省下大量工作:不在表里、或 recLSN 已超过该 LSN 的页,内容早已落盘,整条日志直接略过,连那页都不必读进内存。
③ 撤销:把所有输家事务的日志按 LSN 从大到小合并成一条倒序流,逐条撤销、逐条写 CLR,直到每个输家都撤到起点。合并倒序而非一个事务一个事务地撤,是为了让同一个页尽量只被访问一次。
而这里的检查点是模糊检查点(fuzzy checkpoint):只把两张表写进日志,既不强制刷脏页,也不暂停系统;脏页由后台慢慢刷,最小的 recLSN 同时决定日志能安全截断到哪里。
招牌折中在这里:重做永远是页级的——一条日志只作用于一页、不依赖别页的状态(论文称之为 physiological logging:对页是物理的、页内部是逻辑的,如「在这页里删掉这条记录,其余挪一挪」),因而既快又能用 page_LSN 一票判定;而撤销允许是逻辑的:「把 key=42 那条删掉」,落到哪一页由当时的索引说了算——因为 B 树可能早已分裂。这一对搭配正是细粒度锁能成立的前提。
还有嵌套顶层动作(nested top action):B 树页分裂这类结构调整一旦做完就不该因外层事务回滚而被撤销(别的事务可能已在新结构上工作)。做法是子动作做完后写一条哑 CLR,让它的 UndoNxtLSN 指向子动作开始之前;外层回滚时指针一跃而过,结构改动就此定格。
诚实说明:这是一篇设计论文,不是实验论文——六十余页正文里没有基准测试表格。证据是三类:正确性论证(逐条说明恢复在任意时刻再崩仍收敛且有界);能力矩阵对照(把 ARIES 与 System R 影子分页、DB2 早期版本、IMS、Tandem 等逐项比较,比的不是速度而是能不能做到:是否允许 steal / no-force、是否支持记录级锁与部分回滚、恢复中再崩是否有界、检查点是否停机);工业落地(发表时已实现进 IBM 多个产品与原型:OS/2 Extended Edition 的数据库管理器、Starburst、QuickSilver 等)。
所以说 ARIES「赢了」不是因为某张跑分表,而是它第一次把恢复算法的功能矩阵一次填满:steal + no-force、记录级锁、逻辑撤销、部分回滚、恢复中可再崩且有界、检查点不停机——此前没有任何方案能同时给出这六项。
它成了商用关系数据库恢复子系统的默认蓝本。IBM DB2 自不必说;Microsoft SQL Server 的重启恢复被明确描述为分析 / 重做 / 撤销三段;MySQL 的 InnoDB 用带 LSN 的物理逻辑重做日志加独立回滚段,骨架同源。PostgreSQL 是个有意思的对照:它同样用 WAL 与 LSN、按 LSN 判据重放,但它的 MVCC 把未提交版本原地留着、靠可见性屏蔽、由 VACUUM 事后清理,所以没有 ARIES 那趟 undo——恰好说明这几件零件可以拆开取用。
它还把一批词汇变成了行业通用语:WAL、LSN、脏页表、补偿日志、重演历史。更抽象一层的贡献是:把「恢复」从一堆见招拆招的特例,变成一个可被论证的算法——有明确的不变式与收敛性保证(任何中断点重启都能继续并终止)。在这之前,恢复是各家数据库里最脏、最没人敢碰的那块代码。
① 一句话:WAL + LSN 之上的三趟恢复(分析 → 重做 → 撤销),重做原样重演全部历史,撤销写只重做、不被撤销的 CLR。
② 痛点根源:为性能必须 steal + no-force,REDO 与 UNDO 一个不能少;记录级锁又让整页回退失效、让物理撤销在 B 树分裂后失效。
③ 地基零件:每页页头存 page_LSN,重做时比一次数值就知道该做还是跳过——重做因此幂等,途中再崩也安全。
④ 最反直觉的一招:重做不分赢家输家,先把数据库精确复原到崩溃瞬间,撤销才有认得的现场可依。
⑤ CLR + UndoNxtLSN:撤销自身也写日志且永不被撤销,反复崩溃下撤销工作量有界,部分回滚顺带免费得到。
⑥ 页级重做 + 逻辑撤销(physiological logging)是招牌折中;嵌套顶层动作让 B 树分裂不被外层回滚抹掉;模糊检查点不刷脏页也不停机。
⑦ 证据形态:不是跑分,而是正确性论证 + 与前人的能力矩阵对照 + 在 IBM 产品与原型中的落地。
⑧ 影响与局限:DB2、SQL Server、InnoDB 骨架同源,PostgreSQL 靠 MVCC 省掉 undo 那趟;而它极复杂难实现对、无量化评估,日志更成了多核时代的扩展瓶颈。