常见的分布式事务场景

分布式事务其实就在我们身边,你一直在用,但是你却一直不注意它。

转账

扣你账户的余额,增加别人账户余额,如果只扣了你的,别人没增加这是失败;如果没扣你的钱别人也增加了那银行的赔钱。

下订单/扣库存

电商系统中这是很常见的一个场景,用户下单成功了,店家没收到单,不发货;用户取消了订单,但是店家却看到了订单,发了货。

分库分表场景

当我们的数据量大了之后,我们可能会部署很多独立的数据库,但是你的一个逻辑可能会同时操作很多个数据库的表,这时候该如何保证所有的操作要么成功,要么失败。

分布式系统调用问题

微服务的拆分让各个系统各司其职,但是带来的也有很多痛苦,一个操作可能会伴随很多的外部请求,如果某一个外部系统挂掉了,之前操作已完成的这些是否需要回滚。

针对上面这些问题,我们前面学过的数据库4大特性:ACID 似乎在这里想要达到就变得很困难,单机情况下你还可以通过锁和日志机制来控制数据,在分布式场景又该如何实现呢?在不同的分布式应用架构下,实现一个分布式事务要考虑的问题并不完全一样,比如对多资源的协调、事务的跨服务传播等,实现机制也是复杂多变。尽管有这么多工程细节需要考虑,但分布式事务最核心的还是其 ACID 特性,只是这种 ACID 变换了场景。

分布式理论
CAP 定理

传统的 ACID 模型肯定无法解决分布式环境下的挑战,基于此加州大学伯克利分校 Eric Brewer教授提出 CAP 定理,他指出 现代 WEB 服务无法同时满足以下 3 个属性:

  • 一致性(Consistency) : 所有的客户端都能返回最新的操作。
  • 可用性(Availability) : 非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。
  • 分区容错性(Partition tolerance) : 即使出现单个组件无法可用,操作依然可以完成。

关于一致性的理解后面分出来三个方向:

  • 强一致:任何一次读都能读到某个数据的最近一次写的数据。系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致。简言之,在任意时刻,所有节点中的数据是一样的。
  • 弱一致:数据更新后,如果能容忍后续的访问只能访问到部分或者全部访问不到,则是弱一致性。
  • 最终一致:不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。简单说,就是在一段时间后,节点间的数据会最终达到一致状态。

关于一致性的理解不同,后面对于 CAP 理论的实现就有所不同。

另外 Eric Brewer教授指出现代 WEB 服务无法同时满足这 3 个属性,说的是无法同时满足,那这是为什么呢?

如果在某个分布式系统中无副本,那么必然满足强一致性,同时也满足可用性,但是如果这个数据宕机了,那么可用性就得不到保证。

如果一个系统满足 AP,那么一致性又得不到保证。所以 CAP 原则的精髓就是要么 AP,要么 CP,要么 AC,但是不存在 CAP。

BASE 定理

基于前面提到的 CAP,反正我们都无法同时满足CAP,设计系统的最高目的就是让他跑下去不出错,那么是不是可以不要求强一致性,最终让他一致即可。所以后面又提出来了 BASE 定理:

  • Basically Available(基本可用)
  • Soft state(软状态)
  • Eventually consistent(最终一致性)

基于现在大型分布式系统的复杂性,我们无法保证服务永远达到999,那么是否可以取舍,核心服务达到999,非核心服务允许挂为了保全核心服务。另外在非核心服务 down 机过程中允许系统暂时出现不一致但是这个不一致并不影响系统的核心功能使用。

最终系统恢复,所有服务一起修复数据,最终达到一致的状态。

业内通常把严格遵循 ACID 的事务称为刚性事务,而基于 BASE 思想实现的事务称为柔性事务。柔性事务并不是完全放弃了 ACID,仅仅是放宽了一致性要求:事务完成后的一致性严格遵循,事务中的一致性可适当放宽。

常见的分布式事务实现方案

分布式事务实现方案从类型上去分刚性事务、柔型事务。刚性事务:通常无业务改造,强一致性,原生支持回滚/隔离性,低并发,适合短事务。柔性事务:有业务改造,最终一致性,实现补偿接口,实现资源锁定接口,高并发,适合长事务。

  • 刚性事务:XA 协议(2PC、JTA、JTS)、3PC
  • 柔型事务:TCC/FMT、Saga(状态机模式、Aop模式)、本地事务消息、消息事务(半消息)、最多努力通知型事务
两阶段提交(XA)

与本地事务一样,分布式事务场景下也可以采用两阶段提交的方案来实现。XA 的全称是 eXtended Architecture,它是一个分布式事务协议,通过二阶段提交协议保证强一致性。

XA 规范中定义了分布式事务处理模型,这个模型中包含四个核心角色:

  • RM (Resource Managers):资源管理器,提供数据资源的操作、管理接口,保证数据的一致性和完整性。最有代表性的就是数据库管理系统,当然有的文件系统、MQ 系统也可以看作 RM。
  • TM (Transaction Managers):事务管理器,是一个协调者的角色,协调跨库事务关联的所有 RM 的行为。
  • AP (Application Program):应用程序,按照业务规则调用 RM 接口来完成对业务模型数据的变更,当数据的变更涉及多个 RM 且要保证事务时,AP 就会通过 TM 来定义事务的边界,TM 负责协调参与事务的各个 RM 一同完成一个全局事务。
  • CRMs (Communication Resource Managers):主要用来进行跨服务的事务的传播。

docker java镜像 中文乱码 docker java镜像太大_java

XA 协议大概的两个流程为:

  1. 第一阶段(prepare):事务管理器向所有本地资源管理器发起请求,询问是否是 ready 状态,所有参与者都将本事务能否成功的信息反馈发给协调者;
  2. 第二阶段 (commit/rollback):事务管理器根据所有本地资源管理器的反馈,通知所有本地资源管理器,步调一致地在所有分支上提交或者回滚。

XA 协议是如何满足 ACID 的呢?

原子性和持久性我们就不用说,我们看看隔离性和一致性。

隔离性

XA 协议中没有描述如何实现分布式事务的隔离性,但是 XA 协议要求每个资源管理器都要实现本地事务,也就是说基于 XA 协议实现的分布式事务的隔离性是由每个资源管理器本地事务的隔离性来保证的,当一个分布式事务的所有子事务都是隔离的,那么这个分布式事务天然的就实现了隔离性。

一致性

在单机环境下的一致性就是保证当前服务器数据一致即可。事务执行完毕数据最终一致,不同的隔离级别下事务执行过程的中间状态不能被别的事务观察到。

事务执行完毕最终一致这个好保证,但是在RR 隔离级别下不可见一个未提交事务的中间态在分布式情况该如何做到呢?单机上 MySQL 提供了MVCC机制,采用多版本控制来处理,那分布式事务场景也是否也可以提供这样的机制呢?XA 协议并没有定义怎么实现全局的快照,一个基本思路是用一个集中式或者逻辑上单调递增的东西来控制生成全局 Snapshot,每个事务或者每条 SQL 执行时都去获取一次,从而实现不同隔离级别下的一致性。当然开发的难度还是挺大。

存在的问题:

  • 同步阻塞:当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。
  • 单点故障:一旦事务管理器出现故障,整个系统不可用。
  • 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
  • 不确定性:当事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。

总体来说 XA 方案实现简单,但是带来的问题如果放在数据一致性要求严格的场景是无法保证数据正确性的。另外事务管理器单点会带来隐患,同步阻塞模型也致使并发能力弱。

TCC

关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC 事务机制相比于上面介绍的 XA,解决了其几个缺点:

  1. 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
  2. 同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
  3. 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性。

TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。TCC 模型完全交由业务实现,每个子业务都需要实现 Try-Confirm-Cancel 三个接口,对业务侵入大,资源锁定交由业务方。

  • Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)。
  • Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。
  • Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

docker java镜像 中文乱码 docker java镜像太大_面试_02

一个完整的业务活动由一个主业务服务与若干子业务服务组成:

  1. 主业务服务负责发起并完成整个业务活动;
  2. 业务服务提供 TCC 型业务操作;
  3. 业务活动管理器控制业务活动的一致性,它登记业务活动中的操作,并在业务活动提交时确认所有的TCC 型操作的 Confirm 操作,在业务活动取消时调用所有 TCC 型操作的 Cancel 操作。

比如一个转账操作:

  1. 首先在 Try 阶段先把转账者的钱包冻结起来。
  2. 在 Confirm 阶段,调用转账接口操作转账,转账成功后解冻。
  3. 如果 Confirm 阶段成功那么就转账成功,否则执行转账失败确认逻辑。

基于 TCC 实现分布式事务,会将原来只需要一个接口就可以实现的逻辑拆分为 Try、Confirm、Cancel 三个接口,所以代码实现复杂度相对较高,需要在业务中写很多的补偿机制代码。

TCC将事务提交划分成两个阶段,Try即为一阶段,Confirm 和 Cancel 是二阶段并行的两个分支,二选一。从阶段划分上非常像2PC,我们是否可以说TCC是一种2PC或者2PC变种呢?

对比一下 XA 事务模型,TCC 的两阶段提交与 XA 还是有一些区别:

  1. 2PC 的操作对象在于资源层,对于开发人员无感知;而 TCC 的操作在于业务层,具有较高开发成本。
  2. 2PC 是一个整体的长事务,也是刚性事务;而 TCC 是一组的本地短事务,是柔性事务。
  3. 2PC 的 Prepare (表决阶段)进行了操作表决;而 TCC 的 Try 并没有表决准备,直接兼备资源操作与准备能力。
  4. 2PC 是全局锁定资源,所有参与者阻塞 交互等待 TM 通知;而 TCC 的资源锁定在于 Try 操作,业务方可以灵活选择业务资源的锁定粒度。
本地消息表#

方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。

基于本地消息表的方案,每个事务发起方都需要额外新建事务消息记录表,用于记录分布式事务的消息的发生、处理状态。

docker java镜像 中文乱码 docker java镜像太大_java_03

事务发起方在处理完业务逻辑之后需要将当前事务保存在消息表中,之后将消息发送到消息中间件中,并将消息的状态设置为 “发送中”。

如果消息在投递过程中丢失怎么办呢?事务发起方可以设置一个定时任务主动扫描状态为 “发送中” 的消息重新投送。只有消息被业务方消费完毕返回消费成功的结果才确认成功并将消息状态改为“已发送”。

这里因为网络异常或者发送异常导致一个消息可能会被重复发送,所以要求接收方要做到幂等性,允许重复消费。

这种方案的好处就是方案简单,成本较低,实现也不复杂。

但是坏处也有很多,比如通过消息的方式延迟不好控制;本地消息表与业务耦合在一起没有做到通用性;本地消息表基于数据库来实现,有一定的瓶颈。

事务消息

上面说的本地消息表的模式无法支持本地事务执行和消息发送一致性的问题,如果能在本地事务执行和发消息这两个操作上加上事务,那岂不是完美。

基于这个思路, 在 MQ 中存储消息的状态才是真理,消息生产者先把消息发送给MQ,此时消息状态为“待确认”,接着生产者去执行本地事务,如果执行成功就给MQ发送消息让他更改消息状态为 “待发送”并发送消息,如果执行失败则删除消息。

这样就保证了本地事务和消息发送一致性问题。

docker java镜像 中文乱码 docker java镜像太大_分布式事务_04

  1. 首先事务发起方先往 MQ 发送一条预读消息,这条消息与普通消息的区别在于他只对 MQ 可见不会向下传播。
  2. MQ接受到消息后,先进行持久化,则存储中会新增一条状态为待发送的消息,接着给事务发起方返回处理完成的 ACK;事务发起方收到处理完成的 ACK 之后开始执行本地事务。
  3. 发起方会根据本地事务的执行状态来决定这个预读消息是应该继续往前还是回滚。另外 MQ 也应该支持自己反查来解决异常情况,如果发起方本地事务已经执行完毕发送消息到MQ,但是消息因为网络原因丢失,那么怎么解决。所以这个反查机制很重要。
  4. 本地事务执行成功以后,MQ 也接收到成功通知,接着将消息状态更新为可发送,然后将消息推送给下游的消费者,这个时候消费者就可以去处理自己的本地事务 。

注意点:由于MQ通常都会保证消息能够投递成功,因此,如果业务没有及时返回ACK结果,那么就有可能造成MQ的重复消息投递问题。因此,对于消息最终一致性的方案,消息的消费者必须要对消息的消费支持幂等,不能造成同一条消息的重复消费的情况。

SAGA 事务模型

Saga是什么?Saga的定义是 “长时间活动的事务 ”(Long Lived Transaction,后文简称为LLT)。他是普林斯顿大学 HECTOR GARCIA-MOLINA 教授在1987年的一篇关于分布式数据库的论文中提出来的概念。

Long Lived 从字面意义上不清晰,Long 到底意味着多长?事务持续时间是一个小时、一天甚至一周吗?其实都不是,时间跨度并不重要。重要的是什么?关键的是跨系统的多次“事务”,Saga 往往由多个外部子事务构成,需要通过多次外部系统的消息交互,才能将整体事务从开始迁移到结束状态,这和我们原来常见的在一个数据库的短事务不一样。比如一个旅行的订单,是由机票、旅馆、租车三个子订单构成,都需要外部的确认,缺任何一个步骤,不能成行,这就是一个典型的 LLT。

看起来 Sage 的定义与别的分布式事务没有什么不同。分布式事务不就是多个不同的子事务构成一个整体吗?再来看看 补偿机制:

每个本地事务有相应的执行模块和补偿模块,当 Sage 事务中的任意一个本地事务出错, 可以通过调用相关事务对应的补偿方法恢复,达到事务的最终一致性。

Saga 模型是把一个分布式事务拆分为多个本地事务,每个本地事务都有相应的执行模块和补偿模块(对应TCC 中的 Confirm 和 Cancel),当 Saga 事务中任意一个本地事务出错时,可以通过调用相关的补偿方法恢复之前的事务,达到事务最终一致性。

由于 Saga 模型中没有 Prepare 阶段,因此事务间不能保证隔离性,当多个 Saga 事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:

  • 在应用层面加锁;
  • 应用层面预先冻结资源。

Saga 恢复方式

Saga 支持向前和向后恢复:

  • 向后恢复:补偿所有已完成的事务,如果任一子事务失败;
  • 向前恢复:重试失败的事务,假设每个子事务最终都会成功。

虽然 Saga 和 TCC 都是补偿事务,但是由于提交阶段不同,所以两者也是有不同的:

  • Saga 没有 Try 行为直接 Commit,所以会留下原始事务操作的痕迹,Cancel 属于不完美补偿,需要考虑对业务上的影响。TCC Cancel 是完美补偿的 Rollback,补偿操作会彻底清理之前的原始事务操作,用户是感知不到事务取消之前的状态信息的。
  • Saga 的补偿操作通常可以异步执行,TCC 的 Cancel 和 Confirm 可以跟进需要是否异步化。
  • Saga 对业务侵入较小,只需要提供一个逆向操作的 Cancel 即可;而 TCC 需要对业务进行全局性的流程改造。
  • TCC 最少通信次数为 2n,而 Saga 为 n(n=子事务的数量)。

因为也是采用补偿机制,那么必然要求服务保持幂等性,如果服务调用超时需要通过幂等性来避免多次请求带来的问题。

事务特性的满足:

原子性:Saga 协调器保证整体事务要么全部执行成功,要么全部回滚。

一致性:Sage 保证最终一致性。

持久性:Saga 将整体事务拆分成独立的本地事务,所以持久性在本地事务中很好实现。

但是隔离性 Saga 无法实现,因为大事务被拆分为多个小事务,每个事务提交的时机不同很难保证已提交的小事务不被别人可见。

目前业界提供两类 Saga 的实现方式:

  • 一种是集中式协调的实现方式。
    集中式协调方式就是通过一个 Saga 对象来追踪所有的 Saga 子任务的调用,由它来管理,追踪整个事务是否应该提交或补偿。
    这种方式带来的缺点就是这种协调方式必然要与第一个Saga 事务耦合,即与业务耦合在一起。
  • 一种是分布式实现方式。
    分布式协调方式肯定就能避免耦合的问题。分布式实现的方案也很多,比如通过事件机制来实现,一条 Saga 事务链上的所有事务都订阅同一个事件,如果失败则通过失败对应的事件消息来回滚即可。
    这种方式带来的好处肯定是显而易见的,但是也会有另一个问题,多个事件带来的肯定是高并发的处理,那么会不会因为多个事件处理相关的问题带来一些循环依赖的问题。
开源分布式事务框架简介
Seata

Seata(Simple Extensible Autonomous Transaction Architecture,简单可扩展自治事务框架)是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。

Seata 会有 4 种分布式事务解决方案,分别是 AT 模式、TCC 模式、Saga 模式和 XA 模式。

docker java镜像 中文乱码 docker java镜像太大_分布式事务_05

XA 模式

XA 模式是 Seata 将会开源的另一种无侵入的分布式事务解决方案,任何实现了 XA 协议的数据库都可以作为资源参与到分布式事务中,目前主流数据库,例如 MySql、Oracle、DB2、Oceanbase 等均支持 XA 协议。

XA 协议有一系列的指令,分别对应一阶段和二阶段操作。“xa start” 和 “xa end” 用于开启和结束XA 事务;“xa prepare” 用于预提交 XA 事务,对应一阶段准备;“xa commit”和“xa rollback”用于提交、回滚 XA 事务,对应二阶段提交和回滚。

在 XA 模式下,每一个 XA 事务都是一个事务参与者。分布式事务开启之后,首先在一阶段执行“xa start”、“业务 SQL”、“xa end”和 “xa prepare” 完成 XA 事务的执行和预提交;二阶段如果提交的话就执行 “xa commit”,如果是回滚则执行“xa rollback”。这样便能保证所有 XA 事务都提交或者都回滚。

docker java镜像 中文乱码 docker java镜像太大_面试_06

XA 模式下,用户只需关注自己的“业务 SQL”,Seata 框架会自动生成一阶段、二阶段操作;XA 模式的实现如下:

docker java镜像 中文乱码 docker java镜像太大_docker java镜像 中文乱码_07

  • 一阶段:

在 XA 模式的一阶段,Seata 会拦截“业务 SQL”,在“业务 SQL”之前开启 XA 事务(“xa start”),然后执行“业务 SQL”,结束 XA 事务“xa end”,最后预提交 XA 事务(“xa prepare”),这样便完成 “业务 SQL”的准备操作。

  • 二阶段提交:

执行“xa commit”指令,提交 XA 事务,此时“业务 SQL”才算真正的提交至数据库。

  • 二阶段回滚:

执行“xa rollback”指令,回滚 XA 事务,完成“业务 SQL”回滚,释放数据库锁资源。

XA 模式下,用户只需关注“业务 SQL”,Seata 会自动生成一阶段、二阶段提交和二阶段回滚操作。XA 模式和 AT 模式一样是一种对业务无侵入性的解决方案;但与 AT 模式不同的是,XA 模式将快照数据和行锁等通过 XA 指令委托给了数据库来完成,这样 XA 模式实现更加轻量化。

AT 模式

AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。

docker java镜像 中文乱码 docker java镜像太大_后端_08

AT 模式的一阶段、二阶段提交和回滚

均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。

TCC 模式

2019 年 3 月份,Seata 开源了 TCC 模式,该模式由蚂蚁金服贡献。TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;事务发起方在一阶段 执行 Try 方式,在二阶段提交执行 Confirm 方法,二阶段回滚执行 Cancel 方法。

docker java镜像 中文乱码 docker java镜像太大_面试_09

TCC 三个方法描述:

  • Try:资源的检测和预留;
  • Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功;
  • Cancel:预留资源释放。

用户接入 TCC 模式,最重要的事情就是考虑如何将业务模型拆成 2 阶段,实现成 TCC 的 3 个方法,并且保证 Try 成功 Confirm 一定能成功。相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模式高很多。

Saga 模式

Saga 模式是 Seata 即将开源的长事务解决方案,将由蚂蚁金服主要贡献。在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。