本地事务

本地事务(Local Transactions)其实应该翻译成“局部事务”才好与稍后的“全局事务”相对应,不过现在“本地事务”的译法似乎已经成为主流,笔者就不去纠结名称了。本地事务是指仅操作特定单一事务资源的、不需要“全局事务管理器”进行协调的事务,如果这个定义现在不能理解的话,不妨暂且先放下,等读完下一节“全局事务”后再回过头来想一想,对比一下。

本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(典型如数据库系统)本身的事务能力来工作的,在程序代码层面,最多只能对事务接口做了一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程当中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持,这一点与后续介绍的XA、TCC、SAGA等主要靠应用程序代码来实现的事务有着十分明显的区别。举个具体的例子,假设你的代码调用了JDBC中的Transaction::rollback()方法,方法的成功执行并不代表事务就已经被成功回滚,如果数据表采用引擎的是MyISAM,那rollback()方法便是一项没有意义空操作。因此,我们要想深入地讨论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身的事务实现原理,弄明白传统数据库管理系统是如何实现ACID的。

如今研究事务的实现原理,必定会追溯到ARIES理论(Algorithms for Recovery and Isolation Exploiting Semantics,翻译过来是“基于语义的恢复与隔离算法”,起这拗口的名字应该多少也有些拼凑“ARIES”这单词目的,跟ACID一样地恶趣味)。不能说所有的数据库都实现了ARIES理论,但现代的主流关系型数据库(Oracle、MS SQLServer、MySQL-InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。

上世纪90年代,IBM Almaden研究院总结了研发原型数据库系统“IBM System R”的经验,发表了ARIES理论中最主要的三篇论文,其中《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》着重解决了事务的ACID的两个属性:原子性(A)和持久性(D)在算法层面上应当如何实现。而另一篇《ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes》则是现代数据库隔离性(I)奠基式的文章,我们先从原子性和持久性说起。

实现原子性和持久性

原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。显而易见,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到程序忽然崩溃、数据库崩溃、操作系统崩溃,机器突然断电宕机(后文我们都统称为崩溃,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相对简单,但涉及到隔离性与锁时,Shadow Paging实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

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

Commit Logging实现事务简单清晰,也有一些数据库就是采用Commit Logging机制来实现事务的(较具代表性的是阿里的OceanBase)。但是,Commit Logging存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了Commit Record之后,即使事务提交前磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。为了解决这个缺陷,前面提到的ARIES理论终于可以登场,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中的信息回滚这些事务。

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

force-steal FORCE和STEAL的四种组合关系

实现隔离性

这节我们来探讨数据库库如何实现隔离性。隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。但现实情况不可能没有并发,要在并发下实现串行的数据访问该怎样做?几乎所有程序员都会回答到:加锁同步呀!现代数据库均提供了以下三种锁:

  • 写锁(Write Lock,也叫做排他锁eXclusive Lock,简写为X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

  • 读锁(Read Lock,也叫做共享锁Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有一个事务加了读锁,那可以直接将其升级为写锁,然后写入数据。

  • 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:

    SELECT * FROM books WHERE price < 100 FOR UPDATE;
    

    请注意“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。可串行化比较符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化(“即可”是简化理解,实际要分成Expanding和Shrinking两阶段去处理读锁、写锁与数据间的关系,称为Two-Phase Lock,2PL)。但数据库不考虑性能肯定是不行的,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及到的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读可串行化弱化的地方在于幻读问题(Phantom Reads)。譬如我现在准备统计一下Fenix's Bookstore中售价小于100元的书有多少本,会执行以下SQL:

SELECT count(1) FROM books WHERE price < 100

根据前面对范围锁、读锁和写锁的定义可知,假如这条SQL语句在同一个事务中重复执行了两次,这两次执行之间恰好有另外一个事务在数据库插入了一本小于100元的书籍,那这两次重复执行的结果就会不一样,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务遭到其他事务影响,隔离性被破坏的表现。

可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及到的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。读已提交可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads)。譬如我要统计Fenix's Bookstore中售价小于100元的书有多少本,同样执行了两条SQL语句,在此之间,恰好另外一个事务修改了其中某一本书的价格,从90元涨价到110元,那这两次重复执行的结果就会不一样,原因是读已提交在数据缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。这也是一个事务遭到其他事务影响,隔离性被破坏的表现。

读已提交的下一个级别是读未提交(Read Uncommitted),读未提交对事务涉及到的数据只加写锁,会一直持续到事务结束,但完全不加读锁。读未提交读已提交弱化的地方在于脏读问题(Dirty Reads)。譬如我觉得一本书从90元随便涨价到110元是损害消费者利益的行为,执行了一条UPDATE语句把价格改回了90元,在提交事务之前,同事过来告诉我这并不是随便涨价,是之前价格标错了,按90卖要亏本,于是我随即回滚了事务。不过,在我修改价格后这本书已经按90元的价格卖出了好几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据(如果不能理解这句话,请再读一次写锁的定义,它禁止施加读锁,而不是禁止读取数据),导致事务未提交的数据也马上就能被其他事务所读到。这同样是一个事务遭到其他事务影响,隔离性被破坏的表现。

理论上还有更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write,即一个事务的没提交之前的修改可以被另外一个事务的修改覆盖掉),脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不将它纳入讨论范围它,而将读未提交视为是最低级的隔离级别。

以上四种隔离级别属于数据库的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,这导致很多同学只能对这些现象死记硬背。其实不同隔离级别以及幻读、脏读等问题都只是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是根本的原因。

除了以锁来实现外,以上对四种隔离级别介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。这句话里“版本”是关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID(事务ID是一个全局严格递增的数值),然后:

  • 数据被插入时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。
  • 数据被删除时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。
  • 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务ID,CREATE_VERSION为空。复制出来的新数据的CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为空。

此时,有另外一个事务要读取这些发生了变化的数据时,根据隔离级别来决定到底应该读取哪个版本的数据:

  • 隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被Commit的那个版本的数据记录。

另外两个隔离级别都没有必要用到MVCC,读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以查看到,根本无须版本字段。可串行化本来的语义就要阻塞其他事务的读取操作,而MVCC是做读取时无锁优化的,自然就不会放到一起用。

MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁的策略是“乐观加锁”(Optimistic Locking)还是“悲观加锁”(Pessimistic Locking),这点还可以根据实际情况去商量一下。前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不先做加锁再访问数据,就肯定会出现问题。相对的乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该一开始就加锁,而是应当出现竞争时再找补救措施。这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),囿于字数原因就不再展开了,不过笔者提醒一句,不要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而会更慢。

Kudos to Star
总字数: 6,725 字  最后更新: 10/4/2020, 10:18:35 PM