引言
分布式事务一直都是一个令人头疼的问题,但也是高频的面试考点,很多同学都栽在上面,与offer失之交臂,本文将带你全面剖析分布式事务,由浅入深,理论+实践助你全面掌握分布式事务,吊打面试官!
通过本文,你可以掌握以下内容:
-
了解什么是分布式事务以及其产生的原因
-
掌握几种分布式事务解决方案:XA、TCC、消息事务、AT
-
掌握分布式事务各种解决方案的优缺点和使用场景
-
学会使用Seata来解决分布式事务
下面让我们一起来进入正题吧!
何为分布式事务
要了解分布式事务,必须先了解本地事务。
1 本地事务
本地事务即传统的单机数据库事务,必须具备ACID原则:
1.1 原子性(A)
所谓的原子性就是说,在整个事务中的所有操作,都是一个整体,要么全部完成,要么全部不做,不存在中间状态。如果事务在执行中发生错误,则所有的操作都会被回滚,整个事务就像从没被执行过一样。
1.2 一致性(C)
一致性指事务从开始之前到事务结束以后,数据的完整性没有被破坏,举个例子:A有100元,B有100元,如果在一个事务中A成功转给B50元,那么不管发生什么,那么最后A和B的数据之和必须是200元,否则事务的一致性就被破坏,如A少了50元,但B没有增加50元,显然这是不可接受的!
1.3 隔离性(I)
隔离性是指事务与事务之间不会互相影响,一个事务的中间状态不会被其他事务感知。数据库保证隔离性包括四种不同的隔离级别:
Read Uncommitted(读未提交)
Read Committed(读已提交)
Repeatable Read(可重复读)
Serializable(串行化)
1.4 持久性(D)
只要事务被提交,那么事务对数据所做的变更就完全保存在了数据库中,即使发生停电,系统宕机也是如此,这便是持久性。
在传统项目中,系统基本都是单点部署:即单个服务器,单个数据库。这种情况下,数据库本身的事务机制就能保证ACID的原则,这样的事务就是本地事务。
本地事务以mysql为例,原子性和持久性是靠undo和redo日志来实现的。
1.5 undo和redo日志在整个数据库系统中,有两种数据库日志与事务有关:undo和redo日志。
1.5.1 undo日志
事务具备原子性(Atomicity),如果事务执行失败,需要把数据回滚,原子性则使用undo log来实现。
undo log为了满足事务的原子性,在操作任何数据之前,首先将数据备份到undo log。然后进行数据的修改。如果出现了错误或者用户执行了roolback语句,系统可以利用undo log中的备份将数据恢复到事务开始之前的状态。
1.5.2 redo日志
和undo log相反,redo log记录的是新数据的备份。
undo+redo的事务执行过程:
假设有A,B两条数据,值分别为0和1,分别修改为1和2:
-
开始事务
-
记录 A=0 到 undo log 缓存区
-
修改 A=1 到内存
-
记录 A=1 到 redo log 缓存区
-
记录 B=1 到 undo log 缓存区
-
修改 B=2 到内存
-
记录 B=2 到 redo log 缓存
-
将 undo log 写入磁盘
-
将 redo log 写入磁盘
-
提交事务
如上步骤,如果任意一步出现错误,都可以根据 undo log 进行回滚操作,保证了原子性,同时 log 写入磁盘又保证了持久性!
在事务提交前,只要将redo log持久化即可,不需要将数据写入磁盘进行持久化,减少了IO的次数。
因为之前数据已写入到了内存中,所以事务提交后,即使数据没有被写入磁盘我们从内存中读取的数据仍然是正确的,事务提交后,数据库会把内存中的数据异步刷入磁盘(也可设定固定频率刷新内存数据到磁盘)。
简单概括一下:undo log 记录更新前的数据,用于保证事务原子性,redo log 记录更新后的数据,保证事务持久性。
2 分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务。
单库单表不足以支撑现有的业务数据,从而进行分库分表,于是便产生了跨数据源的分布式事务。
随着业务体系的膨胀,单体架构逐渐成为业务发展瓶颈,解决系统的高耦合,可伸缩问题的需求越来越强烈,于是将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,随之也产生了跨服务的分布式事务。
在实际的生产环境中,分布式事务往往是既跨数据源又跨服务的,例如电商系统中最常见的下单付款,包括以下行为:
-
创建订单
-
扣除商品库存
-
从账户余额扣除金额
完成以上操作需要访问三个不同的服务和数据库:
在分布式环境下,肯定会出现部分操作成功、部分操作失败的问题,比如:订单生成了,库存也扣减了,但用户账户的余额不足,这就造成数据不一致。
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
但当我们把三件事情看做一个整体,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的情况,这就是分布式系统下的事务了。
此时ACID难以满足,这是分布式事务要解决的问题!
分布式事务的几种解决方案
说解决方案之前,不得不提一下CAP定理和BASE理论,它可以让我们了解到为什么分布式系统下,难以满足事务的ACID原则。
1 CAP定理
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
-
一致性(Consistency)
-
可用性(Availability)
-
分区容错性(Partition tolerance)
它们的首字母分别是 C、A、P,Eric Brewer说这三个指标不可能被同时满足,最多只能同时满足两个,这个结论被称作CAP定理:
1.1 分区容错性(Partition tolerance)
大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在北京,另一台服务器放在上海,它们属于两个区,之间可能因网络问题无法通信。
一般来说,分布式系统,分区容错是必须的,因此可以认为 CAP 的 P 总是成立,那么剩下的 C 和 A 只能取其一。
1.2 一致性(Consistency)
一致性是指写操作之后的读操作,必须返回该值。举例来说,某条记录是 v0,用户想节点 G1 发起一个写操作,将其改为 v1,接下来,用户无论从哪个节点读取这条记录,返回的值应该都是v1,否则就不满足一致性了。
1.3 可用性(Availability)
可用性是指只要服务接收到用户请求,无论对错,都必须给予响应,用户可以向节点 G1 或 G2 发起读请求,这时 G1 或 G2 必须告诉用户,读取的值是 v0 还是 v1,否则便不满足可用性。
1.4 一致性和可用性的矛盾
一致性和可用性,为什么不可能同时成立?答案很简单,因为可能出现通信失败或延迟的情况。
如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性。
反之,如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。
综上,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。
2 BASE理论
这里的BASE是三个英文单词的缩写:
-
Basically Available(基本可用)
-
Soft state(软状态)
-
Eventually consistent(最终一致性)
我们解决分布式事务,就是根据上述理论来实现。以上一节的下单减库存扣款为例:订单服务、库存服务、用户服务及它们对应的数据库就是分布式应用中的三个部分。
-
CP方式:现在如果要满足事务的强一致性,就必须在订单服务数据库锁定的同时,对库存服务、用户服务数据资源同时锁定。等待三个服务业务全部处理完成,才可以释放资源。此时如果有其他请求想要操作被锁定的资源就会被阻塞,这样就是满足了CP,这就是强一致,弱可用
-
AP方式:三个服务的对应数据库各自独立执行自己的业务,执行本地事务,不要求互相锁定资源。但是这个中间状态下,我们去访问数据库,可能遇到数据不一致的情况,不过我们需要做一些后补措施,保证在经过一段时间后,数据最终满足一致性,这就是高可用,但弱一致(最终一致)。
由上面的两种思想,延伸出了很多分布式事务解决方案:
-
XA
-
TCC
-
可靠消息最终一致
-
AT
2.1 XA(两阶段提交)
分布式事务的解决手段之一,就是两阶段提交协议(2PC:Two-Phase Commit),那么到底什么是两阶段提交协议呢?1994 年,X/Open 组织(即现在的 Open Group )定义了分布式事务处理的DTP 模型。该模型包括这样几个角色:
-
应用程序( AP ):我们的微服务
-
事务管理器( TM ):全局事务管理者
-
资源管理器( RM ):一般是数据库
-
通信资源管理器( CRM ):是TM和RM间的通信中间件
在该模型中,一个分布式事务(全局事务)可以被拆分成许多个本地事务,运行在不同的AP和RM上。每个本地事务的ACID很好实现,但是全局事务必须保证其中包含的每一个本地事务都能同时成功,若有一个本地事务失败,则所有其它事务都必须回滚。但问题是,本地事务处理过程中,并不知道其它事务的运行状态。因此,就需要通过CRM来通知各个本地事务,同步事务执行的状态。
因此,各个本地事务的通信必须有统一的标准,否则不同数据库间就无法通信。XA就是 X/Open DTP中通信中间件与TM间联系的接口规范,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须实现这些接口。
两阶段提交协议应运而生,即将全局事务拆分成两个阶段来执行:
-
阶段一:准备阶段,各个本地事务完成本地事务的准备工作。
-
阶段二:执行阶段,各个本地事务根据上一阶段执行结果,进行提交或回滚。
整个过程中需要一个协调者(coordinator),还有事务的参与者(voter)。
正常情况:
投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行成功的信息(agree)
提交阶段:协调组发现每个参与者都可以执行事务(agree),于是向各个事务参与者发出commit指令,各个事务参与者提交事务。
异常情况:
投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行结果,但只要有一个参与者返回的是Disagree,则说明执行失败。
提交阶段:协调组发现有一个或多个参与者返回的是Disagree,认为执行失败。于是向各个事务参与者发出abort指令,各个事务参与者回滚事务。
两阶段提交的缺陷:
-
单点故障问题:假设coordinator和voter3都在Commit这个阶段crash了, 而voter1和voter2没有收到commit消息. 这时候voter1和voter2就陷入了一个困境. 因为他们并不能判断现在是上轮全票通过然后voter3第一个收到了commit的消息并在commit操作之后crash了还是上轮voter3投票没有通过。
-
阻塞问题:在准备阶段、提交阶段,每个事物参与者都会锁定本地资源,并等待其它事务的执行结果,阻塞时间较长,资源锁定时间太久,因此执行的效率会比较低。
使用场景:
对事务有强一致性要求,对事务执行效率不敏感,并且不希望有太多代码侵入。但由于两阶段提交的阻塞和资源锁定问题,且XA目前对商业数据库的支持较为理想,mysql支持并不理想,所以实际使用的场景很少。
2.2 TCC
TCC模式可以解决2PC中的资源锁定和阻塞问题,减少资源锁定时间,它本质是一种补偿的思路。
事务运行过程包括三个方法:
-
Try:资源的检测和预留
-
Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功
-
Cancel:如果失败则释放预留资源
执行分两个阶段:
-
准备阶段(try):资源的检测和预留
-
执行阶段(confirm/cancel):根据上一步结果,判断下面的执行方法。如果上一步中所有事务参与者都成功,则这里执行confirm,反之,执行cancel
以之前的下单业务中的扣减余额为例来看下三个不同的方法要怎么编写,假设账户A原来余额是100,需要余额扣减30元。如图:
优势和缺点:
优势:TCC执行的每一个阶段都会提交本地事务并释放锁,并不需要等待其它事务的执行结果。而如果其它事务执行失败,最后不是回滚,而是执行补偿操作。这样就避免了资源的长期锁定和阻塞等待,执行效率比较高,属于性能比较好的分布式事务方式。
缺点:代码侵入:需要人为编写代码实现try、confirm、cancel,代码侵入较多;开发成本高:一个业务需要拆分成3个步骤,分别编写业务实现,业务编写比较复杂;安全性考虑:cancel动作如果执行失败,资源就无法释放,需要引入重试机制,而重试可能导致重复执行,还要考虑重试时的幂等问题。
使用场景:对事务有一定的一致性要求(最终一致);对性能要求较高;开发人员具备较高的编码能力和幂等处理经验。实际开发中其实也不常用。
2.3 可靠消息服务
这种实现方式的思路,其实是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。
一般分为事务的发起者A和事务的其它参与者B:
-
事务发起者A执行本地事务
-
事务发起者A通过MQ将需要执行的事务信息发送给事务参与者B
-
事务参与者B接收到消息后执行本地事务
几个注意事项:
-
事务发起者A必须确保本地事务成功后,消息一定发送成功
-
MQ必须保证消息正确投递和持久化保存
-
事务参与者B必须确保消息最终一定能消费,如果失败需要多次重试
-
事务B执行失败,会重试,但不会导致事务A回滚
那么我们如何保证消息发送一定成功?如何保证消费者一定能收到消息?答案就是使用本地消息表,把消息持久化到数据库中:
事务发起者:
-
开启本地事务
-
执行事务相关业务
-
发送消息到MQ
-
把消息持久化到数据库,标记为已发送
-
提交本地事务
事务接收者:
-
接收消息
-
开启本地事务
-
处理事务相关业务
-
修改数据库消息状态为已消费
-
提交本地事务
额外的定时任务:
-
定时扫描表中超时未消费消息,重新发送
优缺点:
优点:与tcc相比,实现方式较为简单,开发成本低。
缺点:数据一致性完全依赖于消息服务,因此消息服务必须是可靠的;需要处理被动业务方的幂等问题;被动业务失败不会导致主动业务的回滚,而是重试被动的业务;事务业务与消息发送业务耦合、业务数据与消息表要在一起。
为了解决上述问题,我们会引入一个独立的消息服务,来完成对消息的持久化、发送、确认、失败重试等一系列行为,大概的模型如下:
时序图如下:
事务发起者A的基本执行步骤:
-
开启本地事务
-
通知消息服务,准备发送消息(消息服务将消息持久化,标记为准备发送)
-
执行本地业务:执行失败则终止,通知消息服务,取消发送(消息服务修改订单状态);执行成功则继续,通知消息服务,确认发送(消息服务发送消息、修改订单状态)
-
提交本地事务
消息服务本身提供以下接口:
-
准备发送:把消息持久化到数据库,并标记状态为准备发送
-
取消发送:把数据库消息状态修改为取消
-
确认发送:把数据库消息状态修改为确认发送。尝试发送消息,成功后修改状态为已发送
-
确认消费:消费者已经接收并处理消息,把数据库消息状态修改为已消费
-
定时任务:定时扫描数据库中状态为确认发送的消息,然后询问对应的事务发起者,事务业务执行是否成功,结果:业务执行成功:尝试发送消息,成功后修改状态为已发送;业务执行失败:把数据库消息状态修改为取消
事务参与者B的基本步骤:
-
接收消息
-
开启本地事务
-
执行业务
-
通知消息服务,消息已经接收和处理
-
提交事务
优缺点:
优点:解除了事务业务与消息相关业务的耦合。
缺点:实现复杂。
RabbitMQ的消息确认:
RabbitMQ确保消息不丢失的思路比较奇特,并没有使用传统的本地表,而是利用了消息的确认机制。
生产者确认机制:确保消息从生产者到达MQ不会有问题:
-
消息生产者发送消息到RabbitMQ时,可以设置一个异步的监听器,监听来自MQ的ACK
-
MQ接收到消息后,会返回一个回执给生产者:消息到达交换机后路由失败,会返回失败ACK;消息路由成功,持久化失败,会返回失败ACK;消息路由成功,持久化成功,会返回成功ACK
-
生产者提前编写好不同回执的处理方式:失败回执:等待一定时间后重新发送;成功回执:记录日志等行为
消费者确认机制:确保消息能够被消费者正确消费:
-
消费者需要在监听队列的时候指定手动ACK模式
-
RabbitMQ把消息投递给消费者后,会等待消费者ACK,接收到ACK后才删除消息,如果没有接收到ACK消息会一直保留在服务端,如果消费者断开连接或异常后,消息会投递给其它消费者。
-
消费者处理完消息,提交事务后,手动ACK。如果执行过程中抛出异常,则不会ACK,业务处理失败,等待下一条消息
经过上面的两种确认机制,可以确保从消息生产者到消费者的消息安全,再结合生产者和消费者两端的本地事务,即可保证一个分布式事务的最终一致性。
消息事务的优缺点:
总结上面的几种模型,消息事务的优缺点如下:
优点:业务相对简单,不需要编写三个阶段业务;是多个本地事务的结合,因此资源锁定周期短,性能好
缺点:代码侵入;依赖于MQ的可靠性;消息发起者可以回滚,但是消息参与者无法引起事务回滚;事务时效性差,取决于MQ消息发送是否及时,还有消息参与者的执行情况
针对事务无法回滚的问题,有人提出说可以在事务参与者执行失败后,再次利用MQ通知消息服务,然后由消息服务通知其他参与者回滚。那么,恭喜你,你利用MQ和自定义的消息服务再次实现了 2PC 模型,又造了一个大轮子!
使用场景:对事务有一定的一致性要求(最终一致),保证消息服务稳定可靠。
2.4 AT
2019年 1 月份,Seata 开源了 AT 模式。AT 模式是一种无侵入的分布式事务解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入、编码复杂等问题。
在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
基本原理
先来看一张流程图:
有没有感觉跟TCC的执行很像,都是分两个阶段:
-
一阶段:执行本地事务,并返回执行结果
-
二阶段:根据一阶段的结果,判断二阶段做法:提交或回滚
但AT模式底层做的事情完全不同,而且第二阶段根本不需要我们编写,全部由Seata自己实现了。也就是说:我们写的代码与本地事务时代码一样,无需手动处理分布式事务。
那么,AT模式如何实现无代码侵入,如何帮我们自动实现二阶段代码的呢?
在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后获取全局行锁,提交事务。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
这里的before image和after image类似于数据库的undo和redo日志,但其实是用数据库模拟的:
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理:
不过因为有全局锁机制,所以可以降低出现脏写的概率。
AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。
Seata中的几个基本概念:
-
TC(Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,驱动全局事务提交或回滚(TM之间的协调者)。
-
TM(Transaction Manager) - 事务管理器:定义全局事务的范围:开始全局事务、提交或回滚全局事务。
-
RM(Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
一阶段:
-
TM开启全局事务,并向TC声明全局事务,包括全局事务XID信息
-
TM所在服务调用其它微服务
-
微服务,主要由RM来执行:查询before_image;执行本地事务;查询after_image;生成undo_log并写入数据库;向TC注册分支事务;告知事务执行结果;获取全局锁(阻止其它全局事务并发修改当前数据);释放本地锁(不影响其它业务对数据的操作)
-
待所有业务执行完毕,事务发起者(TM)会尝试向TC提交全局事务
二阶段:
-
TC统计分支事务执行情况,根据结果判断下一步行为:分支都成功:通知分支事务,提交事务;有分支执行失败:通知执行成功的分支事务,回滚数据
-
分支事务的RM:提交事务:直接清空before_image和after_image信息,释放全局锁;回滚事务:校验after_image,判断是否有脏写;如果没有脏写,回滚数据到before_image,清除before_image和after_image;如果有脏写,请求人工介入
优缺点
优点:与2PC(XA)相比:每个分支事务都是独立提交,无需持有数据库的锁到事务结束,而是在一阶段的时候默认提交,减少了资源阻塞的时间,那么此时如果其他事务对未完成的事务的数据进行查询时,必然会查到一个脏数据(因为数据状态无法确定,可能这个数据会回滚),在Seata的实现中,会利用代理select for update语句的方式,去TC端查询该数据是否目前正在被操作,如果没有,那么数据就可以立马读取,而正在被操作将会和XA一样进行阻塞,实现强一致;与TCC相比:二阶段的执行操作全部自动化生成,无代码侵入,开发成本低。
缺点:与TCC相比,需要动态生成二阶段的反向补偿操作,执行性能略低于TCC。
使用场景
适用于绝大多数场景,如果对系统性能没有严格的要求,推荐使用,AT 性能介于 XA 和 TCC 之间,取长补短,是一种较为完美的解决方案。
seata实战
Seata中最常用的是AT模式,这里我们拿AT模式来做演示,看看如何在SpringCloud微服务中集成Seata。
我们假定一个用户购买商品的业务逻辑。整个业务逻辑由3个微服务提供支持:
-
仓储服务:对给定的商品扣除仓储数量
-
订单服务:根据采购需求创建订单
-
帐户服务:从用户帐户中扣除余额
流程图:
订单服务在下单时,同时调用库存服务和用户服务,此时就会发生跨服务和跨数据源的分布式事务问题。
1 数据准备
创建数据库seata_demo,执行以下sql:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`money` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of account_tbl
-- ----------------------------
INSERT INTO `account_tbl` VALUES (1, 'user202003032042012', 1000);
-- ----------------------------
-- Table structure for order_tbl
-- ----------------------------
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) NULL DEFAULT 0,
`money` int(11) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Table structure for storage_tbl
-- ----------------------------
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of storage_tbl
-- ----------------------------
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);
-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime(0) NOT NULL,
`log_modified` datetime(0) NOT NULL,
`ext` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
SET FOREIGN_KEY_CHECKS = 1;
其中包含order(订单)表,storage_tbl(商品库存)表,account_tbl(用户)表,undo_log(Seata中的事务日志表,中会包含after_image和before_image数据,用于数据回滚)
用idea打开seata-demo微服务项目,源码可以在文末得到获取方式,项目结构如下:
结构说明:
-
account-service:用户服务,提供操作用户账号余额的功能,端口8083
-
eureka-server:注册中心,端口8761
-
order-service:订单服务,提供根据数据创建订单的功能,端口8082
-
storage-service:仓储服务,提供扣减商品库存功能,端口8081
2 准备TC服务在之前讲解Seata原理的时候,我们就聊过,其中包含重要的3个角色:
-
TC:事务协调器
-
TM:事务管理器TM
-
RM:资源管理器
其中,TC是一个独立的服务,负责协调各个分支事务,而TM和RM通过jar包的方式,集成在各个事务参与者中。
因此,首先我们需要搭建一个独立的TC服务。
首先去官网下载TC的服务端安装包,也可以在文末获取,这里我们使用1.1.0版本的安装包:
解压,目录结构如下:
Seata的核心配置主要是两部分:
-
注册中心的配置:在conf目录中,一般是registry.conf文件
-
当前服务的配置,两种配置方式:通过分布式服务的统一配置中心,例如eureka;通过本地文件
打开registry.conf,进行配置:
registry {
# 指定注册中心类型,这里使用eureka类型
type = "eureka"
# 各种注册中心的配置。。这里保留了eureka和Zookeeper
eureka {
serviceUrl = "http://127.0.0.1:8761/eureka"
application = "seata_tc_server"
weight = "1"
}
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
}
}
config {
# 配置文件方式,可以支持 file、nacos 、apollo、zk、consul、etcd3
type = "file"
nacos {
serverAddr = "localhost"
namespace = ""
group = "SEATA_GROUP"
}
zk {
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
}
file {
name = "file.conf"
}
}
这个文件主要配置两个内容:
-
注册中心的类型及地址,本例我们选择eureka做注册中心
-
配置中心的类型及地址,本例我们选择本地文件做配置,在当前目录的file.conf文件中进行配置
再看file.conf文件:
## transaction log store, only used in seata-server
store {
## store mode: file、db
mode = "file"
## file store property
file {
## store location dir
dir = "sessionStore"
# branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
maxBranchSessionSize = 16384
# globe session size , if exceeded throws exceptions
maxGlobalSessionSize = 512
# file buffer size , if exceeded allocate new buffer
fileWriteBufferCacheSize = 16384
# when recover batch read size
sessionReloadReadSize = 100
# async, sync
flushDiskMode = async
}
## database store property
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
datasource = "dbcp"
## mysql/oracle/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
url = "jdbc:mysql://127.0.0.1:3306/seata_demo"
user = "root"
password = "123"
minConn = 1
maxConn = 10
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
}
}
关键配置:
store:TC的服务端数据存储配置
mode:数据存储方式,支持两种:file和db
file:将数据存储在本地文件中,性能比较好,但不支持水平扩展db:将数据保存在指定的数据库中,需要指定数据库连接信息如果用文件作为存储介质,不需要其它配置了,直接运行即可。如果使用db作为存储介质,还需要在数据库中创建3张表:
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_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
-- 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,
`gmt_modified` DATETIME,
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
接下来,在idea中启动eurka服务,启动完成进入bin目录,启动TC,linux环境执行seata-server.sh,window环境则执行seata-server.bat(环境中必须安装jdk),如下,TC启动成功:
3 改造微服务
接下来是微服务的改造,不管是哪一个微服务,只要是事务的参与者,步骤基本一致。
首先改造order服务:
我们在父工程seata-demo中已经对依赖做了管理:
<alibaba.seata.version>2.1.0.RELEASE</alibaba.seata.version>
<seata.version>1.1.0</seata.version>
因此,我们在项目order-service的pom文件中,引入依赖坐标即可:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-seata</artifactId>
<version>${alibaba.seata.version}</version>
<exclusions>
<exclusion>
<artifactId>seata-all</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<artifactId>seata-all</artifactId>
<groupId>io.seata</groupId>
<version>${seata.version}</version>
</dependency>
在application.yml中添加一行配置:
spring:
cloud:
alibaba:
seata:
tx-service-group: test_tx_group # 定义事务组的名称
这里是定义事务组的名称,接下来会用到。
然后是在resources目录下放两个配置文件:file.conf和registry.conf其中,registry.conf与TC服务端的一样,此处不再讲解。
我们来看下file.conf:
transport {
# tcp udt unix-domain-socket
type = "TCP"
#NIO NATIVE
server = "NIO"
#enable heartbeat
heartbeat = true
# the client batch send request enable
enableClientBatchSendRequest = true
#thread factory for netty
threadFactory {
bossThreadPrefix = "NettyBoss"
workerThreadPrefix = "NettyServerNIOWorker"
serverExecutorThread-prefix = "NettyServerBizHandler"
shareBossWorker = false
clientSelectorThreadPrefix = "NettyClientSelector"
clientSelectorThreadSize = 1
clientWorkerThreadPrefix = "NettyClientWorkerThread"
# netty boss thread size,will not be used for UDT
bossThreadSize = 1
#auto default pin or 8
workerThreadSize = "default"
}
shutdown {
# when destroy server, wait seconds
wait = 3
}
serialization = "seata"
compressor = "none"
}
service {
vgroupMapping.test_tx_group = "seata_tc_server"
#only support when registry.type=file, please don't set multiple addresses
seata_tc_server.grouplist = "127.0.0.1:8091"
#degrade, current not support
enableDegrade = false
#disable seata
disableGlobalTransaction = false
}
client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}
配置解读:
-
transport:与TC交互的一些配置
-
heartbeat:client和server通信心跳检测开关
-
enableClientBatchSendRequest:客户端事务消息请求是否批量合并发送
-
service:TC的地址配置,用于获取TC的地址
-
test_tx_group:是事务组名称,要与application.yml中配置一致,
-
seata_tc_server:是TC服务端在注册中心的id,将来通过注册中心获取TC地址
-
enableDegrade:服务降级开关,默认关闭。如果开启,当业务重试多次失败后会放弃全局事务
-
disableGlobalTransaction:全局事务开关,默认false。false为开启,true为关闭
-
seata_tc_server.grouplist:这个当注册中心为file的时候,才用到
-
client:客户端配置
-
exceptionRate:出现回滚异常时的日志记录频率,默认100,百分之一概率。回滚失败基本是脏数据,无需输出堆栈占用硬盘空间
-
dataValidation:是否开启二阶段回滚镜像校验,默认true
-
logSerialization:undo序列化方式,默认Jackson
-
logTable:自定义undo表名,默认是undo_log
-
commitRetryCount:一阶段全局提交结果上报TC重试次数,默认1
-
rollbackRetryCount:一阶段全局回滚结果上报TC重试次数,默认1
-
asynCommitBufferLimit:二阶段提交默认是异步执行,这里指定异步队列的大小
-
lock:全局锁配置
-
reportRetryCount:一阶段结果上报TC失败后重试次数,默认5次
-
retryInterval:校验或占用全局锁重试间隔,默认10,单位毫秒
-
retryTimes:校验或占用全局锁重试次数,默认30次
-
retryPolicyBranchRollbackOnConflict:分支事务与其它全局回滚事务冲突时锁策略,默认true,优先释放本地锁让回滚成功
-
rm:资源管理器配
-
tm:事务管理器配置
-
undo:undo_log的配置
-
log:日志配置
Seata的二阶段执行是通过拦截sql语句,分析语义来指定回滚策略,因此需要对DataSource做代理。我们在项目的demo.order.config包中,添加一个配置类:
package demo.order.config;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import io.seata.rm.datasource.DataSourceProxy;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class DataSourceProxyConfig {
@Bean
public SqlSessionFactory sqlSessionFactoryBean(DataSource dataSource) throws Exception {
// 订单服务中引入了mybatis-plus,所以要使用特殊的SqlSessionFactoryBean
MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
// 代理数据源
sqlSessionFactoryBean.setDataSource(new DataSourceProxy(dataSource));
// 生成SqlSessionFactory
return sqlSessionFactoryBean.getObject();
}
}
然后给事务发起者order_service的OrderServiceImpl中的create()方法添加@GlobalTransactional注解,开启全局事务:
@Override
//添加GlobalTransactional注解
@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
// 扣款
accountClient.debit(order.getUserId(), order.getMoney());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8());
throw new RuntimeException(e.contentUTF8());
}
return order.getId();
}
重启即可。
然后改造 storage,account 服务
与OrderService类似,这里也要经过下面的步骤:
-
引入依赖:与order-service一致,略
-
添加配置文件:与order-service一致,略
-
代理DataSource,与order-service一致,略
事务注解使用@Transactionnal,而不是@GlobalTransactional,事务发起者才需要添加@GlobalTransactional。
配置完毕后,重启所有微服务,最好也重启下 TC ,在 window 环境下的支持不是很好。
4 测试
目前数据情况:用户余额1000,库存为10:
正常情况测试
我们利用订单服务的 swagger 请求下单接口,点击发送,如下:
看一下 TC 控制台,提示分布式事务已成功结束:
看一下数据库,数据已成功更新,用户余额900,库存9:
异常情况测试
需要注意的是,用户表中的余额为 int 类型无符号,也就是说如果余额不足被扣至负数会报错,这个时候就会触发事务回滚,我们借此来测试分布式事务产生异常时的回滚:
我们利用订单服务的 swagger 请求下单接口,把 money 字段的值调至1200,点击发送,如下,接口报错提示余额不足:
看一下 TC 控制台,显示事务已被回滚:
看一下数据库,用户余额900,库存9,事务回滚成功:
综上,分布式事务测试成功!我们利用 seata 实现了跨服务级别的分布式事务,跨库的大家可以自行尝试一下,原理都是一样的,需要注意的是,我们在测试的时候,需要先启动 eurka 服务,再启动 TC 以及其他的服务,这样不容易出现问题。
结语
如果你能坚持看到这里,相信一定会有所收获,对分布式事务有了更深刻的理解,没有实操的小伙伴还是建议实操一下,毕竟实践是检验真理的唯一标准。大家在实际的项目中还是要根据业务和需求去判断是否有必要使用分布式事务,因为无论是多么优秀的框架,只要用上了分布式事务性能一定是会有所下降的,另外也要考虑强一致和最终一致的问题,一般的业务满足最终一致即可,但涉及到钱相关的业务往往需要满足强一致,现在使用 mq 或者 seata 是实现分布式事务两种较为主流的方式,seata并不是万金油,如果涉及到对性能要求严格的系统,seata目前还是显得有些乏力,这种情况就需要我们自己根据业务做一些改造和优化。
好了,本文到这里就结束了,我们下次再见啦!
关注公众号 螺旋编程极客 发送 分布式事务 可获取本文demo源码等资料!