在上篇文章中,我们简单介绍了EarlyLockRelease的优化思路:通过将索引上的Lock提前释放以提高并发程度,但同时会在事务之间产生依赖关系,导致级联回滚。比如第一个事务已经释放锁,但在刷日志时出现故障需要回滚,此时锁已被下一个事务获得,那下一个事务要和前面的事务一起回滚,极大地影响系统性能。
基于这样的情况,恢复系统中引入了LogicalUndoLogging,在一定程度上解决了上述问题。LogicalUndoLogging的基本思想是在事务回滚时撤销修改操作,而不是对数据的修改,如插入的撤销是删除,对数据项+1的撤销是对数据项-1。
此外处理LogicalUndo时引入了OperationLogging的概念,即记录日志时对事务一些操作采用特殊的日志记录方式,称为TransactionOperationLogging。当Operation开始时,会记录一条特殊的OperationLogging,形式为logTi,Oj,operation-begin,其中Oj为唯一的OperationID。在Operation执行过程中,系统正常进行PhysicalRedo和Undo日志记录;而Operation结束后,则会记录特殊的LoggingTi,Oj,operation-end,U,其中U便是对于已经进行的一组操作的LogicalUndo操作。
举例来说,如果索引插入新键值对(K,RID7)到叶子节点IndexI9,其中K存储在IndexI9页面的X位置,替换原值Old1;RID7存储在X+8位置上替换原值Old2。对于该事务,记录日志时会在最前面添加操作开始记录T1,O1,operation-begin,中间PhysicalLogging正常记录数值的更新操作,结束时记录结束日志T1,O1,operation-end,(deleteI9,K,RID7),其中(deleteI9,K,RID7)是对T1操作的LogicalUndo,即删除I9节点页面的K和RID7。
在Operation执行结束后,可以提前释放锁,允许其他事务能成功向该页面插入Key,RecordID,但导致所有Key值重新排序,使得K和RID7离开X和X+8的位置。此时如果进行PhysicalUndo,需要将K和RID7从X和X+8撤回,但实际中二者的位置已发生改变,按原日志记录进行PhysicalUndo并不现实。但从逻辑上看,Undo只需要把K和RID7从索引节点I9删掉即可,因此在日志中加入操作日志(deleteI9,K,RID7),表示如果进行Undo,只需要按照这个逻辑指令进行即可。
在回滚时,系统对日志进行扫描:如果没有operation-end的日志记录,则进行PhysicalUndo,这是因为只有在Operation结束时才会释放锁,否则就说明锁还没有被释放,其他事务不可能修改其锁定的数据项。如果发现存在operation-end日志,说明锁已经被释放掉,此时只能进行LogicalUndo,把beginoperation和endoperation之间的所有日志跳过,因为这些日志的Undo已被LogicalUndo替代。如果在Undo时发现了T,O,operation-abort日志,则说明这个Operation已经Abort成功,可以直接跳过中间日志,回到该事务的T,O,operation-begin继续往上Undo;当Undo到T,start的日志时,表明该事务的全部日志均已经撤销完成,记录T,abort日志表示Undo结束。
其中需要注意的是,所有Redo操作都是PhysicalRedo,这是因为LogicalRedo的实现非常复杂,例如需要决定Redo顺序等,因此大多数系统里所有的Redo信息都是Physical的。此外,PhysicalRedo和锁提前释放并不冲突,因为Redo只有在出现故障时才会进行,此时系统挂掉导致所有的锁已经都没有了,重做的时候要重新申请锁。
—SQLServer:ConstantTimeRecovery—对于大多数商业的数据库系统如SQLServer等,大多采用ARIES恢复系统,因此Undo所有未Commit事务的工作量与每个事务所做的工作内容成正比。事务做的操作越多,Undo所花费的时间就越长。因为事务操作可能是一条语句但更新很多记录,Undo就需要对记录逐条撤销,导致撤销时间可能过长。虽然正确性得到保证,但对于云服务或者对可用性要求高的系统将难以接受。为了应对这种情况,出现了CTR优化技术(ConstantTimeRecovery),将ARIES系统和多版本并发控制相结合,实现固定时间恢复。固定时间恢复是指不管遇到什么情况,都利用数据库系统中的多版本信息,保证恢复操作在确定的时间内完成。其基本思想是利用多版本数据库系统中的不同数据版本,确保数据Undo到一个正确的状态,而不是用原来WAL日志里的信息进行恢复。MS-SQL的并发控制
SQLServer在0年开始引入了多版本并发控制,但早期多版本仅用来实现Snapshot隔离级别而非恢复,支持系统根据Snapshot的时间戳去读数据库中的数据。在更新数据时,多版本并发控制可以在数据Page上对记录进行就地更新,但旧版本没有丢掉,而是被单独放在VersionStore中。VersionStore是一个特殊的表,只允许不断的添加数据(Append-Only),通过指针链接指出该记录的老版本,老版本又会指向上一个更老的版本,进而构成一个数据版本链。在访问数据时,可以根据事务的时间戳来决定读取哪个版本数据。因此,早期多版本VersionStore的更新并不需要记录日志,因为一旦出现故障,重启以后所有时间戳都是最新的,只要保持最后一个版本即可,不会有比这个版本更早的Snapshot访问需求。对于当前Active的事务,可以根据当下时间戳,通过GarbageCollection机制将老版本丢弃。
CTR基于原VersionStore进行优化,实现了PersistentVersionStore,使得旧版本能够持久化存储。在CTR技术下,系统更新VersionStore时会记录日志为恢复进行的准备,使得VersionStore的体量和开销变大,于是出现两种实现老版本存储的策略In-RowVersioning和Off-RowVersioning。
In-RowVersioning是指对于更新一条记录的操作,如果只是改动很小的数据量,就不需要放进VersionStore存储,而是在记录后加一个delta值,说明属性的数值变化。其目的是为了降低Versioning时的开销,因为同一个位置改动的磁盘I/O相对较小。
Off-RowVersioning则有一个特殊的系统表,用来存储所有表的老版本,通过WAL记录Insert操作的Redo记录。当修改量较大导致In-RowVersioning无法完全保存数据更新时,便采用Off-RowVersioning方式。如下图中,A4行的Col2为,在更新为后会写入一个delta,用于记录版本变化。但这种修改受限于数据量的大小,以及记录本身所在的页面上是否有空闲空间。如果有空闲空间就可以写入,若没有就需要把更新的记录放入Off-RowVersioning表中。
恢复过程中,CTR分为三个阶段实现(Analytics、Redo和Undo)。分析阶段和ARIES类似,用来确定每一个事务的当下状态如Active、Commit和需要Undo的事务。在Redo时,系统会把主表和VersionStore的表重放一遍,都恢复到出现crash的状态。Redo完成后,数据库就可以上线对外服务。CTR的第三步是Undo,在分析阶段结束后,已经知道哪些事务未提交,Undo阶段可以直接将这些事务标记为Abort。由于每条Record的不同版本都会记录与该版本相关的事务号,因此后续事务在读到该版本时,首先判断相关事务的状态,如果是Abort就忽略掉该版本而读上一个旧版本。这样的恢复方式使得在读到不可用版本时,需要根据链接去找前一个版本,虽然会带来额外性能开销,但减少了数据库的下线时间。在继续提供服务后,系统可以在剩下时间进行GarbageCollection,将失效老版本慢慢清除,这样的机制叫做LogicalRevert。
LogicalRevert
LogicalRevert有两种方式。第一种是用后台进程BackgroundCleanup把所有数据块扫描一遍,判断哪些是Garbage可以回收。判断条件为:如果主表中最后一个版本来自于已经Abort的事务,那就从VersionStore里拿上一个已经Commit的旧版本放到主表。即使此时不做这件事情,后面使用数据时也是会到VersionStore中读数据。因此,可以通过后台的GarageCollection进程,慢慢进行版本搬迁。第二种是如果事务在更新数据时,发现主表里的版本是Abort的事务的版本,就会覆盖该版本,而此时这个事务的正确版本应该在VersionStore中。
可以看到,CTR的恢复是一个固定时间,只要前两个阶段结束即可,而前两个阶段所需时间实际上只与事务的Checkpoint有关。如果做Checkpoint的间隔是按照固定的日志大小决定,当Redo阶段结束,数据库便可以恢复工作,且恢复时间不会超过一个固定值。
—Silo:ForceRecovery—Silo是哈佛大学和MIT合作研究的一个高性能内存数据库系统原型,解决了并发程度过高导致的吞吐率下降问题。如果一个CPU内核对应一个线程来执行事务,在不存在竞争的情况下,吞吐率随着核数增加而增加,但高到一定程度后会出现下降,可能的情况是因为出现了某些资源竞争所导致的瓶颈。尽管在事务执行过程中每个线程单独执行,但最终所有事务在提交前都需要拿到事务ID,事务ID是全局范围的,事务通过原子性操作atomic_fetch_and_add(global_tid)获得