分布式事务

一、基础概念

1. 假如没有分布式事务

在一系列微服务系统当中,假如不存在分布式事务,会发生什么呢?让我们以互联网中常用的交易业务为例子:

上图中包含了库存和订单两个独立的微服务,每个微服务维护了自己的数据库。在交易系统的业务逻辑中,一个商品在下单之前需要先调用库存服务,进行扣除库存,再调用订单服务,创建订单记录。

正常情况下,两个数据库各自更新成功,两边数据维持着一致性。

但是,在非正常情况下,有可能库存的扣减完成了,随后的订单记录却因为某些原因插入失败。这个时候,两边数据就失去了应有的一致性。

2 什么是分布式事务?

分布式事务用于在分布式系统中保证不同节点之间的数据一致性。分布式事务的实现有很多种,最具有代表性的是由Oracle Tuxedo系统提出的XA分布式事务协议。
XA协议包含两阶段提交(2PC)和三阶段提交(3PC)两种实现。

3.分布式事务场景

1.跨库事务

 跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据.

2.分库分表

 通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。如下图,将数据库B拆分成了2个库:

 对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。如,对于sql:insert into user(id,name) values (1,"张三"),(2,"李四")。这条sql是操作单库的语法,单库情况下,可以保证事务的一致性。

    但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。

3.服务化

 微服务架构是目前一个比较一个比较火的概念。例如上面笔者提到的一个案例,某个应用同时操作了9个库,这样的应用业务逻辑必然非常复杂,对于开发人员是极大的挑战,应该拆分成不同的独立服务,以简化业务逻辑。拆分后,独立服务之间通过RPC框架来进行远程调用,实现彼此的通信。下图演示了一个3个服务之间彼此调用的架构:

    Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数据库,Service C也操作了一个库。需要保证这些跨服务的对多个数据库的操作要不都成功,要不都失败,实际上这可能是最典型的分布式事务场景。

    小结:上述讨论的分布式事务场景中,无一例外的都直接或者间接的操作了多个数据库。如何保证事务的ACID特性,对于分布式事务实现方案而言,是非常大的挑战。同时,分布式事务实现方案还必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。

4.X/Open DTP模型与XA规范

 X/Open,即现在的open group,是一个独立的组织,主要负责制定各种行业技术标准。  就分布式事务处理(Distributed Transaction Processing,简称DTP)而言,X/Open主要提供了以下参考文档:

  •    DTP 参考模型
  •    DTP XA规范

1.DTP 参考模型

 构成DTP模型的5个基本元素:

   应用程序(Application Program ,简称AP):用于定义事务边界(即定义事务的开始和结束),并且在事务边界内对资源进行操作。

   资源管理器(Resource Manager,简称RM):如数据库、文件系统等,并提供访问资源的方式。

   事务管理器(Transaction Manager ,简称TM):负责分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚等。

   通信资源管理器(Communication Resource Manager,简称CRM):控制一个TM域(TM domain)内或者跨TM域的分布式应用之间的通信。

   通信协议(Communication Protocol,简称CP):提供CRM提供的分布式应用节点之间的底层通信服务。

2.XA规范

XA规范核心内容是,定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通讯接口。

在DTP本地模型实例中,由AP、RMs和TM组成,不需要其他元素。AP、RM和TM之间,彼此都需要进行交互,如下图所示:

这张图中

(1)表示AP-RM的交互接口,(2)表示AP-TM的交互接口,(3)表示RM-TM的交互接口。

XA规范的最主要的作用是,就是定义了RM-TM的交互接口,XA规范除了定义的RM-TM交互的接口(XA Interface)之外,还对两阶段提交协议进行了优化。

两阶段协议(two-phase commit)是在OSI TP标准中提出的;在DTP参考模型中,指定了全局事务的提交要使用two-phase commit协议;而XA规范只是定义了两阶段提交协议中需要使用到的接口,也就是上述提到的RM-TM交互的接口,因为两阶段提交过程中的参与方,只有TM和RMs。

5.解决分布式事务的思路

分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论,有两种解决思路:

  • AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。

  • CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。

但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC)

二、XA分布式事务协议

1. 两阶段提交(2PC)

在XA协议中包含着两个角色:事务协调者事务参与者

让我们来看一看他们之间的整体的交互流程:

“准备”和“提交”这两个过程,被称为“两段式提交”(2 Phase Commit,2PC)协议。那么,使用了两阶段提交协议,就一定可以成功保证一致性吗?

也不是的,它还需要两个前提条件。

第一,必须假设网络在提交阶段这个短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通讯在全过程都不会出现误差,即可以丢失后消息,但不会传递错误的消息,XA 的设计目标并不是解决诸如拜占庭将军一类的问题。两段式提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而提交阶段的耗时应尽可能短,这也是为了尽量控制网络风险的考虑。

第二,必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,再向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。

我们来看看下面两种情况

1. 两阶段提交(2PC)成功情况下的流程

第一阶段:

如上图准备阶段,又叫做投票阶段。

这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

在XA分布式事务的第一阶段,作为事务协调者的节点会首先向所有的参与者节点发送Prepare请求。

在接到Prepare请求之后,每一个参与者节点会各自执行与事务有关的数据更新,写入Undo Log和Redo Log。如果参与者执行成功,暂时不提交事务,而是向事务协调节点返回“完成”消息。

当事务协调者接到了所有参与者的返回消息,整个分布式事务将会进入第二阶段。

第二阶段:

如上图提交阶段,又叫做执行阶段

协调者如果在准备阶段收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

对于数据库来说,提交阶段的提交操作是相对轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成。回滚阶段则相对耗时,收到 Abort 指令时,需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

在XA分布式事务的第二阶段,如果事务协调节点在之前所收到都是正向返回,那么它将会向所有事务参与者发出Commit请求。

接到Commit请求之后,事务参与者节点会各自进行本地的事务提交,并释放锁资源。当本地事务完成提交后,将会向事务协调者返回“完成”消息。

当事务协调者接收到所有事务参与者的“完成”反馈,整个分布式事务完成。

2.两阶段提交(2PC)失败情况下的流程

第一阶段发生异常

在XA的第一阶段,如果某个事务参与者反馈失败消息,说明该节点的本地事务执行不成功,必须回滚。
于是在第二阶段,事务协调节点向所有的事务参与者发送Abort请求而不是commit请求。接收到Abort请求之后,各个事务参与者节点需要在本地进行事务的回滚操作,回滚操作依照Undo Log来进行。流程如下:

第一阶段:

第二阶段:

3. XA两阶段提交的不足

XA两阶段提交究竟有哪些不足呢?

1.性能问题

XA协议遵循强一致性。在事务执行过程中,各个节点在进入提交或者回滚操作前,占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知提交,参与者提交后释放资源。

这样的过程有着非常明显的性能问题,即事务的执行时间会比较长。

两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用、三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止。这就决定了两段式提交的性能通常都比较差。

2.协调者单点故障问题

事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,参与者收不到提交或是回滚通知,参与者会一直处于中间状态无法完成事务,直到事务超时自动回滚。

协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。

3.丢失消息导致的不一致问题。

在XA协议的第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,触发来事务超时回滚。那么就导致了节点之间数据的不一致。

4.没有完善的容错机制。

太过保守 ,任意一个节点失败就会导致整个事务失败,没有完善的容错机制。


如果避免XA两阶段提交的种种问题呢?有许多其他的分布式事务方案可供选择:

1.XA三阶段提交

XA三阶段提交在两阶段提交的基础上增加了CanCommit阶段,并且引入了超时机制。一旦事物参与者迟迟没有接到协调者的commit请求,会自动进行本地commit。这样有效解决了协调者单点故障的问题。但是性能问题和不一致的问题仍然没有根本解决。

2.MQ事务

利用消息中间件来异步完成事务的后一半更新,实现系统的最终一致性。这个方式避免了像XA协议那样的性能问题。

3.TCC事务

TCC事务是Try、Commit、Cancel三种指令的缩写,其逻辑模式类似于XA两阶段提交,但是实现方式是在代码层面来人为实现。

4. 2PC适用场景

适用于强一致、并且并发量不大的场景下,才考虑使用 2PC。

2. 三阶段提交(3PC)

为了解决两段式提交的单点问题、性能问题和数据一致性问题,“三段式提交”(3 Phase Commit,3PC)协议出现了。但是三段式提交,也并没有解决一致性问题。

XA三阶段提交在两阶段提交的基础上增加了CanCommit阶段,并且引入了超时机制,并且一旦事物参与者迟迟没有接到协调者的commit请求,会自动进行本地commit,这样有效解决了协调者单点故障的问题。

三阶段提交协议是2PC的改进版本,将2PC的提交事务阶段一分为二,这样就变成了三阶段:CanCommit,PreCommit,DoCommit三个阶段。

那么为什么要讲准备阶段在进行拆分?

1.解决性能问题

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,也意味着因某个参与者提交时发生崩溃而导致全部回滚的风险相对变小了。

因此,在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

2.解决单点问题

同样地,也是因为询问阶段使得事务失败回滚的概率变小了,所以在三段式提交中,如果协调者在 PreCommit 阶段开始之后发生了宕机,参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

1.三阶段提交流程

三阶段提交流程如下:

  • CanCommit阶段

1.事务询问: 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
2.响应反馈: 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

注意:如果区间双方任何一方发生超时,则abort.

我自己认为就是做一些前置校验工作比如参数校验等,只等最后一步提交本地事务的操作。 

  • PreCommit阶段(如果成功会进行资源的锁定

协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有以下两种可能。

1.假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。

1.发送预提交请求: 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
2.事务预提交 :参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
3.响应反馈: 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令:提交(Commit)或中止(abort)。

​​​​​​2.假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。当然如果协调者没有收到预提交请求也会abort。

 1.发送中断请求 协调者向所有参与者发送abort请求。

 2.中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。

  • doCommit阶段

该阶段进行真正的事务提交。也可以分为以下两种情况。

1:执行提交

1.发送提交请求: 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
2.事务提交: 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
3.响应反馈: 事务提交完之后,向协调者发送Ack响应。
4.完成事务: 协调者接收到所有参与者的ack响应之后,完成事务。

如果参与者没有及时收到协调者的反馈(doCommit或abort),参与者都会进行事务的提交(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。 )

2:中断事务

协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。

利用参与者二阶段记录的undo信息来执行事务回滚,并向协调者发送ACK消息,协调者收到ACK消息后执行事务中断。

1.发送中断请求 协调者向所有参与者发送abort请求

2.事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。

3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息

4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断

2.三阶段提交的不足

1.一致性问题未解决

一致性问题没有解决的原因:

由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间短时间内存在数据不一致的情况。

2.性能问题未完全解决

事务开启后,要等到commit区间还是占用很长的时间。但对比2PC已经有了较大的性能提升

通过以上分析发现,2PC和3PC都无法彻底解决分布式的一致性问题,接下来会分析最为行之有效的Paxos算法

三、​TCC分布式事务​

1.核心原理

在具体实现上,TCC 的操作其实有点儿麻烦和复杂,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程。

你看名字也能看出来,TCC 的实现过程分为了三个阶段:

Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。

Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等性。

Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等性。

根据 Fenix's Bookstore 在线书店的场景事例,TCC 的执行过程应该是这样的:

第一步,最终用户向 Fenix's Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。

第二步,创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:

用户服务:检查业务可行性,可行的话,把该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。

仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。

商家服务:检查业务可行性,不需要冻结资源。 

第三步,如果第二步中所有业务都反馈业务可行,就将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:

用户服务:完成业务操作(扣减被冻结的 100 元)。

仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。

商家服务:完成业务操作(收款 100 元)。

第四步,如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。

第五步,如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:

用户服务:取消业务操作(释放被冻结的 100 元)。

仓库服务:取消业务操作(释放被冻结的 1 本书)。

商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。 

第六步,如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。

这里我们在针对每个节点可能出现的情况分析一下对应处理办法

1.万一某个服务的 Cancel 或者 Confirm 逻辑执行一直失败怎么办呢?

TCC 事务框架都是要记录一些分布式事务的活动日志的,可以在磁盘上的日志文件里记录,也可以在数据库里记录。保存特定分布式事务下运行的各个阶段和状态。

TCC 事务框架会通过活动日志记录各个服务的状态。举个例子,比如发现某个服务的 Cancel 或者 Confirm 一直没成功,会不停的重试调用它的 Cancel 或者 Confirm 逻辑,务必要它成功!

当然了,如果你的代码没有写什么 Bug,有充足的测试,而且 Try 阶段都基本尝试了一下,那么其实一般 Confirm、Cancel 都是可以成功的!

2.使用场景

TCC 方案的优点

1.TCC 方案,它天生适合用于需要强隔离性的分布式事务中。

2.TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。

下面我们来对比一下TCC

  • XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。
  • TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。

3.TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

TCC 方案的弊端

由于 TCC 的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,也就给我们带来了一些使用上的弊端,那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。

比如说,我们把这个书店的场景事例修改一下:由于中国网络支付日益盛行,在书店系统中,现在用户和商家可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,而是允许在购物时,直接通过 U 盾或扫码支付,在银行账户中划转货款。这个需求完全符合我们现在支付的习惯,但这也给系统的事务设计增加了额外的限制:如果用户、商家的账户余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义了,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以,在 TCC 的执行过程中,第一步 Try 阶段往往就已经无法施行了。

3.电商业务TCC解决方案

1.不进行​TCC分布式事务的危害场景

上述这几个步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。

举个例子,现在订单的状态都修改为“已支付”了,结果库存服务扣减库存失败。那个商品的库存原来是 100 件,现在卖掉了 2 件,本来应该是 98 件了。

结果呢?由于库存服务操作数据库异常,导致库存数量还是 100。这不是在坑人么,当然不能允许这种情况发生了!

但是如果你不用 TCC 分布式事务方案的话,就用个 Spring Cloud 开发这么一个微服务系统,很有可能会干出这种事儿来。

2.使用TCC后正确的交互流程

所以说,我们有必要使用 TCC 分布式事务机制来保证各个服务形成一个整体性的事务。

上面那几个步骤,要么全部成功,如果任何一个服务的操作失败了,就全部一起回滚,撤销已经完成的操作。

比如说库存服务要是扣减库存失败了,那么订单服务就得撤销那个修改订单状态的操作,然后得停止执行增加积分和通知出库两个操作。

3.落地实现 TCC 分布式事务

1.TCC 实现阶段一:Try

首先,订单服务那儿,它的代码大致来说应该是这样子的:

public class OrderService {

    // 库存服务
    @Autowired
    private InventoryService inventoryService;

    // 积分服务
    @Autowired
    private CreditService creditService;

    // 仓储服务
    @Autowired
    private WmsService wmsService;

    // 对这个订单完成支付
    public void pay(){
        //对本地的的订单数据库修改订单状态为"已支付"
        orderDAO.updateStatus(OrderStatus.PAYED);

        //调用库存服务扣减库存
        inventoryService.reduceStock();

        //调用积分服务增加积分
        creditService.addCredit();

        //调用仓储服务通知发货
        wmsService.saleDelivery();
    }
}

订单服务的改变流程

首先,上面那个订单服务先把自己的状态修改为:OrderStatus.UPDATING。

这是啥意思呢?也就是说,在 pay() 那个方法里,你别直接把订单状态修改为已支付啊!你先把订单状态修改为 UPDATING,也就是修改中的意思。

这个状态是个没有任何含义的这么一个状态,代表有人正在修改这个状态罢了。

库存服务改变流程

然后呢,库存服务直接提供的那个 reduceStock() 接口里,也别直接扣减库存啊,你可以是冻结掉库存。

举个例子,本来你的库存数量是 100,你别直接 100 - 2 = 98,扣减这个库存!

你可以把可销售的库存:100 - 2 = 98,设置为 98 没问题,然后在一个单独的冻结库存的字段里,设置一个 2。也就是说,有 2 个库存是给冻结了。

积分服务改变流程

积分服务的 addCredit() 接口也是同理,别直接给用户增加会员积分。你可以先在积分表里的一个预增加积分字段加入积分。

比如:用户积分原本是 1190,现在要增加 10 个积分,别直接 1190 + 10 = 1200 个积分啊!

你可以保持积分为 1190 不变,在一个预增加字段里,比如说 prepare_add_credit 字段,设置一个 10,表示有 10 个积分准备增加。

仓储服务改变流程

仓储服务的 saleDelivery() 接口也是同理啊,你可以先创建一个销售出库单,但是这个销售出库单的状态是“UNKNOWN”。

也就是说,刚刚创建这个销售出库单,此时还不确定它的状态是什么呢!

总结上述过程,如果你要实现一个 TCC 分布式事务,首先你的业务的主流程以及各个接口提供的业务含义,不是说直接完成那个业务操作,而是完成一个 Try 的操作。

这个操作,一般都是锁定某个资源,设置一个预备类的状态,冻结部分数据,等等,大概都是这类操作。

咱们来一起看看下面这张图,结合上面的文字,再来捋一捋整个过程:

2.TCC 实现阶段二:Confirm 

然后就分成两种情况了,第一种情况是比较理想的,那就是各个服务执行自己的那个 Try 操作,都执行成功了,Bingo!

执行Confirm 的前提时所有 服务返回的Try操作都是返回正常的。

这个时候,就需要依靠 TCC 分布式事务框架来推动后续的执行了。这里简单提一句,如果你要玩儿 TCC 分布式事务,必须引入一款 TCC 分布式事务框架,比如国内开源的 ByteTCC、Himly、TCC-transaction。

否则的话,感知各个阶段的执行情况以及推进执行下一个阶段的这些事情,不太可能自己手写实现,太复杂了。

如果你在各个服务里引入了一个 TCC 分布式事务的框架,订单服务里内嵌的那个 TCC 分布式事务框架可以感知到,各个服务的 Try 操作都成功了。

此时,TCC 分布式事务框架会控制进入 TCC 下一个阶段,即第一个 C 阶段,也就是 Confirm 阶段。

订单服务的改变流程

为了实现这个阶段,你需要在各个服务里再加入一些代码。比如说,订单服务里,你可以加入一个 Confirm 的逻辑,就是正式把订单的状态设置为“已支付”了,大概是类似下面这样子:

public class OrderServiceConfirm {

    public void pay(){
        orderDao.updateStatus(OrderStatus.PAYED);
    }
}

库存服务改变流程

库存服务也是类似的,你可以有一个 InventoryServiceConfirm 类,里面提供一个 reduceStock() 接口的 Confirm 逻辑,这里就是将之前冻结库存字段的 2 个库存扣掉变为 0。

这样的话,可销售库存之前就已经变为 98 了,现在冻结的 2 个库存也没了,那就正式完成了库存的扣减。

积分服务改变流程

积分服务也是类似的,可以在积分服务里提供一个 CreditServiceConfirm 类,里面有一个 addCredit() 接口的 Confirm 逻辑,就是将预增加字段的 10 个积分扣掉,然后加入实际的会员积分字段中,从 1190 变为 1120。

仓储服务改变流程

仓储服务也是类似,可以在仓储服务中提供一个 WmsServiceConfirm 类,提供一个 saleDelivery() 接口的 Confirm 逻辑,将销售出库单的状态正式修改为“已创建”,可以供仓储管理人员查看和使用,而不是停留在之前的中间状态“UNKNOWN”了。

好了,上面各种服务的 Confirm 的逻辑都实现好了,一旦订单服务里面的 TCC 分布式事务框架感知到各个服务的 Try 阶段都成功了以后,就会执行各个服务的 Confirm 逻辑。

订单服务内的 TCC 事务框架会负责跟其他各个服务内的 TCC 事务框架进行通信,依次调用各个服务的 Confirm 逻辑。然后,正式完成各个服务的所有业务逻辑的执行。

同样,给大家来一张图,顺着图一起来看看整个过程:

好,这是比较正常的一种情况,那如果是异常的一种情况呢?

3.TCC 实现阶段三:Cancel 

执行Cancel 的前提是,其中有服务返回的Try操作不是正常的。

举个例子:在 Try 阶段,比如积分服务吧,它执行出错了,此时会怎么样?

那订单服务内的 TCC 事务框架是可以感知到的,然后它会决定对整个 TCC 分布式事务进行回滚。

也就是说,会执行各个服务的第二个 C 阶段,Cancel 阶段。同样,为了实现这个 Cancel 阶段,各个服务还得加一些代码。

首先订单服务,它得提供一个 OrderServiceCancel 的类,在里面有一个 pay() 接口的 Cancel 逻辑,就是可以将订单的状态设置为“CANCELED”,也就是这个订单的状态是已取消。

库存服务也是同理,可以提供 reduceStock() 的 Cancel 逻辑,就是将冻结库存扣减掉 2,加回到可销售库存里去,98 + 2 = 100。

积分服务也需要提供 addCredit() 接口的 Cancel 逻辑,将预增加积分字段的 10 个积分扣减掉。

仓储服务也需要提供一个 saleDelivery() 接口的 Cancel 逻辑,将销售出库单的状态修改为“CANCELED”设置为已取消。

然后这个时候,订单服务的 TCC 分布式事务框架只要感知到了任何一个服务的 Try 逻辑失败了,就会跟各个服务内的 TCC 分布式事务框架进行通信,然后调用各个服务的 Cancel 逻辑。

4.总结与思考

好了,兄弟们,聊到这儿,基本上大家应该都知道 TCC 分布式事务具体是怎么回事了!

总结一下,你要玩儿 TCC 分布式事务的话:首先需要选择某种 TCC 分布式事务框架,各个服务里就会有这个 TCC 分布式事务框架在运行。

然后你原本的一个接口,要改造为 3 个逻辑,Try-Confirm-Cancel:

  • 先是服务调用链路依次执行 Try 逻辑。
  • 如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务。
  • 如果某个服务的 Try 逻辑有问题,TCC 分布式事务框架感知到之后就会推进执行各个服务的 Cancel 逻辑,撤销之前执行的各种操作。

这就是所谓的 TCC 分布式事务。TCC 分布式事务的核心思想,说白了,就是当遇到下面这些情况时:

  • 某个服务的数据库宕机了。
  • 某个服务自己挂了。
  • 那个服务的 Redis、Elasticsearch、MQ 等基础设施故障了。
  • 某些资源不足了,比如说库存不够这些。

先来 Try 一下,不要把业务逻辑完成,先试试看,看各个服务能不能基本正常运转,能不能先冻结我需要的资源。

如果 Try 都 OK,也就是说,底层的数据库、Redis、Elasticsearch、MQ 都是可以写入数据的,并且你保留好了需要使用的一些资源(比如冻结了一部分库存)。

接着,再执行各个服务的 Confirm 逻辑,基本上 Confirm 就可以很大概率保证一个分布式事务的完成了。

那如果 Try 阶段某个服务就失败了,比如说底层的数据库挂了,或者 Redis 挂了,等等。

此时就自动执行各个服务的 Cancel 逻辑,把之前的 Try 逻辑都回滚,所有服务都不要执行任何设计的业务逻辑。保证大家要么一起成功,要么一起失败。

最后,再给大家来一张图,来看看给我们的业务,加上分布式事务之后的整个执行流程:

四、本地消息表分布式事务​

1.基础理论

2PC 它的适用场景其实是很窄的,更多的情况下,只要保证数据最终一致就可以了。比如说,在购物流程中,用户在购物车界面选好商品后,点击“去结算”按钮进入订单页面创建 一个新订单。

这个过程我们的系统其实做了两件事儿。
第一,订单系统需要创建一个新订单,订单关联的商品就是购物车中选择的那些商品。
第二,创建订单成功后,购物车系统需要把订单中的这些商品从购物车里删掉。
这也是一个分布式事务问题,创建订单和清空购物车这两个数据更新操作需要保证,要么都成功,要么都失败。但是,清空购物车这个操作,它对一致性要求就没有扣减优惠券那么 高,订单创建成功后,晚几秒钟再清空购物车,完全是可以接受的。只要保证经过一个小的延迟时间后,最终订单数据和购物车数据保持一致就可以了。
本地消息表非常适合解决这种分布式最终一致性的问题。
我们一起来看一下,如何使用本地 消息表来解决订单与购物车的数据一致性问题。
本地消息表的实现思路是这样的,订单服务在收到下单请求后,正常使用订单库的事务去更新订单的数据,并且,在执行这个数据库事务过程中,在本地记录一条消息。这个消息就是一个日志,内容就是“清空购物车”这个操作。 因为这个日志是记录在本地的,这里面没有分布式的问题,那这就是一个普通的单机事务,那我们就可以让订单库的事务,来保证记录本地消息和订单库的一致性 。完成这一步之后,就可以给客户端返回成功响应了。
然后,我们再当前进程中用一个异步的线程,去读取刚刚记录的清空购物车的本地消息,通过RPC调用购物车系统的服务清空购物车。购物车清空之后,把本地消息的状态更新成已完成就可以了。异步清 空购物车这个过程中,如果操作失败了,可以通过重试来解决。最终,可以保证订单系统和 购物车系统它们的数据是一致的。
这里面,本地消息表,你可以选择存在订单库中,也可以用文件的形式,保存在订单服务所在服务器的本地磁盘中,两种方式都是可以的,相对来说,放在订单库中更简单一些。
消息队列 RocketMQ 提供一种事务消息的功能,其实就是本地消息表思想的一个实现
使用事务消息可以达到和本地消息表一样的最终一致性,相比我们自己来实现本地消息表,使
用起来更加简单,你也可以考虑使用。

2.使用场景

如果看事务的 ACID 四个特性,本地消息表这种方法,它只能满足 D(持久性),A(原子
性)C(一致性)、I(隔离性)都比较差,但是,它的优点非常突出。 首先,实现简单,在单机事务的基础上稍加改造就可以实现分布式事务,另外,本地消息表的性能非常好,和单机事务的性能几乎没有差别。在这个基础上,还提供了大部分情况下都能接受的“数据最终一致性”的保证,所以,本地消息表是更加实用的分布式事务实现方法。

1.库存锁定问题

即使能接受数据最终一致,本地消息表也不是什么场景都可以适用的。它有一个前提条件就是,异步执行的那部分操作,不能有依赖的资源。比如说,我们下单的时候,除了要清空购物车以外,还需要锁定库存。库存系统锁定库存这个操作,虽然可以接受数据最终一致,但是,锁定库存这个操作是有一 个前提的,这个前提是:库存中得有货。这种情况就不适合使用本地消息表,不然就会出现 用户下单成功后,系统的异步任务去锁定库存的时候,因为缺货导致锁定失败。这样的情况就很难处理了。
当然在并发量不是很大的情况下,也可以通过下单前提前检测库存是否有货,然后在进行下单操作来解决库存问题。

2.库存超卖问题

在书店的业务场景下,很有可能会出现这样的情况:两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和,却超过了库存。

如果这件事情是发生在刚性事务且隔离级别足够的情况下,其实是可以完全避免的。比如,我前面提到的“超售”场景,就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败。但用可靠消息队列就无法保证这一点了。

3.在线书城场景

最终一致性的概念,是由 eBay 的系统架构师丹 · 普利切特(Dan Pritchett)在 2008 年发表于 ACM 的论文“Base: An Acid Alternative”中提出的。

事例场景:Fenix's Bookstore 是一个在线书店。
一份商品成功售出,需要确保以下三件事情被正确地处理:

1.用户的账号扣减相应的商品款项;

2.商品仓库中扣减库存,将商品标识为待配送状态;

3.商家的账号增加相应的商品款项。

我们继续以 Fenix's Bookstore 的事例场景,来解释下丹 · 普利切特提出的“可靠事件队列”的具体做法,下图为操作时序:
我们按照顺序,一步步来解读一下。
第一步,最终用户向 Fenix's Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
第二步,Fenix's Bookstore 应该对用户账户扣款、商家账户收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序(这个一般体现在程序代码中,有一些大型系统也可能动态排序)。比如,最有可能出错的地方,是用户购买了,但是系统不同意扣款,或者是账户余额不足;其次是商品库存不足;最后是商家收款,一般收款不会遇到什么意外。那么这个顺序就应该是最容易出错的最先进行,即:账户扣款 → 仓库出库 → 商家收款。
第三步,账户服务进行扣款业务,如果扣款成功,就在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:UUID;扣款:100 元(状态:已完成);仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中);某商家收款:100 元(状态:进行中)”。注意,这个步骤中“扣款业务”和“写入消息”是依靠同一个本地事务写入自身数据库的。
第四步,系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去。
这时候可能会产生以下几种情况:
1.商家和仓库服务成功完成了收款和出库工作,向用户账户服务器返回执行结果,用户账户服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。
2.商家或仓库服务有某些或全部因网络原因,未能收到来自用户账户服务的消息。此时,由于用户账户服务器中存储的消息状态,一直处于“进行中”,所以消息服务器将在每次轮询的时候,持续地向对应的服务重复发送消息。这个步骤的可重复性,就决定了所有被消息服务器发送的消息都必须具备幂等性。通常我们的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作只会被处理一次。
3.商家或仓库服务有某个或全部无法完成工作。比如仓库发现《深入理解 Java 虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(比如补充了库存),或者被人工介入为止。
4.商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失。此时,用户账户服务仍会重新发出下一条消息,但因消息幂等,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息。此过程会一直重复,直至双方网络恢复。
也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。
前面这种靠着持续重试来保证可靠性的操作,在计算机中就非常常见,它有个专门的名字,叫做“最大努力交付”(Best-Effort Delivery),比如 TCP 协议中的可靠性保障,就属于最大努力交付。而“可靠事件队列”有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),意思就是系统会把最有可能出错的业务,以本地事务的方式完成后,通过不断重试的方式(不限于消息系统)来促使同个事务的其他关联业务完成。

五、​mq分布式事务​

在实际系统的开发过程中,可能服务间的调用是异步的。也就是说,一个服务发送一个消息给 MQ,即消息中间件,比如 RocketMQ、RabbitMQ、Kafka、ActiveMQ 等等。

然后,另外一个服务从 MQ 消费到一条消息后进行处理。这就成了基于 MQ 的异步调用了。

那么针对这种基于 MQ 的异步调用,如何保证各个服务间的分布式事务呢?也就是说,我希望的是基于 MQ 实现异步调用的多个服务的业务逻辑,要么一起成功,要么一起失败。

这个时候,就要用上可靠消息最终一致性方案,来实现分布式事务。

1.上游服务投递消息

如果要实现可靠消息最终一致性方案,一般你可以自己写一个可靠消息服务,实现一些业务逻辑。

首先,上游服务需要发送一条消息给可靠消息服务。这条消息说白了,你可以认为是对下游服务一个接口的调用,里面包含了对应的一些请求参数,并且保证可以正常的投入到消息队列中。

然后,可靠消息服务就得把这条消息存储到自己的数据库(也可以是redis中)里去,状态为“待确认”。

接着,上游服务就可以执行自己本地的数据库操作,根据自己的执行结果,再次调用可靠消息服务的接口。

如果本地数据库操作执行成功了,那么就找可靠消息服务确认那条消息。如果本地数据库操作失败了,那么就找可靠消息服务删除那条消息。

此时如果是确认消息,那么可靠消息服务就把数据库里的消息状态更新为“已发送”,同时将消息发送给 MQ。

这里有一个很关键的点,就是更新数据库里的消息状态和投递消息到 MQ。这俩操作,你得放在一个方法里,而且得开启本地事务。

啥意思呢?如果数据库里更新消息的状态失败了,那么就抛异常退出了,就别投递到 MQ;如果投递 MQ 失败报错了,那么就要抛异常让本地数据库事务回滚。

这俩操作必须得一起成功,或者一起失败。

如果上游服务是通知删除消息,那么可靠消息服务就得删除这条消息。

2.下游服务接收消息

下游服务就一直等着从 MQ 消费消息好了,如果消费到了消息,那么就操作自己本地数据库。

如果操作成功了,就反过来通知可靠消息服务,说自己处理成功了,然后可靠消息服务就会把消息的状态设置为“已完成”。

 3.可靠消息服务

作为连接上游和下游的桥梁,用于跟踪消息的流转过程,如果几个微服务都是公司内部自己的,建议使用redis作为存储。只有可靠消息服务的状态为已完成,这套分布式事务才算真正完成。

思考

1.如何保证上游服务对消息的 100% 可靠投递?

a:如果上游服务给可靠消息服务发送待确认消息的过程出错了,那没关系,上游服务可以感知到调用异常的,就不用执行下面的流程了并回滚,这是没问题的。

b:如果上游服务操作完本地数据库之后,通知可靠消息服务确认消息或者删除消息的时候,出现了问题。

比如:没通知成功,或者没执行成功,或者是可靠消息服务没成功的投递消息到 MQ。这一系列步骤出了问题怎么办?

其实也没关系,因为在这些情况下,那条消息在可靠消息服务的数据库里的状态会一直是“待确认”。

此时,我们在可靠消息服务里开发一个后台定时运行的线程,不停的检查各个消息的状态。

然后进行重试,超过阀值时回滚并手动介入。即只有上游服务执行成功后,才会真正投递消息到mq中。当然会牺牲系统的性能,但为了保证强一致性牺牲性能在所不免。

2.如何保证下游服务对消息的 100% 可靠接收?

那如果下游服务消费消息出了问题,没消费到?或者是下游服务对消息的处理失败了,怎么办?

其实也没关系,对消费端设置手动提交,没有消费成功就不要提交,当然也可以设定阀值,超过阀值手动介入来决定继续重试还是回滚所有。

只要下游服务的接口逻辑实现幂等性,保证多次处理一个消息,不会插入重复数据即可。

3.mq中间件高可用降级方案?

大家应该发现了这套方案里保障高可用性最大的一个依赖点,就是 MQ 的高可用性。

这套机制不算太复杂,可以非常简单有效的保证那位朋友公司的高可用保障场景,一旦 MQ 中间件出现故障,立马自动降级为备用方案。

1.自行封装 MQ 客户端组件与故障感知

首先第一点,你要做到自动感知 MQ 的故障接着自动完成降级,那么必须动手对 MQ 客户端进行封装,发布到公司 Nexus 私服上去。

然后公司需要支持 MQ 降级的业务服务都使用这个自己封装的组件来发送消息到 MQ,以及从 MQ 消费消息。

在你自己封装的 MQ 客户端组件里,你可以根据写入 MQ 的情况来判断 MQ 是否故障。

比如说,如果连续 10 次重新尝试投递消息到 MQ 都发现异常报错,网络无法联通等问题,说明 MQ 故障,此时就可以自动感知以及自动触发降级开关。

2.基于 KV 存储中队列的降级方案

如果 MQ 挂掉之后,要是希望继续投递消息,那么就必须得找一个 MQ 的替代品。

举个例子,比如公司是没有高并发场景的,消息的量很少,只不过可用性要求高。此时就可以使用类似 Redis 的 KV 存储中的队列来进行替代。

由于 Redis 本身就支持队列的功能,还有类似队列的各种数据结构,所以你可以将消息写入 KV 存储格式的队列数据结构中去。

但是,这里有几个大坑,一定要注意一下:

第一个,任何 KV 存储的集合类数据结构,建议不要往里面写入数据量过大,否则会导致大 Value 的情况发生,引发严重的后果。

因此绝不能在 Redis 里搞一个 Key,就拼命往这个数据结构中一直写入消息,这是肯定不行的。

第二个,绝对不能往少数 Key 对应的数据结构中持续写入数据,那样会导致热 Key 的产生,也就是某几个 Key 特别热。

大家要知道,一般 KV 集群,都是根据 Key 来 Hash 分配到各个机器上的,你要是老写少数几个 Key,会导致 KV 集群中的某台机器访问过高,负载过大。

基于以上考虑,下面是笔者当时设计的方案:

  • 根据它们每天的消息量,在 KV 存储中固定划分上百个队列,有上百个 Key 对应。这样保证每个 Key 对应的数据结构中不会写入过多的消息,而且不会频繁的写少数几个 Key。
  • 一旦发生了 MQ 故障,可靠消息服务可以对每个消息通过 Hash 算法,均匀的写入固定好的上百个 Key 对应的 KV 存储的队列中。

同时需要通过 ZK 触发一个降级开关,整个系统在 MQ 这块的读和写全部立马降级。

4.下游服务消费 MQ 的降级感知

下游服务消费 MQ 也是通过自行封装的组件来做的,此时那个组件如果从 ZK 感知到降级开关打开了,首先会判断自己是否还能继续从 MQ 消费到数据?

如果不能了,就开启多个线程,并发的从 KV 存储的各个预设好的上百个队列中不断的获取数据。

每次获取到一条数据,就交给下游服务的业务逻辑来执行。通过这套机制,就实现了 MQ 故障时候的自动故障感知,以及自动降级。如果系统的负载和并发不是很高的话,用这套方案大致是没问题的。

因为在生产落地的过程中,包括大量的容灾演练以及生产实际故障发生时的表现来看,都是可以有效的保证 MQ 故障时,业务流程继续自动运行的。

5.故障的自动恢复

如果降级开关打开之后,自行封装的组件需要开启一个线程,每隔一段时间尝试给 MQ 投递一个消息看看是否恢复了。

如果 MQ 已经恢复可以正常投递消息了,此时就可以通过 ZK 关闭降级开关,然后可靠消息服务继续投递消息到 MQ,下游服务在确认 KV 存储的各个队列中已经没有数据之后,就可以重新切换为从 MQ 消费消息。

六、SAGA分布式事务​

SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。

文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。

1.SAGA实现原理

SAGA 由两部分操作组成。一部分是把大事务拆分成若干个小事务,将整个分布式事务 T 分解为 n 个子事务,我们命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务 T 能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

另一部分是为每一个子事务设计对应的补偿动作,我们命名为 C1,C2,…,Ci,…,Cn。

Ti 与 Ci 必须满足以下条件:

  • Ti 与 Ci 都具备幂等性;
  • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
  • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。

如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。

否则,我们就要采取以下两种恢复策略之一:

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

所以你能发现,与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。我给你举个例子。我在前面提到的账户余额直接在银行维护的场景,从银行划转货款到 Fenix's Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是作为补偿措施,我们让 Fenix's Bookstore 系统将货款转回到用户账上(比如中羽联的羽币),下次在进行付款时,直接用羽币支付就可以了。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

另外你还要注意,尽管补偿操作通常比冻结 / 撤销更容易实现,但要保证正向、反向恢复过程能严谨地进行,也需要你花费不少的工夫。比如,你可能需要通过服务编排、可靠事件队列等方式来完成。所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

我前面提到的 Seata 就同样支持 SAGA 事务模式。

七、​AT分布式事务​

从整体上看,AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。
 

它的大致设计思路如下:

它大致的做法是在业务数据提交时,自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。

如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。

所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。

比如,当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。

一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。

所以,GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。

另外,在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。

所以到这里,你其实能发现,AT 分布式事务中并没有能一揽子包治百病的解决办法,你只有因地制宜地选用合适的事务处理方案,才是唯一有效的做法。

1. AT模式 (自动事务Auto Transaction)

AT模式根据其名称也能反馈出来他的特性,他是自动型的分布式事务解决方案。这个自动提现在他无需代码入侵,也就是说我们不需要再编写多余的代码来实现这个模式,只需要在方法中添加上指定的注解即可。

那么AT模式是如何实现无代码入侵的呢?他的工作原理是什么?

2. AT模式实现原理

1.整体流程

AT模式分成两阶段来工作,我们先省略部分细节来整体了解其过程:

一阶段:


(1)拦截并解析业务SQL,找到需要在数据表中更新的数据,将其转换为undo_log,并且保存到提前在每个数据库中创建的undo_log表中
打个比方,如果你是在做update product set price = 20 where price=10 and id=1的操作
那么undo_log中保存的就是反向sqlupdate product set price=10 where price=20 and id=1。当然它的存储不会直接这么存,会经过处理。
(2)然后执行业务SQL,这时你会发现数据库中的数据是发生变化了的,同时undo_log中也有对应的新增数据


二阶段


(1)因为第一阶段已经提交了本地事务,数据已经更新过了,这个时候如果没有报错,那么直接删除掉undo_log以及行锁的数据即可
(2)但是如果发生了报错,就只需要根据undo_log来回退数据

流程图如下:
 

在这里插入图片描述

 这个就是AT模式执行的两阶段的整体视角,我们可以体会到的是AT模式下,自动帮助我们生成了undo_log、一阶段、二阶段的提交都是由seata完成的,并不需要我们写代码来实现,所以它的无代码入侵体现在这里。

2.AT模式 本地事务与全局事务流程

讲解之前我们要明确几个角色,这几个角色不是在AT模式中独有,而是也适用于其他模式

  • TM: Transaction Manager 事务管理器

全局事务的管理者,或者说是全局事务的发起方,再通俗一点就是标注了@GlobalTransactional的方法所在的服务

  • RM: Resources Manager 资源管理

负责分支(本地)事务注册、提交和回滚。每个服务都是一个RM,负责本地事务的管理

  • TC:Transaction Coordinate 事务协调器

全局事务的协调者,TM,RM启动的时候要向TC注册,TM创建的时候要向TC申请一个全局事务ID,所以整个事务的把控是在TC中的,但是各自事务的管理是在RM中。

故:这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务

全局事务:由TM发起,管理整个链路的事务

本地事务:每个服务各自的事务


 

在这里插入图片描述

同时基于上图,我们也知道全局事务由TM发起,本地事务由各服务自己管理,本地事务和全局事务之间是通过TC来进行协调

3.AT模式 全局锁

定义

它不是数据库的那个行锁,它是把分支事务数据库中的数据的主键的某个值注册到 TC,它是全局的。保证了先拿到全局锁的全局事务做完了所有事之后,其它全局事务才能提交本地事务。并且,高并发下它也不会出现死锁,只是会有等待,性能有点衰减

那么我们再把锁的概念加入进来进一步理解

我们先假设一个场景:

1.业务1和业务2同样需要对product表中的同一行数据进行修改
2.业务1先执行,其中本地事务1执行的时候获取到product表的对应行锁
3.这时业务2也开始执行,要更新product时发现锁已被人拿了,那么他就先等着
4.当业务1的本地事务1执行完成并提交后,业务2拿到了行锁,这时他再去修改获取到的是业务1修改过后的数据,他再进行修改提交
5.可这时业务1并没有执行完,它在执行本地事务2时发生了错误,要执行回滚,可就在这时发现,自己事务1中的数据被其他事务篡改过了,他再执行undo_log可能就无法再回滚了
 

这里想象下为什么不可以再回滚?

我们假设下如果业务1的本地事务1的操作是

update product price = 20 where price=30 and name=1;

那么事务1的undo_log就是

update product price=30 where price=20 and name=1;

而业务2的操作是

update product price=25 where name=1;

那么到业务2操作完,事务1进行回退时就出现问题了,它根本找不到一条price=20 and id=1的数据,也就导致无法回滚。从而出现了脏写

在这里插入图片描述


那么这个问题我们要如何解决呢?

解决办法就是加全局行锁

我们的全局事务开启后,会申请全局行锁,同时业务2也添加上全局行锁,如seata实际的处理就是在业务2方法上也添加注解@GlobalTransactional或者@GlobalLock

这样当业务2执行会发现全局行锁被获取了,就会一直等待锁释放。只有到业务1整体提交完成后,全局锁才会释放,这样也就避免了脏写的发生

在这里插入图片描述

 4 AT模式完整流程

我们加入锁,同时也加入了before image和after image的概念,来完整的讲解整个流程

首先理解before image指的是本地事务未开始之前查询出来的原数据的快照。after image指本地事务执行完后生成的事务后数据快照。

通过before image可以反向生成回滚sql并保存到undo_log中

如果事务执行无报错那么就删除掉锁、undo_log、image等中间数据

如果事务执行有报错,那么先比较after image与当前数据库中的数据来判断是否发生了脏读/写,如果发生了说明中间有其他业务操作了这些数据,这时就将报错上报,由人工处理,而避免脏写可以通过如上述所说的在其他业务中添加上@GlobalTransactional或者@GlobalLock注解;如果没有脏读那就通过undo_log回退数据。

在这里插入图片描述

3 AT模式应用场景


AT模式是实际开发中最常用的模式,他无代码入侵的特性,适应于不希望对代码进行改造的场景,因为AT模式要求数据库是支持本地事务的数据库,但是因为市面上多数使用的都是支持事务的数据库,所以其应用也十分广泛

同时其性能也还不错,完全满足普通业务,如果对于性能有较高要求的,就要用到我们其他模式的,这个我们也在下一章进行讲解

AT应用场景总结:

  • 不希望对代码进行改造
  • 数据库支持事务操作
  • 对性能没有特别高的要求
     

参考资料:

1.springcloud进阶:四种分布式事务模式之AT模式(一)_分布式事务at_wu@55555的博客-CSDN博客

参考资料

1.漫画:什么是分布式事务?https://blog.csdn.net/bjweimengshu/article/details/79607522

2.两阶段提交协议(2PC)、三阶提交协议(3PC) 两阶段提交协议(2PC)、三阶提交协议(3PC) - 简书

3.终于有人把“TCC分布式事务”实现原理讲明白了! https://www.cnblogs.com/jajian/p/10014145.html

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值