目录
1、数据库事务ACID
2、什么是分布式事务
3、单体事务和分布式事务
3.1 传统单体架构事务
3.2 微服务或者多数据源分布式事务
4、分布式事务理论基础
4.1 CAP
4.2 BASE
5、分布式事务解决方案
5.1 XA两阶段提交
5.2 TCC补偿
5.3 Saga长事务
5.4 消息最终一致性
6、解决方案实现
6.1 XA两阶段提交
6.2 TCC补偿
6.3 Saga长事务
6.4 消息最终一致性
A:原子性(Atomicity)
一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 就像你买东西要么交钱收货一起都执行,要么要是发不出货,就退钱。
C:一致性(Consistency)
事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态。如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。
I:隔离性(Isolation)
指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。 打个比方,你买东西这个事情,是不影响其他人的,其他人也影响不了你。
D:持久性(Durability)
指的是只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。 打个比方,你买东西的时候需要记录在账本上,即使老板忘记了那也有据可查
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
特点:同一个数据库连接池,本地事务进行管理
特点:不同的服务或者不同的数据库连接池,需要分布式事务进行管理
CAP定理,又被叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP就是你的入门理论。
C (一致性):对某个指定的客户端来说,读操作能返回最新的写操作。对于数据分布在不同节点上的数据上来说,如果在某个节点更新了数据,那么在其他节点如果都能同步读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。 A (可用性):非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的。 P (分区容错性):当出现网络分区后,系统能够继续工作。打个比方,一个集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。
熟悉CAP的人都知道,三者不能共有,在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。 对于CP来说,放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致。 对于AP来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展;我们的Eureka其实就是追求的高可用性 顺便一提,CAP理论中是忽略网络延迟,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,使其他机器恢复至可用。
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。是对CAP中AP的一个扩展
基本可用:
分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
软状态:
什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。 软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
最终一致性:
系统能够保证在没有其他新的更新操作的情况下,所有节点数据最终一定能够达到一致的状态, 因此所有客户端对系统的数据访问最终都能够获取到最新的值。
BASE解决了CAP中理论没有网络延迟,在BASE中用软状态和最终一致,保证了延迟后的一致性。BASE和 ACID 是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态
流程图
说到2PC就不得不聊数据库分布式事务中的 XA Transactions。
在XA协议中分为两阶段: 第一阶段TM要求所有的RM准备提交对应的事务分支,询问RM是否有能力保证成功的提 交事务分支,RM根据自己的情况,如果判断自己进行的工作可以被提交,那就就对工作 内容进行持久化,并给TM回执OK;否者给TM的回执NO。RM在发送了否定答复并回滚 了已经的工作后,就可以丢弃这个事务分支信息了。
第二阶段TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的 RM都prepare成功,那么TM通知所有的RM进行提交;如果有RM prepare回执NO的 话,则TM通知所有RM回滚自己的事务分支
举例:
队长:要打BOSS了,各位你们就位了吗?(Prepare)
队员A:我就位了(Ready)
队员B:我就位了(Ready)
队员C:我就位了(Ready)
队员D:我就位了(Ready)
队长:所有人都就位了,兄弟们,丢技能吧!(Commit)
---------------------------------------------------------------
队长:要打BOSS了,各位你们就位了吗?(Prepare)
队员A:我就位了(Ready)
队员B:我就位了(Ready)
队员C:我就位了(Ready)
队员D:我没蓝....(No Ready)
队长:各位兄弟,还有个傻X没准备好,本次团战计划取消,大家继续打野(Cancel)
优点: 尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5开始支持。
缺点:
1)、单点问题:事务管理器是整个XA模型的核心,一旦事务协调者节点挂掉,参与者收不到提交或是回滚通知,参与者会一直处于中间状态无法完成事务。(XA三阶段提交在两阶段提交的基础上增加了CanCommit阶段,并且引入了超时机制。一旦事物参与者迟迟没有接到协调者的commit请求,会自动进行本地commit。这样有效解决了协调者单点故障的问题)
2)、同步阻塞:XA协议遵循强一致性。在事务执行过程中,各个节点占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知提交,参与者提交后释放资源。这样的过程有着非常明显的性能问题。(利用消息中间件来异步完成事务的后一半更新,实现系统的最终一致性。这个方式避免了像XA协议那样的性能问题。)
3)、数据不一致:在XA协议的第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。
总的来说,XA协议比较简单,成本较低,但是其单点问题,以及不能支持高并发(由于同步阻塞)依然是其最大的弱点。
补充
3PC
三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。
与两阶段提交不同的是,三阶段提交有两个改动点。
1、引入超时机制。同时在协调者和参与者中都引入超时机制。
2、在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
CanCommit阶段
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
1.事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
2.响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
PreCommit阶段
协调者根据参与者的反应情况来决定是否可以记性事务的PreCommit操作。根据响应情况,有以下两种可能。
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
1.发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
2.事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
3.响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
1.发送中断请求 协调者向所有参与者发送abort请求。
2.中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
doCommit阶段
该阶段进行真正的事务提交,也可以分为以下两种情况。
执行提交
1.发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
2.事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
3.响应反馈 事务提交完之后,向协调者发送Ack响应。
4.完成事务 协调者接收到所有参与者的ack响应之后,完成事务。
中断事务 协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
1.发送中断请求 协调者向所有参与者发送abort请求
2.事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。 )
2PC与3PC的区别
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
核心思想
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应 的确认和补偿(撤销)操作。它分为三个阶段:
Try阶段:尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性)
Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作满足幂等性。要求具备幂等设计,Confirm失败后需要进行重试。
Cancel阶段:取消执行,释放Try阶段预留的业务资源,Cancel操作满足幂等性并且Cancel阶段的异常和Confirm阶段异常处理方案基本上一致。
**例如**: A要向B转账,思路如下:
我们有一个本地方法,里面依次调用
1、首先在 Try 阶段,要先调用远程接口把B和A的钱给冻结起来。
2、在Confirm阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优缺点
优点: 相比两阶段提交,可用性比较强,毕竟有补偿措施,而不是阻塞在那里
缺点: 数据的一致性要差一些。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。
使用TCC时要注意Try - Confirm - Cancel 3个操作的幂等控制,网络原因,或者重试操作都有可能导致这几个操作的重复执行:
大家在自己系统里操作资金账户时,为了防止并发情况下数据不一致的出现,肯定会避免出现这种代码
//根据userId查到账户
Account account = accountMapper.selectById(userId);
//取出当前资金
int availableMoney = account.getAvailableMoney();
account.setAvailableMoney(availableMoney-1000);
//更新剩余资金
accountMapper.update(account);
因为这本质上是一个 读-改-写的过程,不是原子的,在并发情况下会出现数据不一致问题
所以最简单的做法是:
update account set available_money = available_money-1000 where user_id=#{userId}
这利用了数据库行锁特性解决了并发情况下的数据不一致问题,但是TCC中,单纯使用这个方法适用么?
答案是不行的,该方法能解决并发单次操作下的扣减余额问题,但是不能解决多次操作带来的多次扣减问题,假设我执行了两次,按这种方案,用户账户就少了2000块
那么具体怎么做?上诉转账例子中,可以引入转账订单状态来做判断,若订单状态为已支付,则直接return
if( order!=null && order.getStatus().equals("转账成功")){
return;
}
当然,新建一张去重表,用订单id做唯一建,若插入报错返回也是可以的,不管怎么样,核心就是保证操作幂等性
优缺点
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处 理
Saga事务模型又叫做长时间运行的事务,由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发Saga中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga会执行在这个失败的事务之前成功提交的所有事务的补偿操作。
Saga的实现有很多种方式,其中最流行的两种方式是:
基于事件的方式。这种方式没有协调中心,第一个服务执行完本地事务之后,会产生一个事件。其它服务会监听这个事件,触发该服务本地事务的执行,并产生新的事件。
基于命令的方式。我们会定义一个新的服务,这个服务扮演的角色就和一支交响乐乐队的指挥一样,告诉各个业务参与方,在什么时候做什么事情。我们管这个新服务叫做协调中心。协调中心通过命令/回复的方式来和Saga中其它服务进行交互。
5.3.1 基于事件的方式
我们以电商平台订单流程为例说明该模式
说明:
订单服务创建一笔新订单,将订单状态设置为"待处理",产生事件ORDER_CREATED_EVENT。
支付服务监听ORDER_CREATED_EVENT,完成扣款并产生事件BILLED_ORDER_EVENT。
库存服务监听BILLED_ORDER_EVENT,完成库存扣减和备货,产生事件ORDER_PREPARED_EVENT。
物流服务监听ORDER_PREPARED_EVENT,完成商品配送,产生事件ORDER_DELIVERED_EVENT。
订单服务监听ORDER_DELIVERED_EVENT,将订单状态更新为"完成"。
在这个流程中,订单服务很可能还会监听BILLED_ORDER_EVENT,ORDER_PREPARED_EVENT来完成订单状态的实时更新。将订单状态分别更新为"已经支付"和"已经出库"等状态来及时反映订单的最新状态。
事务的回滚
为了在异常情况下回滚整个分布式事务,我们需要为相关服务提供补偿操作接口。
假设库存服务由于库存不足没能正确完成备货,我们可以按照下面的流程来回滚整个Saga事务:
说明:
库存服务产生事件PRODUCT_OUT_OF_STOCK_EVENT。
订单服务和支付服务都会监听该事件并做出响应:
支付服务完成退款。
订单服务将订单状态设置为"失败"。
优缺点:
基于事件方式的优缺点
优点:简单且容易理解。各参与方相互之间无直接沟通,完全解耦。这种方式比较适合整个分布式事务只有2-4个步骤的情形。
缺点:这种方式如果涉及比较多的业务参与方,则比较容易失控。各业务参与方可随意监听对方的消息,以至于最后没人知道到底有哪些系统在监听哪些消息。更悲催的是,这个模式还可能产生环形监听,也就是两个业务方相互监听对方所产生的事件。
5.3.2 基于命令的方式
说明:
订单服务创建一笔新订单,将订单状态设置为"待处理",然后让Order Saga Orchestrator(OSO)开启创建订单事务。
OSO发送一个"支付命令"给支付服务,支付服务完成扣款并回复"支付完成"消息。
OSO发送一个"备货命令"给库存服务,库存服务完成库存扣减和备货,并回复"出库"消息。
OSO发送一个"配送命令"给物流服务,物流服务完成配送,并回复"配送完成"消息。
OSO向订单服务发送"订单结束命令"给订单服务,订单服务将订单状态设置为"完成"。
OSO清楚一个订单处理Saga的具体流程,并在出现异常时向相关服务发送补偿命令来回滚整个分布式事务。实现协调中心的一个比较好的方式是使用状态机(Sate Machine)。
事务的回滚
说明:
库存服务回复OSO一个"库存不足"消息。
OSO意识到该分布式事务失败了,触发回滚流程:
OSO发送"退款命令"给支付服务,支付服务完成退款并回复"退款成功"消息。
OSO向订单服务发送"将订单状态改为失败命令",订单服务将订单状态更新为"失败"。
优缺点:
优点:
避免了业务方之间的环形依赖(通过协调中心处理,不会直接面对面)。
将分布式事务的管理交由协调中心管理,协调中心对整个逻辑非常清楚。
减少了业务参与方的复杂度。这些业务参与方不再需要监听不同的消息,只是需要响应命令并回复消息。
测试更容易(分布式事务逻辑存在于协调中心,而不是分散在各业务方)。
回滚也更容易。
缺点:
一个可能的缺点就是需要维护协调中心,而这个协调中心并不属于任何业务方。
Saga模式很难调试,特别是涉及许多微服务时。此外,如果系统变得复杂,事件消息可能变得难以维护。Saga模式的另一个缺点是它没有读取隔离。例如,客户可以看到正在创建的订单,但在下一秒,订单将因补偿交易而被删除。
消息最终一致性应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理
基本思路
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成 功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
举例:
(1)当订单服务发生异常时(比如创建失败),发送消息给mq ,消息内容为购物车数据,用于恢复库存
(2)商品服务从mq提取消息,保存到库存回滚表中
(3)在管理后台开启定时任务,定时扫描库存回滚表执行库存回滚。
6.1.1 Seata AT
Seata(前身Fescar)是阿里开源的一个分布式事务框架,能够让大家在操作分布式事务时,像操作本地事务一样简单。一个注解搞定分布式事务。
Seata AT 模式: 一种业务无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作
其中TC也就是我们要安装的资源管理器Seata Server:
6.1.2 Seata Server安装部署(TC)
从https://github.com/seata/seata/releases下载服务器包,解压缩,可自行选择Windows环境或者Linux环境(大家就不要去下载了,直接用课件里面提供的,我不喜欢听到那些问我下载安装的问题!)
#下载压缩包(有点慢,用课件里面的即可)
wget https://github.com/seata/seata/releases/download/v1.3.0/seata-server-1.3.0.tar.gz
tar -zxvf seata-server-1.3.0.tar.gz
#启动
./bin/seata-server.sh
6.1.3 构建示例工程
通过https://github.com/fescar-group/fescar-samples获取示例工程 ,本文只演示dubbo作为分布式服务架构,其他模式的自行查看
获取代码后,将dubbo工程导入idea:
接下来创建数据库seata,执行sql脚本,sql脚本在示例项目中有dubbo_biz.sql,因为本范例使用的是AT模式,需要导入undo_log.sql
如果大家mysql和zookeeper没有部署在本地,需要进入jdbc.properties文件修改为具体的地址,端口,数据库连接信息,分别创建三个德鲁伊连接池(用于区别本地事务,你高兴也可以创建三个库,分别创建账户表,订单表和库存表)
# account db config
jdbc.account.url=jdbc:mysql://192.168.223.128:3306/seata-at?serverTimezone=UTC
jdbc.account.username=root
jdbc.account.password=root
jdbc.account.driver=com.mysql.jdbc.Driver
# storage db config
jdbc.storage.url=jdbc:mysql://192.168.223.128:3306/seata-at?serverTimezone=UTC
jdbc.storage.username=root
jdbc.storage.password=root
jdbc.storage.driver=com.mysql.jdbc.Driver
# order db config
jdbc.order.url=jdbc:mysql://192.168.223.128:3306/seata-at?serverTimezone=UTC
jdbc.order.username=root
jdbc.order.password=root
jdbc.order.driver=com.mysql.jdbc.Driver
修改dubbo-account-service.xml配置文件(RM)
修改dubbo-order-service.xmll配置文件(RM)
修改dubbo-storage-service.xml配置文件(RM)
修改dubbo-business.xml配置文件(TM)
registry.conf文件直接使用默认的,采用file类型注册Seata Server,所以需要修改file.conf配置文件,设置Seata Server地址
transport {
# tcp udt unix-domain-socket
type = "TCP"
#NIO NATIVE
server = "NIO"
#enable heartbeat
heartbeat = true
# the client batch send request enable
enableClientBatchSendRequest = true
#thread factory for netty
threadFactory {
bossThreadPrefix = "NettyBoss"
workerThreadPrefix = "NettyServerNIOWorker"
serverExecutorThread-prefix = "NettyServerBizHandler"
shareBossWorker = false
clientSelectorThreadPrefix = "NettyClientSelector"
clientSelectorThreadSize = 1
clientWorkerThreadPrefix = "NettyClientWorkerThread"
# netty boss thread size,will not be used for UDT
bossThreadSize = 1
#auto default pin or 8
workerThreadSize = "default"
}
shutdown {
# when destroy server, wait seconds
wait = 3
}
serialization = "seata"
compressor = "none"
}
service {
#transaction service group mapping
vgroupMapping.my_test_tx_group = "default"
#only support when registry.type=file, please don't set multiple addresses
default.grouplist = "192.168.223.128:8091"
#degrade, current not support
enableDegrade = false
#disable seata
disableGlobalTransaction = false
}
client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}
6.1.4 启动测试
启动zookeeper
启动Seata Server
运行库存服务:DubboStorageServiceStarter(RM)
运行账户服务:DubboAccountServiceStarter(RM)
运行订单服务:DubboOrderServiceStarter(RM)
运行DubboBusinessTester(TM)
抛出异常
三个RM服务打印回滚日志:
同时数据库三个表数据都没有变化
我们将全局事务注解去掉,事务并不会回滚!(自测)
部署测试成功!
6.1.5 Seata AT模式分析
6.1.5.1 前期初始化
@GlobalTransactional这个注解为什么有这么大的魔力!
初始化过程
Spring启动时,初始化了2种客户端TmClient、RmClient
TmClient与Server通过Netty(客户端/服务器框架)建立连接并发送消息(Scheduler定时器)
RmClient与Server通过Netty建立连接,负责接收二阶段提交、回滚消息并在回调器(RmHandler)中做处理
6.1.5.2 一阶段提交
流程图
拦截器中开启事务(TM)
1、在需要加全局事务的方法中,会加上@GlobalTransactional注解,注解往往对应着拦截器,Seata中拦截全局事务的拦截器是
GlobalTransactionalInterceptor`,其拦截方法 如下
如果配置了事务注解,执行:
再看execute方法里面的内容,完全就是一个AOP切面!
核心就在这里了,如果业务本体执行失败,那么还会提交事务吗?只会进到异常通知进行回滚(本地)!
beginTransaction做了什么事情,更进去看看:
看到这里,也就明确了一点,全局事务开启时,是由TM来发起的(为什么?因为注解是在Business添加的啊)
commitTransaction()事务提交方法类似,由TM通过RPC远程调用发送事务commit信息给seata-server!
Sql解析与undolog生成(RM)
主要是通过对DataSource,Connection,Statement,PrepareStatement做了代理封装:DataSourceProxy;ConnectionProxy;StatementProxy;PrepareStatementProxy!
PrepareStatementProxy
随便找一个请求类型跟进去,一直找到AbstractDMLBaseExecutor中的doExecute方法,核心逻辑在这里:
生成行锁,并 将日志,快照,行锁追加到ConnectionProxy上下文,用来干嘛?全局事务提交或者回滚用嘛!
再回到到executeAutoCommitFalse中,分为4步
获取sql执行前快照beforeImage
执行sql
获取sql执行后afterImage
根据beforeImage,afterImage生成undolog记录并添加到connectionProxy的上下文中
分支事务注册与事务提交(RM)
业务sql执行以及undolog执行完后会在ConnectionProxy中执行commit操作
undolog入库和普通业务sql的执行用的同一个connection,处于一个本地事务中,保证了业务数据变更时,一定会有对应undolog存在;
同时注册分支事务到Seata-Server(汇报事务操作完成),Seata-server可以感知该本地事务操作已经完成!
跟进flushUndoLogs方法,可以看到保存undolog日志入库信息!
至此,第一阶段中undolog提交与本地事务提交,分支事务注册与汇报也已完成
6.1.5.3 二阶段提交
流程图
RMHandlerAT RM-AT处理器
Seata会使用SPI拓展机制找到RmClient的回调处理器RMHandlerAT,该类是负责接送二阶段Seata-server发给RmClient的提交、回滚消息命令,并作出提交,回滚操作 (这两个操作都封装在其父类AbstractRMHandler),它自己本身实现了UndoLog Delete Request处理方法
然后根据undolog进行回滚或者提交!
执行Cancel或者Commit操作
在回滚或者提交之前,根据xid+brandId查询undolog并锁住,防止多次提交
将之前的快照解析出来干活!
如果当前数据和后快照数据不一样,说明数据被脏写,需要手动处理
拼凑回滚sql执行
再回调上一步AbstractUndoLogManager,执行完回滚后,删除undolog日志信息
最后删除上下文中快照和行锁信息:
6.1.6 关于AT模式的问题
最大的问题是运行期锁导致性能低!
举例:两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
情况1:
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁 ,本地提交释放本地锁。
tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
情况2:
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2的全局锁 等锁超时,放弃 全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程全局锁在 tx1结束前一直是被tx1持有的,所以不会发生脏写 的问题。
总结:这两种情况时,情况1需要等待,情况2陷入死锁并最终都放弃并回滚(啥也没干成),所以不管哪种情况发生,自然会导致性能严重下降
6.2.1 前言
前面讲过了Seata实现分布式解决方案AT模式,使用非常简单,对业务代码也没有侵入,但是超高并发情况下也发现了其中两个最大的问题:
1、全局锁等待,性能不理想
2、全局锁和本地锁互斥导致死锁,啥也没干成!同样性能不理想
这也就是AT模式提交最大的痛点!
所以我们针对高并发场景推出TCC模式,他的特点是:高性能,但代码侵入!
6.2.2 TCC模式原理
TCC模式也属于二阶段提交,它 将事务提交分为 Try - Confirm - Cancel 3个操作。其和两阶段提交有点类似,Try为第一阶段,Confirm - Cancel为第二阶段,是一种应用层面侵入业务的两阶段提交
原理图
原理术语解读
操作方法 | 含义 |
---|---|
Try | 预留业务资源/数据效验 |
Confirm | 确认执行业务操作,实际提交数据,不做任何业务检查,try成功,confirm必定成功,需保证幂等 |
Cancel | 取消执行业务操作,实际回滚数据,需保证幂等 |
其核心在于将业务分为两个操作步骤完成。不依赖 RM (业务模块)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
6.2.3 Seata框架实现TCC模式
Seata框架同样也支持TCC模式解决分布式事务,下面我们以转账操作来看看具体实现!
6.2.4 初始化表结构和数据
/*
Navicat MySQL Data Transfer
Source Server : 192.168.223.128
Source Server Version : 50649
Source Host : 192.168.223.128:3306
Source Database : seata-tcc
Target Server Type : MYSQL
Target Server Version : 50649
File Encoding : 65001
Date: 2020-09-18 22:21:16
*/
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for account 账户表
-- ----------------------------
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account` (
`account_no` varchar(256) NOT NULL DEFAULT '',
`amount` double DEFAULT NULL,
`freezed_amount` double DEFAULT NULL,
PRIMARY KEY (`account_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;
-- ----------------------------
-- Records of account 初始化三个账号和余额
-- ----------------------------
INSERT INTO `account` VALUES ('A', '100', '0');
INSERT INTO `account` VALUES ('B', '100', '0');
INSERT INTO `account` VALUES ('C', '100', '0');
6.2.5 定义TCC事务参与者
两个TCC事务参与者,一个负责加钱,一个负责扣钱
package io.seata.samples.tcc.transfer.action;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* TCC参与者:扣钱
*
*/
public interface FirstTccAction {
/**
* 一阶段方法 @TwoPhaseBusinessAction二阶段TCC补偿提交注解
* name TCC参与者名称,你高兴怎么取名都可以
* commitMethod:二阶段提交方法,与下面接口名对应(底层就是invoke反射)
* rollbackMethod:二阶段回滚方法,注意与下面接口名对应
* @param businessActionContext 用于在上下文中获取全局事务ID
* @param accountNo 扣钱账号
* @param amount 扣除金额
*/
@TwoPhaseBusinessAction(name = "firstTccAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepareMinus(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext);
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext);
}
package io.seata.samples.tcc.transfer.action.impl;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.samples.tcc.transfer.action.FirstTccAction;
import io.seata.samples.tcc.transfer.dao.AccountDAO;
import io.seata.samples.tcc.transfer.domains.Account;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 扣钱参与者实现
*/
public class FirstTccActionImpl implements FirstTccAction {
/**
* 扣钱账户 DAO
*/
private AccountDAO fromAccountDAO;
/**
* 扣钱数据源事务模板
*/
private TransactionTemplate fromDsTransactionTemplate;
/**
* 一阶段准备,冻结 转账资金
* @param businessActionContext
* @param accountNo
* @param amount
* @return
*/
public boolean prepareMinus(BusinessActionContext businessActionContext, final String accountNo, final double amount) {
//分布式事务ID
final String xid = businessActionContext.getXid();
return fromDsTransactionTemplate.execute(new TransactionCallback(){
public Boolean doInTransaction(TransactionStatus status) {
try {
//校验账户余额
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
throw new RuntimeException("账户不存在");
}
if (account.getAmount() - amount < 0) {
throw new RuntimeException("余额不足");
}
//冻结转账金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
fromAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("prepareMinus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
} catch (Throwable t) {
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return fromDsTransactionTemplate.execute(new TransactionCallback() {
public Boolean doInTransaction(TransactionStatus status) {
try{
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
//扣除账户余额
double newAmount = account.getAmount() - amount;
if (newAmount < 0) {
throw new RuntimeException("余额不足");
}
account.setAmount(newAmount);
//释放账户 冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateAmount(account);
System.out.println(String.format("minus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return fromDsTransactionTemplate.execute(new TransactionCallback() {
public Boolean doInTransaction(TransactionStatus status) {
try{
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
//账户不存在,回滚什么都不做
return true;
}
//释放冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("Undo prepareMinus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
public void setFromAccountDAO(AccountDAO fromAccountDAO) {
this.fromAccountDAO = fromAccountDAO;
}
public void setFromDsTransactionTemplate(TransactionTemplate fromDsTransactionTemplate) {
this.fromDsTransactionTemplate = fromDsTransactionTemplate;
}
}
package io.seata.samples.tcc.transfer.action;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* TCC参与者:加钱
*
*/
public interface SecondTccAction {
/**
* 一阶段方法 @TwoPhaseBusinessAction二阶段TCC补偿提交注解
* name TCC参与者名称,你高兴怎么取名都可以
* commitMethod:二阶段提交方法,与下面接口名对应(底层就是invoke反射)
* rollbackMethod:二阶段回滚方法,注意与下面接口名对应
* @param businessActionContext 用于在上下文中获取全局事务ID
* @param accountNo 扣钱账号
* @param amount 扣除金额
*/
@TwoPhaseBusinessAction(name = "secondTccAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepareAdd(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext);
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext);
}
package io.seata.samples.tcc.transfer.action.impl;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.samples.tcc.transfer.action.SecondTccAction;
import io.seata.samples.tcc.transfer.dao.AccountDAO;
import io.seata.samples.tcc.transfer.domains.Account;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 加钱参与者实现
*/
public class SecondTccActionImpl implements SecondTccAction {
/**
* 加钱账户 DAP
*/
private AccountDAO toAccountDAO;
private TransactionTemplate toDsTransactionTemplate;
/**
* 一阶段准备,转入资金 准备
* @param businessActionContext
* @param accountNo
* @param amount
* @return
*/
public boolean prepareAdd(final BusinessActionContext businessActionContext, final String accountNo, final double amount) {
//分布式事务ID
final String xid = businessActionContext.getXid();
return toDsTransactionTemplate.execute(new TransactionCallback(){
public Boolean doInTransaction(TransactionStatus status) {
try {
//校验账户
Account account = toAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
System.out.println("prepareAdd: 账户["+accountNo+"]不存在, txId:" + businessActionContext.getXid());
return false;
}
//待转入资金作为 不可用金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
toAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
} catch (Throwable t) {
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return toDsTransactionTemplate.execute(new TransactionCallback() {
public Boolean doInTransaction(TransactionStatus status) {
try{
Account account = toAccountDAO.getAccountForUpdate(accountNo);
//加钱
double newAmount = account.getAmount() + amount;
account.setAmount(newAmount);
//冻结金额 清除
account.setFreezedAmount(account.getFreezedAmount() - amount);
toAccountDAO.updateAmount(account);
System.out.println(String.format("add account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return toDsTransactionTemplate.execute(new TransactionCallback() {
public Boolean doInTransaction(TransactionStatus status) {
try{
Account account = toAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
//账户不存在, 无需回滚动作
return true;
}
//冻结金额 清除
account.setFreezedAmount(account.getFreezedAmount() - amount);
toAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("Undo prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
public void setToAccountDAO(AccountDAO toAccountDAO) {
this.toAccountDAO = toAccountDAO;
}
public void setToDsTransactionTemplate(TransactionTemplate toDsTransactionTemplate) {
this.toDsTransactionTemplate = toDsTransactionTemplate;
}
}
6.2.6 TCC事务参与者发布订阅配置
服务发布配置:seata-dubbo-provider.xml
服务订阅配置:seata-dubbo-reference.xml
6.2.7 开启全局事务扫描配置
seata-tcc.xml
6.2.8 定义转账服务实现service
package io.seata.samples.tcc.transfer.activity.impl;
import com.alibaba.dubbo.config.annotation.Reference;
import io.seata.samples.tcc.transfer.action.FirstTccAction;
import io.seata.samples.tcc.transfer.action.SecondTccAction;
import io.seata.samples.tcc.transfer.activity.TransferService;
import io.seata.spring.annotation.GlobalTransactional;
/**
* 转账服务实现
*
*/
public class TransferServiceImpl implements TransferService {
/*set注入订阅的服务接口FirstTccAction*/
private FirstTccAction firstTccAction;
/*set注入订阅的服务接口SecondTccAction*/
private SecondTccAction secondTccAction;
public void setFirstTccAction(FirstTccAction firstTccAction) {
this.firstTccAction = firstTccAction;
}
public void setSecondTccAction(SecondTccAction secondTccAction) {
this.secondTccAction = secondTccAction;
}
/**
* 转账操作 需要添加全局事务注解@GlobalTransactional
* @param from 扣钱账户
* @param to 加钱账户
* @param amount 转账金额
* @return
*/
@GlobalTransactional
public boolean transfer(final String from, final String to, final double amount) {
//扣钱参与者,一阶段执行
boolean ret = firstTccAction.prepareMinus(null, from, amount);
if(!ret){
//扣钱参与者,一阶段失败; 回滚本地事务和分布式事务
throw new RuntimeException("账号:["+from+"] 预扣款失败");
}
//加钱参与者,一阶段执行
ret = secondTccAction.prepareAdd(null, to, amount);
/*int i = 1/0;*/
if(!ret){
throw new RuntimeException("账号:["+to+"] 预收款失败");
}
System.out.println(String.format("transfer amount[%s] from [%s] to [%s] finish.", String.valueOf(amount), from, to));
return true;
}
}
6.2.9 转账数据源和mybatis配置
注意:我们这里是使用两个数据源来达到分布式的效果
扣钱数据源:from-datasource-bean.xml
com.mysql.jdbc.Driver
jdbc:mysql://192.168.223.128:3306/seata-tcc?serverTimezone=UTC
root
root
加钱数据源配置to-datasource-bean.xml:
com.mysql.jdbc.Driver
jdbc:mysql://192.168.223.128:3306/seata-tcc?serverTimezone=UTC
root
root
6.2.10 启动测试
其他内容就不再阐述了,大家直接看demo即可:
1、启动zookeeper服务器
2、启动seata服务器
3、执行TransferProviderStarter启动器,加载dubbo容器发布服务接口,初始化数据源,实体bean等并阻塞等待转账调用
4、执行TransferApplication类,注册服务到zookeeper,并且执行转账功能
我们看到:
异常生效,事务回滚,数据未变!
不发生异常,事务提交,数据变更成功!
6.2.11 关于TCC模式思考
1、查看代码,我们发现业务转账方法上跟AT模式一样,有@GlobalTransactional注解,这个跟AT模式一样,其实就是一个扫描的标识,说明这个方法要开启全局事务!
2、转账扣钱的接口代码如下(转账加钱的接口也一样):
@TwoPhaseBusinessAction(name = "firstTccAction", commitMethod = "commit",
rollbackMethod = "rollback")
public boolean prepareMinus(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
#@TwoPhaseBusinessAction二阶段提交注解中,有三个参数:
#name = "firstTccAction" --TCC参与者名称,你高兴怎么取名都可以,需要注册到Seata-Server
#commitMethod = "commit" ---指定订阅者全局事务提交的方法
#rollbackMethod = "rollback" ---指定订阅者全局事务回滚的方法
3、接下来看看全局提交和全局回滚的方法实现
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
@Override
public boolean commit(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
try{
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
//扣除账户余额
double newAmount = account.getAmount() - amount;
if (newAmount < 0) {
throw new RuntimeException("余额不足");
}
account.setAmount(newAmount);
//释放账户 冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateAmount(account);
System.out.println(String.format("minus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
return false;
}
}
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
//分布式事务ID
final String xid = businessActionContext.getXid();
//账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
//转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
try{
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
//账户不存在,回滚什么都不做
return true;
}
//释放冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("Undo prepareMinus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){
t.printStackTrace();
return false;
}
}
#无非是将账户表中冻结金额字段清空,然后对本金进行扣减或者追加!
现在的重点是两个:
1、Seata是怎么样通知TCC参与者调用全局提交或者回滚方法?
2、全局提交或者回滚方法中BusinessActionContext上下文数据是怎么来的?
我们通过调试来追踪:
1、我们知道,所有的起因都是因为配置了扫描bean:io.seata.spring.annotation.GlobalTransactionScanner,而这个类重写了AbstractAutoProxyCreator父类的代理bean判断方法wrapIfNecessary(还记得Spring BeanFactoryAware吗),如下代码所示,进入这个方法会有一个TCC动态代理判断:
判断了bean如果是个TCC的接口实现,则将拦截器初始化,TccActionInterceptor是TCC方法的核心拦截器
2、一直跟进这个判断方法到parserRemotingServiceInfo方法里面,可以看到这么一段代码:
通过反射拿到了TwoPhaseBusinessAction注解中声明的Commit方法和Rollback方法并封装成TCCResource对象,最终调用ResourceManager的registerResource方法
----》是不是很熟悉,对,这个就是我们的TCC参与者接口上的注解@TwoPhaseBusinessAction,完成TCC资源注册
3、继续跟进注册方法registerResource,一直跟到TCCResourceManager,看如下代码:
做了两件事:将TCCResource资源对象保存到本地缓存,方便后面使用;通过RmRpcClient发送RPC请求给Seata-server(TC)进行资源注册,注册的内容就是:firstTccAction,sencondTccAction两个参与者(RM)
4、开启全局TCC事务
使用GlobalTransactional注解来开启全局事务 , 业务方法执行时,最终会被AT模式源码分析中提到过的拦截器GlobalTransactionalInterceptor(TM)拦截,开启一个全局事务,获得全局事务id,即xid ,这一步跟AT模式一模一样,只是拦截的通知对应的业务逻辑不同而已:
还是分为四步
1、开启全局事务beginTransaction(TM与TC通信并获得xid,保存到RootContext中,备用)
2、执行业务方法
3、提交事务commitTransaction或者异常回滚(TM与TC通信,发起事务提交请求(依据就是xid))
4、执行completeTransactionAfterThrowing回滚操作(TM与TC通信,发起事务回滚请求(依据就是xid))
这里就解决了第一个问题,TC是怎么知道该调用confirm方法还是rollback方法的?根据业务方调用的结果而定,为什么,跟第三步TCCResourceManager注册资源有关,从Seata-server服务器拿到资源xid,再到本地缓存中获取TCCResource资源对象!
到现在为止,我们的第一个问题已经解决了!
现在看第二个问题
上面我们其实实例化了TCC方法的核心拦截器 TccActionInterceptor,它是对TCC方法请求时,对方法参数保存到上下文中!
我们可以看到,参数信息上下文保存就在这里进行了!其实也就是从另外一种角度完成了历史数据的追溯!
最后再看一下回滚调用:
看到这里,我们可以总结一下AT和TCC模式的优缺点
比较面 | AT | TCC |
---|---|---|
应用复杂度 | 不要太简单 | 相对复杂,但是Seata-TCC模式并不需要表来额外记录回滚的信息,因为你都给他做了,他只需要记录下你操作的信息到上下文! |
代码侵入度 | 几乎没有 | 侵入太深 |
性能 | 低(有全局行锁) | 高(可以无全局行锁,除非你要处理回滚幂等性问题) |
高并发下安全性 | 低(死锁导致无效操作) | 高(Try 成功 Confirm 一定能成功,且有防悬挂控制) |
概念拓展
防悬挂控制
悬挂的意思是:Cancel 比 Try 接口先执行,出现的原因是 Try 由于网络拥堵而超时,事务管理器生成回滚,触发 Cancel 接口,而最终又收到了 Try 接口调用,一句话: Cancel 比 Try 先到。按照前面允许空回滚的逻辑,回滚会返回成功,事务管理器认为事务已回滚成功,则此时的 Try 接口不应该执行,否则会产生数据不一致,所以我们在 Cancel 空回滚返回成功之前先记录该条事务 xid 或业务主键,标识这条记录已经回滚过,Try 接口先检查这条事务xid或业务主键如果已经标记为回滚成功过,则不执行 Try 的业务操作。
幂等控制
幂等性的意思是:对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,通常我们可以用事务 xid 或业务主键判重来控制。
6.3.1 初始化数据库脚本
存储saga状态机定义,状态机实例,执行实例
/*
Navicat MySQL Data Transfer
Source Server : 192.168.223.128
Source Server Version : 50649
Source Host : 192.168.223.128:3306
Source Database : seata-saga
Target Server Type : MYSQL
Target Server Version : 50649
File Encoding : 65001
Date: 2020-09-21 22:28:40
*/
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for seata_state_inst 事务状态实例执行记录(事务参与者执行方法记录,包括正向服务和补偿服务接口方法)
-- ----------------------------
DROP TABLE IF EXISTS `seata_state_inst`;
CREATE TABLE `seata_state_inst` (
`id` varchar(100) NOT NULL,
`machine_inst_id` varchar(46) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`type` varchar(20) DEFAULT NULL,
`service_name` varchar(255) DEFAULT NULL,
`service_method` varchar(255) DEFAULT NULL,
`service_type` varchar(16) DEFAULT NULL,
`business_key` varchar(48) DEFAULT NULL,
`state_id_compensated_for` varchar(32) DEFAULT NULL,
`state_id_retried_for` varchar(32) DEFAULT NULL,
`gmt_started` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`is_for_update` tinyint(4) DEFAULT NULL,
`input_params` varchar(255) DEFAULT NULL,
`output_params` varchar(255) DEFAULT NULL,
`status` varchar(2) DEFAULT NULL COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`excep` longblob,
`gmt_end` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`,`machine_inst_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of seata_state_inst
-- ----------------------------
-- ----------------------------
-- Table structure for seata_state_machine_def 状态机定义对象(一个事务定义json文件生成一个)
-- ----------------------------
DROP TABLE IF EXISTS `seata_state_machine_def`;
CREATE TABLE `seata_state_machine_def` (
`id` varchar(100) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`tenant_id` varchar(32) DEFAULT NULL,
`app_name` varchar(32) DEFAULT NULL,
`type` varchar(20) DEFAULT NULL COMMENT 'state language type',
`comment_` varchar(255) DEFAULT NULL,
`ver` varchar(16) DEFAULT NULL COMMENT 'version',
`gmt_create` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`status` varchar(2) DEFAULT NULL COMMENT 'status(AC:active|IN:inactive)',
`content` blob,
`recover_strategy` varchar(16) DEFAULT NULL COMMENT 'transaction recover strategy(compensate|retry)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of seata_state_machine_def
-- ----------------------------
-- ----------------------------
-- Table structure for seata_state_machine_inst 状态机实例记录(执行一次事务流程生成一个)
-- ----------------------------
DROP TABLE IF EXISTS `seata_state_machine_inst`;
CREATE TABLE `seata_state_machine_inst` (
`id` varchar(100) NOT NULL,
`machine_id` varchar(32) DEFAULT NULL COMMENT 'state machine definition id',
`tenant_id` varchar(32) DEFAULT NULL,
`parent_id` varchar(46) DEFAULT NULL,
`gmt_started` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`business_key` varchar(48) DEFAULT NULL,
`start_params` blob,
`gmt_end` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`excep` longblob COMMENT 'exception',
`end_params` blob,
`status` varchar(2) DEFAULT NULL COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`compensation_status` varchar(255) DEFAULT NULL COMMENT 'compensation status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`is_running` tinyint(1) DEFAULT NULL COMMENT 'is running(0 no|1 yes)',
`gmt_updated` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `unikey_buz_tenant` (`id`,`business_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of seata_state_machine_inst
-- ----------------------------
6.3.2实际状态机事务运行流程
可以使用seata官网的设计器设计,有点类似于BPM工作流工具:http://seata.io/saga_designer/index.html#/
在saga模式下,一个状态机实例就是一个全局事务,状态机中的每个状态是分支事务
Start:流程启动节点,Saga状态机根据其名称启动执行事务流程
ServiceTask:Saga事务参与者(Dubbo服务)
Choice:分支判断,根据事务参与者输出决定下面的流程怎么走(进入另外一个服务还是失败!)
Compensation:补偿服务(注意虚线,只有执行了捕获异常后通过CompensationTrigger触发)
Succeed:事务流程执行成功节点
Fail:事务流程执行失败节点,补偿之后进入
Catch:捕获事务参与者异常
CompensationTrigger:异常捕获后触发补偿(Compensation)
SubStateMachine:分支状态机,可以在任意节点接入
事务流程设计JSON,设计好流程后把源码复制出来即可:
{
"nodes": [
{
"type": "node",
"size": "72*72",
"shape": "flow-circle",
"color": "#FA8C16",
"label": "Start",
"stateId": "Start1",
"stateType": "Start",
"stateProps": {
"StateMachine": {
"Name": "reduceInventoryAndBalance",
"Comment": "reduce inventory then reduce balance in a transaction",
"Version": "0.0.1"
}
},
"x": 388.125,
"y": 68,
"id": "a9a59d33",
"index": 2
},
{
"type": "node",
"size": "110*48",
"shape": "flow-capsule",
"color": "red",
"label": "CompensationTrigger",
"stateId": "CompensationTrigger",
"stateType": "CompensationTrigger",
"x": 164.625,
"y": 505,
"id": "af31a1d8",
"index": 6
},
{
"type": "node",
"size": "110*48",
"shape": "flow-capsule",
"color": "#722ED1",
"label": "CompensateReduceInventory",
"stateId": "CompensateReduceInventory",
"stateType": "Compensation",
"stateProps": {
"ServiceName": "inventoryAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
],
"Output": {},
"Status": {},
"Retry": []
},
"x": 787.125,
"y": 199,
"id": "8bd39eef",
"index": 7
},
{
"type": "node",
"size": "110*48",
"shape": "flow-rect",
"color": "#1890FF",
"label": "ReduceInventory",
"stateId": "ReduceInventory",
"stateType": "ServiceTask",
"stateProps": {
"ServiceName": "inventoryAction",
"ServiceMethod": "reduce",
"Input": [
"$.[businessKey]",
"$.[count]"
],
"Output": {
"reduceInventoryResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
},
"Retry": []
},
"x": 424.625,
"y": 198,
"id": "ceaf9a49",
"index": 12
},
{
"type": "node",
"size": "110*48",
"shape": "flow-rect",
"color": "#1890FF",
"label": "ReduceBalance",
"stateId": "ReduceBalance",
"stateType": "ServiceTask",
"stateProps": {
"ServiceName": "balanceAction",
"ServiceMethod": "reduce",
"Input": [
"$.[businessKey]",
"$.[amount]",
{
"throwException": "$.[mockReduceBalanceFail]"
}
],
"Output": {
"compensateReduceBalanceResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
},
"Catch": [
{
"Exceptions": [
"java.lang.Throwable"
],
"Next": "CompensationTrigger"
}
],
"Retry": []
},
"x": 445.625,
"y": 503.5,
"id": "d2fe2fe1",
"index": 14
},
{
"type": "node",
"size": "72*72",
"shape": "flow-circle",
"color": "#05A465",
"label": "Succeed",
"stateId": "Succeed1",
"stateType": "Succeed",
"x": 426.125,
"y": 655,
"id": "57c888a5",
"index": 15
},
{
"type": "node",
"size": "80*72",
"shape": "flow-rhombus",
"color": "#13C2C2",
"label": "ChoiceState",
"stateId": "ChoiceState",
"stateType": "Choice",
"x": 425.125,
"y": 342,
"id": "75a85965",
"index": 16
},
{
"type": "node",
"size": "110*48",
"shape": "flow-capsule",
"color": "#722ED1",
"label": "CompensateReduceBalance",
"stateId": "CompensateReduceBalance",
"stateType": "Compensation",
"stateProps": {
"ServiceName": "balanceAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
],
"Output": {},
"Status": {},
"Retry": []
},
"x": 742.625,
"y": 643.5,
"id": "2f81d284",
"index": 18
},
{
"type": "node",
"size": "72*72",
"shape": "flow-circle",
"color": "red",
"label": "Fail",
"stateId": "Fail2",
"stateType": "Fail",
"stateProps": {
"ErrorCode": "PURCHASE_FAILED",
"Message": "purchase failed"
},
"x": 190.125,
"y": 379,
"id": "fd090fed"
},
{
"type": "node",
"size": "39*39",
"shape": "flow-circle",
"color": "red",
"label": "Catch",
"stateId": "Catch",
"stateType": "Catch",
"x": 384.625,
"y": 535,
"id": "466c6c6d"
}
],
"edges": [
{
"source": "a9a59d33",
"sourceAnchor": 2,
"target": "ceaf9a49",
"targetAnchor": 0,
"id": "9a546c28",
"index": 0
},
{
"source": "ceaf9a49",
"sourceAnchor": 2,
"target": "75a85965",
"targetAnchor": 0,
"id": "2e8fb592",
"index": 1
},
{
"source": "ceaf9a49",
"sourceAnchor": 1,
"target": "8bd39eef",
"targetAnchor": 0,
"id": "c99e9e20",
"style": {
"lineDash": "4"
},
"index": 5
},
{
"source": "d2fe2fe1",
"sourceAnchor": 1,
"target": "2f81d284",
"targetAnchor": 0,
"id": "36b62244",
"style": {
"lineDash": "4"
},
"index": 8
},
{
"source": "75a85965",
"sourceAnchor": 2,
"target": "d2fe2fe1",
"targetAnchor": 0,
"id": "b0ce1f74",
"stateProps": {
"Expression": "[reduceInventoryResult] == true",
"Default": false
},
"label": "",
"shape": "flow-smooth",
"index": 10
},
{
"source": "d2fe2fe1",
"sourceAnchor": 2,
"target": "57c888a5",
"targetAnchor": 0,
"id": "a7b05618",
"index": 11
},
{
"source": "af31a1d8",
"sourceAnchor": 0,
"target": "fd090fed",
"targetAnchor": 2,
"id": "afa0f72e"
},
{
"source": "75a85965",
"sourceAnchor": 3,
"target": "fd090fed",
"targetAnchor": 1,
"id": "047b569c",
"stateProps": {
"Expression": "[reduceInventoryResult] == false",
"Default": false
},
"label": "",
"shape": "flow-smooth"
},
{
"source": "466c6c6d",
"sourceAnchor": 3,
"target": "af31a1d8",
"targetAnchor": 2,
"id": "5248fda9",
"stateProps": {
"Exceptions": [
"java.lang.Throwable"
]
},
"label": "",
"shape": "flow-smooth"
}
]
}
6.3.3 定义Saga事务参与者
package io.seata.samples.saga.action;
/**
* Inventory Actions
*/
public interface InventoryAction {
/**
* reduce(业务接口)
*
* @param count
* @return
*/
boolean reduce(String businessKey, int count);
/**
* increase(补偿接口)
*
* @return
*/
boolean compensateReduce(String businessKey);
}
/**
* Balance Actions
*/
public interface BalanceAction {
/**
* reduce
* @param businessKey
* @param amount
* @param params
* @return
*/
boolean reduce(String businessKey, BigDecimal amount, Map params);
/**
* compensateReduce
* @param businessKey
* @param params
* @return
*/
boolean compensateReduce(String businessKey, Map params);
}
实现代码:我们只是展示执行的过程,具体的业务逻辑自己在业务接口和补偿接口中实现
package io.seata.samples.saga.action.impl;
import io.seata.samples.saga.action.InventoryAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class InventoryActionImpl implements InventoryAction {
private static final Logger LOGGER = LoggerFactory.getLogger(InventoryActionImpl.class);
@Override
public boolean reduce(String businessKey, int count) {
System.out.println("reduce inventory succeed, count: " + count + ", businessKey:" + businessKey);
return true;
}
@Override
public boolean compensateReduce(String businessKey) {
System.out.println("compensate reduce inventory succeed, businessKey:" + businessKey);
return true;
}
}
package io.seata.samples.saga.action.impl;
import io.seata.samples.saga.action.BalanceAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.util.Map;
/**
*
*/
public class BalanceActionImpl implements BalanceAction {
private static final Logger LOGGER = LoggerFactory.getLogger(BalanceActionImpl.class);
@Override
public boolean reduce(String businessKey, BigDecimal amount, Map params) {
if(params != null && "true".equals(params.get("throwException"))){
throw new RuntimeException("reduce balance failed");
}
System.out.println("reduce balance succeed, amount: " + amount + ", businessKey:" + businessKey);
return true;
}
@Override
public boolean compensateReduce(String businessKey, Map params) {
if(params != null && "true".equals(params.get("throwException"))){
throw new RuntimeException("compensate reduce balance failed");
}
System.out.println("compensate reduce balance succeed, businessKey:" + businessKey);
return true;
}
}
6.3.4 Dubbo服务发布订阅配置
发布配置:
订阅配置:
6.3.5 Seata Saga状态机配置
com.mysql.jdbc.Driver
jdbc:mysql://192.168.223.128:3306/seata-saga?serverTimezone=UTC
root
root
6.3.6 测试
老规矩,启动zookeeper,启动seata server
1)、启动DubboSagaProviderStarter,初始化Dubbo服务发布
2)、启动DubboSagaTransactionStarter,初始化Dubbo订阅
/*
* Copyright 1999-2019 Seata.io Group.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.seata.samples.saga.starter;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import io.seata.saga.engine.AsyncCallback;
import io.seata.saga.engine.StateMachineEngine;
import io.seata.saga.proctrl.ProcessContext;
import io.seata.saga.statelang.domain.ExecutionStatus;
import io.seata.saga.statelang.domain.StateMachineInstance;
import io.seata.samples.saga.ApplicationKeeper;
import org.springframework.context.support.AbstractApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.util.Assert;
/**
*
*/
public class DubboSagaTransactionStarter {
public static void main(String[] args) {
//初始化Spring容器,加载Dubbo服务订阅,Seata Saga状态机配置
AbstractApplicationContext applicationContext
= new ClassPathXmlApplicationContext(new String[] {"spring/seata-saga.xml", "spring/seata-dubbo-reference.xml"});
//获取状态机引擎实例
StateMachineEngine stateMachineEngine
= (StateMachineEngine) applicationContext.getBean("stateMachineEngine");
//执行正常示例,不进入补偿接口
/*transactionCommittedDemo(stateMachineEngine);*/
//执行补偿示例,需要进入补偿接口
transactionCompensatedDemo(stateMachineEngine);
new ApplicationKeeper(applicationContext).keep();
}
private static void transactionCommittedDemo(StateMachineEngine stateMachineEngine) {
//准备业务接口参数
Map startParams = new HashMap<>(3);
String businessKey = String.valueOf(System.currentTimeMillis());
startParams.put("businessKey", businessKey);//事务流程执行的唯一标志
startParams.put("count", 10); //扣款
startParams.put("amount", new BigDecimal("100"));//总金额
//sync test 根据reduceInventoryAndBalance(相当于事务流程名----Start节点配置)
StateMachineInstance inst = stateMachineEngine.startWithBusinessKey("reduceInventoryAndBalance", null, businessKey, startParams);
System.out.println("saga transaction commit succeed. XID: " + inst.getId());
//async test 根据reduceInventoryAndBalance(相当于事务流程名----Start节点配置),有异步回调
/* businessKey = String.valueOf(System.currentTimeMillis());
inst = stateMachineEngine.startWithBusinessKeyAsync("reduceInventoryAndBalance", null, businessKey, startParams, CALL_BACK);
//等待示例返回,继续执行后续逻辑
waittingForFinish(inst);
System.out.println("saga transaction commit succeed. XID: " + inst.getId());*/
}
private static void transactionCompensatedDemo(StateMachineEngine stateMachineEngine) {
Map startParams = new HashMap<>(4);
String businessKey = String.valueOf(System.currentTimeMillis());
startParams.put("businessKey", businessKey);//事务流程执行的唯一标志
startParams.put("count", 10);//扣款
startParams.put("amount", new BigDecimal("100"));//总金额
startParams.put("mockReduceBalanceFail", "true");//人造异常,在业务接口中判断
//sync test
StateMachineInstance inst = stateMachineEngine.startWithBusinessKey("reduceInventoryAndBalance", null, businessKey, startParams);
System.out.println("saga transaction compensate succeed. XID: " + inst.getId());
/* //async test
businessKey = String.valueOf(System.currentTimeMillis());
inst = stateMachineEngine.startWithBusinessKeyAsync("reduceInventoryAndBalance", null, businessKey, startParams, CALL_BACK);
waittingForFinish(inst);
Assert.isTrue(ExecutionStatus.SU.equals(inst.getCompensationStatus()), "saga transaction compensate failed. XID: " + inst.getId());
System.out.println("saga transaction compensate succeed. XID: " + inst.getId());*/
}
private static volatile Object lock = new Object();
private static AsyncCallback CALL_BACK = new AsyncCallback() {
@Override
public void onFinished(ProcessContext context, StateMachineInstance stateMachineInstance) {
synchronized (lock){
lock.notifyAll();
}
}
@Override
public void onError(ProcessContext context, StateMachineInstance stateMachineInstance, Exception exp) {
synchronized (lock){
lock.notifyAll();
}
}
};
private static void waittingForFinish(StateMachineInstance inst){
synchronized (lock){
if(ExecutionStatus.RU.equals(inst.getStatus())){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
测试结果:
当没有异常时,不需要执行补偿接口,事务状态实例记录了两个Saga事务参与者的业务操作
当有异常时,需要执行补偿接口,因为第二个Saga事务参与者抛出了符合捕获的异常,并且有触发器,所以进入了两个补偿接口:
除了以上三种常见的解决方案,我们还有一种利用MQ消息中间件异步解耦的方式来实现分布式事务,达到数据一致性的效果,请注意,基于MQ的异步特性,所以这个一致性叫最终一致性!
消息最终一致性也是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理!
基本思路:
消息生产方,需要额外建一个消息表,并记录消息发送状态,消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库中,然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送!
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表名已经处理成功了,如果处理失败,那么会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通过生产方进行回滚操作
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍,当然,由于需要有自动对账补账逻辑,实现起来会耦合,所以需要封装成一个专门处理分布式事务的消息服务系统
6.4.1 架构图
6.4.2 需求分析及实现思路
比如在电商系统中,订单表和库存表一般都会在两个不同的数据源,所以可能会出现这么一种情况:提交购物车数据进行订单创建时发生了异常,此时库存已经扣减了,这样库存数量就闭实际数量要少,造成数据不一致,而对于库存来说,一般都不会是一个需要即时更新的业务,所以我们可以采用消息最终一致性的分布式事务解决方案!
实现思路:
1)、当订单服务发生异常时,发送消息给MQ,消息内容为购物车数据,用于恢复数据
2)、商品服务从mq提取消息,保存到库存回滚表中
3)、定时任务扫描回滚表,更新库存数据
6.4.3 pom依赖
两个项目都配置pom依赖如下:
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-amqp
mysql
mysql-connector-java
runtime
com.alibaba
fastjson
1.2.47
com.alibaba
druid
1.1.9
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
1.3.2
6.4.4 spring配置
两个项目都配置Spring配置文件application.yml如下,注意端口需要区分:
spring:
application:
name: server-order
datasource:
url: jdbc:mysql://192.168.223.128/sku?characterEncoding=utf-8&useSSL=false
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
rabbitmq:
addresses: 192.168.223.128
port: 5672
username: guest
password: guest
server:
port: 9999 #注意端口需要区分
6.4.5 订单服务
package com.ydt.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 服务启动类
*/
@SpringBootApplication
@MapperScan(basePackages = "com.ydt.order.dao")
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
package com.ydt.order.controller;
import com.ydt.order.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 订单服务controller接口
*/
@RestController
public class OrderController
{
@Autowired
private OrderService orderService;
@RequestMapping(value="create")
public String create(){
return orderService.create();
}
}
package com.ydt.order.service;
import com.alibaba.fastjson.JSON;
import com.ydt.order.dao.MessageDao;
import com.ydt.order.model.Message;
import com.ydt.order.model.Order;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 订单服务service接口
*/
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private MessageDao messageDao;
/**
* 按照架构图来
* 1、保存预发送消息到本地数据库,状态为待发送
* 2、执行业务逻辑,成功:将上一步消息状态置位可发送;失败,数据回滚,删除预发送消息
*
* @return
*/
public String create() {
System.out.println("创建订单开始...........");
Order order = new Order(001,0001,2);
//这个地方省略了创建订单和削减库存的操作,请大家参照springcloud feign client来进行远程调用
try {
int i = 1/0;
}catch (Exception e){
//当创建订单失败,将消息发送到MQ,并且本地保存消息(防止消息发送到MQ失败后重试)
Message message = new Message(order.getId(),"back",JSON.toJSONString(order));
messageDao.insert(message);
rabbitTemplate.convertAndSend("sku.back", JSON.toJSONString(order));
return "订单创建失败,启动回滚流程..........";
}
return "订单创建成功";
}
}
/**
*
* @Description
* @author joker
* @date 创建时间:2018年9月18日 上午9:32:15
*
*/
package com.ydt.order.dao;
import com.ydt.order.model.Message;
import com.ydt.order.model.Order;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
/**
* MQ消息数据本地存根DAO
*/
@Repository
@Mapper
public interface MessageDao
{
@Insert("insert into tb_message (id,status,detail) values (#{id},#{status},#{detail})")
Integer insert(Message message);
}
/**
*
* @Description
* @author joker
* @date 创建时间:2018年9月18日 上午9:57:04
*
*/
package com.ydt.order.model;
import java.io.Serializable;
/**
* 发送的回滚消息记录实体类
*/
public class Message implements Serializable
{
private static final long serialVersionUID = -5341680692625777960L;
private int id;
private String status;
private String detail;
public Message(int id, String status, String detail) {
this.id = id;
this.status = status;
this.detail = detail;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getDetail() {
return detail;
}
public void setDetail(String detail) {
this.detail = detail;
}
@Override
public String toString() {
return "Message{" +
"id=" + id +
", status='" + status + '\'' +
", detail='" + detail + '\'' +
'}';
}
}
package com.ydt.order.model;
import java.io.Serializable;
/**
* 订单对象实体类
*/
public class Order implements Serializable {
private static final long serialVersionUID = -5341680692625777961L;
private int id ;
private int skuId;
private int num;
public Order(int id, int skuId, int num) {
this.id = id;
this.skuId = skuId;
this.num = num;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getSkuId() {
return skuId;
}
public void setSkuId(int skuId) {
this.skuId = skuId;
}
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
@Override
public String toString() {
return "Order{" +
"id=" + id +
", num=" + num +
'}';
}
}
6.4.6 库存服务
package com.ydt.sku;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 服务启动类,需要开启定时任务
*/
@SpringBootApplication
@EnableScheduling
public class SkuApplication {
public static void main(String[] args) {
SpringApplication.run(SkuApplication.class, args);
}
}
package com.ydt.sku.consumer;
import com.alibaba.fastjson.JSON;
import com.ydt.sku.dao.MessageDao;
import com.ydt.sku.model.SkuBack;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 消费者监听,进行回滚消息处理
*/
@Component
public class MessageConsumer {
@Autowired
private MessageDao messageDao;
@RabbitListener(queues = "sku.back")
public void handler(Message message){
String msg = new String(message.getBody());
System.out.println("库存服务获取到回滚消息:" + msg);
Map map = (Map) JSON.parse(msg);
SkuBack skuBack = new SkuBack(map.get("skuId"),map.get("num"));
messageDao.insert(skuBack);
}
}
/**
*
* @Description
* @author joker
* @date 创建时间:2018年9月18日 上午9:32:15
*
*/
package com.ydt.sku.dao;
import com.ydt.sku.model.SkuBack;
import org.apache.ibatis.annotations.*;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 消息数据DAO
*/
@Repository
@Mapper
public interface MessageDao
{
@Insert("insert into tb_sku_back (sku_id,back_num) values (#{sku_id},#{back_num})")
Integer insert(SkuBack skuBack);
@Select("select * from tb_sku_back")
List list();
@Update("update tb_sku set count = (count + #{back_num}) where id = #{sku_id}")
void updateSkuBySkuBack(SkuBack skuBack);
@Delete("delete from tb_sku_back")
void deleteSkuBack();
}
/**
*
* @Description
* @author joker
* @date 创建时间:2018年9月18日 上午9:57:04
*
*/
package com.ydt.sku.model;
import java.io.Serializable;
/**
* 发送的回滚消息记录实体类
*/
public class SkuBack implements Serializable
{
private static final long serialVersionUID = -5341680692625777962L;
private int sku_id;
private int back_num;
public SkuBack(int sku_id, int back_num) {
this.sku_id = sku_id;
this.back_num = back_num;
}
public int getSku_id() {
return sku_id;
}
public void setSku_id(int sku_id) {
this.sku_id = sku_id;
}
public int getBack_num() {
return back_num;
}
public void setBack_num(int back_num) {
this.back_num = back_num;
}
@Override
public String toString() {
return "SkuBack{" +
"sku_id=" + sku_id +
", back_num=" + back_num +
'}';
}
}
package com.ydt.sku.task;
import com.ydt.sku.dao.MessageDao;
import com.ydt.sku.model.SkuBack;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 定时任务调度
*/
@Component
public class TimeTask {
@Autowired
private MessageDao messageDao;
@Scheduled(cron = "0/5 * * * * ?")
public void back(){
System.out.println("定时任务开始扫描需要回滚的库存..........");
List list = messageDao.list();
for (SkuBack skuBack : list) {
messageDao.updateSkuBySkuBack(skuBack);
}
messageDao.deleteSkuBack();
}
}
6.4.7 测试
通过order服务创建订单,当出现异常时,订单服务会发送回滚消息到MQ
库存服务通过消费MQ中回滚消息,保存到本地DB回滚表
定时任务不停的调度,如果回滚表有数据,进行数据回滚,然后删除
6.4.8 思考
考虑下如果订单服务创建失败,而发送消息到MQ也失败,咋办?