事务的具体定义
事务(Transaction)是访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。在关系数据库中,一个事务由一组SQL语句组成。事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。
事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。
简单地说,事务提供一种“要么什么都不做,要么做全套(All or Nothing)”机制。
数据库本地事务
在计算机系统中,更多的是通过关系型数据库来控制事务,这是利用数据库本身的事务特性来实现的,因此叫数据库事务,由于应用主要靠关系数据库来控制事务,而数据库通常和应用在同一个服务器,所以基于关系型数据库的事务又被称为本地事务。
回顾一下数据库事务的四大特性 ACID:
说到数据库事务就不得不说,数据库事务中的四大特性 ACID:
A:原子性(Atomicity):一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
就像你买东西要么交钱收货一起都执行,要么发不出货,就退钱。
C:一致性(Consistency):事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。
* 如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态。
* 如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。
I:隔离性(Isolation):指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。
由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。
D:持久性(Durability):指的是只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。
分布式事务
随着互联网的快速发展,软件系统由原来的单体应用转变为分布式应用,随着应用服务化,出现各个微服务,以及这些服务对应的库表,多个库表之间的数据操作可能需要保证原子性。
下图描述了单体应用向微服务的演变:
分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务,例如用户注册送积分事务、创建订单减库存事务,银行转账事务等都是分布式事务。
分布式事务基础理论
CAP定理
CAP定理,又被叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP就是你的入门理论。
CAP理论告诉我们,一个分布式系统不可能同时满足一致性(C:Consistency)、可用性(A:Availability)和分区容错性(P:Partion tolerance)这三个基本需求,最多只能同时满足其中的两项。
C - Consistency(一致性):
在分布式环境下,一致性是指数据在多个副本之间是否能够保持一致的特性。对某个指定的客户端来说,读操作能返回最新的写操作。对于数据分布在不同节点上的数据上来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。
如何实现一致性?
- 1、写入主数据库后要将数据同步到从数据库。
- 2、写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功后,向从数据库查询到旧的数据。
分布式系统一致性的特点:
- 1、由于存在数据同步的过程,写操作的响应会有一定的延迟。
- 2、为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。
- 3、如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。
A - Availability(可用性):
可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回正确结果。非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回50,而不是返回40。
如何实现可用性?
- 1、写入主数据库后要将数据同步到从数据库。
- 2、由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。
- 3、即使数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。
分布式系统可用性的特点:
- 1、 所有请求都有响应,且不会出现响应超时或响应错误。
P - Partition tolerance(分区容错性):
分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。打个比方,这里个集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。
网络分区是指在分布式系统中,不同的节点分布在不同的子网络(机房或异地网络等)中,由于一些特殊的原因导致这些子网络之间出现网络不连通的状况,但各个子网络的内部网络是正常的,从而导致整个系统的网络环境被切分成了若干孤立的区域。需要注意的是,组成一个分布式系统的每个节点的加入与退出都可以看作是一个特殊的网络分区。
如何实现分区容错性?
- 1、尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
- 2、添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。
分布式分区容错性的特点:
1、分区容错性分是布式系统具备的基本能力。
CAP组合方式
熟悉CAP的人都知道,三者不能共有。在所有分布式事务场景中不会同时具备CAP三个特性,因为在具备了P的前提下C和A是不能共存的。
在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。
顺便一提,CAP理论中是忽略网络延迟,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,是其他机器回复至可用。
CAP有哪些组合方式呢?
所以在生产中对分布式事务处理时要根据需求来确定满足CAP的哪两个方面。
1)AP:
放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性。这是很多分布式系统设计时的选择。
通常实现AP都会保证最终一致性,后面讲的BASE理论就是根据AP来扩展的,一些业务场景 比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可。
2)CP:
放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
3)CA:
放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,我们最常用的关系型数据就满足了CA。
阶段总结
- CAP是一个已经被证实的理论,一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项。
- 它可以作为我们进行架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,结点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到N个9(99.99..%),并要达到良好的响应性能来提高用户体验,因此一般都会做出如下选择:保证P和A,舍弃C强一致,保证最终一致性。
BASE理论
1、理解强一致性和最终一致性
CAP理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中AP在实际应用中较多,AP即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和CAP中的一致性不同,CAP中的一致性要求在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。
BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent (最终一致性)三个短语的缩写。BASE理论是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致状态。满足BASE理论的事务,我们称之为“柔性事务”。
BASE理论指的是:
Basically Available(基本可用):允许响应时间拉长,允许功能上的损失,允许降级页面(系统繁忙,稍后重试等),即分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如,电商网站交易付款出现问题了,商品依然可以正常浏览。
Soft state(软状态):是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。如订单的"支付中"、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
Eventually consistent(最终一致性):本质就是需要保证最终数据能够达到一致性,而不需要实时保证系统数据的强一致性。如订单的"支付中"状态,最终会变为“支付成功”或者"支付失败",使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
分布式事务解决方案
以理论为基础,针对不同的分布式场景业界常见的解决方案有2PC、TCC、可靠消息最终一致性、最大努力通知这几种。
两阶段提交:分布式事务两阶段提交——XA方案 Seata方案
TCC方案:分布式事务TCC方案——Hmily方案
可靠消息最终一致性:分布式事务解决方案——可靠消息最终一致性
最大努力通知:分布式事务解决方案——最大努力通知
分布式事务对比分析:
2阶段提交(2PC):
最大的诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞并锁定资源。由于其阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。
TCC方案:
如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。典型的使用场景:满,登录送优惠券等。
可靠消息最终一致性
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注册送积分,登录送优惠券等。
最大努力通知
最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果通知等。
2PC | TCC | 可靠消息 | 最大努力通知 | |
---|---|---|---|---|
一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 |
吞吐量 | 低 | 中 | 高 | 高 |
实现复杂度 | 易 | 难 | 中 | 易 |
总结:
在条件允许的情况下,我们尽可能选择本地事务单数据源,因为它减少了网络交互带来的性能损耗,且避免了数据弱一致性带来的种种问题。若某系统频繁且不合理的使用分布式事务,应首先从整体设计角度观察服务的拆分是否合理,是否高内聚低耦合?是否粒度太小?分布式事务一直是业界难题,因为网络的不确定性,而且我们习惯于拿
分布式事务与单机事务ACID做对比。
无论是数据库层的XA、还是应用层TCC、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。
分布式事务解决方案之2PC(两阶段提交)
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。
整个事务过程由事务管理器和参与者组成,事务管理器负责决策整个分布式事务的提交和回滚,事务参与者负责自己本地事务的提交和回滚。
在计算机中部分关系数据库如Oracle、MySQL支持两阶段提交协议,如下图:
-
1、准备阶段(Prepare phase):事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数据文件)
-
2、提交阶段(commit phase):如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。
下图展示了2PC的两个阶段,分成功和失败两个情况说明:
成功情况:
失败情况:
解决方案
XA方案
2PC的传统方案是在数据库层面实现的,如Oracle、MySQL都支持2PC协议,为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)。
为了让大家更明确XA方案的内容程,下面新用户注册送积分为例来说明:
执行流程如下:
- 1、应用程序(AP)持有用户库和积分库两个数据源。
- 2、应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事务,此时用户和积分资源锁定。
- 3、TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。
- 4、TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
DTP模型定义如下角色:
- AP(Application Program):即应用程序,可以理解为使用DTP分布式事务的程序。
- RM(Resource Manager):即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
- TM(Transaction Manager):事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。
- DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。
以上三个角色之间的交互方式如下:- 1)TM向AP提供 应用程序编程接口,AP通过TM提交及回滚事务。
- 2)TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等。
总结:
整个2PC的事务流程涉及到三个角色AP、RM、TM。AP指的是使用2PC分布式事务的应用程序;RM指的是资源管理器,它控制着分支事务;TM指的是事务管理器,它控制着整个全局事务。
-
1)在准备阶段RM执行实际的业务操作,但不提交事务,资源锁定;
-
2)在提交阶段TM会接受RM在准备阶段的执行回复,只要有任一个RM执行失败,TM会通知所有RM执行回滚操作,否则,TM将会通知所有RM提交该事务。提交阶段结束资源锁释放。
应用落地方案是:atomikos + druid的DruidXADataSource
需要引入maven依赖:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
</dependency>
XA方案的问题:
- 1、需要本地数据库支持XA协议。
- 2、资源锁需要等到两个阶段结束才释放,性能较差。
Seata方案
Seata是由阿里中间件团队发起的开源项目 Fescar,后更名为Seata,它是一个是开源的分布式事务框架。
传统2PC的问题在Seata中得到了解决,它通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。主要优点是性能较好,且不长时间占用连接资源,它以高效并且对业务0侵入的方式解决微服务场景下面临的分布式事务问题,它目前提供AT模式、XA模式、Saga模式和TCC模式的分布式事务解决方案。
Seata的设计思想如下:
Seata的设计目标其一是对业务无侵入,因此从业务无侵入的2PC方案着手,在传统2PC的基础上演进,并解决2PC方案面临的问题。
Seata把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务。
下图是全局事务与分支事务的关系图:
Seata有3个基本组成部分:
-
Transaction Coordinator (TC): 事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收TM指令发起全局事务的提交与回滚,负责与RM通信协调各各分支事务的提交或回滚。
-
Transaction Manager (TM): 事务管理器,TM需要嵌入应用程序中工作,它负责开启一个全局事务,并最终向TC发起全局提交或全局回滚的指令。
-
Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令,驱动分支(本地)事务的提交和回滚。
Seata管理的分布式事务的典型生命周期:
- 1、TM要求TC开始一项新的全局事务,TC生成代表全局事务的XID。
- 2、XID通过微服务的调用链传播。
- 3、RM将本地事务注册为XID到TC的相应全局事务的分支。
- 4、TM要求TC提交或回退XID的相应全局事务。
- 5、TC驱动XID对应的全局事务下的所有分支事务以完成分支提交或回滚。
Seata实现2PC与传统2PC的差别:
-
架构层次方面,传统2PC方案的 RM 实际上是在数据库层,RM 本质上就是数据库自身,通过 XA 协议实现,而Seata的 RM 是以jar包的形式作为中间件层部署在应用程序这一侧的。
-
两阶段提交方面,传统2PC无论第二阶段的决议是commit还是rollback,事务性资源的锁都要保持到Phase2完成才释放。而Seata的做法是在Phase1 就将本地事务提交,这样就可以省去Phase2持锁的时间,整体提高效率。
分布式事务Seata的一致性解读
在微服务的架构下,数据不一致的产生原因。
在微服务的环境下,由于调用链路跨越多个应用,甚至跨越多个数据源,数据的一致性在普通情况下难以保证,导致数据不一致的原因非常多,这里列举了三个最常见的原因
-
1、业务异常一个服务链路调用中,如果调用的过程出现业务异常,产生异常的应用独立回滚,非异常的应用数据已经持久化到数据库。
-
2、网络异常调用的过程中,由于网络不稳定,导致链路中断,部分应用业务执行完成,部分应用业务未被执行。
-
3、服务不可用若服务不可用,无法被正常调用,也会导致问题的产生。
在以往如果出现数据不一致的问题,相信大多数的解决方案是这样的:
- 1、人工补偿数据。
- 2、定时任务检查和补偿数据。
但是这两种方式的缺点也是显然意见的,一种是浪费大量的人力成本和时间,另外一种是浪费大量的系统资源去检查数据是否一致和额外的人力成本。
原理
在接触一项新技术之前,我们应该先从宏观的角度去理解它大概包含些什么。在Seata中,它大概分为以下三个角色。
- 黄色,Transaction Manager(TM),client端
- 蓝色,Resource Manager(RM),client端
- 绿色,Transaction Coordinator(TC),server端
你可以根据颜色,名字,缩写甚至客户端/服务端去区分这三者的关系,同时简单去理解它们每一个自身的职责大概是要干些什么事情,后面的讲解我也会保持一样的颜色和名字来区分它们。
Seata其中只一个核心是数据源代理,意味着在你执行一句Sql语句时,Seata会帮你在执行之前和之后做一些额外的操作,从而保证数据的一致性,并且尽可能做到无感知,让你使用起来感觉非常方便和神奇。这里首先要去理解两个知识点。
- 前置镜像(Before Image):保存数据变更前的样子。
- 后置镜像(After Image):保存数据变更后的样子。
- Undo Log:保存镜像。
有时候新项目接入的时候,有同事会问,为什么事务不生效,如果你也遇到过同样的问题,那首先要检查一下自己的数据源是否已经代理成功。
当执行一句Sql时,Seata会尝试去获取这条/批数据变更前的内容,并保存到前置镜像中(Insert语句没有前置镜像),然后执行业务Sql,执行完后会尝试去获取这条/批数据变更后的内容,并保存到后置镜像中(Delete语句没有后置镜像),之后会进行分支事务注册,TC在收到分支事务注册请求时,会持久化这些分支事务信息和根据操作数据的主键为维度作为全局锁并持久化,可选持久化方式有:
- file
- db
- redis
在收到TC返回的分支注册成功响应后,会把镜像持久化到应用所在的数据源的Undo Log表中,最后提交本地事务。
以上所有操作都会保证在同一个本地事务中,保证业务操作和Undo Log操作的原子性。
一阶段
理解了单个应用的处理流程,再从一个完全的调用链路,去看Seata的处理过程,相信理解起来会简单很多:
-
1、首先一个使用了@GlobalTransactional的接口被调用,Seata会对其进行拦截,拦截的角色我们称之为TM,这个时候会访问TC开启一个新的全局事务,TC收到请求后会生成XID和全局事务信息并持久化,然后返回XID。
-
2、在每一层的调用链路中,XID都必须往下传递,然后每一层都经过之前说过的处理逻辑,直到执行完成/异常抛出。
直到这里,一阶段已经执行完成。
注意:如果发现事务不生效,需要检查XID是否成功往下传递。
二阶段提交
如果在整个调用链路的过程,没有发生任何异常,那么二阶段提交的过程是非常简单而且非常的高效,只有两步:
- 1、TC清理全局事务对应的信息。
- 2、RM清理对应Undo Log信息。
二阶段回滚
若调用过程中出现异常,会自动触发反向回滚
反向回滚表示,如果调用链路顺序为 A -> B -> C,那么回滚顺序为 C -> B -> A。
例:A=Insert,B=Update,如果回滚时不按照反向的顺序进行回滚,则有可能出现回滚时先把A删除了,再更新A,引发错误。
在回滚的过程中有可能会遇到一种非常极端的情况,回滚到对应的模块时,找不到对应的Undo Log,这种情况主要发生在:
- 分支事务注册成功,但是由于网络原因收不到成功的响应,Undo Log未被持久化。
- 同时全局事务超时(超时时间可自由配置)触发回滚。
这时候RM会持久化一个特殊的Undo Log,状态为GlobalFinished。由于这个全局事务已经回滚,需要防止网络恢复时,未持久化Undo Log的应用收到了分支注册成功的响应和持久化Undo Log,并提交本地最终引发的数据不一致。
读已提交
由于在一阶段的时候,数据已经保存到数据库并提交,所以Seata默认的隔离级别为读未提交,如果需要把隔离级别提升至读已提交则需要使用@GlobalLock标签并且在查询语句上加上for update
@GlobalLock
@Transactional
public PayMoneyDto detail(ProcessOnEventRequestDto processOnEventRequestDto) {
return baseMapper.detail(processOnEventRequestDto.getProcessInfoDto().getBusinessKey())
}
@Mapper
public interface PayMoneyMapper extends BaseMapper<PayMoney> {
@Select("select id, name, amount, account, has_repayment, pay_amount from pay_money m where m.business_key = #{businessKey} for update")
PayMoneyDto detail(@Param("businessKey") String businessKey);
}
这个时候Seata会对添加了for update的查询语句进行代理:
如果一个全局事务1正在操作,并且未进行二阶段提交/回滚的时候,全局锁是被全局事务1锁持有的,同时另外一个全局事务2尝试去查询相同的数据,由于查询语句被代理,seata会尝试去获取这条数据的全局锁,直到获取成功/失败(重试次数达到配置值)为止。
问题
总体来说遇到的问题不算多,解决起来也比较容易,比如以下这个问题:
经过排查发现,由于Seata会使用jdbc标准接口尝试获取业务操作所对应的表结构,由于表结构改动频率较少,并且考虑到表结构变更后应用会进行重启,所以会对表结构进行缓存,如果表结构改动后不对应用进行重启,有可能引发构建镜像时出现NullPointerException。下面贴出关键代码
@Override
public TableMeta getTableMeta(final Connection connection, final String tableName, String resourceId) {
if (StringUtils.isNullOrEmpty(tableName)) {
throw new IllegalArgumentException("TableMeta cannot be fetched without tableName");
}
TableMeta tmeta;
final String key = getCacheKey(connection, tableName, resourceId);
//错误关键处,尝试从缓存获取表结构
tmeta = TABLE_META_CACHE.get(key, mappingFunction -> {
try {
return fetchSchema(connection, tableName);
} catch (SQLException e) {
LOGGER.error("get table meta of the table `{}` error: {}", tableName, e.getMessage(), e);
return null;
}
});
if (tmeta == null) {
throw new ShouldNeverHappenException(String.format("[xid:%s]get table meta failed," +
" please check whether the table `%s` exists.", RootContext.getXID(), tableName));
}
return tmeta;
}
修改表结构,需要对应用进行重启,即可解决此问题,非常简单
第二个遇到的问题就是运行一段时间后,发现branch_table和lock_table存在数据残留,并且根据xid查询global_table没有对应的数据,导致后续操作相同的数据行会出现获取全局锁失败,并且会每隔一段时间小量出现。这个异常隐藏的比较深,而且在开发环境和测试环境无法复现,通过跟踪源码和总结原因发现,是由于开启了Mysql主从,导致提交/回滚时,Seata通过xid查询分支事务时,数据未同步到从库,导致遗漏了一部分分支事务数据。
源码部分
@Override
public GlobalStatus commit(String xid) throws TransactionException {
//根据xid查询信息,如果开启主从,会有可能导致查询信息不完整
GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
if (globalSession == null) {
return GlobalStatus.Finished;
}
globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
// just lock changeStatus
boolean shouldCommit = SessionHolder.lockAndExecute(globalSession, () -> {
// Highlight: Firstly, close the session, then no more branch can be registered.
globalSession.closeAndClean();
if (globalSession.getStatus() == GlobalStatus.Begin) {
if (globalSession.canBeCommittedAsync()) {
globalSession.asyncCommit();
return false;
} else {
globalSession.changeStatus(GlobalStatus.Committing);
return true;
}
}
return false;
});
if (shouldCommit) {
boolean success = doGlobalCommit(globalSession, false);
//If successful and all remaining branches can be committed asynchronously, do async commit.
if (success && globalSession.hasBranch() && globalSession.canBeCommittedAsync()) {
globalSession.asyncCommit();
return GlobalStatus.Committed;
} else {
return globalSession.getStatus();
}
} else {
return globalSession.getStatus() == GlobalStatus.AsyncCommitting ? GlobalStatus.Committed : globalSession.getStatus();
}
}
@Override
public GlobalStatus rollback(String xid) throws TransactionException {
//根据xid查询信息,如果开启主从,会有可能导致查询信息不完整
GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
if (globalSession == null) {
return GlobalStatus.Finished;
}
globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
// just lock changeStatus
boolean shouldRollBack = SessionHolder.lockAndExecute(globalSession, () -> {
globalSession.close(); // Highlight: Firstly, close the session, then no more branch can be registered.
if (globalSession.getStatus() == GlobalStatus.Begin) {
globalSession.changeStatus(GlobalStatus.Rollbacking);
return true;
}
return false;
});
if (!shouldRollBack) {
return globalSession.getStatus();
}
doGlobalRollback(globalSession, false);
return globalSession.getStatus();
}
部署-高可用
Seata和其他中间件的高可用部署方式差别不大,如图片所示,确保应用服务和TC访问相同的注册中心和配置中心,同时只需要启动多台TC,并将store.mode改为db模式即可完成高可用部署,并选择合适的注册中心和配置中心即可,目前支持的配置中心有
- nacos
- consul
- etcd3
- eureka
- redis
- sofa
- zookeeper
可选的配置中心有
- nacos
- etcd3
- consul
- apollo
- zk
部署-单节点多应用
当然也有更加灵活的部署方式,通过vgoup-mapping(事务集群),可以做到单节点多应用的隔离,比如A应用和B应用访问A-Group的两个TC,C应用和D应用访问B-Group的两个TC,E应用和F应用访问C-Group的两个TC。
部署-异地容灾
通过vgoup-mapping也可以做到异地容灾,当原有集群出现不可用时,可以通过变更配置立刻转移到备用的集群上。此处以Nacos作为注册中心举例,TC配置方式如下:
# 广州机房
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
loadBalance = "RandomLoadBalance"
loadBalanceVirtualNodes = 10
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
cluster = "Guangzhou"
username = ""
password = ""
}
}
# 上海机房
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
loadBalance = "RandomLoadBalance"
loadBalanceVirtualNodes = 10
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
cluster = "Shanghai"
username = ""
password = ""
}
}
基于可靠消息的分布式事务
项目使用技术
springboot、dubbo、zookeeper、定时任务、消息中间件MQ
一、项目结构
maven父子工程:
父工程:consis
子工程:api-service、order、product、message
api-service:该项目主要是提供接口调用的,还包含实体类、枚举等一些通用内容
order:该项目是专门处理订单相关操作的系统
product:该项目是专门处理产品相关操作的系统
message:该项目是提供消息服务的系统,好包括定时任务
介绍下各个系统的实现
二、order订单系统
核心代码:
@Override
@Transactional
public void add(Orders order) {
String messageBody = JSONObject.toJSONString( order );
//添加消息到数据库
String messageId = transactionMessageService.savePreparMessage(order.getMessageId(), messageBody, Constant.ORDER_QUEUE_NAME );
log.info(">>> 预发送消息,消息编号:{}", messageId);
boolean flag = false;
boolean success = false;
try{
Orders orders = orderDao.saveAndFlush( order );
//int i = 1/0 ;
log.info(">>> 插入订单,订单编号:{}", orders.getId());
flag = true;
}catch (Exception e){
transactionMessageService.delete( messageId );
log.info(">>> 业务执行异常删除消息,消息编号:{}", messageId, e);
throw new RuntimeException( ">>> 创建订单失败" );
}finally {
if(flag){
try {
transactionMessageService.confirmAndSend( messageId );
success = true;
log.info(">>> 确认并且发送消息到实时消息中间件,消息编号:{}", messageId);
}catch (Exception e){
log.error(">>> 消息确认异常,消息编号:{}", messageId, e);
if(!success){
transactionMessageService.delete( messageId );
throw new RuntimeException( ">>> 确认消息异常,创建订单失败" );
}
}
}
}
}
- 插入订单表之前,首先创建预发送消息,保存到事务消息表中,此时消息状态为:未发送
- 插入订单,如果插入订单失败则将事务消息表中预发送消息删除
- 插入订单成功后,修改消息表预发送消息状态为发送中,并发送消息至mq
- 如果发送消息失败,则订单回滚并删除事务消息表消息
三、message消息系统
核心代码一:
@Override
public void sendMessageToMessageQueue(String queueName,final String messageBody) {
jmsTemplate.convertAndSend( queueName,messageBody );
log.info(">>> 发送消息到mq 队列:{},消息内容:{}", queueName, messageBody);
}
- 主要是activemq生产者讲消息发送至MQ消息中间件
核心代码二:
/**
* 定时重发消息(每分钟)
*/
@Scheduled(cron = "0 */1 * * * ?")
public void handler(){
//查询transaction_message表中已发送但未被删除的消息
List<TransactionMessage> list = transactionMessageService.queryRetryList( Constant.MESSAGE_UNDEAD, maxTimeOut, Constant.MESSAGE_SENDING );
if(list!=null && list.size() > 0){
for (TransactionMessage message:list){
try {
transactionMessageService.retry( message.getMessageId() );
} catch (Exception e) {
log.warn(">>> 消息不存在,可能已经被消费,消息编号:{}", message.getMessageId());
}
}
}
}
/**
* 定时通知工作人员(每隔5分钟)
*/
@Scheduled(cron = "0 */5 * * * ?")
public void advance(){
List<Long> messages = transactionMessageService.queryDeadList();
log.warn(">>> 共有:{}条消息需要人工处理", messages.size());
String ids = JSONObject.toJSONString( messages );
//发邮件或者是发送短信通知工作人员处理
}
- 定时重发消息
- 定时将死亡的消息通知给工作人员,进行人工补偿操作
四、product产品系统
核心代码:
@Transactional
@JmsListener( destination = Constant.ORDER_QUEUE_NAME)
public void receiveQueue(String msg){
boolean flag = false;
Orders orders = JSONObject.parseObject( msg, Orders.class );
log.info(">>> 接收到mq消息队列,消息编号:{} ,消息内容:{}", orders.getMessageId(), msg);
TransactionMessage transactionMessage = transactionMessageService.findByMessageId( orders.getMessageId() );
try {
//保证幂等性
if(transactionMessage!=null){
List<OrderDetail> list = orders.getList();
for(OrderDetail detail : list){
Product product = productService.findById( detail.getId() );
Long skuNum = product.getProductSku() - detail.getNum();
if(skuNum >= 0){
product.setProductSku( skuNum );
productService.update( product );
}else {
throw new Exception( ">>> 库存不足,修改库存失败!" );
}
}
//int i = 1 /0 ;
flag = true;
}
}catch (Exception e){
e.printStackTrace();
throw new RuntimeException( e );
}finally {
if(flag){
transactionMessageService.delete( orders.getMessageId() );
DbLog dbLog = dbLogService.findByMesageId( orders.getMessageId() );
if(dbLog!=null){
dbLog.setState( "1" );//已处理成功
dbLogService.update( dbLog );
}
log.info(">>> 业务执行成功删除消息! messageId:{}", orders.getMessageId());
}
}
}
- 从mq消息中间件中监听并消费消息,将json消息转为订单对象
- 根据消息编号查询该消息是否已被消费,保证幂等性
- 如果消息未被消费(即存在此消息),则产品表扣减库存;如果已经消费(不存在此消息),则不做处理
- 产品表扣减库存成功,则删除此消息,如果待处理消息日志表中有此消息,则更改状态为1,表示已处理;扣减失败,则不做处理