本地事务、全局事务、分布式事务

事务处理

事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为了保证系统中所有数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。
按照数据库的经典理论,要达成这个目标,需三方面共同努力来保障:即熟知的原子性(Atomic 同时成功同时失败),隔离性(Isolation 保证各业务读写数据相互独立不受影响),持久性(Durability 所有成功被提交的数据都会持久化)
A、I、D 是手段,C 是目的,前者是因,后者是果。
事务的概念虽然最初起源于数据库系统,但今天已不再局限于数据库本身,所有需要保证数据一致性的场景。包括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等等。这些统一称为数据源。但是各个场景所说的事务和一致性含义可能并不完全一致,说明如下:

  • 当一个服务只是用一个数据源时,通过 A、I、D 来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间上的最终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致性”
  • 当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得相对困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及到多个数据源的事务间一致性被称为“外部一致性”

外部一致性问题通常很难用 AID 来解决,因为这样需要付出很大乃至不切实际的代价;但是外部一致性又是分布式系统中必然会遇到且必须要解决的问题,为此我们需要转变观念,将一致性从“是与否”的二元属性转变为可以按照不同强度分开讨论的多元属性,在确保代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此,事务处理才从一个具体操作上的编程问题上升为一个需全局权衡的“架构问题”。

下面通过场景事例讲解如何在不同方案中处理事务

场景案例:有一个在线书店。每当一本书被成功售出时,需要确保以下三件事被正确的处理:

  • 用户的账号扣减相应的商品款项;
  • 商品仓库中扣减库存,将商品标示为待配送状态;
  • 商家的账号增加相应的商品款项。
本地事务(局部事务)

本地事务是指仅仅操作单一事务资源的、不需全局事务管理器进行协调的事务
本地事务是最基础的一种事务解决方案,只适合于单个服务使用单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层标准化的包装(如JDBC 接口),并不能深入参与到事务的运作过程当中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至于与应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作,这一点与后续介绍的 XA、TCC、SAGA 等主要靠应用程序代码来实现的事务有明显的区别。举个例子,假设你的代码调用了 JDBC 中的 Transaction::rollback()方法,方法的成功执行也并不一定代表事务就已经被成功回滚,如果数据表采用的引擎是 MyISAM,那 rollback()方法便是没有意义的空操作。因此,如果想要深入的探讨本地事务,便不得不越过应用层代码,去了解一些数据库本身的事务实现原理,弄明白传统数据库管理系统是如何通过 ACID 来实现事务的
如今研究事务的实现原理,必定会追溯到 ARIES 理论(Algorithms for Recovery and Isolation Exploiting Semantics,基于语义的恢复与隔离算法),ARIES 是现代数据库的基础理论,原子性持久性和隔离性如何实现。

实现原子性和持久性

原子性和持久性在事务中是密切相关的两个属性,原子性保证了事物的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
实现原子性和持久性的最大困难在于“写入磁盘”这个操作不是原子的,不仅有“写入”、“未写入”,还客观存在着“正在写”的中间状态。正因为写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性和持久性。下面举例说明:

按照前面预设的场景:从某书店买一本书需要修改是哪个数据:在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态。
由于写入有中间状态,所以可能发生如下情形。

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

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

Commit Logging 的原理很清晰,但也存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交后,即日志写入了 Commit Logging 后,在此之前即使磁盘 I/O 足够空闲、即使某个事务修改的数量非常庞大,占用了大量的内存缓冲区,都决不允许在事务提交之前就修改磁盘上的数据,这一点是 Commit Logging 成立的前提,却对提升数据库的性能十分不利。为了就解决这个问题,前面提到的 ARIES 的理论终于登场。ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”,就是允许在事务提交前,提前写。

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 在崩溃恢复时会执行以下三个阶段操作:

  1. 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前的所有应持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Check 的事务,组成待恢复的事务集合,这个集合至少会包括 Transaction Table 和 Dirty Page Table 两个组成部分。
  2. 重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作为:找出所有包含 Commit Record 的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条 End Record,然后移除出待恢复事务集合
  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称作 Loser,根据 Undo Log 中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚目的。
实现隔离性

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

  1. 写锁(Write Lock,也叫排他锁,eXclusive Lock,X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据家吃着写锁是,其他事务不能写入数据,也不能施加读锁。
  2. 读锁(Read Lock,也叫共享锁,Shared Lock,S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。
  3. 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。

数据库有熟知的四种数据库隔离级别,其实不同隔离级别以及幻读、不可重复读、脏读等问题都是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因

除了都以锁来实现外,以上四种隔离级别还有一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务读数据的过程中,受另外一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+另一个事务写”的隔离问题,“多版本并发控制”(Mutil-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 是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁策略:选“乐观加锁”还是“悲观加锁”,前面介绍的都是悲观加锁,即认为如果不先加锁再访问数据就肯定会出现错误。而乐观锁是先不加锁,出现竞争时再找补救措施。很多时候乐观锁比悲观锁要快,但如果竞争剧烈的话,乐观锁会变慢。

全局事务

与本地事务相对的是全局事务(Global Transaction),在本节中,全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。理论上真正的全局事务并没有“单个服务”的约束,它本来就是 DTP(Distributed Transaction Processing)模型中的概念。本节所讨论的内容是一种在分布式环境中仍追求强一致性的事务处理方案,对于多节点且互相调用彼此服务的场合(典型的就是现在的微服务系统)是极不合适的,今天它几乎只实际应用于单服务多数据源的场合中,为了避免与后续介绍的放弃了 ACID 的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,将其称为“分布式事务”。

1991年,为了解决分布式事务的一致性问题,X/Open 组织提出了一套名为 XA 的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager。用于协调全局事务)和局部的资源管理器(Resource Manager 用于驱动本地事务)之间的通信接口。XA接口是双向的,能在一个事务管理器和多个资源管理器之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或统一回滚。基于 XA 模式(XA 提出的时候 Java 还没诞生)Java 语言中实现了全局事务处理的标准,这也是我们现在所熟知(?)的 JTA,JTA 最主要的两个接口是:

  • 事务管理器的接口:javax.transcation.TransactionManager这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的,还提供了另外一套javax.transaction.UserTransaction接口,用于通过程序代码手动开启、提交和回滚事务。
  • 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource 任何资源如果想要支持 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();
    }
}

从程序上看,其目的是做三次事务提交,但实际上代码并不能这样写,这会导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证,为了解决这个问题,XA 将事务提交拆分为两阶段过程:

  • 准备阶段:又叫投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。这里所说的准备操作是针对数据库来说,指在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂时不写入最后一条 Commit Record 而已,这意味着在数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态
  • 提交阶段:又叫执行阶段,协调者如果在上一阶段收到所有事务参与者的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是执行一条 Commit Record 而已,通常能快速完成,只有收到 Abort 指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。

以上这两个过程被称为“两段式提交”(2 Phase Commit,2PC)协议,而它能成功保证一致性还需要一些其他前提条件。

  • 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不丢失消息。也就是提交阶段失败了没有办法回滚。
  • 必须假设因为网络分区、机器崩溃或其他原因而导致失联的节点最终能够恢复,不会永久处于失联。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能从日志中找出已准备妥当但并未提交的事务数据,并向协调者查询该事务的状态,确定下一步的动作。

上面所说的协调者、参与者都是可以由数据库自己来扮演的,不需要应用程序介入。协调者一般在参与者之间选举产生,而应用程序相对于数据库来说只扮演客户端的角色。

两段式提交原理简单,不难实现,但有几个非常显著的缺点:

  • 单点问题:一旦协调者宕机,所有参与者都会受到影响。
  • 性能问题:所有参与者相当于被绑定为一个调度整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段重写日志,协调者做状态持久化,提交阶段再日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那个处理操作结束为止。
  • 一致性风险:前面已经提到,两段式提交的成立是有条件的,当网络稳定性和宕机恢复能力的假设不成立时,就可能出现一致性问题。

为了缓解两段式提交协议地一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了三段式提交(3PC)协议。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别为 CanCommit、PreCommit,把提交阶段改称为DoCommit阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。

将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都做了无用功。所以增加一轮询问阶段,如果都得到了正面响应,那事务就能成功提交的概率就大多了。
因此,在事务需要回滚的场景中,三段式的性能通常是比二段式好很多的,但在事务能正常提交的场景中,两者的性能都依然很差,甚至三段式稍微更差。
同样也是由于事务失败回滚概率变小的原因,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有等到 DoCommit 消息的话,默认的操作策略是提交事务而不是回滚或持续等待,这就相当于避免了协调者单点问题的风险

从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但它对一致性风险问题并未有任何改进,如进入 PreCommit 后,协调者发出的指令不是 Ack 而是 Abort,而此时因网络问题,有部分参与者直至超时也未能收到协调者的 Abort 指令,这些参与者将会错误的提交事务,这就产生了数据不一致的问题。

分布式事务

本章中所说的分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制,请注意它与 DTP 模型中的“分布式”事务的差异(TODO)。DTP 模型所指的“分布式”是相对于数据源而言的,并不涉及服务,这部分内容已经在全局事务中讨论过。本节所指的分布式是相对于服务而言的,如果严谨的说,它更应该被称为“在分布式服务环境下的事务处理机制”。

2000年以前,人们曾寄希望于 XA 的事务机制可以在本节说的分布式事务中良好运用,但是被 CAP 理论彻底击碎,接下来就先从 CAP 和 ACID 的矛盾说起。

CAP 与 ACID

CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理,起源于在 2000 年 7 月,是加州大学伯克利分校的 Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。两年后,麻省理工学院的两个人以严谨的数学推理证明了 CAP 猜想。自此,CAP 正式从猜想称为分布式计算领域所公认的著名定理,这个定理里描述了一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个

  • 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,后面的分布式共识算法中的一致性(面向副本复制的一致性)与这里面向数据库状态的一致性严格来说并不完全等同;
  • 可用性(Availability):代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关的两个指标:可靠性和可维护性。可靠性使用平均无故障时间来度量;可维护性使用平局可修复时间来度量。
  • 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力

单说概念 CAP 是比较抽象的,我们仍以网上书店来举例,一个来自用户的交易请求,将交由账号(服务集群)、商家(服务集群)和仓库(服务集群)中某一节点来完成响应。在这套系统中,每一个单独的服务节点都有自己的数据库(这是假设,实际生产系统中,一般应避免将用户余额这这样的数据设计成存储在多个可写的数据中的),假设某次交易请求分别由“账号节点1”、“商家节点2”、“仓库节点 N”联合进行响应。当用户购买一件 100 元商品后,账号节点1 首先给用户账号扣减 100,它在自己的数据库扣减很容易,但它还要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况:

  • 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一件商品时,被分配到另一个节点处理,由于看不到账号上有不正确的余额而错误地发生了原本无法进行的交易,此为一致性问题。
  • 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统无法服务而被拒绝交易,此为可用性问题。
  • 如果由于账号服务集群中某一部分节点,因网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点连接中断而仍能够正确提供服务,此为分区容忍性。

以上还仅仅涉及了账号服务集群自身的 CAP 问题,对于整个网上书店站点来说,它更是面临着来自于账号、商家、仓库服务集群所带来的 CAP 问题,譬如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库中有不正确的库存数据而发生超售(商品本身)。又譬如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了可用性问题(商品相关联的),等等。

由于 CAP 定理已有严格证明,本节不去探讨为何 CAP 不可兼得,而是直接分析如果舍弃 C、A、P 时所带来的不同影响。

  • 如果放弃分区容忍性(CA without P):意味着我们将假设节点之间的通信永远是可靠的。永远可靠地通信在分布式系统中必定是不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以 Oracle 的 RAC 集群为例,它的每个节点均有自己独立的 SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因为 Oracle RAC 虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库。
  • 如果放弃可用性(CP without A):意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制的延长,此时,问题相当于退化到“全局事务”中讨论的一个系统使用多个数据源的场景之中,我们可以通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中,除了DTP 模型的分布式数据库事务外,注明的 HBase 也是数据 CP 系统,以 HBase 集群为例,如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到恢复数据,这个时间消耗是无法估计的。
  • 如果放弃一致性(AP without C):意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。

读到这里,不知道你是否对“选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题“事务”原本的目的就是获得一致性,而在分布式环境中,一致性却不得不成为被牺牲、被放弃的那一项属性。但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付时是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”。有时也叫“线性一致性”(通常实在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为“最终一致性”。它是指:如果数据在一段时间之内没有被另外的操作所更改,那他最终会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。

人们把使用 ACID 的事务称为“刚性事务”,而把之后介绍的几种分布式事务称为“柔性事务”。

可靠事件队列

最终一致性的概念是 eBay 的系统架构师在 08 年在 ACM 发表的论文《Base:An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的地途径。BASE 分别是基本可用性(Basically Availability)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写
BASE 系统性的总结了针对分布式事务的技术手段。

我们继续以网上书店的场景事例来解释“可靠消息队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和商家服务中的数据。
其与之前不同只是在账号服务之后添加了消息队列并不断轮询让后续步骤执行事务并返回成功。

  1. 用户向书店发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》;
  2. 首先应对用户账户扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接出现在程序代码中,有一些大型系统也可能会实现动态排序。如:根据统计,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或因账户余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的就是收款,如果到了商家收款环节,一般就不会出现什么意外了。那顺序就应该安排成最容易出错的最先进行,即:账户扣款→仓库出库→商家收款。
  3. 账号服务进行扣款业务,如扣款成功,则在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:UUID,扣款:100(状态:已完成),仓库出库《深入理解 JVM》:1(状态:进行中),某商家收款:100(状态:进行中)”。注意,这个步骤在“扣款业务”和“写入消息”时使用同一个本地事务写入账号服务自己的数据库的
  4. 在系统中建立一个消息服务,定时轮询消息表,将状态时“进行中”的消息同时发送到库存和商家服务节点中去(也可以串行地发,即一个成功后再发送另一个,但我们讨论的场景中没有必要)。这时候可能产生以下几种情况:
    1. 商家和仓库服务都成功完成了收款和出库工作,向用户账号服务器返回执行结果,用户账号服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。
    2. 商家和仓库服务中至少一个因网络原因,未能收到来自用户账号服务的消息。此时,由于用户账户服务器中的消息状态一直处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作会且只会被处理一次。
    3. 商家或仓库服务有某个或全部无法完成工作,如仓库发现《深入理解 JVM》没有库存了,此时仍然是持续自动重发消息,直至操作成功(如补库存),或者被人工介入。由此可见,可靠消息队列只要第一步业务完成了,后续就没有失败回滚的概念,只需成功,,不许失败
    4. 商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息,但因操作具有幂等性,不会重复出库或扣款,只会让商家、仓库服务器重发一条应答消息,此过程重复直至网络通信恢复正常。
    5. 也有一些支持分布式事务的消息框架,如 RocketMQ 原生就支持分布式事务操作,这时候上述 2、4 也可以交由消息框架来保障。

以上这种靠着持续重试来保证可靠性的解决方案也叫“最大努力交付”,如 TCP 中未收到 ACK 应答自动重新发包的可靠性保障。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交 BE1PC”。指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息队列系统)来促使同一个分布式事务中的其他关联业务全部完成。

TCC 事务

TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写。前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠地,过程也足够简单(相对于 TCC 来说),但整个过程完全没有任何隔离性可言,而有一些业务中缺乏隔离性会带来许多麻烦。如本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是“超售”:完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是完全可以避免的,如,以上场景就需要“可重复读”的隔离级别,但用可靠消息队列无法保证这一点。如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,这种方案天生适合用于需要强隔离性的分布式事务中。

在具体实现上,TCC 较为繁琐,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程
TCC 分为以下三个阶段:

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性);
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作都需具备幂等性。
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。

TCC执行过程流程图

TCC 的执行过程:

  1. 最终用户向网上书店发送交易请求:购买一本价值 100 元的《深入理解 JVM》。
  2. 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
    1. 用户服务:检查业务可行性,可行的话,将该用户的 100 元 设置为冻结状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    2. 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 JVM》设置为冻结状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    3. 商家服务:检查业务可行性,不需冻结资源。
  3. 如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
    1. 用户服务:完成业务操作(扣减那被冻结的 100 元);
    2. 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,,扣减相应库存);
    3. 商家服务:完成业务操作(收款 100 元)。
  4. 第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。
  5. 如果第 2 步有任何一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
    1. 用户服务:取消业务操作(释放被冻结的 100 元);
    2. 仓库服务:取消业务操作(释放被冻结的 1 本书);
    3. 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
  6. 第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。

由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC 在业务执行时只操作预留资源,几乎不会设计锁和资源的争用,具有很高的性能潜力。但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,我们通常并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(如阿里开源的 Seata),尽量减轻一些编码工作。

SAGA 事务

TCC 事务具有较强的隔离性,避免了“超售”的问题,而且性能一般来说是本片提及的几种柔性事务中最高的,但它仍不能满足所有的场景。TCC 的最主要的限制是它的业务侵入性很强,如把我们的场景事例修改如下:由于中国网络支付日益盛行,现在于鸿鹄和商家在书店系统中可以选择不再开设充值账号。至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过 U 盾或扫码支付,再在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲的自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以 TCC 中的第一步 Try 阶段往往无法施行,我们只能考虑采用另外一种柔性事务方案:SAGA 事务。SAGA 在英文中是“长篇故事、长篇记叙、长串事件”的意思。

SAGA 事务模式的历史十分悠久,还早于分布式事务概念的提出。大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式事务环境中的大事务分解为一系列本地事务的设计模式。

SAGA 由两部分操作组成:

  • 大事务拆分为若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式时事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Ti 等价。
  • 为每一个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件:
    1. Ti 与 Ci 都具备幂等性。
    2. Ti 与 Ci 满足交换律(Commutative),即先执行 Ti 还是先执行 Ci,其效果都是一样的。
    3. Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情形,如果出现就必须持续重试直至成功,或者要人工介入。

如果 T1 到 Tn 都成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:
1. 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为: T1,T2,…,Ti(失败),Ti(重试),…,Tn。
2. 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现的多。如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到网上书店系统中,这步是经由用户支付操作(扫毛或 U 盾)来促使银行提供服务‘如果后续业务操作失败,尽管我们无法要求银行撤销之前的用户转账操作,但是由网上书店系统将货款转会到用户账户上作为补措施却是完全可行的。

SAGA 必须保证所有子事务都得一提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,如执行至哪一步或者补偿到哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的功夫,如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸代码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务。

基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”。
从整体上看 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应,设计了针对性的解决方案。大致的做法是在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比起 2PC 极大地提升了系统的吞吐量水平。而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定总能成功。如,本地事务提交后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写,这时候一旦出现分布式事务需要回滚,就不可能在通过自动的逆向 SQL 来实现补偿,只能由人工介入处理。

通常来说,脏写是一定要避免的,所有传统关系型数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写一旦发生,人工其实也很难进行有效处理。所以 GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交以前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决方案,因地制宜地选用合适的事务处理方案才是唯一有效的做法。

分布式事务中,可靠消息队列结果可靠但是没有隔离性,TCC 是有隔离性,TCC 是类似于 2PC 的,但是TCC 是在代码层面进行操作;但是 TCC 业务侵入性很强,并不是所有服务的代码你都可以接触到。SAGA 分段事务适合银行事务。

  • 15
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值