目录
- 一、事务基础概念
- 二、分布式事务概念
- 什么是分布式事务
- 分布式事务场景
- CAP定理
- CAP理论
- 理解CAP
- CAP的应用
- BASE定理
- 强一致性和最终一致性
- BASE理论
- 分布式事务分类
- 刚性事务
- 柔性事务
- 三、分布式事务解决方案
- 方案汇总
- XA规范
- 方案1:2PC
- 第一阶段:准备阶段
- 第二阶段:提交阶段
- 优缺点
- 方案2:3PC
- 阶段一:CanCommit 准备阶段
- 阶段二:PreCommit
- 阶段三:doCommit阶段
- 2PC和3PC的区别
- 优缺点
- 补偿型事务
- 方案3:TCC
- Try(尝试)
- Confirm(确认)
- Cancel(取消)
- TCC事务模型的要求
- 优缺点
- TCC与2PC对比
- 方案4:SAGA
- 概念
- 命令协调
- 事件编排
- 异常恢复
- 命令VS事件
- Saga和TCC对比
- 通知型事务
- 方案5:本地消息表
- 操作流程
- 优缺点
- 方案6:MQ消息事务
- 操作流程
- 基于 RocketMQ实现MQ异步确保型事务
- 优缺点
- MQ事务消息 VS 本地消息表
- 方案7:最大努力通知
- 方案选择
- 四、分布式事务最佳实践-Seata
- 简介
- 常见术语
- 项目准备
- 数据库准备
- 库存服务
- 账户服务
- 订单服务
- 业务服务
- 测试
- Seata配置
- 下载
- 修改配置文件
- nacos配置
- 数据库建表
- 启动seata-server
- Seata使用XA模式
- 原理
- 代码
- 优缺点
- Seata使用AT模式
- 原理
- 问题
- 代码
- 优缺点
- 踩坑
- Seata使用TCC模式
- 原理
- 问题
- 代码
- 优缺点
- Seata使用SAGA模式
- 概述
- 原理
- 优缺点
- 模式选择
一、事务基础概念
什么是事务
事务是并发控制的单位,是用户定义的一个操作序列。
事务特性
- 原子性(Atomicity): 事务是数据库的逻辑工作单位,事务中包括的诸操作要么全做,要么全不做。
- 一致性(Consistency): 事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
- 隔离性(Isolation): 一个事务的执行不能被其他事务干扰。
- 持续性/永久性(Durability): 一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。
数据库事务在实现时会将一次事务的所有操作全部纳入到一个不可分割的执行单元,该执行单元的所有操作要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚。
MySQL的本地事务实现
大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(Local Transaction)。本地事务的ACID特性是数据库直接提供支持。为了达成本地事务,MySQL做了很多的工作,比如回滚日志,重做日志,MVCC,读写锁等。
以MySQL 的InnoDB (InnoDB 是 MySQL 的一个存储引擎)为例,介绍一下单一数据库的事务实现原理。
- 通过数据库锁的机制,保障事务的隔离性;
- 通过 Redo Log(重做日志)来,保障事务的持久性;
- 通过 Undo Log (撤销日志)来,保障事务的原子性;
- 通过 Undo Log (撤销日志)来,保障事务的一致性;
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定理
CAP 是 Consistency、Availability、Partition tolerance 三个单词的缩写,分别表示一致性、可用性、分区容忍性。
CAP理论
CAP定理是由加州大学伯克利分校Eric Brewer教授提出来的,他指出WEB服务无法同时满足一下3个属性:
- 一致性(Consistency) : 客户端知道一系列的操作都会同时发生(生效)
- 可用性(Availability) : 每个操作都必须以可预期的响应结束
- 分区容错性(Partition tolerance) : 即使出现单个组件无法可用,操作依然可以完成
具体地讲在分布式系统中,一个Web应用至多只能同时支持上面的两个属性。因此,设计人员必须在一致性与可用性之间做出选择。
理解CAP
下面为了方便对CAP理论的理解,我们结合电商系统中的一些业务场景来理解CAP。
如下图,是商品信息管理的执行流程:
C - Consistency
一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。
上图中,商品信息的读写要满足一致性就是要实现如下目标:
商品服务写入主数据库成功,则向从数据库查询新数据也成功。 商品服务写入主数据库失败,则向从数据库查询新数据也失败。
如何实现一致性?
- 写入主数据库后要将数据同步到从数据库。
- 写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功后,向从数据库查询到旧的数据。
分布式系统一致性的特点:
由于存在数据同步的过程,写操作的响应会有一定的延迟。 为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。 如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。
A - Availability
可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。
上图中,商品信息读取满足可用性就是要实现如下目标:
从数据库接收到数据查询的请求则立即能够响应数据查询结果;从数据库不允许出现响应超时或响应错误。
如何实现可用性:
- 由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定
- 即时数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时
分布式系统可用性的特点:
所有请求都有响应,且不会出现响应超时或响应错误。
P - Partition tolerance
通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。
上图中,商品信息读写满足分区容忍性就是要实现如下目标:
- 主数据库向从数据库同步数据失败不影响读写操作。
- 其一个结点挂掉不影响另一个结点对外提供服务。
如何实现分区容忍性:
- 尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
- 添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。
分布式分区容忍性的特点:
分区容忍性分是布式系统具备的基本能力。
CAP的应用
目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
基于 CAP理论,很多系统在设计之初就要对这三者做出取舍:任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
在生产中对分布式事务处理时要根据需求来确定满足 CAP 的以下哪两个方面:
-
AP 放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。 例如:上边的商品管理,完全可以实现 AP,前提是只要用户可以接受所查询到的数据在一定时间内不是最新的即可。 通常实现 AP 都会保证最终一致性,后面将的 BASE 理论就是根据 AP 来扩展的,一些业务场景比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定的时间内到账即可。
-
CP 放弃可用性,追求一致性和分区容错性,zookeeper 其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
-
CA 放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,最常用的关系型数据就满足了 CA。
对于分布式系统而言,分区容错性是一个最基本的要求,因此基本上我们在设计分布式系统的时候只能从一致性(C)和可用性(A)之间进行取舍。
BASE定理
强一致性和最终一致性
CAP 理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中AP在实际应用中较多,AP 即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和 CAP 中的一致性不同,CAP 中的一致性要求 在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。
BASE理论
BASE是Basically Available(基本可用)、**Soft state(软状态)和Eventually consistent(最终一致性)**三个短语的简写。
BASE是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的,其核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方法来使系统达到最终一致性。
- Basically Available(基本可用)
基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。
响应时间上的损失:当出现故障时,响应时间增加;
功能上的损失: 当流量高峰期时,屏蔽一些功能的使用以保证系统稳定性(服务降级)
- Soft state(软状态)
指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。
与硬状态相对,即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
- Eventually consistent(最终一致性)
强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
分布式事务分类
刚性事务
刚性事务指的是,要使分布式事务,达到像本地式事务一样,具备数据强一致性,从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规范
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范 的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
XA 规范 使用两阶段提交(2PC,Two-Phase Commit)协议来保证所有资源同时提交或回滚任何特定的事务。
DTP标准中包含有几个角色:
- AP(Application Program) : 既应用程序,可以理解为使用DTP分布式事务的程序。
- RM(Resource Manager) : 即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
- TM(Transaction Manager) : 事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。
XA则规范了TM与RM之间的通信接口,在TM与多个RM之间形成一个双向通信桥梁,从而在多个数据库资源下保证ACID四个特性。目前知名的数据库,如Oracle, DB2,mysql等,都是实现了XA接口的,都可以作为RM。
XA是数据库的分布式事务,强一致性,在整个过程中,数据一张锁住状态,即从prepare到commit、rollback的整个过程中,TM一直把持折数据库的锁,如果有其他人要修改数据库的该条数据,就必须等待锁的释放,存在长事务风险。
XA的主要限制
- 必须要拿到所有数据源,而且数据源还要支持XA协议。目前MySQL中只有InnoDB存储引擎支持XA协议。
- 性能比较差,要把所有涉及到的数据都要锁定,是强一致性的,会产生长事务。
方案1:2PC
2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者(TM)的角色来协调管理各参与者(也可称之为各本地资源RM)的提交和回滚,二阶段分别指的是准备和提交两个阶段。
第一阶段:准备阶段
准备阶段,事务协调者™会给各事务参与者(RM)发送准备命令(prepare),参与者准备成功后返回(ready)
- 协调者向所有参与者发送事务操作指令,参与者执行除了事务提交外所有操作
- 如参与者执行成功,给协调者反馈执行成功,否则反馈中止,表示事务失败
第二阶段:提交阶段
协调者收到各个参与者的准备消息后,根据反馈情况通知各个参与者commit提交或者rollback回滚
1>commit提交
当第一阶段所有参与者都反馈成功时,协调者发起正式提交事务的请求,当所有参与者都回复提交成功时,则意味着完成事务。
- 协调者节点向所有参与者发出正式提交的 commit 请求。
- 收到协调者的 commit 请求后,参与者正式执行事务提交操作,并释放在整个事务期间内占用的资源。
- 参与者完成事务提交后,向协调者节点发送已提交消息。
- 协调者节点收到所有参与者节点反馈的已提交消息后,完成事务。
2>rollback回滚
如果任意一个参与者节点在第一阶段返回的消息为中止(或者异常),或者协调者节点在第一阶段的询问超时,无法获取到全部参数者反馈,那么这个事务将会被回滚。
-
协调者向所有参与者发出 rollback 回滚操作的请求
-
参与者执行事务回滚,并释放在整个事务期间内占用的资源
-
参与者在完成事务回滚之后,向协调者发送回滚完成的反馈消息
-
协调者收到所有参与者反馈的消息后,取消事务
优缺点
缺点
-
性能问题:执行过程中,所有参与节点都是事务阻塞性的,当参与者占有公共资源时,其他第三方节点访问公共资源就不得不处于阻塞状态,为了数据的一致性而牺牲了可用性,对性能影响较大,不适合高并发高性能场景
-
可靠性问题:2PC非常依赖协调者,当协调者发生故障时,尤其是第二阶段,那么所有的参与者就会都处于锁定事务资源的状态中,而无法继续完成事务操作
-
数据一致性问题:在阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
-
二阶段无法解决的问题:协调者在发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了,那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
优点
- 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。
方案2:3PC
3PC,三阶段提交协议,是二阶段提交协议的改进版本,以解决2PC存在的缺陷问题, 具体改进如下:
-
在协调者和参与者中都引入超时机制
-
引入确认机制,当所有参与者能正常工作才执行事务
所以3PC分为3个阶段:CanCommit 准备阶段、PreCommit 预提交阶段、DoCommit 提交阶段。
阶段一:CanCommit 准备阶段
协调者向参与者发送 canCommit 请求,参与者如果可以提交就返回Yes响应,否则返回No响应,具体流程如下:
- 事务询问:协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
- 响应反馈:参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。
阶段二:PreCommit
协调者根据参与者的反应情况来决定是否可以进行事务的 PreCommit 操作。根据响应情况,有以下两种可能:
执行事务:返回都是yes
所有参与者向协调者发送了Yes响应,将会执行执行事务
- 协调者向参与者发送 PreCommit 请求,并进入准备阶段
- 参与者接收到 PreCommit 请求后,会执行本地事务操作,但不提交事务
- 参与者成功的执行了事务操作后,返回ACK响应,同时开始等待最终指令。
中断事务:返回存在no
如果存在一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断
- 协调者向所有参与者发送 abort 请求。
- 参与者收到来自协调者的 abort 请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
阶段三:doCommit阶段
该阶段进行真正的事务提交,也存在2种情况:
提交事务:返回都是yes
第二阶段的preCommit 请求,所有参与者向协调者发送了Yes响应,将会提交事务
- 协调接收到所有参与者发送的ACK响应,那么他将从预提交状态进入到提交状态,并向所有参与者发送 doCommit 请求
- 参与者接收到doCommit请求之后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源
- 事务提交完之后,参与者向协调者发送ack响应。
- 协调者接收到所有参与者的ack响应之后,完成事务。
中断事务:返回存在no
如果存在一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断
- 协调者处向所有参与者发出 abort 请求
- 参与者接收到abort请求之后,马上回滚事务,释放所有的事务资源。
- 参与者完成事务回滚之后,向协调者反馈ACK消息
- 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
2PC和3PC的区别
三阶段提交协议在协调者和参与者中都引入 超时机制,并且把两阶段提交协议的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,继续进行事务的提交。
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞, 因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。
但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
优缺点
缺点
- 数据不一致问题依然存在,当在参与者收到
preCommit
请求后等待doCommit
指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
优点
- 相比二阶段提交,三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。
补偿型事务
补偿模式使用一个额外的协调服务来协调各个需要保证一致性的业务服务,协调服务按顺序调用各个业务微服务,如果某个业务服务调用异常(包括业务异常和技术异常)就取消之前所有已经调用成功的业务服务。
方案3:TCC
TCC(Try Confirm Cancel)方案是一种应用层面侵入业务的两阶段提交。是目前最火的一种分布式事务方案,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
一个完整的 TCC 业务由一个主业务服务和若干个从业务服务组成,主业务服务发起并完成整个业务活动,TCC 模式要求从服务提供三个接口:Try、Confirm、Cancel。
TCC 分布式事务模型包括三部分:
- 主业务服务:主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动。
- 从业务服务:从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用。
- 业务活动管理器:业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。
TCC 提出了一种新的事务模型,基于业务层面的事务定义,锁粒度完全由业务自己控制,目的是解决复杂业务中,跨表跨库等大颗粒度资源锁定的问题。
相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。
Try(尝试)
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源。
以电商中订单系统为例,用户下单:创建订单,扣库存,扣款流程。假设: 库存总数10,购买2,账户余额1000,扣款200
Confirm(确认)
确认执行业务操作,不做任何业务检查, 只使用Try阶段预留的业务资源。通常情况下,采用TCC,则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
Cancel(取消)
取消Try阶段预留的业务资源。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
TCC事务模型的要求
- 可查询操作:服务操作具有全局唯一的标识,操作唯一的确定的时间。
- 幂等操作:重复调用多次产生的业务结果与调用一次产生的结果相同。一是通过业务操作实现幂等性,二是系统缓存所有请求与处理的结果,最后是检测到重复请求之后,自动返回之前的处理结果。
- TCC操作:Try阶段,尝试执行业务,完成所有业务的检查,实现一致性;预留必须的业务资源,实现准隔离性。Confirm阶段:真正的去执行业务,不做任何检查,仅适用Try阶段预留的业务资源,Confirm操作还要满足幂等性。Cancel阶段:取消执行业务,释放Try阶段预留的业务资源,Cancel操作要满足幂等性。TCC与2PC(两阶段提交)协议的区别:TCC位于业务服务层而不是资源层,TCC没有单独准备阶段,Try操作兼备资源操作与准备的能力,TCC中Try操作可以灵活的选择业务资源,锁定粒度。TCC的开发成本比2PC高。实际上TCC也属于两阶段操作,但是TCC不等同于2PC操作。
- 可补偿操作:Do阶段:真正的执行业务处理,业务处理结果外部可见。Compensate阶段:抵消或者部分撤销正向业务操作的业务结果,补偿操作满足幂等性。约束:补偿操作在业务上可行,由于业务执行结果未隔离或者补偿不完整带来的风险与成本可控。实际上,TCC的Confirm和Cancel操作可以看做是补偿操作。
优缺点
优点
- 性能提升:具体业务来实现,控制资源锁的粒度变小,不会锁定整个资源。
- 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
- 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群
缺点
- TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
TCC与2PC对比
TCC与XA两阶段提交有着异曲同工之妙,下图列出了二者之间的对比
在阶段1:
- 在XA中,各个RM准备提交各自的事务分支,事实上就是准备提交资源的更新操作(insert、delete、update等);
- 而在TCC中,是主业务活动请求(try)各个从业务服务预留资源。
在阶段2:
- XA根据第一阶段每个RM是否都prepare成功,判断是要提交还是回滚。如果都prepare成功,那么就commit每个事务分支,反之则rollback每个事务分支。
- TCC中,如果在第一阶段所有业务资源都预留成功,那么confirm各个从业务服务,否则取消(cancel)所有从业务服务的资源预留请求。
方案4:SAGA
概念
Saga是分布式事务领域最有名气的解决方案之一,最初出现在1987年Hector Garcaa-Molrna & Kenneth Salem发表的论文SAGAS里。
Saga是由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发Saga中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga会执行在这个失败的事务之前成功提交的所有事务的补偿操作。
Saga的实现有很多种方式,其中最流行的两种方式是:
-
命令协调(Order Orchestrator):这种方式的工作形式就像一只乐队,由一个指挥家(协调中心)来协调大家的工作。协调中心来告诉Saga的参与方应该执行哪一个本地事务。
-
事件编排(Event Choreographyo):这种方式没有协调中心,整个模式的工作方式就像舞蹈一样,各个舞蹈演员按照预先编排的动作和走位各自表演,最终形成一只舞蹈。处于当前Saga下的各个服务,会产生某类事件,或者监听其它服务产生的事件并决定是否需要针对监听到的事件做出响应。
命令协调
中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。
- 主业务接口发起事务业务,开启订单事务
- Saga协调器库存服务请求扣减库存,库存服务操作后,回复处理结果。
- Saga协调器账户服务请求扣减余额,账户服务操作后,回复处理结果。处理结果。
- Saga协调器订单服务请求创建订单,订单服务操作后,回复
- 主业务逻辑接收并处理Saga协调器事务处理结果回复。
中央协调器 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):
对于执行不通过的事务,会尝试重试事务,这里有一个假设就是每个子事务最终都会成功,这种方式适用于必须要成功的场景,事务失败了重试,不需要补偿。
命令VS事件
命令协调设计
优点
- 服务之间关系简单,避免服务间循环依赖,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器。
- 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
- 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试。
缺点
- 中央协调器处理逻辑容易变得庞大复杂,导致难以维护。
- 存在协调器单点故障风险。
事件编排设计
优点
- 避免中央协调器单点故障风险。
- 当涉及的步骤较少服务开发简单,容易实现。
缺点
- 服务之间存在循环依赖的风险。
- 当涉及的步骤较多,服务间关系混乱,难以追踪调测。
命令协调方式与事件编排方式2者怎么选择?
- 系统复杂性:如果系统的业务逻辑复杂,事务需要严格控制和编排,命令方式可以提供更好的可见性和可控性。
- 系统扩展性:如果系统需要频繁扩展和修改,需要一定的灵活性,事件方式可以提供解耦和扩展性更好的架构。
- 性能需求:如果需要更好的性能和可伸缩性,并行执行事务的各个步骤,事件方式更适合。
- 异步需求:如果系统需要异步处理和解耦,事件方式提供了更好的可行性。
Saga和TCC对比
Saga和TCC都是补偿型事务,他们的区别为:
劣势:
- 都无法保证隔离性;
优势:
- 都在一阶段提交本地事务,无锁,高性能;
- 都是事件驱动模式,参与者可异步执行,高吞吐;
- Saga 对业务侵入较小,只需要提供一个逆向操作的Cancel即可;而TCC需要对业务进行全局性的流程改造;
通知型事务
通知型事务的主流实现是通过MQ(消息队列)来通知其他事务参与者自己事务的执行状态,引入MQ组件,有效的将事务参与者进行解耦,各参与者都可以异步执行,所以通知型事务又被称为异步事务。
通知型事务主要适用于那些需要异步更新数据,并且对数据的实时性要求较低的场景,主要包含:
- 异步确保型事务:主要适用于内部系统的数据最终一致性保障,因为内部相对比较可控,如订单和购物车、收货与清算、支付与结算等等场景;
- 最大努力通知:主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以只能尽最大努力去通知实现数据最终一致性,比如充值平台与运营商、支付对接等等跨网络系统级别对接;
方案5:本地消息表
操作流程
本地消息表的核心思路就是将分布式事务拆分成本地事务进行处理,在该方案中主要有两种角色:事务主动方和事务被动方。事务主动发起方需要额外新建事务消息表,并在本地事务中完成业务处理和记录事务消息,并轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
操作步骤:
- 发生分布式事务操作时, 事务主动方在DB1中的操作业务表, 记录事务信息在消息表中,状态为未处理
- 事务主动方向消息中间件,推送一个事务操作消息,并通知事务被动方处理事务消息。
- 事务被动方,监控消息中间件,读取事务消息,完成DB2中业务操作,往消息中间件返回ack
- 事务主动方,监控消息中间件,读取事务消息,更新消息表状态为已处理
异常情况处理:
当1处理出错,事务主动方在本地事务中,直接回滚就行。
当2处理出错,由于DB1中还是保存事务消息,可以设置轮询逻辑,将消息重新推送给消息中间件,在通知事务被动方。
当3处理出错,重复获取消息,重复执行即可。
如果是业务上处理失败,事务被动方可以发消息给事务主动方回滚事务
如果事务被动方已经消费了消息,事务主动方需要回滚事务的话,需要发消息通知事务主动方进行回滚事务。
优缺点
优点
- 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
- 方案轻量,容易实现。
缺点
- 与具体的业务场景绑定,耦合性强,不可公用
- 消息数据与业务数据同库,占用业务系统资源
- 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限
方案6:MQ消息事务
操作流程
基于MQ的事务消息方案主要依靠MQ的半消息机制来实现投递消息和参与者自身本地事务的一致性保障。半消息机制实现原理其实借鉴的2PC的思路,是二阶段提交的广义拓展。
半消息:在原有队列消息执行后的逻辑,如果后面的本地逻辑出错,则不发送该消息,如果通过则告知MQ发送;
流程
- 事务发起方首先发送半消息到MQ;
- MQ通知发送方消息发送成功;
- 在发送半消息成功后执行本地事务;
- 根据本地事务执行结果返回commit或者是rollback;
- 如果消息是rollback, MQ将丢弃该消息不投递;如果是commit,MQ将会消息发送给消息订阅方;
- 订阅方根据消息执行本地事务;
- 订阅方执行本地事务成功后再从MQ中将该消息标记为已消费;
- 如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
- Consumer端的消费成功机制有MQ保证;
可以看到该事务形态过程简单,性能消耗小,发起方与跟随方之间的流量峰谷可以使用队列填平,同时业务开发工作量也基本与单机事务没有差别,都不需要编写反向的业务逻辑过程
因此基于消息队列实现的事务是我们除了单机事务外最优先考虑使用的形态。
基于 RocketMQ实现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反查消息状态,都是通过回调机制完成。
优缺点
优点
- 消息数据独立存储 ,降低业务系统与消息系统之间的耦合
- 吞吐量大于使用本地消息表方案
缺点
- 一次消息发送需要两次网络请求(half 消息 + commit/rollback 消息) 。
- 业务处理服务需要实现消息状态回查接口。
MQ事务消息 VS 本地消息表
二者的共性:
1、 事务消息都依赖MQ进行事务通知,所以都是异步的。
2、 事务消息在投递方都是存在重复投递的可能,需要有配套的机制去降低重复投递率,实现更友好的消息投递去重。
3、 事务消息的消费方,因为投递重复的无法避免,因此需要进行消费去重设计或者服务幂等设计。
二者的区别:
MQ事务消息:
- 需要MQ支持半消息机制或者类似特性,在重复投递上具有比较好的去重处理;
- 具有比较大的业务侵入性,需要业务方进行改造,提供对应的本地操作成功的回查功能;
DB本地消息表:
- 使用了数据库来存储事务消息,降低了对MQ的要求,但是增加了存储成本;
- 事务消息使用了异步投递,增大了消息重复投递的可能性;
方案7:最大努力通知
最大努力通知也称为定期校对,是对MQ事务方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到主动方发送的消息,此时可以调用事务主动方提供的消息校对的接口主动获取。
在可靠消息事务中,事务主动方需要将消息发送出去,并且让接收方成功接收消息,这种可靠性发送是由事务主动方保证的;但是最大努力通知,事务主动方仅仅是尽最大努力(重试,轮询…)将事务发送给事务接收方,所以存在事务被动方接收不到消息的情况,此时需要事务被动方主动调用事务主动方的消息校对接口查询业务消息并消费,这种通知的可靠性是由事务被动方保证的。
方案选择
属性 | 2PC/3PC | TCC | Saga | 本地消息表 | 尽最大努力通知(MQ) |
---|---|---|---|---|---|
事务一致性 | 强 | 弱 | 弱 | 弱 | 弱 |
复杂性 | 中 | 高 | 中 | 低 | 低 |
业务侵入性 | 小 | 大 | 小 | 中 | 中 |
使用局限性 | 大 | 大 | 中 | 小 | 中 |
性能 | 低 | 中 | 高 | 高 | 高 |
维护成本 | 低 | 高 | 中 | 低 | 中 |
- 2PC/3PC:依赖于数据库,能够很好的提供强一致性和强事务性,但延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
- TCC:适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
- 本地消息表/MQ 事务:适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底, 性能高。
- Saga 事务:由于 Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。Saga 由于缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。所以,Saga 事务较适用于补偿动作容易处理的场景
四、分布式事务最佳实践-Seata
简介
官网: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>
<!-- bootstrap 启动器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!--nacos客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--sentinel组件-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</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>
<!-- bootstrap 启动器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!--nacos客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--sentinel组件-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</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>
<!-- bootstrap 启动器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!--nacos客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--sentinel组件-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</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>
<!-- bootstrap 启动器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<!--nacos客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--fegin组件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--sentinel组件-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</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
Seata配置
下载
官网: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中心。
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);
启动seata-server
运行bin
下的bat
脚本启动服务。
访问:http://127.0.0.1:7091
默认账号与密码都是seata
Seata使用XA模式
原理
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依赖
<!-- 注意一定要引入对版本,要引入spring-cloud版本seata,而不是springboot版本的seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<!-- 排除掉springcloud默认的seata版本,以免版本不一致出现问题-->
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.7.0</version>
</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
优缺点
优点
- 事务强一致性,满足ACID原则
- 常用的数据库都支持,实现简单,并且没有代码侵入
缺点
- 第一阶段锁定数据库资源,等待二阶段结束才释放,锁定资源过长,性能较差
- 依赖关系型数据库的实现事务
Seata使用AT模式
原理
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
- XA模式一阶段不提交事务,锁定资源; AT模式一阶段直接提交,不锁定资源
- XA模式依赖数据库实现回滚; AT利用数据快照实现数据回顾
- XA模式强一致性;AT模式最终一致(一阶段提交,此时有事务查询,就存在不一致)
问题
AT模式因为在全局事务中第一阶段就提交了事务,释放资源。如果这个时,另外RM/外部事务(非RM)操作相同资源,可能存在读写隔离问题(更新丢失问题)。
问题出现原理
读写隔离问题-2个seata事务解决方案
- 加全局锁
- 1个seata事务 + 非seata事务解
代码
配置seata-AT相关快照/全局锁/快照表数据库
配置数据库
- TM数据库-seata中
源sql: seata-server-1.7.0/script/server/db 中
- 各个RM数据库
添加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
优缺点
优点
- 一阶段完成直接提交事务,释放资源,性能较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚与提交
缺点
- 两阶段之间存在数据不一致情况,只能保证最终一致
- 框架的快照功能影响性能,但比XA模式要好很多
踩坑
测试时遇到错误如下:
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;
Seata使用TCC模式
原理
TCC模式的seata版实现。TCC模式与AT模式非常相似,每阶段都是独立事务,不同的TCC通过人工编码来实现数据恢复。
- Try:资源的检测和预留
- Confirm:完成资源操作业务;要求Try 成功,Confirm 一定要能成功
- Cancel:预留资源释放,可以理解为try的反向操作
操作流程:
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改造就行。
- account-service服务
新增: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";
}
}
- order-service服务
新增: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";
}
}
- stock-service服务
新增: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";
}
}
上面操作,在理想情况下是没有问题的,但是一旦出现需要回滚操作,就出问题了,无法进行数据回补。此时就需要使用到事务状态表实现数据回补,同时实现空回滚,避免业务悬挂。
- account-service
在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;
}
}
- order-service
在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;
}
}
- stock-service
在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
优缺点
优点
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务性数据库
缺点
- 代码侵入,需要认为编写try,confirm和cancel接口,麻烦
- 没提交/回滚事务前数据是不一致的,事务属于最终一致
- 需要考虑confirm 和cancel失败情况,要做好幂等处理
Seata使用SAGA模式
概述
Saga模式是SEATA提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
简单理解:
saga模式也分为2个阶段
一阶段: 直接提交本地事务(所有RM)
二阶段:一阶段成功了,啥都不做,如果存在某个RM本地事务失败,则编写补偿业务(反向操作)来实现回滚
原理
左边是所有参与者事务,右边是补偿反向操作
正常执行顺序: T1–T2–T3–TN
需要回滚执行顺序:T1–T2–T3–TN—回滚—TN—T3—T2—T1
优缺点
优点
- 事务参与者可以居于事件驱动实现异步调用,吞吐量高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三哥阶段,实现简单
缺点
- 一阶段到二阶段时间不定,时效性差
- 没有锁,没有事务隔离,会有脏写可能
模式选择
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