3.1.1 实现原子性和持久性

原子性和持久性在事务里是密切相关的两个属性:原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩溃,或者数据库、操作系统一侧崩溃,甚至是机器突然断电宕机等情况就会丢失,后文我们将这些意外情况都统称为“崩溃”(Crash)。实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观存在着“正在写”的中间状态。由于写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。下面通过具体事例来说明。

按照前面预设的场景事例,从Fenix’s Bookstore购买一本书需要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态。由于写入存在中间状态,所以可能出现以下情形。

·未提交事务,写入后崩溃:程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。

·已提交事务,写入前崩溃:程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称为“崩溃恢复”(Crash Recovery,也有资料称作Failure Recovery或Transaction Recovery)。

为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中的变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“提交日志”(Commit Logging)。

额外知识

Shadow Paging

通过日志实现事务的原子性和持久性是当今的主流方案,但并不是唯一的选择。除日志外,还有另外一种称为“Shadow Paging”(有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库SQLite Version 3采用的事务机制就是Shadow Paging。

Shadow Paging的大体思路是对数据的变动会写到硬盘的数据中,但不是直接就地修改原先的数据,而是先复制一份副本,保留原数据,修改副本数据。在事务处理过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是修改数据的引用指针,将引用从原数据改为新复制并修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作的作用可以认为是保证了在硬件上不会出现“改了半个值”的现象。所以Shadow Paging也可以保证原子性和持久性。Shadow Paging实现事务要比Commit Logging更加简单,但涉及隔离性与并发锁时,Shadow Paging实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。

Commit Logging保障数据持久性、原子性的原理并不难理解:首先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功写入Commit Record就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有Commit Record的日志,将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。

Commit Logging的原理很清晰,也确实有一些数据库就是直接采用Commit Logging机制来实现事务的,譬如较具代表性的是阿里的OceanBase。但是,Commit Logging存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲,即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,却对提升数据库的性能十分不利。为此,ARIES提出了“提前写入日志”(Write-Ahead Logging)的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前写入变动数据的意思。

Write-Ahead Logging按照事务提交时点,将何时写入变动数据划分为FORCE和STEAL两类情况。

·FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。现实中绝大多数数据库采用的都是NO-FORCE策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强制数据写入时立即进行。

·STEAL:在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL。从优化磁盘I/O性能考虑,允许数据提前写入,有利于利用空闲I/O资源,也有利于节省数据库缓存区的内存。

Commit Logging允许NO-FORCE,但不允许STEAL。因为假如事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。

Write-Ahead Logging允许NO-FORCE,也允许STEAL,它给出的解决办法是增加了另一种被称为Undo Log的日志类型,当变动数据写入磁盘前,必须先记录Undo Log,注明修改了哪个位置的数据、从什么值改成什么值等,以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除。Undo Log现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为Redo Log,一般翻译为“重做日志”。由于Undo Log的加入,Write-Ahead Logging在崩溃恢复时会经历以下三个阶段。

·分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合,这个集合至少会包括事务表(Transaction Table)和脏页表(Dirty Page Table)两个组成部分。

·重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作是找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移出待恢复事务集合。

·回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为Loser,根据Undo Log中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚这些Loser事务的目的。

重做阶段和回滚阶段的操作都应该设计为幂等的。为了追求高I/O性能,以上三个阶段无可避免地会涉及非常烦琐的概念和细节(如Redo Log、Undo Log的具体数据结构等),囿于篇幅限制,笔者并不打算具体介绍这些内容,感兴趣的读者可以阅读本节开头引用的那两篇论文进行了解。Write-Ahead Logging是ARIES理论的一部分,整套ARIES拥有严谨、高性能等诸多优点,但这些也是以高度复杂为代价的。数据库按照是否允许FORCE和STEAL可以产生四种组合,从优化磁盘I/O的角度看,NO-FORCE加STEAL的组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE加STEAL的组合的复杂度无疑也是最高的。这四种组合与Undo Log、Redo Log之间的具体关系如图3-1所示。

图3-1 FORCE和STEAL的四种组合关系