【分布式】分布式事务
分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
1)跨数据源
随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单库单表拆分成数据库分片,于是就产生了跨数据库事务问题。
2)跨服务
在业务发展初期,“一块大饼”的单业务系统架构,能满足基本的业务需求。但是随着业务的快速发展,系统的访问量和业务复杂程度都在快速增长,单系统架构逐渐成为业务发展瓶颈,解决业务系统的高耦合、可伸缩问题的需求越来越强烈。
如下图所示,按照面向服务(SOA)的架构的设计原则,将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统容量的伸缩。
3)分布式系统的数据一致性问题
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。在分布式网络环境下,我们无法保障所有服务、数据库都百分百可用,一定会出现部分服务、数据库执行成功,另一部分执行失败的问题。
当出现部分业务操作成功、部分业务操作失败时,业务数据就会出现不一致。例如电商行业中比较常见的下单付款案例,包括下面几个行为:
- 创建新订单
- 扣减商品库存
- 从用户账户余额扣除金额
完成上面的操作需要访问三个不同的微服务和三个不同的数据库。
在分布式环境下,肯定会出现部分操作成功、部分操作失败的问题,比如:订单生成了,库存也扣减了,但是用户账户的余额不足,这就造成数据不一致。订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
但是当我们把三件事情看做一个事情事,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。
此时ACID难以满足,这是分布式事务要解决的问题。为什么分布式系统下,事务的ACID原则难以满足?这得从CAP定理和BASE理论说起。
CAP 定理
本小节内容摘自:CAP 定理的含义
什么是CAP定理呢?
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)
它们的第一个字母分别是 C、A、P。Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。
由于当前的网络硬件肯定会出现延迟丢包等问题,所以分区容忍性是我们必须需要实现的。所以我们只能在一致性和可用性之间进行权衡,没有NoSQL系统能同时保证这三点。
- CA:传统Oracle数据库
- AP:大多数网站架构的选择
- CP:Redis、Mongodb
Partition tolerance
先看 Partition tolerance,中文叫做"分区容错"。
大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在上海,另一台服务器放在北京,这就是两个区,它们之间可能因网络问题无法通信。如图:
上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。
一般来说,分布式系统,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。根据CAP 定理,剩下的 C 和 A 无法同时做到。
Consistency
Consistency 中文叫做"一致性"。意思是,写操作之后的读操作,必须返回该值。举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。
接下来,用户的读操作就会得到 v1。这就叫一致性。
问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。
为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。
这样的话,用户向 G2 发起读操作,也能得到 v1。
Availability
Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须给出回应(对和错不论)。
用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。
Consistency 和 Availability 的矛盾
一致性和可用性,为什么不可能同时成立?答案很简单,因为可能通信失败(即出现分区容错)。
- 如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性不。
- 如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。
综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。
几点疑问
- 怎样才能同时满足CA?除非是单点架构。
- 何时要满足CP?对一致性要求高的场景。例如我们的Zookeeper就是这样的,在服务节点间数据同步时,服务对外不可用。
- 何时满足AP?对可用性要求较高的场景。例如Eureka,必须保证注册中心随时可用,不然拉取不到服务就可能出问题。
BASE 理论
BASE理论是对CAP理论的延伸,思想是即使无法做到强一致性(CAP的一致性就是强一致性),但是可以采用适当的弱一致性,即最终一致性。BASE就是为了解决关系数据库强一致性而引起的可用性降低问题(即为了满足CP,导致A降低)而提出的解决方案。它的思想是通过让系统放松对某一时刻数据一致性的要求来换取系统整体伸缩性和性能上改观。
BASE是三个单词的缩写:
- Basically Available(基本可用):基本可用是指分布式系统中在出现故障的时候,允许损失部分可用性(例如响应时间,功能上的可用性)。需要注意的是基本可用不等价于系统不可用。服务在等待同步的一小段时间内是不可用的,所以只是基本可用。即一两个节点的不可用不能影响整个系统的不可用(这个节点宕机了去查其他节点也行,只要有能用的就可以,慢一些也无所谓。不必所有节点都得可用)
- 响应时间上的损失:正常情况下搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房断电断网故障),查询的结果响应时间增加到了1~2秒
- 功能上的损失:购物网站双十一购物高峰,为了保证系统的稳定性,部分消费者会被引入到一个降级页面
- Soft state(软状态):不像强一致性系统只有两个状态,要么成功要么失败。软状态是指允许 系统存在中间状态(“正在同步中”状态),而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体现。MySQL 主从复制的异步复制就是一种体现。也就是说:系统允许有延迟,等待一定时间后能保证数据一致即可。
- Eventually consistent(最终一致性):最终致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。不是数据立刻一致,而是等一段时间后一致,最终结果是一致的即可。
强一致性、弱一致性、最终一致性
从客户端角度,多进程并发访问时,更新过的数据在不同进程如何获取的不同策略,决定了不同的一致性。对于关系型数据库,
- 要求更新过的数据能被后续的访问都能看到,这是强一致性
- 如果能容忍后续的部分或者全部访问不到,则是弱一致性
- 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性
17.3 分布式事务
我们解决分布式事务就是根据上述理论来实现。还以上面的下单减库存和扣款为例:订单服务、库存服务、用户服务及他们对应的数据库就是分布式应用中的三个部分。
- CP方式:现在如果要满足事务的强一致性,就必须在订单服务数据库锁定的同时,对库存服务、用户服务数据资源同时锁定。等待三个服务业务全部处理完成,才可以释放资源。此时如果有其他请求想要操作被锁定的资源就会被阻塞,这样就是满足了CP。这就是强一致,弱可用
- AP方式:三个服务的对应数据库各自独立执行自己的业务,执行本地事务,不要求互相锁定资源。但是这个中间状态下,我们去访问数据库,可能遇到数据不一致的情况,不过我们需要做一些后补措施,保证在经过一段时间后,数据最终满足一致性,例如高峰期视频的点击量等数据不需要强一致性,但要保证高可用。又例如订单服务下单成功后,库存服务锁库存失败,此时不要求订单服务立即回滚保证一致,而是可以允许等待一定时间后再将该订单数据删除以保证最终一致。这就是高可用,但弱一致(最终一致)。
由上面的两种思想,延伸出了很多的分布式事务解决方案:
- XA
- TCC
- 可靠消息最终一致
- AT
下文将逐一介绍这些解决方案。
2PC 二阶段提交
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间联系的接口规范,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须实现这些接口。
二阶段提交协议
二阶段提交协议(2 Phase Commit)就是根据XA Transactions这一思想衍生出来的,将全局事务拆分为两个阶段来执行:
- 阶段一:准备阶段,各个本地事务完成本地事务的准备工作。
- 阶段二:执行阶段,各个本地事务根据上一阶段执行结果,进行提交或回滚。
这个过程中需要一个协调者(coordinator),还有事务的参与者(voter)。二阶段提交是一个刚性事务。
正常情况
- 投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行成功的信息(
agree
) - 提交阶段:协调组发现每个参与者都可以执行事务(
agree
),于是向各个事务参与者发出commit
指令,各个事务参与者提交事务。
异常情况
当然,也有异常的时候:
- 投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行结果,但只要有一个参与者返回的是
Disagree
,则说明执行失败。 - 提交阶段:协调组发现有一个或多个参与者返回的是
Disagree
,认为执行失败。于是向各个事务参与者发出abort
指令,各个事务参与者回滚事务。
3PC 在 2PC 的基础上引入了超时机制:无论协调者还是参与者,在向对方发送请求后,若长时间未收到回应则做出相应处理
缺陷
二阶段提交的问题:
1. 单点故障问题
2PC的缺点在于不能处理fail-stop
形式的节点failure,即coordinator在提交阶段宕机时,voters不能得知其他事务到底是执行成功还是失败。比如下图这种情况:
假设coordinator和voter3都在Commit这个阶段crash了, 而voter1和voter2没有收到commit消息. 这时候voter1和voter2就陷入了一个困境. 因为他们并不能判断现在是两个场景中的哪一种:
- 上轮全票通过然后voter3第一个收到了commit的消息并在commit操作之后crash了
- 上轮voter3反对所以干脆没有通过。
2. 阻塞问题
在准备阶段、提交阶段,每个事务参与者都会锁定本地资源,并等待其它事务的执行结果,阻塞时间较长,资源锁定时间太久,因此执行的效率就比较低了,在高并发情境下基本无法工作。
面对二阶段提交的上述缺点,后来又演变出了三阶段提交,但是依然没有完全解决阻塞和资源锁定的问题,而且引入了一些新的问题,因此实际使用的场景较少。
使用场景
对事务有强一致性要求,对事务执行效率不敏感,并且不希望有太多代码侵入。
TCC - 事务补偿型方案
TCC模式可以解决2PC中的资源锁定和阻塞问题,减少资源锁定时间。TCC是一种柔性事务。它是一种事务补偿型方案,使用补偿的思路。
刚性事务与柔性事务
- 刚性事务:遵循ACID原则,强一致性
- 柔性事务:遵循BASE理论,最终一致性
与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
基本原理
它本质是一种补偿的思路。事务运行过程包括三个方法:
- Try:资源的检测和预留;
- Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功;
- Cancel:预留资源释放。
执行分两个阶段:
- 准备阶段(try):资源的检测和预留;
- 执行阶段(confirm/cancel):根据上一步结果,判断下面的执行方法。如果上一步中所有事务参与者都成功,则这里执行confirm。反之,执行cancel
粗看似乎与两阶段提交没什么区别,但其实差别很大:
- try、confirm、cancel都是独立的事务,不受其它参与者的影响,不会阻塞等待
- try、confirm、cancel由程序员在业务层编写,锁粒度有代码控制
实例
我们以之前的下单业务中的扣减余额为例来看下三个不同的方法要怎么编写,假设账户A原来余额是100,需要余额扣减30元。如图:
一阶段(Try):余额检查,并冻结用户部分金额,此阶段执行完毕,事务已经提交
- 检查用户余额是否充足,如果充足,冻结部分余额
- 在账户表中添加冻结金额字段,值为30,余额不变
二阶段:提交或补偿
- 提交(Confirm):真正的扣款,把冻结金额从余额中扣除,冻结金额清空
- 修改冻结金额为0,修改余额为100-30 = 70元
- 补偿(Cancel):释放之前冻结的金额,并非回滚
- 余额不变,修改账户冻结金额为0
总结:一阶段不修改真实的字段,只修改添加的冻结金额字段;二阶段若提交则修改真实字段,清除冻结金额字段,若失败则只需清除冻结金额字段
优点
TCC执行的每一个阶段都会提交本地事务并释放锁,并不需要等待其它事务的执行结果。而如果其它事务执行失败,最后不是回滚,而是执行补偿操作。这样就避免了资源的长期锁定和阻塞等待,执行效率比较高,属于性能比较好的分布式事务方式。
缺点
- 代码侵入:需要人为编写代码实现try、confirm、cancel,代码侵入较多
- 开发成本高:一个业务需要拆分成3个步骤,分别编写业务实现,业务编写比较复杂
- 安全性考虑:cancel动作如果执行失败,资源就无法释放,需要引入重试机制,而重试可能导致重复执行,还要考虑重试时的幂等问题
总结
每个单独的事务是强一致,但整体不是强一致(因为一阶段中每个事务都会立即提交,不会等待其他的事务一起提交,这样如果其他的事务失败了就不是整体强一致,还需要再在二阶段手动写补偿的代码把一阶段提交的额外字段数据给补偿掉)
使用场景
- 对事务有一定的一致性要求(最终一致)
- 对性能要求较高
- 开发人员具备较高的编码能力和幂等处理经验
可靠消息服务 - 最大努力通知型方案
这种实现方式的思路,其实是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务,一旦某个服务执行失败,就使用消息队列通知其他服务进行回滚,之所以叫最大通知型是因为他会不断地发送消息直到其他服务受到该消息后进行回滚。它也是一种柔性事务。
按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。这种方案主要用在与第三方系统通讯时,比如调用微信或支付宝支付后的支付结果通知。这种方案也是结合MQ进行实现。例如:通过MQ发送http请求,设置最大通知次数。达到通知次数后即不再通知。
案例:银行通知、商户通知等(各大交易业务平台间的商户通知:多次通知、查询校对、对账文件),支付宝的支付成功异步回调
基本原理
一般分为事务的发起者A和事务的其它参与者B:
- 事务发起者A执行本地事务
- 事务发起者A通过消息队列MQ将需要执行的事务信息发送给事务参与者B
- 事务参与者B事先订阅该消息队列,在接收到消息后执行本地事务,若失败则一直重试执行事务
如图:
这个过程有点像你去学校食堂吃饭:
- 拿着钱去收银处,点一份红烧牛肉面,付钱
- 收银处给你发一个小票,还有一个号牌,你别把票弄丢!
- 你凭小票和号牌一定能领到一份红烧牛肉面,不管需要多久
几个注意事项:
- 事务发起者A必须确保本地事务成功后,消息一定发送成功
- MQ必须保证消息正确投递和持久化保存
- 事务参与者B必须确保消息最终一定能消费,如果失败需要多次重试,时效性可能比较差
- 事务B执行失败,会一直重试,但不会导致事务A回滚
那么问题来了,我们如何保证消息发送一定成功?如何保证消费者一定能收到消息?
本地消息表
为了避免消息发送失败或丢失,我们可以把消息持久化到数据库中。实现时有简化版本和解耦合版本两种方式。
简化版本
原理图:
-
事务发起者:
- 开启本地事务
- 执行事务相关业务
- 发送消息到MQ
- 把消息持久化到数据库,标记为已发送
- 提交本地事务
-
事务接收者:
- 接收消息
- 开启本地事务
- 处理事务相关业务
- 修改数据库消息状态为已消费
- 提交本地事务
-
额外的定时任务
- 定时扫描表中超时未消费消息,重新发送
优点:与tcc相比,实现方式较为简单,开发成本低。
缺点:
- 数据一致性完全依赖于消息服务,因此消息服务必须是可靠的。
- 需要处理被动业务方的幂等问题
- 被动业务失败不会导致主动业务的回滚,而是重试被动的业务
- 事务业务与消息发送业务耦合、业务数据与消息表要在一起
独立消息服务
为了解决上述问题,我们会引入一个独立的消息服务,来完成对消息的持久化、发送、确认、失败重试等一系列行为,大概的模型如下:
一次消息发送的时序图:
事务发起者A的基本执行步骤:
- 开启本地事务
- 通知消息服务,准备发送消息(消息服务将消息持久化,标记为准备发送)
- 执行本地业务,
- 执行失败则终止,通知消息服务,取消发送(消息服务修改订单状态)
- 执行成功则继续,通知消息服务,确认发送(消息服务发送消息、修改订单状态)
- 提交本地事务
消息服务本身提供下面的接口:
- 准备发送:把消息持久化到数据库,并标记状态为准备发送
- 取消发送:把数据库消息状态修改为取消
- 确认发送:把数据库消息状态修改为确认发送。尝试发送消息,成功后修改状态为已发送
- 确认消费:消费者已经接收并处理消息,把数据库消息状态修改为已消费
- 定时任务:定时扫描数据库中状态为确认发送的消息,然后询问对应的事务发起者,事务业务执行是否成功,结果:
- 业务执行成功:尝试发送消息,成功后修改状态为已发送
- 业务执行失败:把数据库消息状态修改为取消
事务参与者B的基本步骤:
- 接收消息
- 开启本地事务
- 执行业务
- 通知消息服务,消息已经接收和处理
- 提交事务
优点: 解除了事务业务与消息相关业务的耦合
缺点: 实现起来比较复杂
RocketMQ 事务消息
RocketMQ本身自带了事务消息,可以保证消息的可靠性,原理其实就是自带了本地消息表,与上面的思路类似。
RabbitMQ 的消息确认
RabbitMQ 确保消息不丢失的思路比较奇特,并没有使用传统的本地表,而是利用了消息的确认机制:
- 生产者确认机制:确保消息从生产者到达MQ不会有问题
- 消息生产者发送消息到RabbitMQ时,可以设置一个异步的监听器,监听来自MQ的ACK
- MQ接收到消息后,会返回一个回执给生产者:
- 消息到达交换机后路由失败,会返回失败ACK
- 消息路由成功,持久化失败,会返回失败ACK
- 消息路由成功,持久化成功,会返回成功ACK
- 生产者提前编写好不同回执的处理方式
- 失败回执:等待一定时间后重新发送
- 成功回执:记录日志等行为
- 消费者确认机制:确保消息能够被消费者正确消费
- 消费者需要在监听队列的时候指定手动ACK模式
- RabbitMQ把消息投递给消费者后,会等待消费者ACK,接收到ACK后才删除消息,如果没有接收到ACK消息会一直保留在服务端,如果消费者断开连接或异常后,消息会投递给其它消费者。
- 消费者处理完消息,提交事务后,手动ACK。如果执行过程中抛出异常,则不会ACK,业务处理失败,等待下一条消息
经过上面的两种确认机制,可以确保从消息生产者到消费者的消息安全,再结合生产者和消费者两端的本地事务,即可保证一个分布式事务的最终一致性。
消息事务的优缺点
总结上面的几种模型,消息事务的优缺点如下:
- 优点:
- 业务相对简单,不需要编写三个阶段业务
- 是多个本地事务的结合,因此资源锁定周期短,性能好
- 缺点:
- 代码侵入
- 依赖于MQ的可靠性
- 消息发起者可以回滚,但是消息参与者无法引起事务回滚
- 事务时效性差,取决于MQ消息发送是否及时,还有消息参与者的执行情况
AT(自动提交)模式
Seata 官方文档。
2019年 1 月份,Seata 开源了 AT(Auto Transaction)模式。AT 模式是一种无侵入的分布式事务解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入、编码复杂等问题。
在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
但是,AT 模式不适合高并发场景,其吞吐量和并发非常低。这是因为其需要给数据库添加全局行锁,并且频繁删除 before image 和 after image,这就会导致并发降低。
基本原理
流程图:
有没有感觉跟TCC的执行很像,都是分两个阶段:
- 一阶段:执行本地事务,并返回执行结果
- 二阶段:根据一阶段的结果,判断二阶段做法是提交还是回滚
但AT模式底层做的事情可完全不同,而且第二阶段根本不需要我们编写,全部有Seata自己实现了。也就是说:我们写的代码与本地事务时代码一样,无需手动处理分布式事务。
那么,AT模式如何实现无代码侵入,如何帮我们自动实现二阶段代码的呢?
一阶段
在一阶段,Seata 会拦截“业务 SQL”:
- 首先解析 SQL 语义,根据SQL语义找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将这些要更新的数据保存成“before image”(前镜像,undo log,用于二阶段回滚时恢复原数据)
- 然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将这些更新后的数据保存成“after image”(后镜像,redo log,其用于二阶段回滚前对比当前数据有无被脏写)
- 将前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到
UNDO_LOG
表中(数据库中除了业务表还需要准备一张UNDO_LOG
表存放这些回滚日志,用于回滚时利用记录的SQL语句与前镜像进行回滚补偿)。 - 最后获取全局行锁,立即提交事务(不阻塞)。
以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。这里的before image
和after image
类似于数据库的undo和redo日志,但其实是用数据库模拟的,即将undo和redo日志保存到日志Log表里。
二阶段 提交
二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段 回滚
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据。
但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。不过因为有全局锁机制,所以可以降低出现脏写的概率。
AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。AT 模式的补偿等操作是框架自动实现,TCC 则需要手动添加补偿代码。
优缺点
优点:
- 与2PC相比:每个分支事务都是独立提交,不互相等待,减少了资源锁定和阻塞时间
- 与TCC相比:二阶段的执行操作全部自动化生成,无代码侵入,开发成本低
缺点:
- 与TCC相比,需要动态生成二阶段的反向补偿操作,执行性能略低于TCC
- AT 模式不适合高并发场景,因为其需要给数据库添加全局锁,一旦加锁就会导致并发降低
几种模式对比
- XA 二阶段:一阶段会有阻塞问题,必须等到所有事务都提交才能一起提交,每个事务都会阻塞等待其他事务
- TCC:一阶段不阻塞,二阶段需要手动添加补偿代码,较繁琐
- AT:一阶段不阻塞,每个事务执行完立即提交;二阶段框架自动实现补偿,性能略低于TCC;不适合高并发场景(因为要加行锁并且频繁删除 before image 和 after image)
Saga 模式
Saga 模式是 Seata 即将开源的长事务解决方案,将由蚂蚁金服主要贡献。其理论基础是Hector & Kenneth 在1987年发表的论文Sagas。
Seata 官网对于Saga的指南:https://seata.io/zh-cn/docs/user/saga.html
基本模型
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 模式下分布式事务通常是由事件驱动的,各个参与者之间是异步执行的,Saga 模式是一种长事务解决方案。
适用场景:
- 业务流程长、业务流程多
- 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口
优点
- 一阶段提交本地事务,无锁,高性能
- 事件驱动架构,参与者可异步执行,高吞吐
- 补偿服务易于实现
缺点
不保证隔离性(应对方案见用户文档)
Seata
介绍
Seata(Simple Extensible Autonomous Transaction Architecture,简单可扩展自治事务框架)是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。Seata 开源半年左右,目前已经有接近一万 star,社区非常活跃。
Seata 会有 4 种分布式事务解决方案,分别是 AT 模式、TCC 模式、Saga 模式和 XA 模式:
Seata中比较常用的是AT模式。下面将介绍其工作原理。
Seata 产品模块
如下图所示,Seata 中有三大模块,分别是 TM、RM 和 TC。 其中 TM 和 RM 是作为 Seata 的客户端与业务系统集成在一起(集成在业务代码中),TC 作为 Seata 的服务端独立部署,并被注册到注册中心和配置中心。
Seata 详细架构和流程
Seata 中的几个基本概念:
- TC(Transaction Coordinator) :事务协调者,维护全局和分支事务的状态,驱动全局事务提交或回滚(TM之间的协调者),TC作为Seata的服务端 Seata-Serve,下载后直接使用jar包运行,其将被注册到注册中心和配置中心从而被各个微服务订阅。
- TM(Transaction Manager) :事务管理器,定义全局事务的范围:开始全局事务、提交或回滚全局事务。TM对应的方法使用 @GlobalTransactional 注解标注,其内的业务代码处于同一个全局事务下。
- RM(Resource Manager) :资源管理器,管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
三者工作分工:
- TM:业务模块中全局事务的开启者
- 向TC开启一个全局事务(标注 @GlobalTransactionnal 注解代表开启一个全局事务)
- 全局事务创建成功并生成一个全局唯一的
XID
,XID
在微服务调用链路的上下文中传播; - 调用其它微服务(例如订单模块作为TM调用了库存模块和支付模块)
- TM向TC发起针对
XID
的全局提交或回滚决议
- RM:业务模块执行者中包含RM部分,负责向TC汇报事务执行状态
- 负责与数据库交互,执行本地事务
- 向TC注册分支事务,并提交本地事务执行结果
- 将其纳入XID对应全局事务的管辖
- TM:结束对微服务的调用,通知TC全局事务执行完毕,事务一阶段结束
- TC:汇总各个分支事务执行结果,决定分布式事务是提交还是回滚,通知所有 RM提交/回滚资源,事务二阶段结束。
三者的关系:
- TM用于开启全局事务生成
XID
,调用其他微服务,并在全局事务的方法执行完毕后通知TC全局事务执行完毕(即一阶段结束),此时TC即可统计各个分支事务的执行结果判断二阶段应该是提交还是回滚; - 每个RM与本地数据库交互,执行本地事务并立即提交(不阻塞),之后向TC注册当前分支事务的信息,最后将本地事务提交的结果上报给 TC;
- TC调度
XID
下管辖的全部分支事务完成提交或回滚请求。其先保存每个RM注册的分支事务信息,当收到TM的一阶段结束通知后,根据接收汇总到的各个分支事务的执行结果判断二阶段应该是提交还是回滚:若都成功,则通知每个RM二阶段提交;若有失败,则通知每个RM二阶段回滚
示例:
- TC 管理全局,在服务端部署。
- TM 标注在 Service 层,在业务代码处控制整个事务。例如下订单的 Service 方法
- RM 标注在每个微服务上,控制本地事务,操纵数据库。例如分别标注在操作订单表和操作库存表的方法上
例如 TM 标注在下订单业务。其先通知 TC,自己准备开启一个全局事务 ,然后各个 RM 开始注册分支事务,并且实时通知 TC 自己的状态,这样 TC 就实时知道 RM 的状态是成功还是失败。一旦某一个RM 通知 TC 自己失败了,TC 就命令其他的 RM 回滚。
Seata 两个阶段具体工作流程
一阶段:
- TM开启全局事务,并向TC声明全局事务,包括全局事务
XID
信息 - TM所在服务调用其它微服务
- 微服务主要由RM来执行
- 查询
before_image
- 执行本地事务
- 查询
after_image
- 生成
undo_log
并写入数据库 - 向TC注册分支事务,告知事务执行结果
- 获取全局锁(阻止其它全局事务并发修改当前数据)
- 释放本地锁(不影响其它业务对数据的操作)
- 查询
- 待所有业务执行完毕,事务发起者(TM)会尝试向TC提交全局事务
二阶段:
- TC统计分支事务执行情况,根据结果判断下一步行为:
- 分支都成功:通知分支事务,提交事务
- 有分支执行失败:通知执行成功的分支事务,回滚数据
- 分支事务的RM收到TC传来的消息:
- 提交事务:直接清空
before_image
和after_image
信息,释放全局锁 - 回滚事务:
- 校验
after_image
,判断是否有脏写 - 如果没有脏写,回滚数据到
before_image
,清除before_image
和after_image
- 如果有脏写,请求人工介入
- 校验
- 提交事务:直接清空
Seata 工作流程示例
详见Seata的官方文档:https://seata.io/zh-cn/docs/overview/what-is-seata.html
场景
以一个示例来说明 AT 分支的整个工作过程。
业务表:product
Field | Type | Key |
---|---|---|
id | bigint(20) | PRI |
name | varchar(100) | |
since | varchar(100) |
AT 分支事务的业务逻辑:
1 | update product set name = 'GTS' where name = 'TXC'; |
一阶段
过程:
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
- 查询 before image(前镜像):根据解析得到的条件信息,生成查询语句,定位数据(即修改前先查询出来当前这,保存到undo log)。
1 | select id, name, since from product where name = 'TXC'; |
得到前镜像:
id | name | since |
---|---|---|
1 | TXC | 2014 |
- 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
- 查询 after image(后镜像):根据前镜像的结果,通过 主键(id) 定位数据。
1 | select id, name, since from product where id = `1`; |
得到后镜像:
id | name | since |
---|---|---|
1 | GTS | 2014 |
- 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到
UNDO_LOG
表中:
1 | { |
- 提交前,向 TC 注册分支:申请
product
表中,主键值等于 1 的记录的 全局锁 。 - 本地事务提交:业务数据的更新和前面步骤中生成的
UNDO LOG
一并提交。 - 将本地事务提交的结果上报给 TC。
二阶段-回滚
若收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作:
- 通过
XID
和Branch ID
查找到相应的UNDO LOG
记录(同一个微服务可能同时参与多个不同的事务组,有多个XID
)。 - 数据校验:拿
UNDO LOG
中的后镜像与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理。 - 根据
UNDO LOG
中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
1 | update product set name = 'TXC' where id = 1; |
- 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
二阶段-提交
- 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
- 异步任务阶段的分支提交请求将异步和批量地删除相应
UNDO LOG
记录。
以上即为 Seata 框架的工作原理,关于 Seata 框架的使用见文章 【Spring Cloud】Spring Cloud Alibaba Nacos