java中的分布式事务解决方式
作者:猰貐的新时代
背景
分布式事务,后端开发中比较常见啦。因为在面试的时候,总是有interviewers让我给他普及一下分布式事务,虽然我会的也不多呀但是还是浅浅说一说;
今天心血来潮,好好地总结一下分布式事务,希望每一位后端工程师都能彻底理解分布式事务。
什么是分布式事务?
答:既然是分布式,首先必然是分布式系统中的一个概念啦。单体应用没这个东西,也不需要这个东西。本地事务就够啦,Spring给我们提供的注解@Transactional, InnoDB引擎会为我们保证事务的ACID特性。但是分布式系统中,目前大多数互联网公司都在用分布式系统,微服务架构等。所以,学好分布式事务太有必要。
废话不多说,直接上原理。
总结来说,分布式事务涉及了多个独立的数据源(数据库)或者参与者的事务操作,这些数据源分布在不同的计算机或网络中;分布式事务确保在不同节点之间的多个操作要么全部成功,要么全部失败。
分布式事务解决方案
2PC(XA协议)
两阶段提交协议,也叫XA协议。主要包含两个阶段,第一个阶段是预备阶段,第二个阶段是提交阶段。
2PC协议首先有分事务协调者角色和事务参与者。协调者是事先指定好的一个节点。参与者是一些涉及到数据库操作的表,暂时可以这样理解。这些多个参与者一般是分布在不同的节点上。
- 准备阶段。协调者向所有参与者发送事务准备请求,参与者执事务操作,并回复协调者准备就绪的消息;如果多个参与者中有一个参与者未准备就绪或者发生错误,那么协调者会发送中止请求。只有所有参与者都回复准备就绪,才会进入第二阶段。
- 提交阶段。所有参与者都已经准备就绪,协调者分别发送提交的消息,参与者收到消息以后,执行事务的提交操作,并向协调者回复提交完成。协调者收到了所有事务参与者提交完成的消息后,整个分布式事务才算提交完成。如果有一个参与者未能提交或者发生错误,那么协调者会向所有参与者发送中止请求,进行事务的回滚操作。
如何评价2PC?
1、2PC有单点故障的问题。一旦事务协调者故障(因为是使用到了某个节点嘛),那么整个事务将无法继续进行,陷入故障。
2、数据不一致。如果协调者在发送提交信息时,只有部分参与者收到了消息,并执行了提交,此时网络异常,就导致只有部分参与者执行了事物的提交,另一部分则没有提交,从而造成一个数据不一致性。
3、阻塞风险。如果准备阶段,有一个参与者无法响应或者失败,那么整个系统都会陷入阻塞状态,等待超时处理。
4、性能问题。整个链路是串行的,响应时间较长,不适合高并发的场景。
3PC
三阶段提交又称3PC,相对于2PC来说增加了CanCommit阶段和超时机制。如果某段时间内没有收到协调者的commit请求,那么就会自动进行commit,解决了2PC单点故障的问题。
但是性能问题和数据不一致性问题还是没解决。3PC的步骤是这样的:
- 1、询问节点。CanCommit, 首先询问参与者,是否有能力完成此次事务?如果都返回yes,则进入第二阶段有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求。
- 2、准备阶段;同2PC。需要注意的是,参与者收到消息后开始执行事务操作,会首先将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后,向协调者反馈ACK, 表示已经准备好提交了。
- 3、提交阶段。同2PC。
TCC
TCC ,Try, Confirm, Cancel; 其实是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
它分为三个阶段:
- 1、Try, 对业务系统做检测及资源预留;
- 2、Confirm主要对业务系统做确认提交;try阶段执行成功,并开始执行confirm,默认是不会出错的;只要Try成功,那么Confirm 一定会成功。
- 3、Cancle, 主要是在业务执行错误,需要回滚的状态下执行的业务取消,对预留资源释放!
举个例子,假入 Bob 要向 Smith 转账,思路大概是:我们有一个本地方法,里面依次调用
1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。冻结可以理解为一种特殊的扣减,以放在后续转账的时候,金额不够转。并发访问的时候,冻结操作需要加分布式锁,避免我在执行冻结扣减的时候,此时的金额发生了变化,导致冻结失败或者不一致性。扣减以后,生成一条冻结交易记录,表示该冻结操作成功。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功;成功后查询出冻结交易记录,将冻结状态变更为已解冻;
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻接口,将数据恢复为冻结前的样子。
Try部分完成业务的准备工作,confirm部分完成业务的提交,cancel部分完成事务的回滚。基本原理如下图所示:
如上图所示,每个分支事务都需要实现Try,Confirm,Cancel接口。TCC事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的Try接口,完成一阶段准备。之后事务协调器会根据Try接口返回情况,决定调用Confirm接口或者Cancel接口。如果接口调用失败,会进行重试。
总结的说,所有分支的Try操作成功,会进入到Confirm; 有一个分支未成功,已经执行的操作都要回滚。从而恢复到尝试阶段之前的状态,以确保数据的一致性。
TCC的优点:
- 1、降低了锁的粒度,减少了并发冲突;从而提高了吞吐量;每个业务执行操作都有相应的补偿操作,不需要人工干预进行补偿。
- 不足之处也很明显,
- 2、对业务有一定的入侵;改造成本高,代码冗长;
- 3、实现难度较大。需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口必须实现幂等。
本地消息表+MQ最终一致性事务
本地消息表的思想是将分布式事务拆分为本地事务来处理,通过引入一个额外的消息表,存储消息的处理状态,从而实现消息可靠性和一致性。
如上图中的订单系统和库存系统是两个独立的系统,所以数据库也是独立的。如果我们想要保证下订单以及订单中商品库存扣减的原子性,就必然要使用分布式事务。使用本地消息表解决分布式事务的思路是这样。首先,订单系统正常进行订单业务数据存DB,同时将这个消息实体记录存入消息表内,消息实体至少要有消息的id以及消息的处理状态;然后向MQ发送这条消息。
订单系统不断往MQ中生产消息,库存系统去消费这个MQ中的消息,取到消息以后,执行库存相关业务操作,保存至DB,最终返回一个成功处理的响应给MQ。订单系统发完消息以后,也会去读取该消息的响应处理消息,读到以后,如果是成功处理,那么就更新订单库中本地消息表中的该消息状态,更新为已完成。
整个简单的思路是这样,需要注意的是,我们要保证下订单和订单消息存入消息表是一个原子性的,使用本地事务处理,要么都成功,要么都失败。
第二,如果最终消息表中的消息都是已完成就没什么问题,但是有时候, 由于数据库网络等不稳定因素导致消息的状态还是未完成。这种问题,我们必须要解决。
可以使用定时任务,定时任务周期性去轮询本地消息表中的消息,如果某消息是未完成,那么可以触发重试操作,继续往MQ中发送这条消息,交给库存系统处理。
如果1步骤订单消息在保存的时候失败,那么直接触发回滚即可。1和2是同时成功,同时回滚的。如果3失败了,由于本地消息表中已经存在消息体,这时只需要轮询消息重新通过消息中间件发送一次。
第三,如果库存系统中在业务处理上失败了,此时再重试已无效,可以发消息给事务主动方订单系统回滚事务。
如果库存系统已经消费了消息,订单系统需要回滚事务的话,需要发消息通知库存进行回滚事务。
本地消息表+MQ由于消息是异步发送,实际上是一种最终一致性方案,即满足base理论,所以在一些要求实时性的业务场景就不那么适用的,适用于实时性不高,能接受最终一致性的场景。
优缺点总结
优点
1、简单可靠。通过本地消息表,将消息先存储到本地数据库中,再进行异步发送,可以保证消息的可靠性和一致性。
2、高可用性。相对XA协议,由于消息是异步发送的,所以即使其他分支事务出现了服务不可用或者故障,也不会影响当前事务的提交,保证了系统的高可用。
3、提升性能:将消息发送过程异步化,减少了事务的等待时间,提升了系统的性能。
缺点
1、需要维护额外的消息表,增加了系统的复杂性。
2、依赖于数据库。由于本地消息表依赖于数据库,如果数据库出现故障或性能问题,会对整个系统的可用性和性能产生影响。
3、业务耦合。本地消息表与业务耦合在一起,难于做成通用性,不可独立伸缩。
4、无法保证实时性:由于消息发送是异步的,无法保证消息的实时性,存在一定的延迟。
最后,对于实时性要求较高、对数据一致性要求更严格的场景,可能需要考虑使用分布式事务管理框架或消息中间件等更复杂的方案。
Seata
Seata的设计思路是将一个分布式事务理解为一个全局事务,全局事务下面挂着若干个分支事务。每个分支事务又相当于是本地事务,满足ACID的特性,因此我们操作分布式事务就像操作本地事务一样。Seata 内部定义了 3个模块来处理全局事务和分支事务的关系和处理过程,这三个组件分别是:
- Transaction Coordinator (TC): 事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
- Transaction Manager (TM): 控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
- Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
通过这三个模块,完成全局事务的执行流程,执行步骤如下:
首先,TM向TC申请一个全局事务,TC创建一个全局事务,并返回一个XID,XID是全局事务的唯一标识。然后,RM向TC注册分支事务,该分支归属于该XID的全局事务。RM注册本地事务,会完成一系列的本地事务操作,用于全局事务的提交或者回滚。
最后,TM将全局事务提交或者回滚的决策发送TC,TC调度 XID 全局事务下的分支事务完成提交或者回滚。
一般的,我们都使用Seate的AT模式,其实也是分为两个阶段,类似于XA协议的方式。第一阶段是准备阶段,第二阶段是分布式事务的提交或者回滚。
第一阶段
分支事务主要利用RM模块中的JDBC代理,在业务数据提交时,自动拦截业务SQL,把业务数据在更新前后的数据镜像组织成回滚日志,进而生成undoLog; 然后利用本地事务的特性,将业务SQL和undolog写入同一个事务中,一起提交到数据库中,保证业务SQL必定存在回滚日志,最后对分支事务状态向TC进行上报。
第二阶段
1、TM决议全局事务提交。首先通知TC全局事务提交,说明各个RM分支事务已经完成了第一阶段;此时,TC会异步调度各个RM分支事务删除对应的undolog日志。这个过程是异步的,所以速度也比较快。
2、TM决议全局事务回滚。同样,首先通知TC全局事务回滚,RM会收到TC发送的回滚请求,RM会通过XID找到对应的undolog日志,利用本地事务 ACID 特性,执行回滚日志完成回滚操作,并删除 undo log 日志,最后向TC进行回滚结果上报。
业务对以上所有的流程都无感知,业务完全不关心全局事务的具体提交和回滚,而且最重要的一点是 Seata 将两段式提交的同步协调分解到各个分支事务中了,分支事务与普通的本地事务无任何差异,这意味着我们使用 Seata 后,分布式事务就像使用本地事务一样。
然后想说一下,将数据库层的事务协调机制交给了中间件层 Seata , Seata为什么是个中间层,这是因为,XA协议依赖的是数据库层面来保障事务的一致性,也即是说 XA 的各个分支事务是在数据库层面上驱动的。而Seata在数据库做了一层代理层,所以我们使用 Seata 时,使用的数据源实际上用的是Seata自带的数据源代理 DataSourceProxy。这个代理层具体体现在RM模块。
其主要作用是解析 SQL,把业务数据在更新前后的数据镜像组织成回滚日志,并将 undo log 日志插入 undo log 表中,保证每条更新数据的业务 sql 都有对应的回滚日志存在。这样做的好处是:本地事务执行完以后,可以立即释放资源,然后向 TC上报分支状态。TM决议全局提交时,也不需要同步协调处理。
总接的来说,Seate有如下优点:
- 1、较高的性能表现。它极大减少了分支事务对资源的锁定时间,完美避免了 XA 协议需要同步协调导致资源锁定时间过长的问题。
- 2、易集成。 Seata 提供了与各种主流框架和中间件的集成支持,方便在现有系统中集成和使用。
- 3、支持多种存储后端: Seata 支持多种存储后端,可以根据实际需求选择合适的存储方式。
- 4、灵活配置: Seata 提供了丰富的配置选项,可以根据需求进行灵活配置,满足不同的分布式事务需求。
但是因为在使用时需要部署Seata服务端,集成Seata客户端,所以也存在一些缺点,比如:
- 部署和维护成本: 部署和维护分布式事务解决方案需要一定的成本和精力,特别是在大规模系统中。
- 依赖性: 引入 Seata 可能会增加系统的依赖性,需要谨慎评估是否真正需要使用分布式事务解决方案。
- 配置复杂性: 配置 Seata 可能需要一定的复杂性,特别是针对复杂的分布式系统和场景,需要仔细配置各种参数。
所以,可按需看业务情况是否真的需要使用Seate实现分布式事务。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。