“异时页镜像的介质恢复为这项边界给出固定页集合的完整构造:开始元数据确定redo下界20和undo保留下界10,四页分别复制为A0@0、P0@0、Q7@30、R4@70,再从完整日志恢复到80…”
一次事务已做完正确的前半段,后半段却遇到约束错误。保存点让程序只撤回后半段,再尝试另一条分支。要做到这一点,不能简单删掉后面的日志:其中某些页可能已经落盘,别的事务也在同一条日志流里继续写。数据库要保留“发生过什么”,同时改变“哪些效果仍属于本次事务”。
形式陈述
保存的是撤销边界
本页沿用补偿日志记录的事务链:普通更新有before/after与prevLSN;CLR只有补偿的redo信息,另带undoNextLSN。LSN全局严格递增,页保存最后应用的pageLSN。事务使用严格两阶段锁的终点持锁口径;回退中的页修改仍受短期保护,所有逻辑锁一直保留到整个事务commit或完整abort。
教学输入是固定页面上的整数单元,更新替换一个值。排除外部副作用、游标位置、文件分配、索引结构改变及分布式prepared事务。CLR先于含它的页图像稳定,提交先稳定全部事务日志及commit记录;这些WAL条件不因局部回退而变弱。
建立保存点时,返回属于当前事务的唯一令牌,记录当前lastLSN和创建顺序。rollback_to(s)撤销该边界之后尚未撤销的普通更新,保留目标保存点,销毁比它晚建立的保存点;release(s)销毁目标及更晚的保存点,但不撤销写入,也不提交事务。同一LSN上可以连续建立多个保存点,故先后关系必须按令牌创建序号,不能仅比较LSN。
这里目标仍有效的接口采用PostgreSQL命令所说明的行为。[2,Description] 原ARIES论文§5.2讨论的SaveLSN接口会消费目标保存点;两种API可以共用下面的撤销链,不能把名字相同理解为所有生命周期细节相同。附件使用不重复的整数令牌,不实现SQL同名遮蔽规则。
回退游标与追加日志分别前进
事务状态暂从ACTIVE变为ROLLING_BACK,禁止它并发执行新语句。令停止位置为s.lsn,撤销游标c起于事务lastLSN,执行到c≤s.lsn:
- 若c指向普通更新u,追加CLR。它的prevLSN指向此刻最新的事务日志,undoNextLSN取u.prevLSN,redo内容为把该单元恢复成u.before;应用该CLR并推进页LSN,再令c=u.prevLSN
- 若c指向CLR,不逆转补偿;令c=该记录的undoNextLSN
- 若是其他可跳过的事务记录,沿prevLSN后退。合法事务内保存点不会越过BEGIN;不接受别的事务或已失效的令牌
结束后清除较晚保存点,状态回到ACTIVE,lastLSN仍指向新写出的最后一条CLR。后续更新的prevLSN接在这里,绝不把lastLSN重置成保存点位置。部分回滚本身不必强制刷日志;若CLR尚未稳定就崩溃,内存中的回退可以丢失,再由恢复重新完成。已经刷出的页仍必须遵守WAL。
该循环的游标严格下降,追加日志的LSN却上升,两者不是同一个进度。需要回退的每条普通更新恰有一个补偿动作;游标遇到既有CLR时跨过已处理区段。新的CLR还把这个跳跃保存在以后完整中止会经过的事务链上,因此“以后不再次撤销已撤销更新”与“现在保留旧前缀”可以同时成立。
直觉
保存点像在草稿的一条操作后做标记。回退不是把纸撕掉,而是在后面追加“撤回第四步、撤回第三步”的记录。继续写第七步时,要让它接在这两条撤回记录之后。将来若整份草稿作废,恢复者先撤第七步,再顺着撤回记录跳到第二步;不能把第三、第四步重新当作仍有效的工作。
保存点也不是一个可供其他事务读取的提交版本。回退后保留的前缀仍可能在最终abort中全部消失,其他事务不能提前把它当成成功结果。
例子与边界
四个单元的一次分支重试
A、P、Q、R初值均为0,事务T的记录如下。保存点S在20之后建立,不额外占一条持久日志。
| LSN | 记录 | prevLSN | undoNextLSN | 当前相关值 |
|---|---|---|---|---|
| 10 | BEGIN T | 0 | — | 全0 |
| 20 | UPDATE A:0→2 | 10 | — | A=2;建立S |
| 30 | UPDATE Q:0→7 | 20 | — | Q=7 |
| 40 | UPDATE P:0→9 | 30 | — | P=9 |
| 50 | CLR撤销40 | 40 | 30 | P=0 |
| 60 | CLR撤销30 | 50 | 20 | Q=0;回到S |
| 70 | UPDATE R:0→4 | 60 | — | R=4;继续执行 |
| 80 | COMMIT T | 70 | — | A2/P0/Q0/R4 |
CLR60的prevLSN是50,undoNextLSN是20。前者保存真实事务历史,后者说明剩余有效前缀从哪里继续撤销。提交80稳定后,A=2和R=4是本次成功事务的效果,P与Q的试探性写入已被补偿。
同一前缀,选择完整中止
另一次运行在70后没有commit就崩溃。重做稳定日志恢复历史后,T是loser。先撤销70,写CLR80,其prevLSN=70、undoNextLSN=60,令R=0;接着读60,沿undoNextLSN直接到20,再写CLR90撤销A=2,指向10。处理BEGIN后写END。最终四项全0,30和40不再进入普通undo分支。
若CLR80稳定而R的补偿页尚未稳定时再次崩溃,重做先应用CLR80,让R回到0;撤销游标从80跳60、再跳20。已经提交的运行与这一未提交运行是两个分支,不能把第一条运行的commit80混进第二条运行的CLR80。
三个容易混淆的动作
把lastLSN重置为20,会让新写70跳过50/60。全局redo仍可能读到50/60,但事务链已不再保留本页要求的完整追加历史;这不是本协议支持的链更新。若再截掉全局日志30以后的部分,还会删除其他事务的记录。保留追加日志和正确链是协议条件,不能用一次内存值相等替代。
把CLR当普通update反向撤销,会把P=9或Q=7重新引入。只按prevLSN盲目重复撤销普通更新,在有操作型补偿时尤其危险;本页用绝对before值演示,也不能据此把协议简化成“多写几次同值没关系”。
提前释放保存点后获得的锁,需要另一份隔离和再获锁证明。本页直接保留全部锁,因此回退后仍可继续取得新锁,增长阶段没有结束。保存点不是消除死锁的充分办法;应用若仍持有循环中的锁,局部回退可以没有解除等待。
推论与应用
崩溃重启不默认恢复应用控制流
下载器在崩溃后把没有稳定commit的事务完整撤销,不自动回到某个保存点继续业务代码。这样不需要恢复应用栈、游标和下一条语句。即使更高级系统支持事务续跑,也还需恢复锁与执行入口;仅保存一个LSN不足以完成这项承诺。
分析日志时,CLR本身也不等于事务已经决定全abort。正常局部回退之后可以有UPDATE和COMMIT。本单元用事务最终稳定状态确定winner/loser;旧ARIES完整中止教学例的简化状态机不能未经扩展便拿来限制这里的正常分支。
工作量与可复算迁移
若本次游标访问k条旧记录、产生u条CLR,字长整数和期望常数时间LSN字典下,核心回退花O(1+k+u),日志新增O(u),返回的游标访问列表另占O(k)。附件先在线性保存点栈中查令牌,s个有效保存点的定位花O(1+s);删除j个较晚令牌另付O(j),已有页与全部历史另存。附件为测试保留页图像快照和执行记录,其复制成本按页面数另计,不把它算成一次常数更新。整数内容、日志序号变长时再计位成本。
试在70之后建立保存点V,再写P=6,随后回到S:应先撤新P、再撤R,沿旧CLR跳过30/40;V失效而S仍有效。重复回到S不会产生第二份针对30/40的补偿。最后改成只release S,说明它为什么不会恢复任何值,也不能让其他事务读到未提交前缀。完整分支、再次崩溃和备份输入见恢复与保留终点。
参考资料
- C. Mohan et al.,ARIES原论文,ACM TODS 17(1),1992,§5.2,印刷pp.117–120:SaveLSN、部分回退后的前进、CLR两链及锁处理。本文四单元、令牌栈和终点持锁为明确的教学构造
- PostgreSQL 18,ROLLBACK TO SAVEPOINT,Description与Notes;RELEASE SAVEPOINT,Description。用于对照保存点存活和销毁接口,不把其MVCC引擎称为本页物理undo实现