什么是事务
事务是并发控制的单位,是用户定义的一个操作序列。
事务特性
数据库事务在实现时会将一次事务的所有操作全部纳入到一个不可分割的执行单元,该执行单元的所有操作要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚。
MySQL的本地事务实现
大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(Local Transaction)。本地事务的ACID特性是数据库直接提供支持。为了达成本地事务,MySQL做了很多的工作,比如回滚日志,重做日志,MVCC,读写锁等。
以MySQL 的InnoDB (InnoDB 是 MySQL 的一个存储引擎)为例,介绍一下单一数据库的事务实现原理。
Undo Log 如何保障事务的原子性呢?
具体的方式为:在操作任何数据之前,首先将数据备份到一个地方(这个存储数据备份的地方称为 Undo Log),然后进行数据的修改。如果出现了错误或者用户执行了 Rollback 语句,系统可以利用 Undo Log 中的备份将数据恢复到事务开始之前的状态。
Redo Log如何保障事务的持久性呢?
具体的方式为:Redo Log 记录的是新数据的备份(和 Undo Log 相反)。在事务提交前,只要将 Redo Log 持久化即可,不需要将数据持久化。当系统崩溃时,虽然数据没有持久化,但是 Redo Log 已经持久化。系统可以根据 Redo Log 的内容,将所有数据恢复到崩溃之前的状态。
分布式事务是针对分布式系统而言。分布式事务需要保证分布式系统中的数据一致性,保证数据在子系统中始终保持一致,避免业务出现问题。分布式系统中对数要么一起成功,要么一起失败,必须是一个整体性的事务。
分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
简单的说,在分布式系统上一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务节点上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。
举个例子:在电商网站中,用户对商品进行下单,需要在订单表中创建一条订单数据,同时需要在库存表中修改当前商品的剩余库存数量,两步操作一个添加,一个修改,我们一定要保证这两步操作一定同时操作成功或失败,否则业务就会出现问题。
任何事务机制在实现时,都应该考虑事务的ACID特性,包括:本地事务、分布式事务。对于分布式事务而言,即使不能都很好的满足,也要考虑支持到什么程度。
跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。笔者见过一个相对比较复杂的业务,一个业务中同时操作了9个库。
下图演示了一个服务同时操作2个库的情况:
通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。
如下图,将数据库B拆分成了2个库:
对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。
微服务架构是目前一个比较一个比较火的概念。例如上面笔者提到的一个案例,某个应用同时操作了9个库,这样的应用业务逻辑必然非常复杂,对于开发人员是极大的挑战,应该拆分成不同的独立服务,以简化业务逻辑。拆分后,独立服务之间通过RPC框架来进行远程调用,实现彼此的通信。下图演示了一个3个服务之间彼此调用的架构:
Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数据库,Service C也操作了一个库。需要保证这些跨服务的对多个数据库的操作要不都成功,要不都失败,实际上这可能是最典型的分布式事务场景。
分布式事务实现方案必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。
CAP 是 Consistency、Availability、Partition tolerance 三个单词的缩写,分别表示一致性、可用性、分区容忍性。
CAP定理是由加州大学伯克利分校Eric Brewer教授提出来的,他指出WEB服务无法同时满足一下3个属性:
具体地讲在分布式系统中,一个Web应用至多只能同时支持上面的两个属性。因此,设计人员必须在一致性与可用性之间做出选择。
下面为了方便对CAP理论的理解,我们结合电商系统中的一些业务场景来理解CAP。
如下图,是商品信息管理的执行流程:
C - Consistency
一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。
上图中,商品信息的读写要满足一致性就是要实现如下目标:
商品服务写入主数据库成功,则向从数据库查询新数据也成功。 商品服务写入主数据库失败,则向从数据库查询新数据也失败。
如何实现一致性?
分布式系统一致性的特点:
由于存在数据同步的过程,写操作的响应会有一定的延迟。 为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。 如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。
A - Availability
可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。
上图中,商品信息读取满足可用性就是要实现如下目标:
从数据库接收到数据查询的请求则立即能够响应数据查询结果;从数据库不允许出现响应超时或响应错误。
如何实现可用性:
分布式系统可用性的特点:
所有请求都有响应,且不会出现响应超时或响应错误。
P - Partition tolerance
通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。
上图中,商品信息读写满足分区容忍性就是要实现如下目标:
如何实现分区容忍性:
分布式分区容忍性的特点:
分区容忍性分是布式系统具备的基本能力。
目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
基于 CAP理论,很多系统在设计之初就要对这三者做出取舍:任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
在生产中对分布式事务处理时要根据需求来确定满足 CAP 的以下哪两个方面:
AP 放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。 例如:上边的商品管理,完全可以实现 AP,前提是只要用户可以接受所查询到的数据在一定时间内不是最新的即可。 通常实现 AP 都会保证最终一致性,后面将的 BASE 理论就是根据 AP 来扩展的,一些业务场景比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定的时间内到账即可。
CP 放弃可用性,追求一致性和分区容错性,zookeeper 其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
CA 放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,最常用的关系型数据就满足了 CA。
对于分布式系统而言,分区容错性是一个最基本的要求,因此基本上我们在设计分布式系统的时候只能从一致性(C)和可用性(A)之间进行取舍。
CAP 理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中AP在实际应用中较多,AP 即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和 CAP 中的一致性不同,CAP 中的一致性要求 在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。
BASE是Basically Available(基本可用)、**Soft state(软状态)和Eventually consistent(最终一致性)**三个短语的简写。
BASE是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的,其核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方法来使系统达到最终一致性。
基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。
响应时间上的损失:当出现故障时,响应时间增加;
功能上的损失: 当流量高峰期时,屏蔽一些功能的使用以保证系统稳定性(服务降级)
指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。
与硬状态相对,即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
刚性事务指的是,要使分布式事务,达到像本地式事务一样,具备数据强一致性,从CAP来看,就是说,要达到CP状态。
通常无业务改造,强一致性,原生支持回滚/隔离性,低并发,适合短事务。
刚性事务:XA 协议(2PC、JTA、JTS)、3PC,但由于同步阻塞,处理效率低,不适合大型网站分布式场景。
柔性事务指的是,不要求强一致性,而是要求最终一致性,允许有中间状态,也就是Base理论,换句话说,就是AP状态。
与刚性事务相比,柔性事务的特点为:有业务改造,最终一致性,实现补偿接口,实现资源锁定接口,高并发,适合长事务。
柔性事务分为:
柔型事务:TCC/FMT、Saga(状态机模式、Aop模式)、本地事务消息、消息事务(半消息)
很明显可以看出分布式事务后续演变成2条路径
CP(一致性 + 分区)
放弃可用性,保证数据强一致性.
**经典方案: 1>2PC 2>3PC **
AP(可用性 + 分区)
暂时放弃一致性,保证可用,后续通过某种手段(比如: MQ/程序补偿)打到最终一致性性.
经典方案: 1>本地消息表 2>MQ消息事务 3>TCC 4>SAGA
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范 的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
XA 规范 使用两阶段提交(2PC,Two-Phase Commit)协议来保证所有资源同时提交或回滚任何特定的事务。
DTP标准中包含有几个角色:
XA则规范了TM与RM之间的通信接口,在TM与多个RM之间形成一个双向通信桥梁,从而在多个数据库资源下保证ACID四个特性。目前知名的数据库,如Oracle, DB2,mysql等,都是实现了XA接口的,都可以作为RM。
XA是数据库的分布式事务,强一致性,在整个过程中,数据一张锁住状态,即从prepare到commit、rollback的整个过程中,TM一直把持折数据库的锁,如果有其他人要修改数据库的该条数据,就必须等待锁的释放,存在长事务风险。
XA的主要限制
2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者(TM)的角色来协调管理各参与者(也可称之为各本地资源RM)的提交和回滚,二阶段分别指的是准备和提交两个阶段。
准备阶段,事务协调者™会给各事务参与者(RM)发送准备命令(prepare),参与者准备成功后返回(ready)
协调者收到各个参与者的准备消息后,根据反馈情况通知各个参与者commit提交或者rollback回滚
1>commit提交
当第一阶段所有参与者都反馈成功时,协调者发起正式提交事务的请求,当所有参与者都回复提交成功时,则意味着完成事务。
2>rollback回滚
如果任意一个参与者节点在第一阶段返回的消息为中止(或者异常),或者协调者节点在第一阶段的询问超时,无法获取到全部参数者反馈,那么这个事务将会被回滚。
协调者向所有参与者发出 rollback 回滚操作的请求
参与者执行事务回滚,并释放在整个事务期间内占用的资源
参与者在完成事务回滚之后,向协调者发送回滚完成的反馈消息
协调者收到所有参与者反馈的消息后,取消事务
缺点
性能问题:执行过程中,所有参与节点都是事务阻塞性的,当参与者占有公共资源时,其他第三方节点访问公共资源就不得不处于阻塞状态,为了数据的一致性而牺牲了可用性,对性能影响较大,不适合高并发高性能场景
可靠性问题:2PC非常依赖协调者,当协调者发生故障时,尤其是第二阶段,那么所有的参与者就会都处于锁定事务资源的状态中,而无法继续完成事务操作
数据一致性问题:在阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
二阶段无法解决的问题:协调者在发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了,那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
优点
3PC,三阶段提交协议,是二阶段提交协议的改进版本,以解决2PC存在的缺陷问题, 具体改进如下:
在协调者和参与者中都引入超时机制
引入确认机制,当所有参与者能正常工作才执行事务
所以3PC分为3个阶段:CanCommit 准备阶段、PreCommit 预提交阶段、DoCommit 提交阶段。
协调者向参与者发送 canCommit 请求,参与者如果可以提交就返回Yes响应,否则返回No响应,具体流程如下:
协调者根据参与者的反应情况来决定是否可以进行事务的 PreCommit 操作。根据响应情况,有以下两种可能:
执行事务:返回都是yes
所有参与者向协调者发送了Yes响应,将会执行执行事务
中断事务:返回存在no
如果存在一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断
该阶段进行真正的事务提交,也存在2种情况:
提交事务:返回都是yes
第二阶段的preCommit 请求,所有参与者向协调者发送了Yes响应,将会提交事务
中断事务:返回存在no
如果存在一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断
三阶段提交协议在协调者和参与者中都引入 超时机制,并且把两阶段提交协议的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,继续进行事务的提交。
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞, 因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。
但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
缺点
preCommit
请求后等待 doCommit
指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。优点
补偿模式使用一个额外的协调服务来协调各个需要保证一致性的业务服务,协调服务按顺序调用各个业务微服务,如果某个业务服务调用异常(包括业务异常和技术异常)就取消之前所有已经调用成功的业务服务。
TCC(Try Confirm Cancel)方案是一种应用层面侵入业务的两阶段提交。是目前最火的一种分布式事务方案,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
一个完整的 TCC 业务由一个主业务服务和若干个从业务服务组成,主业务服务发起并完成整个业务活动,TCC 模式要求从服务提供三个接口:Try、Confirm、Cancel。
TCC 分布式事务模型包括三部分:
TCC 提出了一种新的事务模型,基于业务层面的事务定义,锁粒度完全由业务自己控制,目的是解决复杂业务中,跨表跨库等大颗粒度资源锁定的问题。
相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源。
以电商中订单系统为例,用户下单:创建订单,扣库存,扣款流程。假设: 库存总数10,购买2,账户余额1000,扣款200
确认执行业务操作,不做任何业务检查, 只使用Try阶段预留的业务资源。通常情况下,采用TCC,则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
取消Try阶段预留的业务资源。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
优点
缺点
TCC与XA两阶段提交有着异曲同工之妙,下图列出了二者之间的对比
在阶段1:
在阶段2:
Saga是分布式事务领域最有名气的解决方案之一,最初出现在1987年Hector Garcaa-Molrna & Kenneth Salem发表的论文SAGAS里。
Saga是由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发Saga中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga会执行在这个失败的事务之前成功提交的所有事务的补偿操作。
Saga的实现有很多种方式,其中最流行的两种方式是:
命令协调(Order Orchestrator):这种方式的工作形式就像一只乐队,由一个指挥家(协调中心)来协调大家的工作。协调中心来告诉Saga的参与方应该执行哪一个本地事务。
事件编排(Event Choreographyo):这种方式没有协调中心,整个模式的工作方式就像舞蹈一样,各个舞蹈演员按照预先编排的动作和走位各自表演,最终形成一只舞蹈。处于当前Saga下的各个服务,会产生某类事件,或者监听其它服务产生的事件并决定是否需要针对监听到的事件做出响应。
中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。
中央协调器 OSO 必须事先知道执行整个事务所需的流程,如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚,基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。
执行顺序: A–>B–>C 回滚顺序: C–>B—>A
在基于事件的方式中,第一个服务执行完本地事务之后,会产生一个事件。其它服务会监听这个事件,触发该服务本地事务的执行,并产生新的事件。当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。
前面讲到saga模式,在本地事务因为某些业务规则无法满足而失败,Saga会执行在这个失败的事务之前成功提交的所有事务的补偿操作。
上面意思可以理解为,saga模式下,每个事务参与者提供一对接口,一个做正常事务操作,一个做异常事务回滚操作。比如:支付与退款,扣款与回补等。
saga支持事务恢复策略
向后恢复(backward recovery):
当执行事务失败时,补偿所有已完成的事务,是“一退到底”的方式,这种做法的效果是撤销掉之前所有成功的子事务,使得整个 Saga 的执行结果撤销。
从上图可知事务执行到了支付事务T3,但是失败了,因此事务回滚需要从C3,C2,C1依次进行回滚补偿,对应的执行顺序为:T1,T2,T3,C3,C2,C1。
向前恢复(forward recovery):
对于执行不通过的事务,会尝试重试事务,这里有一个假设就是每个子事务最终都会成功,这种方式适用于必须要成功的场景,事务失败了重试,不需要补偿。
命令协调设计
优点
缺点
事件编排设计
优点
缺点
命令协调方式与事件编排方式2者怎么选择?
Saga和TCC都是补偿型事务,他们的区别为:
劣势:
优势:
通知型事务的主流实现是通过MQ(消息队列)来通知其他事务参与者自己事务的执行状态,引入MQ组件,有效的将事务参与者进行解耦,各参与者都可以异步执行,所以通知型事务又被称为异步事务。
通知型事务主要适用于那些需要异步更新数据,并且对数据的实时性要求较低的场景,主要包含:
本地消息表的核心思路就是将分布式事务拆分成本地事务进行处理,在该方案中主要有两种角色:事务主动方和事务被动方。事务主动发起方需要额外新建事务消息表,并在本地事务中完成业务处理和记录事务消息,并轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
操作步骤:
异常情况处理:
当1处理出错,事务主动方在本地事务中,直接回滚就行。
当2处理出错,由于DB1中还是保存事务消息,可以设置轮询逻辑,将消息重新推送给消息中间件,在通知事务被动方。
当3处理出错,重复获取消息,重复执行即可。
如果是业务上处理失败,事务被动方可以发消息给事务主动方回滚事务
如果事务被动方已经消费了消息,事务主动方需要回滚事务的话,需要发消息通知事务主动方进行回滚事务。
优点
缺点
基于MQ的事务消息方案主要依靠MQ的半消息机制来实现投递消息和参与者自身本地事务的一致性保障。半消息机制实现原理其实借鉴的2PC的思路,是二阶段提交的广义拓展。
半消息:在原有队列消息执行后的逻辑,如果后面的本地逻辑出错,则不发送该消息,如果通过则告知MQ发送;
流程
可以看到该事务形态过程简单,性能消耗小,发起方与跟随方之间的流量峰谷可以使用队列填平,同时业务开发工作量也基本与单机事务没有差别,都不需要编写反向的业务逻辑过程
因此基于消息队列实现的事务是我们除了单机事务外最优先考虑使用的形态。
有一些第三方的MQ是支持事务消息的,这些消息队列,支持半消息机制,比如RocketMQ,ActiveMQ。但是有一些常用的MQ也不支持事务消息,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例,其思路大致为:
1.producer(本例中指A系统)发送半消息到broker,这个半消息不是说消息内容不完整, 它包含完整的消息内容, 在producer端和普通消息的发送逻辑一致
2.broker存储半消息,半消息存储逻辑与普通消息一致,只是属性有所不同,topic是固定的RMQ_SYS_TRANS_HALF_TOPIC,queueId也是固定为0,这个tiopic中的消息对消费者是不可见的,所以里面的消息永远不会被消费。这就保证了在半消息提交成功之前,消费者是消费不到这个半消息的
3.broker端半消息存储成功并返回后,A系统执行本地事务,并根据本地事务的执行结果来决定半消息的提交状态为提交或者回滚
4.A系统发送结束半消息的请求,并带上提交状态(提交 or 回滚)
5.broker端收到请求后,首先从RMQ_SYS_TRANS_HALF_TOPIC的queue中查出该消息,设置为完成状态。如果消息状态为提交,则把半消息从RMQ_SYS_TRANS_HALF_TOPIC队列中复制到这个消息原始topic的queue中去(之后这条消息就能被正常消费了);如果消息状态为回滚,则什么也不做。
6.producer发送的半消息结束请求是 oneway 的,也就是发送后就不管了,只靠这个是无法保证半消息一定被提交的,rocketMq提供了一个兜底方案,这个方案叫消息反查机制,Broker启动时,会启动一个TransactionalMessageCheckService 任务,该任务会定时从半消息队列中读出所有超时未完成的半消息,针对每条未完成的消息,Broker会给对应的Producer发送一个消息反查请求,根据反查结果来决定这个半消息是需要提交还是回滚,或者后面继续来反查
7.consumer(本例中指B系统)消费消息,执行本地数据变更(至于B是否能消费成功,消费失败是否重试,这属于正常消息消费需要考虑的问题)
在rocketMq中,不论是producer收到broker存储半消息成功返回后执行本地事务,还是broker向producer反查消息状态,都是通过回调机制完成。
优点
缺点
二者的共性:
1、 事务消息都依赖MQ进行事务通知,所以都是异步的。
2、 事务消息在投递方都是存在重复投递的可能,需要有配套的机制去降低重复投递率,实现更友好的消息投递去重。
3、 事务消息的消费方,因为投递重复的无法避免,因此需要进行消费去重设计或者服务幂等设计。
二者的区别:
MQ事务消息:
DB本地消息表:
最大努力通知也称为定期校对,是对MQ事务方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到主动方发送的消息,此时可以调用事务主动方提供的消息校对的接口主动获取。
在可靠消息事务中,事务主动方需要将消息发送出去,并且让接收方成功接收消息,这种可靠性发送是由事务主动方保证的;但是最大努力通知,事务主动方仅仅是尽最大努力(重试,轮询…)将事务发送给事务接收方,所以存在事务被动方接收不到消息的情况,此时需要事务被动方主动调用事务主动方的消息校对接口查询业务消息并消费,这种通知的可靠性是由事务被动方保证的。
属性 | 2PC/3PC | TCC | Saga | 本地消息表 | 尽最大努力通知(MQ) |
---|---|---|---|---|---|
事务一致性 | 强 | 弱 | 弱 | 弱 | 弱 |
复杂性 | 中 | 高 | 中 | 低 | 低 |
业务侵入性 | 小 | 大 | 小 | 中 | 中 |
使用局限性 | 大 | 大 | 中 | 小 | 中 |
性能 | 低 | 中 | 高 | 高 | 高 |
维护成本 | 低 | 高 | 中 | 低 | 中 |
官网:http://seata.io/zh-cn/
源码:https://github.com/seata/seata
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
需求:用户下单,扣款,扣库存。
根据上面分析,项目设计出3个微服务
业务服务:business-service
订单服务:order-service
账户服务:account-service
库存服务:stock-service
代码如下
创建3个数据库与3张表
seata-account
CREATE TABLE `t_account`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) DEFAULT NULL,
`money` int(11) DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `t_account` VALUES (1, 'U100000', 900);
seata-order
CREATE TABLE `t_order`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) DEFAULT NULL,
`commodity_code` varchar(255) DEFAULT NULL,
`count` int(11) DEFAULT 0,
`money` int(11) DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
seata-stock
CREATE TABLE `t_stock`
(
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) DEFAULT NULL,
`count` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY (`commodity_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `t_stock` VALUES (1, 'C100000', 10);
stock-service
依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-bootstrapartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.2version>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-loadbalancerartifactId>
dependency>
dependencies>
配置文件
# Tomcat
server:
port: 8083
# Spring
spring:
application:
# 应用名称
name: stock-service
profiles:
# 环境配置
active: dev
cloud:
nacos:
discovery:
# 服务注册地址
server-addr: 127.0.0.1:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql:///seata-stock?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: 123456
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
domain
package cn.wolfcode.tx.stock.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_stock")
public class Stock {
@TableId(type = IdType.AUTO)
private Integer id;
private String commodityCode;
private Integer count;
}
mapper
package cn.wolfcode.tx.stock.mapper;
import cn.wolfcode.tx.stock.domain.Stock;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface StockMapper extends BaseMapper<Stock> {
}
service
package cn.wolfcode.tx.stock.service;
import cn.wolfcode.tx.stock.domain.Stock;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IStockService extends IService<Stock> {
/**
* 扣库存
* @param commodityCode
* @param count
*/
void deduct(String commodityCode, int count);
}
service.impl
package cn.wolfcode.tx.stock.service.impl;
import cn.wolfcode.tx.stock.domain.Stock;
import cn.wolfcode.tx.stock.mapper.StockMapper;
import cn.wolfcode.tx.stock.service.IStockService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.seata.core.context.RootContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StockServiceImpl extends ServiceImpl<StockMapper, Stock> implements IStockService {
@Override
@Transactional
public void deduct(String commodityCode, int count) {
Stock one = lambdaQuery().eq(Stock::getCommodityCode, commodityCode).one();
if(one != null && one.getCount() < count){
throw new RuntimeException("Not Enough Count ...");
}
lambdaUpdate().setSql("count = count-" + count)
.eq(Stock::getCommodityCode, commodityCode)
.update();
}
}
controller
package cn.wolfcode.tx.stock.controller;
import cn.wolfcode.tx.stock.service.IStockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("stocks")
public class StockController {
@Autowired
private IStockService StockService;
@Autowired
private IStockService stockService;
@GetMapping(value = "/deduct")
public String deduct(String commodityCode, int count) {
try {
stockService.deduct(commodityCode, count);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
启动类
package cn.wolfcode.tx;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("cn.wolfcode.tx.stock.mapper")
public class StockApplication {
public static void main(String[] args) {
SpringApplication.run(StockApplication.class, args);
}
}
account-service
依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-bootstrapartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.2version>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
dependencies>
配置文件
# Tomcat
server:
port: 8081
# Spring
spring:
application:
# 应用名称
name: account-service
profiles:
# 环境配置
active: dev
cloud:
nacos:
discovery:
# 服务注册地址
server-addr: 127.0.0.1:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql:///seata-account?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: 123456
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
domain
package cn.wolfcode.tx.account.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_account")
public class Account {
@TableId(type = IdType.AUTO)
private Integer id;
private String userId;
private int money;
}
mapper
package cn.wolfcode.tx.account.mapper;
import cn.wolfcode.tx.account.domain.Account;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface AccountMapper extends BaseMapper<Account> {
}
service
package cn.wolfcode.tx.account.service;
import cn.wolfcode.tx.account.domain.Account;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IAccountService extends IService<Account> {
/**
* 账户扣款
* @param userId
* @param money
* @return
*/
void reduce(String userId, int money);
}
service.impl
package cn.wolfcode.tx.account.service.impl;
import cn.wolfcode.tx.account.domain.Account;
import cn.wolfcode.tx.account.mapper.AccountMapper;
import cn.wolfcode.tx.account.service.IAccountService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.seata.core.context.RootContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> implements IAccountService {
@Override
@Transactional
public void reduce(String userId, int money) {
Account one = lambdaQuery().eq(Account::getUserId, userId).one();
if(one != null && one.getMoney() < money){
throw new RuntimeException("Not Enough Money ...");
}
lambdaUpdate().setSql("money = money - " + money)
.eq(Account::getUserId, userId)
.update();
}
}
controller
package cn.wolfcode.tx.account.controller;
import cn.wolfcode.tx.account.domain.Account;
import cn.wolfcode.tx.account.service.IAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("accounts")
public class AccountController {
@Autowired
private IAccountService accountService;
@GetMapping(value = "/reduce")
public String reduce(String userId, int money) {
try {
accountService.reduce(userId, money);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
启动类
package cn.wolfcode.tx;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@MapperScan("cn.wolfcode.tx.account.mapper")
@EnableDiscoveryClient
@EnableFeignClients
public class AccountApplication {
public static void main(String[] args) {
SpringApplication.run(AccountApplication.class, args);
}
}
order-service
依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-bootstrapartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.2version>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-loadbalancerartifactId>
dependency>
dependencies>
配置文件
# Tomcat
server:
port: 8082
# Spring
spring:
application:
# 应用名称
name: order-service
profiles:
# 环境配置
active: dev
cloud:
nacos:
discovery:
# 服务注册地址
server-addr: 127.0.0.1:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql:///seata-order?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: 123456
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
domain
package cn.wolfcode.tx.order.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.AUTO)
private Integer id;
private String userId;
private String commodityCode;
private Integer count;
private Integer money;
}
mapper
package cn.wolfcode.tx.order.mapper;
import cn.wolfcode.tx.order.domain.Order;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface OrderMapper extends BaseMapper<Order> {
}
service
package cn.wolfcode.tx.order.service;
import cn.wolfcode.tx.order.domain.Order;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IOrderService extends IService<Order> {
/**
* 创建订单
*/
void create(String userId, String commodityCode, int orderCount);
}
service.impl
package cn.wolfcode.tx.order.service.impl;
import cn.wolfcode.tx.order.domain.Order;
import cn.wolfcode.tx.order.feign.AccountFeignClient;
import cn.wolfcode.tx.order.mapper.OrderMapper;
import cn.wolfcode.tx.order.service.IOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.seata.core.context.RootContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {
@Autowired
private AccountFeignClient accountFeignClient;
@Override
@Transactional
public void create(String userId, String commodityCode, int count) {
// 定单总价 = 订购数量(count) * 商品单价(100)
int orderMoney = count * 100;
// 生成订单
Order order = new Order();
order.setCount(count);
order.setCommodityCode(commodityCode);
order.setUserId(userId);
order.setMoney(orderMoney);
super.save(order);
// 调用账户余额扣减
String result = accountFeignClient.reduce(userId, orderMoney);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("Failed to call Account Service. ");
}
}
}
controller
package cn.wolfcode.tx.order.controller;
import cn.wolfcode.tx.order.domain.Order;
import cn.wolfcode.tx.order.service.IOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("orders")
public class OrderController {
@Autowired
private IOrderService orderService;
@GetMapping(value = "/create")
public String create(String userId, String commodityCode, int orderCount) {
try {
orderService.create(userId, commodityCode, orderCount);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
Feign接口
package cn.wolfcode.tx.order.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "account-service")
public interface AccountFeignClient {
@GetMapping("/accounts/reduce")
String reduce(@RequestParam("userId") String userId, @RequestParam("money") int money);
}
启动类
package cn.wolfcode.tx;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@MapperScan("cn.wolfcode.tx.order.mapper")
@EnableDiscoveryClient
@EnableFeignClients
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
business-service
依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-bootstrapartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-loadbalancerartifactId>
dependency>
dependencies>
配置文件
# Tomcat
server:
port: 8088
# Spring
spring:
application:
# 应用名称
name: business-service
profiles:
# 环境配置
active: dev
cloud:
nacos:
discovery:
# 服务注册地址
server-addr: 127.0.0.1:8848
测试数据
package cn.wolfcode.tx.business;
public class TestDatas {
public static final String USER_ID = "U100000";
public static final String COMMODITY_CODE = "C100000";
}
service
package cn.wolfcode.tx.business.service;
public interface IBusinessService{
void purchase(String userId, String commodityCode, int orderCount, boolean rollback);
}
service.impl
package cn.wolfcode.tx.business.service.impl;
import cn.wolfcode.tx.business.feign.OrderFeignClient;
import cn.wolfcode.tx.business.feign.StockFeignClient;
import cn.wolfcode.tx.business.service.IBusinessService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class BusinessServiceImpl implements IBusinessService {
private static final Logger LOGGER = LoggerFactory.getLogger(BusinessServiceImpl.class);
@Autowired
private StockFeignClient stockFeignClient;
@Autowired
private OrderFeignClient orderFeignClient;
@Override
public void purchase(String userId, String commodityCode, int orderCount, boolean rollback) {
String result = stockFeignClient.deduct(commodityCode, orderCount);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("库存服务调用失败,事务回滚!");
}
result = orderFeignClient.create(userId, commodityCode, orderCount);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("订单服务调用失败,事务回滚!");
}
if (rollback) {
throw new RuntimeException("Force rollback ... ");
}
}
}
feign接口
package cn.wolfcode.tx.business.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "order-service")
public interface OrderFeignClient {
@GetMapping("/orders/create")
String create(@RequestParam("userId") String userId, @RequestParam("commodityCode") String commodityCode,
@RequestParam("orderCount") int orderCount);
}
package cn.wolfcode.tx.business.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "stock-service")
public interface StockFeignClient {
@GetMapping("/stocks/deduct")
String deduct(@RequestParam("commodityCode") String commodityCode, @RequestParam("count") int count);
}
controller
package cn.wolfcode.tx.business.controller;
import cn.wolfcode.tx.business.TestDatas;
import cn.wolfcode.tx.business.service.IBusinessService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("businesses")
public class BusinessController {
@Autowired
private IBusinessService businessService;
@GetMapping(value = "/purchase")
public String purchase(Boolean rollback, Integer count) {
int orderCount = 10;
if (count != null) {
orderCount = count;
}
try {
businessService.purchase(TestDatas.USER_ID, TestDatas.COMMODITY_CODE, orderCount,
rollback == null ? false : rollback.booleanValue());
} catch (Exception exx) {
return "Purchase Failed:" + exx.getMessage();
}
return "SUCCESS";
}
}
启动类
package cn.wolfcode.tx;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class BusinessApplication {
public static void main(String[] args) {
SpringApplication.run(BusinessApplication.class, args);
}
}
启动nacos, 启动4个服务,
访问: http://localhost:8088/businesses/purchase?rollback=false&count=10
官网:https://github.com/seata/seata/releases/tag/v1.7.0
1.5.0之前的版本配置文件是有多个的,都位于conf
文件夹下,如file.conf
,registry,conf
等。在1.5.0版本之后都整合到一个配置文件里了,即application.yml
。以下配置项请按照自己版本查找修改。
以seata-1.7.0为例,打开conf/application.yml
进行修改,重点修改nacos部分配置。
其中的:application.example.yml 各种注册中心,配置中心配置方式,默认是配置本地,这里以配置在nacos为例子。
server:
port: 7091
spring:
application:
name: seata-server
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
extend:
logstash-appender:
destination: 127.0.0.1:4560
kafka-appender:
bootstrap-servers: 127.0.0.1:9092
topic: logback_to_logstash
console:
user:
username: seata
password: seata
seata:
config:
# support: nacos, consul, apollo, zk, etcd3
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace:
group: SEATA_GROUP
username:
password:
context-path:
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key:
#secret-key:
data-id: seataServer.properties
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
cluster: default
username:
password:
context-path:
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key:
#secret-key:
store:
# support: file 、 db 、 redis
mode: file
# server:
# service-port: 8091 #If not configured, the default is '${server.port} + 1000'
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login
修改成功后,意味着seata将从nacos获取配置信息,同时注册自身服务到nacos中心。
上面配置项中有一项:seata.config.data-id=seataServer.properties
,意思为要读nacos上的seataServer.properties
配置文件,接下来去Nacos
创建该配置文件,注意Group
与第2步中的保持一致,这里是SEATA_GROUP
。
配置内容从seata-server-1.7.0/seata/script/config-center/config.txt
粘贴修改而来,这里只使用对我们有用的配置,主要是数据库配置
信息。
#Transaction storage configuration, only for the server.
store.mode=db
store.lock.mode=db
store.session.mode=db
#These configurations are required if the `store mode` is `db`.
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useSSL=false&useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=admin
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.distributedLockTable=distributed_lock
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
在seata数据库内,执行seata-server-1.7.0/seata/script/server/db
目录下的sql脚本(根据数据库类型),创建服务端所需的表。此处选择:mysql
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);
运行bin
下的bat
脚本启动服务。
访问:http://127.0.0.1:7091
默认账号与密码都是seata
seata 的XA模式相对于2PC做了一些调整。首先seata 的XA模式增加TC-(事务协调者)这个角色,用来维护全局和分支事务的状态,驱动全局事务提交或回滚。TC的作用相当于实际执行TM指令,相当于一个”秘书“。
流程如下:
第一阶段:
1>注册全局事务
2>调用RM事务接口,注册分支事务
3>执行RM事务操作,不提交
4>往TC报告事务状态
第二阶段:
1>所有RM执行完本地事务,TM发起全局事务提交/回滚
2>TC检查所有RM事务状态,yes or no?
全部yes,通知所有RM提交事务
存在no,通知所有RM回滚事务
项目集成seata
依赖
所有微服务导入seata依赖
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-seataartifactId>
<exclusions>
<exclusion>
<groupId>io.seatagroupId>
<artifactId>seata-spring-boot-starterartifactId>
exclusion>
<exclusion>
<groupId>io.seatagroupId>
<artifactId>seata-allartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>io.seatagroupId>
<artifactId>seata-spring-boot-starterartifactId>
<version>1.7.0version>
dependency>
配置文件
在application.yml文件中配置, 每个微服务都要
#seata客户端配置
seata:
enabled: true
application-id: seata_tx
tx-service-group: seata_tx_group
service:
vgroup-mapping:
seata_tx_group: default
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
namespace:
group: SEATA_GROUP
data-source-proxy-mode: XA
其中seata_tx_group
为我们自定义的事务组,名字随便起,但是下面service.vgroup-mapping
下一定要有一个对应这个名字的映射,映射到default
(seata默认的集群名称)。 nacos
方面,我们仅配置注册项,即registry
下的配置,配置内容与服务端保持一致。
配置全局事务
在business-service服务的purchase 方法中加上全局事务标签:@GlobalTransactional
@Override
@GlobalTransactional
public void purchase(String userId, String commodityCode, int orderCount, boolean rollback) {
String result = stockFeignClient.deduct(commodityCode, orderCount);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("库存服务调用失败,事务回滚!");
}
result = orderFeignClient.create(userId, commodityCode, orderCount);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("订单服务调用失败,事务回滚!");
}
if (rollback) {
throw new RuntimeException("Force rollback ... ");
}
}
测试
正常:http://localhost:8088/businesses/purchase?rollback=false&count=2
超库存:http://localhost:8088/businesses/purchase?rollback=false&count=12
超余额:http://localhost:8088/businesses/purchase?rollback=false&count=8
优点
缺点
AT是seata-1.7.0默认的模式。
AT模式同样是分阶段提交事务模式,操作起来算是XA模式的优化版。XA模式在第一阶段存在锁定资源的操作,时间长之后会影响性能。
AT模式在第一阶段直接提交事务,弥补了XA模式中资源锁定周期过长缺陷。
操作流程:
第一阶段:
1>注册全局事务
2>调用RM事务接口,注册分支事务
3>执行RM事务操作,并提交,记录undo log日志快照
4>往TC报告事务状态
第二阶段:
1>所有RM执行完本地事务,TM发起全局事务提交/回滚
2>TC检查所有RM事务状态,yes or no?
全部yes,通知所有RM提交事务,删除undo log日志快照
存在no,通知所有RM回滚事务,恢复undo log日志快照
XA vs AT
AT模式因为在全局事务中第一阶段就提交了事务,释放资源。如果这个时,另外RM/外部事务(非RM)操作相同资源,可能存在读写隔离问题(更新丢失问题)。
问题出现原理
读写隔离问题-2个seata事务解决方案
配置seata-AT相关快照/全局锁/快照表数据库
配置数据库
源sql: seata-server-1.7.0/script/server/db 中
添加undo_log表
源sql: https://seata.io/zh-cn/docs/dev/mode/at-mode.html
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
配置文件
在application.yml文件中配置把模式XT改为AT即可, 每个微服务都要
#seata客户端配置
seata:
enabled: true
application-id: seata_tx
tx-service-group: seata_tx_group
service:
vgroup-mapping:
seata_tx_group: default
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
namespace:
group: SEATA_GROUP
data-source-proxy-mode: AT
测试
正常:http://localhost:8088/businesses/purchase?rollback=false&count=2
超库存:http://localhost:8088/businesses/purchase?rollback=false&count=12
超余额:http://localhost:8088/businesses/purchase?rollback=false&count=8
优点
缺点
测试时遇到错误如下:
java.sql.SQLException: io.seata.core.exception.RmTransactionException: branch register failed, xid: 192.168.7.91:8091:2072106933610864919, errMsg: TransactionException[branch register request failed. xid=192.168.7.91:8091:2072106933610864919, msg=Unknown column 'status' in 'field list']
后来发现用了原来的旧版本的lock_table表,少了字段status,建议从官网找到对应版本的最新的sql。重新使用新的执行如下:
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid_and_branch_id` (`xid` , `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
TCC模式的seata版实现。TCC模式与AT模式非常相似,每阶段都是独立事务,不同的TCC通过人工编码来实现数据恢复。
操作流程:
1>注册全局事务
2>调用RM事务接口,注册分支事务
3>执行RM事务try接口,检查资源,预留资源
4>往TC报告事务状态
5>所有RM执行完本地事务,TM发起全局事务提交/回滚
2>TC检查所有RM事务状态,yes or no?
全部yes,通知所有RM 执行confirm接口,提交事务
存在no,通知所有RM 执行cancel接口,回滚事务
TM 在发起全局事务时生成全局事务记录,全局事务 ID 贯穿整个分布式事务调用链条。
案例演示
TCC模式中,在执行Try,执行Confirm,执行Cancel 过程中会出现意外情况,导致TCC模式经典问题:空回滚,业务悬挂,重试幂等问题。
空回滚
当某个分支事务try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作,RM在没有执行try操作就执行cancel操作,此时cancel无数据回滚,这就是空回滚。
业务悬挂
当发生的空回滚之后,当阻塞的Try正常了,RM先执行空回滚(cancel)后,又收到Try操作指令,执行业务操作,并冻结资源。但是事务已经结束,不会再有confirm 或cancel了,那刚执行try操作冻结资源,就被悬挂起来了。这就是业务悬挂
重试幂等
因为网络抖动等原因,TC下达的Confirm/Cancel 指令可能出现延时,发送失败等问题,此时TC会启用重试机制,到时,RM可能收到多个confirm或cancel指令,这就要求confirm接口或者cancel接口,需要能够保证幂等性。
幂等性:多次执行,结果都一样
解决
上面空回滚/业务悬挂问题解决,一般都一起实现:引入事务状态控制表
表字段: xid,冻结数据,事务状态(try、confirm/cancel)
以RM: account-service 中用户账户余额为例子。
try:1>在状态表中记录冻结金额,与事务状态为try,2>扣减账户余额
confirm:1>根据xid删除状态表中冻结记录
cancel:1>修改状态表冻结金额为0,事务状态改为cancel 2>恢复账户扣减
如何判断是否为空回滚:在cancel中,根据xid查询状态表,如果不存在,说明try执行,需要空回滚
如果避免业务悬挂:try业务中,根据xid查询状态表,如果已经存在,说明已经执行过cancel已经执行过,拒绝执行try业务。
重试幂等:需要引入唯一标识,比如第一次操作成功留下,唯一标识,下次来识别这个标识。
在AT模式基础上做代码TCC改造就行。
新增:IAccountTCCService接口
package cn.wolfcode.tx.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* TCC 二阶段提交业务接口
*/
@LocalTCC
public interface IAccountTCCService {
/**
* try-预扣款
*/
@TwoPhaseBusinessAction(name="tryReduce", commitMethod = "confirm", rollbackMethod = "cancel")
void tryReduce(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
/**
* confirm-提交
* @param ctx
* @return
*/
boolean confirm(BusinessActionContext ctx);
/**
* cancel-回滚
* @param ctx
* @return
*/
boolean cancel(BusinessActionContext ctx);
}
IAccountTCCService实现类AccountTCCServiceImpl
package cn.wolfcode.tx.account.service.impl;
import cn.wolfcode.tx.account.domain.Account;
import cn.wolfcode.tx.account.mapper.AccountMapper;
import cn.wolfcode.tx.account.service.IAccountTCCService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AccountTCCServiceImpl implements IAccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Override
public void tryReduce(String userId, int money) {
System.err.println("-----------tryReduce-------------");
Account one = accountMapper.selectOne(new LambdaQueryWrapper<Account>().eq(Account::getUserId, userId));
if(one != null && one.getMoney() < money){
throw new RuntimeException("Not Enough Money ...");
}
LambdaUpdateWrapper<Account> wrapper = new LambdaUpdateWrapper<>();
wrapper.setSql("money = money - " + money);
wrapper.eq(Account::getUserId, userId);
accountMapper.update(null, wrapper);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("-----------confirm-------------");
return true;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("-----------cancel-------------");
return true;
}
}
controller改动,把IAccountService换成IAccountTCCService
package cn.wolfcode.tx.account.controller;
import cn.wolfcode.tx.account.service.IAccountTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("accounts")
public class AccountController {
// @Autowired
// private IAccountService accountService;
@Autowired
private IAccountTCCService accountTCCService;
@GetMapping(value = "/reduce")
public String reduce(String userId, int money) {
try {
accountTCCService.tryReduce(userId, money);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
新增:IOrderTCCService接口
package cn.wolfcode.tx.order.service;
import cn.wolfcode.tx.order.domain.Order;
import com.baomidou.mybatisplus.extension.service.IService;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* TCC 二阶段提交业务接口
*/
@LocalTCC
public interface IOrderTCCService {
/**
* try-预扣款
*/
@TwoPhaseBusinessAction(name="tryCreate", commitMethod = "confirm", rollbackMethod = "cancel")
void tryCreate(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "commodityCode") String commodityCode,
@BusinessActionContextParameter(paramName = "orderCount") int orderCount);
/**
* confirm-提交
* @param ctx
* @return
*/
boolean confirm(BusinessActionContext ctx);
/**
* cancel-回滚
* @param ctx
* @return
*/
boolean cancel(BusinessActionContext ctx);
}
IOrderTCCService实现类OrderTCCServiceImpl
package cn.wolfcode.tx.order.service.impl;
import cn.wolfcode.tx.order.domain.Order;
import cn.wolfcode.tx.order.feign.AccountFeignClient;
import cn.wolfcode.tx.order.mapper.OrderMapper;
import cn.wolfcode.tx.order.service.IOrderTCCService;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderTCCServiceImpl implements IOrderTCCService {
@Autowired
private AccountFeignClient accountFeignClient;
@Autowired
private OrderMapper orderMapper;
@Override
public void tryCreate(String userId, String commodityCode, int count) {
System.err.println("---------tryCreate-----------");
// 定单总价 = 订购数量(count) * 商品单价(100)
int orderMoney = count * 100;
// 生成订单
Order order = new Order();
order.setCount(count);
order.setCommodityCode(commodityCode);
order.setUserId(userId);
order.setMoney(orderMoney);
orderMapper.insert(order);
// 调用账户余额扣减
String result = accountFeignClient.reduce(userId, orderMoney);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("Failed to call Account Service. ");
}
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("---------confirm-----------");
return true;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("---------cancel-----------");
return true;
}
}
controller改动
package cn.wolfcode.tx.order.controller;
import cn.wolfcode.tx.order.service.IOrderTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("orders")
public class OrderController {
@Autowired
private IOrderTCCService orderTCCService;
@GetMapping(value = "/create")
public String create(String userId, String commodityCode, int orderCount) {
try {
orderTCCService.tryCreate(userId, commodityCode, orderCount);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
新增:IStockTCCService接口
package cn.wolfcode.tx.stock.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* TCC 二阶段提交业务接口
*/
@LocalTCC
public interface IStockTCCService {
/**
* try-预扣款
*/
@TwoPhaseBusinessAction(name="tryDeduct", commitMethod = "confirm", rollbackMethod = "cancel")
void tryDeduct(@BusinessActionContextParameter(paramName = "commodityCode") String commodityCode,
@BusinessActionContextParameter(paramName = "count") int count);
/**
* confirm-提交
* @param ctx
* @return
*/
boolean confirm(BusinessActionContext ctx);
/**
* cancel-回滚
* @param ctx
* @return
*/
boolean cancel(BusinessActionContext ctx);
}
IStockTCCService实现类StockTCCServiceImpl
package cn.wolfcode.tx.stock.service.impl;
import cn.wolfcode.tx.stock.domain.Stock;
import cn.wolfcode.tx.stock.mapper.StockMapper;
import cn.wolfcode.tx.stock.service.IStockTCCService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class StockTCCServiceImpl implements IStockTCCService {
@Autowired
private StockMapper stockMapper;
@Override
public void tryDeduct(String commodityCode, int count) {
System.err.println("---------tryDeduct-----------");
Stock one = stockMapper.selectOne(new LambdaQueryWrapper<Stock>().eq(Stock::getCommodityCode, commodityCode));
if(one != null && one.getCount() < count){
throw new RuntimeException("Not Enough Count ...");
}
stockMapper.update(null, new LambdaUpdateWrapper<Stock>()
.setSql("count = count-" + count)
.eq(Stock::getCommodityCode, commodityCode));
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("---------confirm-----------");
return true;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("---------cancel-----------");
return true;
}
}
controller改动
package cn.wolfcode.tx.stock.controller;
import cn.wolfcode.tx.stock.service.IStockService;
import cn.wolfcode.tx.stock.service.IStockTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("stocks")
public class StockController {
@Autowired
private IStockTCCService stockTCCService;
@GetMapping(value = "/deduct")
public String deduct(String commodityCode, int count) {
try {
stockTCCService.tryDeduct(commodityCode, count);
} catch (Exception exx) {
exx.printStackTrace();
return "FAIL";
}
return "SUCCESS";
}
}
上面操作,在理想情况下是没有问题的,但是一旦出现需要回滚操作,就出问题了,无法进行数据回补。此时就需要使用到事务状态表实现数据回补,同时实现空回滚,避免业务悬挂。
在seata-account 新增事务状态表
CREATE TABLE `t_account_tx` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tx_id` varchar(100) NOT NULL COMMENT '事务id',
`freeze_money` int DEFAULT NULL COMMENT '冻结金额',
`state` int DEFAULT NULL COMMENT '状态 0try 1confirm 2cancel',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
新增domain:AccountTX
package cn.wolfcode.tx.account.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_account_tx")
public class AccountTX {
public static final int STATE_TRY = 0;
public static final int STATE_CONFIRM = 1;
public static final int STATE_CANCEL = 2;
@TableId(type = IdType.AUTO)
private Integer id;
private String txId;
private int freezeMoney;
private int state = STATE_TRY;
}
新增mapper:AccountTXMapper
package cn.wolfcode.tx.account.mapper;
import cn.wolfcode.tx.account.domain.AccountTX;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AccountTXMapper extends BaseMapper<AccountTX> {
}
修改:AccountTCCServiceImpl
package cn.wolfcode.tx.account.service.impl;
import cn.wolfcode.tx.account.domain.Account;
import cn.wolfcode.tx.account.domain.AccountTX;
import cn.wolfcode.tx.account.mapper.AccountMapper;
import cn.wolfcode.tx.account.mapper.AccountTXMapper;
import cn.wolfcode.tx.account.service.IAccountTCCService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AccountTCCServiceImpl implements IAccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountTXMapper accountTXMapper;
@Override
public void tryReduce(String userId, int money) {
System.err.println("-----------tryReduce-------------" + RootContext.getXID());
//业务悬挂
AccountTX accountTX = accountTXMapper.selectOne(new LambdaQueryWrapper<AccountTX>().eq(AccountTX::getTxId, RootContext.getXID()));
if (accountTX != null){
//存在,说明已经canel执行过类,拒绝服务
return;
}
Account one = accountMapper.selectOne(new LambdaQueryWrapper<Account>().eq(Account::getUserId, userId));
if(one != null && one.getMoney() < money){
throw new RuntimeException("Not Enough Money ...");
}
LambdaUpdateWrapper<Account> wrapper = new LambdaUpdateWrapper<>();
wrapper.setSql("money = money - " + money);
wrapper.eq(Account::getUserId, userId);
accountMapper.update(null, wrapper);
AccountTX tx = new AccountTX();
tx.setFreezeMoney(money);
tx.setTxId(RootContext.getXID());
tx.setState(AccountTX.STATE_TRY);
accountTXMapper.insert(tx);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("-----------confirm-------------");
//删除记录
int ret = accountTXMapper.delete(new LambdaQueryWrapper<AccountTX>().eq(AccountTX::getTxId, ctx.getXid()));
return ret == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("-----------cancel-------------");
String userId = ctx.getActionContext("userId").toString();
String money = ctx.getActionContext("money").toString();
AccountTX accountTX = accountTXMapper.selectOne(new LambdaQueryWrapper<AccountTX>().eq(AccountTX::getTxId, ctx.getXid()));
if (accountTX == null){
//为空, 空回滚
accountTX = new AccountTX();
accountTX.setTxId(ctx.getXid());
accountTX.setState(AccountTX.STATE_CANCEL);
if(money != null){
accountTX.setFreezeMoney(Integer.parseInt(money));
}
accountTXMapper.insert(accountTX);
return true;
}
//幂等处理
if(accountTX.getState() == AccountTX.STATE_CANCEL){
return true;
}
//恢复余额
accountMapper.update(null, new LambdaUpdateWrapper<Account>()
.setSql("money = money + " + money)
.eq(Account::getUserId, userId));
accountTX.setFreezeMoney(0);
accountTX.setState(AccountTX.STATE_CANCEL);
int ret = accountTXMapper.updateById(accountTX);
return ret == 1;
}
}
在seata-order 新增事务状态表
CREATE TABLE `t_order_tx` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tx_id` varchar(100) NOT NULL COMMENT '事务id',
`state` int DEFAULT NULL COMMENT '状态 0try 1confirm 2cancel',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
新增domain:OrderTX
package cn.wolfcode.tx.order.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_order_tx")
public class OrderTX {
public static final int STATE_TRY = 0;
public static final int STATE_CONFIRM = 1;
public static final int STATE_CANCEL = 2;
@TableId(type = IdType.AUTO)
private Integer id;
private String txId;
private int state = STATE_TRY;
}
新增mapper:OrderTXMapper
package cn.wolfcode.tx.order.mapper;
import cn.wolfcode.tx.order.domain.OrderTX;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OrderTXMapper extends BaseMapper<OrderTX> {
}
修改:OrderTCCServiceImpl
package cn.wolfcode.tx.order.service.impl;
import cn.wolfcode.tx.order.domain.Order;
import cn.wolfcode.tx.order.domain.OrderTX;
import cn.wolfcode.tx.order.feign.AccountFeignClient;
import cn.wolfcode.tx.order.mapper.OrderMapper;
import cn.wolfcode.tx.order.mapper.OrderTXMapper;
import cn.wolfcode.tx.order.service.IOrderService;
import cn.wolfcode.tx.order.service.IOrderTCCService;
import com.alibaba.nacos.shaded.org.checkerframework.checker.units.qual.A;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderTCCServiceImpl implements IOrderTCCService {
@Autowired
private AccountFeignClient accountFeignClient;
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderTXMapper orderTXMapper;
@Override
public void tryCreate(String userId, String commodityCode, int count) {
System.err.println("---------tryCreate-----------");
//业务悬挂
OrderTX orderTX = orderTXMapper.selectOne(new LambdaQueryWrapper<OrderTX>().eq(OrderTX::getTxId, RootContext.getXID()));
if (orderTX != null){
//存在,说明已经canel执行过类,拒绝服务
return;
}
// 定单总价 = 订购数量(count) * 商品单价(100)
int orderMoney = count * 100;
// 生成订单
Order order = new Order();
order.setCount(count);
order.setCommodityCode(commodityCode);
order.setUserId(userId);
order.setMoney(orderMoney);
orderMapper.insert(order);
OrderTX tx = new OrderTX();
tx.setTxId(RootContext.getXID());
tx.setState(OrderTX.STATE_TRY);
orderTXMapper.insert(tx);
// 调用账户余额扣减
String result = accountFeignClient.reduce(userId, orderMoney);
if (!"SUCCESS".equals(result)) {
throw new RuntimeException("Failed to call Account Service. ");
}
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("---------confirm-----------");
//删除记录
int ret = orderTXMapper.delete(new LambdaQueryWrapper<OrderTX>().eq(OrderTX::getTxId, ctx.getXid()));
return ret == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("---------cancel-----------" );
String userId = ctx.getActionContext("userId").toString();
String commodityCode = ctx.getActionContext("commodityCode").toString();
OrderTX orderTX = orderTXMapper.selectOne(new LambdaQueryWrapper<OrderTX>().eq(OrderTX::getTxId, ctx.getXid()));
if (orderTX == null){
//为空, 空回滚
orderTX = new OrderTX();
orderTX.setTxId(ctx.getXid());
orderTX.setState(OrderTX.STATE_CANCEL);
orderTXMapper.insert(orderTX);
return true;
}
//幂等处理
if(orderTX.getState() == OrderTX.STATE_CANCEL){
return true;
}
//恢复余额
orderMapper.delete(new LambdaQueryWrapper<Order>().eq(Order::getUserId, userId).eq(Order::getCommodityCode, commodityCode));
orderTX.setState(OrderTX.STATE_CANCEL);
int ret = orderTXMapper.updateById(orderTX);
return ret == 1;
}
}
在seata-stock 新增事务状态表
CREATE TABLE `t_stock_tx` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tx_id` varchar(100) NOT NULL COMMENT '事务id',
`count` int DEFAULT NULL COMMENT '冻结库存',
`state` int DEFAULT NULL COMMENT '状态 0try 1confirm 2cancel',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
新增domain:StockTX
package cn.wolfcode.tx.stock.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_stock_tx")
public class StockTX {
public static final int STATE_TRY = 0;
public static final int STATE_CONFIRM = 1;
public static final int STATE_CANCEL = 2;
@TableId(type = IdType.AUTO)
private Integer id;
private String txId;
private int count;
private int state = STATE_TRY;
}
新增mapper:StockTXMapper
package cn.wolfcode.tx.stock.mapper;
import cn.wolfcode.tx.stock.domain.StockTX;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockTXMapper extends BaseMapper<StockTX> {
}
修改:StockTCCServiceImpl
package cn.wolfcode.tx.stock.service.impl;
import cn.wolfcode.tx.stock.domain.Stock;
import cn.wolfcode.tx.stock.domain.StockTX;
import cn.wolfcode.tx.stock.mapper.StockMapper;
import cn.wolfcode.tx.stock.mapper.StockTXMapper;
import cn.wolfcode.tx.stock.service.IStockService;
import cn.wolfcode.tx.stock.service.IStockTCCService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StockTCCServiceImpl implements IStockTCCService {
@Autowired
private StockMapper stockMapper;
@Autowired
private StockTXMapper stockTXMapper;
@Override
public void tryDeduct(String commodityCode, int count) {
System.err.println("---------tryDeduct-----------");
//业务悬挂
StockTX stockTX = stockTXMapper.selectOne(new LambdaQueryWrapper<StockTX>().eq(StockTX::getTxId, RootContext.getXID()));
if (stockTX != null){
//存在,说明已经canel执行过类,拒绝服务
return;
}
Stock one = stockMapper.selectOne(new LambdaQueryWrapper<Stock>().eq(Stock::getCommodityCode, commodityCode));
if(one != null && one.getCount() < count){
throw new RuntimeException("Not Enough Count ...");
}
stockMapper.update(null, new LambdaUpdateWrapper<Stock>()
.setSql("count = count-" + count)
.eq(Stock::getCommodityCode, commodityCode));
StockTX tx = new StockTX();
tx.setCount(count);
tx.setTxId(RootContext.getXID());
tx.setState(StockTX.STATE_TRY);
stockTXMapper.insert(tx);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
System.err.println("---------confirm-----------");
//删除记录
int ret = stockTXMapper.delete(new LambdaQueryWrapper<StockTX>().eq(StockTX::getTxId, ctx.getXid()));
return ret == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
System.err.println("---------cancel-----------");
String count = ctx.getActionContext("count").toString();
String commodityCode = ctx.getActionContext("commodityCode").toString();
StockTX stockTX = stockTXMapper.selectOne(new LambdaQueryWrapper<StockTX>().eq(StockTX::getTxId, ctx.getXid()));
if (stockTX == null){
//为空, 空回滚
stockTX = new StockTX();
stockTX.setTxId(ctx.getXid());
stockTX.setState(StockTX.STATE_CANCEL);
if(count != null){
stockTX.setCount(Integer.parseInt(count));
}
stockTXMapper.insert(stockTX);
return true;
}
//幂等处理
if(stockTX.getState() == StockTX.STATE_CANCEL){
return true;
}
//恢复余额
stockMapper.update(null, new LambdaUpdateWrapper<Stock>()
.setSql("count = count + " + count)
.eq(Stock::getCommodityCode, commodityCode));
stockTX.setCount(0);
stockTX.setState(StockTX.STATE_CANCEL);
int ret = stockTXMapper.updateById(stockTX);
return ret == 1;
}
}
测试
正常:http://localhost:8088/businesses/purchase?rollback=false&count=2
超库存:http://localhost:8088/businesses/purchase?rollback=false&count=12
超余额:http://localhost:8088/businesses/purchase?rollback=false&count=8
优点
缺点
Saga模式是SEATA提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
简单理解:
saga模式也分为2个阶段
一阶段: 直接提交本地事务(所有RM)
二阶段:一阶段成功了,啥都不做,如果存在某个RM本地事务失败,则编写补偿业务(反向操作)来实现回滚
左边是所有参与者事务,右边是补偿反向操作
正常执行顺序: T1–T2–T3–TN
需要回滚执行顺序:T1–T2–T3–TN—回滚—TN—T3—T2—T1
优点
缺点
XA | AT | TCC | SAGA | |
---|---|---|---|---|
一致性 | 强一致 | 弱一致 | 弱一致 | 最终一致 |
隔离性 | 完全隔离 | 基于全局锁 | 基于资源预留隔离 | 无隔离 |
代码侵入 | 无 | 无 | 有,编写3个接口 | 有,编写状态机和补偿业务 |
性能 | 差 | 好 | 非常好 | 非常好 |
场景 | 对一致性、隔离性有高要求的业务 | 居于关系型数据库的大部分分布式事务场景都可以 | 对性能要求较高的事务,有非关系型数据参与的事务 | 业务流程长,业务流程多,参与者包含其他公司或者遗留系统服务,无法提供TCC模式要求的是3个接口 |
参考:
https://blog.csdn.net/crazymakercircle/article/details/109459593?spm=1001.2014.3001.5502
https://baijiahao.baidu.com/s?id=1717325036148461851&wfr=spider&for=pc