事务处理几乎是每一个信息系统中都会涉及到的问题,它存在的意义就是保证系统中的数据是正确的,不同数据间不会产生矛盾,也就是保证数据状态的一致性(Consistency)
关于一致性,我们重点关注的是数据库状态的一致性,分布式中,即将要讨论的分布式共识算法时所说的一致性,是不一样的。说回数据库状态的一致性,理论上,要达成这个目标需要三方面的共同努力:
原子性(Atomic):在同一项业务处理过程中,事务保证了多个对数据的修改,要么同时成功,要么一起被撤销。
隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
持久性(Durability):事务应当保证所有被成功提交的数据修改都能够正确地被持久化,不丢失数据。
A、I、D 是手段,C 是目的。
事务场景事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。
当一个服务只操作一个数据源的时候,通过 A、I、D 来获得一致性是相对容易的,但当一个服务涉及到多个不同的数据源,甚至多个不同服务同时涉及到多个不同的数据源时,这件事情就变得很困难,有时需要付出很大、甚至是不切实际的代价,因此业界探索过许多其他方案,在确保可操作的前提下获得尽可能高的一致性保障。
由此,事务处理才从一个具体操作上的“编程问题”上升成一个需要仔细权衡的“架构问题”。 人们在探索这些事务方案的过程中,产生了许多新的思路和概念,我们一起探索同一个事例在不同的事务方案中的不同处理,以此来贯穿、理顺这些概念。
先介绍下具体的事例。
Fenix’s Bookstore 是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:
用户的账号扣减相应的商品款项;
商品仓库中扣减库存,将商品标识为待配送状态;
商家的账号增加相应的商品款项。
接下来,我将逐一介绍在“单个服务使用单个数据源”“单个服务使用多个数据源”“多个服务使用单个数据源”以及“多个服务使用多个数据源”的不同场景下,我们可以采用哪些手段来保证以上场景实例的正确性。今天这一讲,我们先来看“单个服务使用单个数据源”,也就是本地事务场景。
本地事务是指仅操作特定单一事务资源的、不需要“全局事务管理器”进行协调的事务。
本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。
在程序代码层面,我们最多只能对事务接口做一层标准化的包装(如 JDBC 接口),并不能深入参与到事务的运作过程当中。
我举个具体的例子,假设你的代码调用了 JDBC 中的Transaction::rollback() 方法,方法的成功执行并不代表事务就已经被成功回滚,如果数据表采用引擎的是MyISAM,那 rollback() 方法便是一项没有意义的空操作。因此,我们要想深入地讨论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身的事务实现原理,弄明白传统数据库管理系统是如何实现 ACID 的。
ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)理论,ARIES是基于语义的恢复与隔离算法 ,着重解决了事务的ACID三个属性中,原子性(Atomic)、隔离性(Isolation)和持久性(Durability)在算法层面上应当如何实现。
实现原子性和持久性原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
显而易见,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到程序忽然崩溃、数据库崩溃、操作系统崩溃,机器突然断电宕机(后面我们都统称为崩溃,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 Logging 实现事务简单清晰,也有一些数据库就是采用 Commit Logging 机制来实现事务的(较具代表性的是阿里的 OceanBase)。但是,Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。
为了解决这个缺陷,ARIES 理论终于可以登场了。ARIES 提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。
Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:
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 之间的具体关系如下图所示:
隔离性保证了每个事物各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。但现实情况不可能没有并发,要在并发下实现串行的数据访问该怎样做?几乎所有程序员都会回答:加锁同步呀!正确,现代数据库均提供了以下三种锁。
写锁:(Write Lock,也叫做排它锁,eXclusive Lock,简写为X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
读锁(Read Lock,也叫做共享锁,Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。
范围锁(Range Lock):对于某个范围直接加排它锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:
SELECT * FROM books WHERE price < 100 FOR UPDATE;
请注意“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排它锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排它锁的集合无法做到的。
不同级别的事务可能出现脏读、幻读、不可重复读的现象,如下图,
脏读:一个事务访问数据并对数据进行修改,但事务还未提交,另一个事务也在读这个数据,这样会造成脏读。
不可重复读:在同一个事务内,多次读同一个数据。比如第一个事务两次读取同一个数据,但第二个事务的修改导致第一个事务两次读取的数据不一致。
幻读:事务不独立执行时发生。比如第一个事务修改表中的全部数据,第二个事务也修改了表中的数据向其添加一行,然后第一个事务发现表中还有没有修改的数据行。
可串行化(Serializable):串行化访问提供了强度最高的隔离性,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 /* 时间顺序:1,事务: T1 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) /* 时间顺序:2,事务: T2 */
SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */
根据前面对范围锁、读锁和写锁的定义可知,假如这条SQL语句在同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事务在数据库插入了一本小于100元的书籍,这是会被允许的,那这两次相同的查询就会得到不一样的结果,原因是可重复读没有范围锁来禁止在范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。
提醒注意一点,这里的介绍是以ARIES理论为讨论目标的,具体的数据库并不一定要完全遵照着理论去实现。一个例子是MySQL/InnoDB的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题(因为InnoDB使用MVCC,可重复读级别只会小于或等于当前事务ID的记录,下面有介绍MVCC),譬如上面例子中事务T1只有查询语句,是一个只读事务,所以例子中的问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,譬如例子中事务T1如果在其他事务插入新书后,不是重新查询一次数量,而是要将所有小于100元的书改名,那就依然会受到新插入书籍的影响。
读已提交(Read Commited):读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上回释放。读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一数据的两次查询得到了不同的结果。譬如笔者想要获取Fenix’s Bookstore中《深入理解Java虚拟机》这本书的售价,同样执行了两条SQL语句,在此两条语句执行之间,恰好另外一个事务修改了这本书的价格,将书的价格从90元调整到了110元,如下SQL所示:
SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */
如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务T2中更新语句可以马上提交成功,这也是一个事务收到其他事务影响,隔离性被破坏的表现。假如隔离级别是可重复读的话,由于数据已被事务T1施加了读锁且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。
读未提交(Read Uncommitted):读未提交对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。譬如笔者觉得《深入理解Java虚拟机》从90元涨价到110元是损害消费者利益的行为,又执行了一条更新语句把价格改回了90元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按90元卖要亏本,于是笔者随即回滚了事务,场景如下SQL所示:
SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
/* 注意没有COMMIT */
UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */
/* 这条SELECT模拟购书的操作的逻辑 */
SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
ROLLBACK; /* 时间顺序:4,事务: T2 */
不过,在之前修改价格后,事务T1已经按90元的价格卖出了几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,即上述事务T1中两条查询语句得到的结果并不相同。如果你不能理解这句话中的“反而”二字,请再重读一次写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据,如果事务T1读取数据时根本就不用去加读锁的话,就会导致事务T2未提交的数据也马上就能被事务T1所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此T1中的查询就会被阻塞,直至事务T2被提交或者回滚后才能得到结果。
其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。
四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,“版本”是个关键词,可以将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID,事务ID是一个全局严格递增的数值,然后根据以下规则写入数据。
此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。
隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
隔离级别是读已提交:总是取最新的版本即可,即最近被Commit的那个版本的数据记录。
另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时无锁优化的,自然就不会放到一起用。
MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁的策略是“乐观加锁”(Optimistic Locking)还是“悲观加锁”(Pessimistic Locking)。前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不先做加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种思路被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),不过笔者提醒一句,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而更慢。
与本地事务相对的是全局事务(Global Transaction),有一些资料中也将其称为外部事务(External Transaction),在本节里,全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。请注意,理论上真正的全局事务并没有“单个服务”的约束,它本来就是DTP(Distributed Transaction Processing)模型中的概念,但本节所讨论的内容是一种在分布式环境中仍追求强一致性的事务处理方案,对于多节点而且互相调用彼此服务的场合(典型的就是现在的微服务系统)是极不合适的,今天它几乎只实际应用于单服务多数据源的场合中,为了避免与后续介绍的放弃了ACID的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,笔者将称其为“分布式事务”。
1991年,为了解决分布式事务的一致性问题,X/Open组织(后来并入了The Open Group)提出了一套名为X/Open XA(XA是eXtended Architecture的缩写)的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。XA接口是双向的,能在一个事务管理器和多个资源管理器(Resource Manager)之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚,现在我们在Java代码中还偶尔能看见的XADataSource、XAResource这些名字都源于此。
不过,XA并不是Java的技术规范(XA提出那时还没有Java),而是一套语言无关的通用规范,所以Java中专门定义了JSR 907 Java Transaction API,基于XA模式在Java语言中的实现了全局事务处理的标准,这也就是我们现在所熟知的JTA。JTA最主要的两个接口是:
JTA原本是Java EE中的技术,一般情况下应该由JBoss、WebSphere、WebLogic这些Java EE容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫Arjuna)都以JAR包的形式实现了JTA的接口,称为JOTM(Java Open Transaction Manager),使得我们能够在Tomcat、Jetty这样的Java SE环境下也能使用JTA。
现在,我们对本章的场景事例做另外一种假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同,那情况会发生什么变化呢?加入你平时以声明式事务来编码,那它与本地事务看起来可能没什么区别,都是标个@Transactional注解而已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代码如下所示:
public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}
从代码上可看出,程序的目的是要做三次事务提交,但实际上代码并不能这样写,试想一下,如果在businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransaction和warehouseTransaction已经完成提交,再去调用rollback()方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证了。为了解决这个问题,XA将事务提交拆分成为两阶段过程:
以上这两个过程被称为“两段式提交”(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。
请注意,上面所说的协调者、参与者通常都是由数据库自己来扮演的,不需要应用程序介入。协调者一般是在参与者之间选举产生的,而应用程序相对于数据库来说只扮演客户端的角色。两段式提交的交互时序如下图所示,
两段式提交原理简单,并不难实现,但有几个非常显著的缺点:
为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了“三段式提交”(3 Phase Commit,3PC)协议。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit、PreCommit,把提交阶段改称为DoCommit阶段。其中,新增的CanCommit是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。
同样也是由于事务失败回滚概率变小的原因,在三段式提交中,如果在PreCommit阶段之后发生了协调者宕机,即参与者没有能等到DoCommit的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。三段式提交的操作时序如下图所示,
从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。譬如,进入PreCommit阶段之后,协调者发出的指令不是Ack而是Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。
与全局事务里讨论的单个服务使用多个数据源正好相反,共享事务(Share Transaction)是指多个服务共用同一个数据源。这里有必要再强调一次“数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。 在部署应用集群时最常采用的模式是将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属的数据源,通常是中间件以 JNDI 的形式开放给程序代码使用。这种情况下,所有副本实例的数据访问都是完全独立的,并没有任何交集,每个节点使用的仍是最简单的本地事务。而本节讨论的是多个服务之间会产生业务交集的场景,举个具体例子,在 Fenix’s Bookstore 的场景事例中,假设用户账户、商家账户和商品仓库都存储于同一个数据库之中,但用户、商户和仓库每个领域都部署了独立的微服务,此时一次购书的业务操作将贯穿三个微服务,它们都要在数据库中修改数据。如果我们直接将不同数据源就视为是不同数据库,那上一节所讲的全局事务和下一节要讲的分布式事务都是可行的,不过,针对这种每个数据源连接的都是同一个物理数据库的特例,共享事务则有机会成为另一条可能提高性能、降低复杂度的途径,当然,也很有可能是一个伪需求。
一种理论可行的方案是直接让各个服务共享数据库连接,在同一个应用进程中的不同持久化工具(JDBC、ORM、JMS 等)间共享数据库连接并不困难,某些中间件服务器,譬如 WebSphere 会内置有“可共享连接”功能来专门给予这方面的支持。但这种共享的前提是数据源的使用者都在同一个进程内,由于数据库连接的基础是网络连接,它是与 IP 地址和端口号绑定的,字面意义上的“不同服务节点共享数据库连接”很难做到,所以为了实现共享事务,就必须新增一个“交易服务器”的中间角色,无论是用户服务、商家服务还是仓库服务,它们都通过同一台交易服务器来与数据库打交道。如果将交易服务器的对外接口按照 JDBC 规范来实现的话,那它完全可以视为是一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时三个服务所发出的交易请求就有可能做到交由交易服务器上的同一个数据库连接,通过本地事务的方式完成。譬如,交易服务器根据不同服务节点传来的同一个事务 ID,使用同一个数据库连接来处理跨越多个服务的交易事务,如下图所示,
之所以强调理论可行,是因为该方案是与实际生产系统中的压力方向相悖的,一个服务集群里数据库才是压力最大而又最不容易伸缩拓展的重灾区,所以现实中只有类似ProxySQL、MaxScale这样用于对多个数据库实例做负载均衡的数据库代理(其实用 ProxySQL 代理单个数据库,再启用 Connection Multiplexing,已经接近于前面所提及的交易服务器方案了),而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。这也是说它更有可能是个伪需求的原因,如果你有充足理由让多个微服务去共享数据库,就必须找到更加站得住脚的理由来向团队解释拆分微服务的目的是什么才行。
在日常开发中,上述方案还存在一类更为常见的变种形式:使用消息队列服务器来代替交易服务器。用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这被称作“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。
“共享事务”的提法和这里所列的两种处理方式在实际应用中并不值得提倡,鲜有采用这种方式的成功案例,能够查询到的资料几乎都发源于十余年前 Spring 的核心开发者Dave Syer撰写的文章《Distributed Transactions in Spring, with and without XA》。笔者把共享事务列为本章四种事务类型之一只是为了叙述逻辑的完备,尽管拆分微服务后仍然共享数据库的情况在现实中并不少见,但笔者个人不赞同将共享事务作为一种常规的解决方案来考量。
本章中所说的分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制,请注意它与DTP 模型中“分布式事务”的差异。DTP 模型所指的“分布式”是相对于数据源而言的,并不涉及服务,这部分内容已经在“全局事务”一节里进行过讨论。本节所指的“分布式”是相对于服务而言的,如果严谨地说,它更应该被称为“在分布式服务环境下的事务处理机制”。
在 2000 年以前,人们曾经寄希望于 XA 的事务机制可以在本节所说的分布式环境中也能良好地应用,但这个美好的愿望今天已经被 CAP 理论彻底地击碎了,接下来就先从 CAP 与 ACID 的矛盾说起。
CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理,起源于在 2000 年 7 月,是加州大学伯克利分校的 Eric Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。
然后到了2022年,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 以严谨的数学推理证明了 CAP 猜想。自此,CAP 正式从猜想变为分布式计算领域所公认的著名定理。这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
单纯只列概念,CAP 是比较抽象的,笔者仍以本章开头所列的场景事例来说明这三种特性对分布式系统来说将意味着什么。假设 Fenix’s Bookstore 的服务拓扑如下图所示,一个来自最终用户的交易请求,将交由账号、商家和仓库服务集群中某一个节点来完成响应:
在这套系统中,每一个单独的服务节点都有自己的数据库(这里是为了便于说明问题的假设,在实际生产系统中,一般应避免将用户余额这样的数据设计成存储在多个可写的数据库中),假设某次交易请求分别由“账号节点 1”、“商家节点 2”、“仓库节点 N”联合进行响应。当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,它在自己数据库扣减 100 元很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。
以上还仅仅涉及了账号服务集群自身的 CAP 问题,对于整个 Fenix’s Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题,譬如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售。又譬如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题,等等。
由于 CAP 定理已有严格的证明,本节不去探讨为何 CAP 不可兼得,而是直接分析如果舍弃 C、A、P 时所带来的不同影响。
意味着我们将假设节点之间通信永远是可靠的。永远可靠的通信在分布式系统中必定不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以 Oracle 的 RAC 集群为例,它的每一个节点均有自己独立的 SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因而 Oracle RAC 虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库。
意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面“全局事务”中讨论的一个系统使用多个数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统,以 HBase 集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的。
意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。
读到这里,不知道你是否对“选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得“一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。不过,如果单纯只说“弱一致性”那其实就是“不保证一致性”的意思……人类语言这东西真的是博大精深。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。
在本节讨论的主题“分布式事务”中,目标同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用 ACID 的事务称为“刚性事务”,而把笔者下面将要介绍几种分布式事务的常见做法统称为“柔性事务”。
最终一致性的概念是 eBay 的系统架构师 Dan Pritchett 在 2008 年在 ACM 发表的论文《Base: An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的的途径。
BASE 分别是:
BASE 这提法简直是把数据库科学家酷爱凑缩写的恶趣味发挥到淋漓尽致,不过有 ACID vs BASE(酸 vs 碱)这个朗朗上口的梗,该论文的影响力的确传播得足够快。在这里笔者就不多谈 BASE 中的概念问题了,虽然调侃它是恶趣味,但这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有价值的。
我们继续以本章的场景事例来解释 Dan Pritchett 提出的“可靠事件队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和商家服务中的数据,下图列出了修改过程的时序图,
也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候上述情况 2、4 也可以交由消息框架来保障。
以上这种靠着持续重试来保证可靠性的解决方案谈不上是 Dan Pritchett 的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字叫作“最大努力交付”(Best-Effort Delivery),譬如 TCP 协议中未收到 ACK 应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。