分布式事务的定义:
分布式事务是指是指事务的发起者、参与者、数据资源服务器以及事务管理器分别位于分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
分布式事务产生的原因:
随着业务的快速发展,网站系统往往由单体架构逐渐演变为分布式、微服务架构,而对于数据库则由单机数据库架构向分布式数据库架构转变。此时,我们会将一个大的应用系统拆分为多个可以独立部署的应用服务,需要各个服务之间进行远程协作才能完成事务操作。
分布式事务的使用场景:
(1)跨JVM进程
当我们将单体项目拆分为分布式、微服务项目之后,各个服务之间通过远程REST或者RPC调用来协同完成业务操作。典型的场景就是:商城系统中的订单微服务和库存微服务,用户在下单时会访问订单微服务,订单微服务在生成订单记录时,会调用库存微服务来扣减库存。各个微服务是部署在不同的JVM进程中的,此时,就会产生因跨JVM进程而导致的分布式事务问题。
(2)跨数据库实例
单体系统访问多个数据库实例,也就是跨数据源访问时会产生分布式事务。例如,我们的系统中的订单数据库和交易数据库是放在不同的数据库实例中,当用户发起退款时,会同时操作用户的订单数据库和交易数据库,在交易数据库中执行退款操作,在订单数据库中将订单的状态变更为已退款。由于数据分布在不同的数据库实例,需要通过不同的数据库连接会话来操作数据库中的数据,此时,就产生了分布式事务。
(3)多服务单数据库
多个微服务访问同一个数据库。例如,订单微服务和库存微服务访问同一个数据库也会产生分布式事务,原因是:多个微服务访问同一个数据库,本质上也是通过不同的数据库会话来操作数据库,此时就会产生分布式事务。
注意:跨数据库实例场景和多服务单数据库场景,本质上都是因为会产生不同的数据库会话来操作数据库中的数据,进而产生分布式事务。这两种场景是大家比较容易忽略的。
分布式事务的分类:
1.刚性事务解决方案(满足传统事务特性ACID)
- 强一致性:各个业务操作必须在事务结束时全部成功,或者全部失败
- XA模型
- 满足CAP理论的CP
2.柔性事务
- 保证最终一致性,事务结束后在可接受的时间范围内,可能出现短暂的不一致,最终会达成一致性
- 满足CAP理论的AP,满足BASE理论
如何在不同的类型中取舍:根据具体的业务
ACID能够保证事务的强一致性,即数据是实时一致的。这在本地事务中是没有问题的,在分布式事务中,强一致性会极大影响分布式系统的性能,因此分布式系统中遵循BASE理论即可。但分布式系统的不同业务场景对一致性的要求也不同。如交易场景下,就要求强一致性,此时就需要遵循ACID理论,而在注册成功后发送短信验证码等场景下,并不需要实时一致,因此遵循BASE理论即可。因此要根据具体业务场景,在ACID和BASE之间寻求平衡。
分布式事务的解决方案:
刚性事务:满足XA模型
XA模型:XA 是出现最早的分布式事务规范,主流数据库 Oracle、MySQL、SQLServer 等都支持 XA 规范,J2EE 中的 JTA 规范也是参照 XA 规范编写的,与 XA 规范兼容。
- 应用程序(AP):应用程序,按照业务规则调用 RM 接口来完成对业务模型数据的变更,当数据的变更涉及多个 RM 且要保证事务时,AP 就会通过 TM 来定义事务的边界,TM 负责协调参与事务的各个 RM 一同完成一个全局事务。
- 资源管理器(RM):资源管理器,提供数据资源的操作、管理接口,保证数据的一致性和完整性。最有代表性的就是数据库管理系统,当然有的文件系统、MQ 系统也可以看作 RM。
- 事务管理器(TM):事务管理器,是一个协调者的角色,协调跨库事务关联的所有 RM 的行为。负责管理全局事务,分配全局事务ID,监测事务的执行速度,并负责事务的提交,回滚,失败恢复等。
XA规范标准的实现:
①2PC(二阶段提交)
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit
phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。
这里,我们用MySQL数据库举例,MySQL数据库支持两阶段提交协议,可以分为成功和失败两种情况。
成功情况
失败情况
具体流程如下:
准备阶段(Prepare phase): 事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事
务,并写本地的Undo/Redo日志,此时事务没有提交。
(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数
据文件)
提交阶段(commit phase): 如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者
发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操
作,并释放事务处理过程中使用的锁资源。
2pc存在的问题:
- 同步阻塞模型,所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
- 数据库资源锁定时间过程
- 全局锁(隔离级别串行化),并发能力低。全局事务的执行过程中,RM 一直持有资源的锁,如果参与的 RM 过多,尤其是跨服务的场景下,网络通信的次数和时间会急剧变多,所以阻塞的时间更长,系统的吞吐能力变得很差,事务死锁出现的概率也会变大,所以并不适合微服务架构场景中的跨服务的分布式事务模式。
- 不适合长事务
- 单点故障:一旦事务管理器出现故障,整个系统不可用
- 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致,从而衍生出了三阶段提交来解决这个问题。
②3pc
三阶段提交方案主要解决了单点故障问题,并在 RM 侧也引入了超时机制,以避免资源的长时间锁定。但是三阶段提交方案依然无法避免数据不一致的异常情况出现
与两阶段提交不同的是,三阶段提交有两个改动点。
引入超时机制。同时在协调者和参与者中都引入超时机制。
在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
1. CanCommit阶段:询问是否可以提交事务
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
事务询问
协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
响应反馈
参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
2. PreCommit阶段:预提交
协调者根据参与者的反应情况来决定是否可以记性事务的PreCommit操作。根据响应情况,有以下两种可能。
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
发送预提交请求
协调者向参与者发送PreCommit请求,并进入Prepared阶段。
事务预提交
参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
响应反馈
如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
发送中断请求
协调者向所有参与者发送abort请求。
中断事务
参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
3. doCommit阶段:正式提交
该阶段进行真正的事务提交,也可以分为以下两种情况。
3.1 执行提交
发送提交请求
协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
事务提交
参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
响应反馈
事务提交完之后,向协调者发送Ack响应。
完成事务
协调者接收到所有参与者的ack响应之后,完成事务。
3.2 中断事务
协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
发送中断请求
协调者向所有参与者发送abort请求
事务回滚
参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
反馈结果
参与者完成事务回滚之后,向协调者发送ACK消息
中断事务
协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
优缺点
-
优点:相比二阶段提交,三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题。阶段 3 中协调者出现问题时,参与者会继续提交事务。
-
缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 do commite 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
2.柔性事务解决方案:
2.1满足CAP模型的AP,柔性事务是对XA协议的妥协,它通过降低强一致性,从而减少数据库资源的锁定时间,提升系统可用性。
典型架构实现
- TCC模型
- Saga模型
TCC模型
TCC即为Try Confirm Cancel,它属于补偿型分布式事务。TCC模型完全交由业务端实现,每个子业务都需要实现try-confirm-cancel接口,对业务侵入性很大,需要业务方把功能的实现上由一个接口拆分为三个,开发成本较高。它的核心思想是通过对资源的预留(提供中间态),尽早释放对资源的加锁,如果事务可以提交,则完成对预留资源的确认,如果事务要回滚,则释放预留的资源。
TCC 事务机制相比于上面介绍的 XA,解决了其几个缺点:
- 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
- 同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
- 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性
顾名思义,TCC实现分布式事务一共有三个步骤:
Try:尝试待执行的业务
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源
Confirm:执行业务
这个过程真正开始执行业务,由于Try阶段已经完成了一致性检查,因此本过程直接执行,而不做任何检查。并且在执行的过程中,会使用到Try阶段预留的业务资源。
Cancel:取消执行的业务
若业务执行失败,则进入Cancel阶段,它会释放所有占用的业务资源,并回滚Confirm阶段执行的操作。
我们通过一个转账案例来分析:
用户A向用户B转账500元
汇款服务:
Try:
1.检查A账户有效性,查看A账户是否存在或者未冻结。
2.检查账户余额是否大于等于500元
3.从A账户扣减500元,并且状态置为“转账中”
4.记录一个转账日志或者消息
Confirm:
1.将“转账中”的状态改为“正常”状态
2.生成转账流水,删除转账日志
Cancel:
1.账户余额加回500元
2.账户状态改为“正常”
收款服务:
Try:
1.检查B账户是否正常
Confirm:
1.从转账日志或者消息中获取账户A往账户B转账500元
2.账户B增加500元
Cancel:
不做操作
saga模型
Saga 和 TCC 一样,也是一种补偿事务,但是它没有 try 阶段,而是把分布式事务看作一组本地事务构成的事务链。
事务链中的每一个正向事务操作,都对应一个可逆的事务操作。Saga 事务协调器负责按照顺序执行事务链中的分支事务,分支事务执行完毕,即释放资源。如果某个分支事务失败了,则按照反方向执行事务补偿操作。
假如一个 Saga 的分布式事务链有 n 个分支事务构成,[T1,T2,...,Tn],那么该分布式事务的执行情况有三种:
- T1,T2,...,Tn:n 个事务全部执行成功了。
- T1,T2,...,Ti,Ci,...,C2,C1:执行到第 i (i<=n) 个事务的时候失败了,则按照 i->1 的顺序依次调用补偿操作。如果补偿失败了,就一直重试。补偿操作可以优化为并行执行。
- T1,T2,...,Ti (失败),Ti (重试),Ti (重试),...,Tn:适用于事务必须成功的场景,如果发生失败了就一直重试,不会执行补偿操作。
Saga 模式非常适合于业务流程长的长事务的场景,实现上对业务侵入低,所以非常适合微服务架构的场景。同时 Saga 采用的是一阶段提交模式,不会对资源长时间加锁,不存在“木桶效应”,所以采用这种模式架构的系统性能高、吞吐高。
Saga是目前行业内落地较多的成功方案,由业务提供一个业务执行接口和一个补偿接口(需要满足幂等性)。
阿里巴巴的 Seata 开源项目和华为的 ServiceComb 开源项目都支持 Saga 模式。
2.2基于消息的分布式事务
2PC,TCC和Saga,其实我们可以发现这些方案都适用于同步调用的一些业务场景。在微服务架构中,我们一个业务功能可能涉及多个服务之间的调用,调用链路非常长,这时我们通常采用非核心业务异步调用来优化调用链路(异步调用)。
拿下单场景来分析下:
- 同步调用:用户请求下单 -> 订单服务进行下单操作 -> 调用支付服务生成一笔待支付记录
- 异步调用:用户请求下单 -> 订单服务进行下单操作 -> 往MQ投递一条下单消息 -> 返回用户下单成功 -> 支付服务异步消费下单消息,生成待支付记录
同步调用场景我们可以使用TCC和Saga来保证事务一致性,那异步调用场景我们又要如何来保证一致性呢?消息最终一致性方案就是用于解决这个问题。
基于消息的分布式事务模式核心思想是通过消息系统来通知其他事务参与方自己事务的执行状态。
消息系统的引入更有效的将事务参与方解耦,各个参与方可以异步执行。该种模式的难点在于解决本地事务执行和消息发送的一致性:两者要同时执行成功或者同时取消执行。
传统消息存在的缺点:普通消息是无法解决本地事务执行和消息发送的一致性问题的。因为消息发送是一个网络通信的过程,发送消息的过程就有可能出现发送失败、或者超时的情况。超时有可能发送成功了,有可能发送失败了,消息的发送方是无法确定的,所以此时消息发送方无论是提交事务还是回滚事务,都有可能不一致性出现。
解决这个问题,需要引入事务消息,事务消息和普通消息的区别在于事务消息发送成功后,处于 prepared 状态,不能被订阅者消费,等到事务消息的状态更改为可消费状态后,下游订阅者才可以监听到次消息。
事务消息的解决思路:
本地事务和事务消息的发送的处理流程如下:
- 事务发起者预先发送一个事务消息。
- MQ 系统收到事务消息后,将消息持久化,消息的状态是“待发送”,并给发送者一个 ACK 消息。
- 事务发起者如果没有收到 ACK 消息,则取消本地事务的执行;如果收到了 ACK 消息,则执行本地事务,并给 MQ 系统再发送一个消息,通知本地事务的执行情况。
- MQ 系统收到消息通知后,根据本地事务的执行情况更改事务消息的状态,如果成功执行,则将消息更改为“可消费”并择机下发给订阅者;如果事务执行失败,则删除该事务消息。
- 本地事务执行完毕后,发给 MQ 的通知消息有可能丢失了。所以支持事务消息的 MQ 系统有一个定时扫描逻辑,扫描出状态仍然是“待发送”状态的消息,并向消息的发送方发起询问,询问这条事务消息的最终状态如何并根据结果更新事务消息的状态。因此事务的发起方需要给 MQ 系统提供一个事务消息状态查询接口。
- 如果事务消息的状态是“可发送”,则 MQ 系统向下游参与者推送消息,推送失败会不停重试。
- 下游参与者收到消息后,执行本地事务,本地事务如果执行成功,则给 MQ 系统发送 ACK 消息;如果执行失败,则不发送 ACK 消息,MQ 系统会持续推送给消息。
基于事务消息的模式对 MQ 系统要求较高,并不是所有 MQ 系统都支持事务消息的,RocketMQ 是目前为数不多的支持事务小的 MQ 系统。如果所依赖的 MQ 系统不支持事务消息,那么可以采用本地消息的分布式模式。
适用范围:原则上只接受下游分支事务的成功,不接受事务的回滚,如果失败就要一直重试,适用于对最终一致性敏感度较低的业务场景,例如跨企业的系统间的调用,适用的场景有限。
基于本地消息的分布式事务:
该种模式的核心思想是事务的发起方维护一个本地消息表,业务执行和本地消息表的执行处在同一个本地事务中。业务执行成功,则同时记录一条“待发送”状态的消息到本地消息表中。系统中启动一个定时任务定时扫描本地消息表中状态为“待发送”的记录,并将其发送到 MQ 系统中,如果发送失败或者超时,则一直发送,知道发送成功后,从本地消息表中删除该记录。后续的消费订阅流程则与基于事务消息的模式雷同。
最大努力通知型分布式事务:
最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。
这个方案的大致意思就是:
- 系统 A 本地事务执行完之后,发送个消息到 MQ;
- 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口;
- 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。
使用场景:最常见的场景就是支付回调。
总结:
单体数据库事务很容易满足事务的 ACID 四个特性,提供强一致性保证,但是分布式事务要完全遵循 ACID 特性会比较困难。为了追求分布式系统的高可用和高吞吐,分布式事务的解决方案一般提供的是最终一致性。
在系统选择分布式方案时,可以根据对一致性的要求进行选择,业务上有强一致性要求的场景时,优先考虑 XA 规范的两阶段提交;业务上只需要最终一致性的场景时,可以在根据具体场景在柔性事务方案中进行选择。每种方案在实际执行过程中需要考虑的点都非常多,复杂度较大,所以在非必要的情况下,分布式事务能不用就尽量不用。
参考链接:
https://xie.infoq.cn/article/3585b8d9200bc4e5211cf8bf2
https://developer.aliyun.com/article/762770
https://xiaomi-info.github.io/2020/01/02/distributed-transaction/