刚性事务
刚性事务:通常无业务改造,强一致性,原生支持回滚/隔离性,低并发,适合短事务。
刚性事务指的是,要使分布式事务,达到像本地式事务一样,具备数据强一致性,从CAP来看,就是说,要达到CP状态。
刚性事务:XA 协议(2PC、JTA、JTS)、3PC,但由于同步阻塞,处理效率低,不适合大型网站分布式场景
XA
用非常官方的话来说:
- XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
- XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范 的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
- XA 规范 使用两阶段提交(2PC,Two-Phase Commit)协议来保证所有资源同时提交或回滚任何特定的事务。
- XA 规范 在上世纪 90 年代初就被提出。目前,几乎所有主流的数据库都对 XA 规范 提供了支持。
XA规范(XA Specification) 是X/OPEN 提出的分布式事务处理规范。XA则规范了TM与RM之间的通信接口,在TM与多个RM之间形成一个双向通信桥梁,从而在多个数据库资源下保证ACID四个特性。目前知名的数据库,如Oracle, DB2,mysql等,都是实现了XA接口的,都可以作为RM。
XA是数据库的分布式事务,强一致性,在整个过程中,数据一张锁住状态,即从prepare到commit、rollback的整个过程中,TM一直把持折数据库的锁,如果有其他人要修改数据库的该条数据,就必须等待锁的释放,存在长事务风险。
以下的函数使事务管理器可以对资源管理器进行的操作:
1)xa_open,xa_close:建立和关闭与资源管理器的连接。
2)xa_start,xa_end:开始和结束一个本地事务。
3)xa_prepare,xa_commit,xa_rollback:预提交、提交和回滚一个本地事务。
4)xa_recover:回滚一个已进行预提交的事务。
5)ax_开头的函数使资源管理器可以动态地在事务管理器中进行注册,并可以对XID(TRANSACTION IDS)进行操作。
6)ax_reg,ax_unreg;允许一个资源管理器在一个TMS(TRANSACTION MANAGER SERVER)中动态注册或撤消注册。
2PC/3PC协议
两阶段提交(2PC)协议是XA规范定义的 数据一致性协议。
三阶段提交(3PC)协议对 2PC协议的一种扩展。
JTA
作为java平台上事务规范 JTA(Java Transaction API)也定义了对XA事务的支持,实际上,JTA是基于XA架构上建模的,在JTA 中,事务管理器抽象为javax.transaction.TransactionManager接口,并通过底层事务服务(即JTS)实现。像很多其他的java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要由以下几种:
1.J2EE容器所提供的JTA实现(JBoss)
2.独立的JTA实现:如JOTM,Atomikos.
这些实现可以应用在那些不使用J2EE应用服务器的环境里用以提供分布事事务保证。如Tomcat,Jetty以及普通的java应用。
JTS规范
事务是编程中必不可少的一项内容,基于此,为了规范事务开发,Java增加了关于事务的规范,即JTA和JTS
JTA定义了一套接口,其中约定了几种主要的角色:TransactionManager、UserTransaction、Transaction、XAResource,并定义了这些角色之间需要遵守的规范,如Transaction的委托给TransactionManager等。
JTS也是一组规范,上面提到JTA中需要角色之间的交互,那应该如何交互?JTS就是约定了交互细节的规范。
总体上来说JTA更多的是从框架的角度来约定程序角色的接口,而JTS则是从具体实现的角度来约定程序角色之间的接口,两者各司其职。
Atomikos分布式事务实现
Atomikos公司旗下有两款著名的分布事务产品:
- TransactionEssentials:开源的免费产品
- ExtremeTransactions:商业版,需要收费
这两个产品的关系如下图所示:
可以看到,在开源版本中支持JTA/XA、JDBC、JMS的事务。
atomikos也支持与spring事务整合。
spring事务管理器的顶级抽象是PlatformTransactionManager接口,其提供了个重要的实现类:
- DataSourceTransactionManager:用于实现本地事务
- JTATransactionManager:用于实现分布式事务
显然,在这里,我们需要配置的是JTATransactionManager
public class JTAService {
@Autowired
private UserMapper userMapper;//操作db_user库
@Autowired
private AccountMapper accountMapper;//操作db_account库
@Transactional
public void insert() {
User user = new User();
user.setName("wangxiaoxiao");
userMapper.insert(user);
//模拟异常,spring回滚后,db_user库中user表中也不会插入记录
Account account = new Account();
account.setUserId(user.getId());
account.setMoney(123456789);
accountMapper.insert(account);
}
}
柔性事务
柔性事务指的是,不要求强一致性,而是要求最终一致性,允许有中间状态,也就是Base理论,换句话说,就是AP状态。
与刚性事务相比,柔性事务的特点为:有业务改造,最终一致性,实现补偿接口,实现资源锁定接口,高并发,适合长事务。
柔性事务分为:
- 补偿型
- 异步确保型
- 最大努力通知型
柔型事务:TCC/FMT、Saga(状态机模式、Aop模式)、本地事务消息、消息事务(半消息)
异步确保型
指将一系列同步的事务操作修改为基于消息队列异步执行的操作,来避免分布式事务中同步阻塞带来的数据操作性能的下降。
事务消息
基于MQ的事务消息方案主要依靠MQ的半消息机制来实现投递消息和参与者自身本地事务的一致性保障。半消息机制实现原理其实借鉴的2PC的思路,是二阶段提交的广义拓展。
半消息:在原有队列消息执行后的逻辑,如果后面的本地逻辑出错,则不发送该消息,如果通过则告知MQ发送;
流程
- 事务发起方首先发送半消息到MQ;
- MQ通知发送方消息发送成功;
- 在发送半消息成功后执行本地事务;
- 根据本地事务执行结果返回commit或者是rollback;
- 如果消息是rollback, MQ将丢弃该消息不投递;如果是commit,MQ将会消息发送给消息订阅方;
- 订阅方根据消息执行本地事务;
- 订阅方执行本地事务成功后再从MQ中将该消息标记为已消费;
- 如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
- Consumer端的消费成功机制有MQ保证;
本地消息表
有时候我们目前的MQ组件并不支持事务消息,或者我们想尽量少的侵入业务方。这时我们需要另外一种方案“基于DB本地消息表“。
本地消息表最初由eBay 提出来解决分布式事务的问题。是目前业界使用的比较多的方案之一,它的核心思想就是将分布式事务拆分成本地事务进行处理。
本地消息表流程
发送消息方:- 需要有一个消息表,记录着消息状态相关信息。
- 业务数据和消息表在同一个数据库,要保证它俩在同一个本地事务。直接利用本地事务,将业务数据和事务消息直接写入数据库。
- 在本地事务中处理完业务数据和写消息表操作后,通过写消息到 MQ 消息队列。使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务消息表记录
- 消息会发到消息消费方,如果发送失败,即进行重试。
消息消费方:
- 处理消息队列中的消息,完成自己的业务逻辑。
- 如果本地事务处理成功,则表明已经处理成功了。
- 如果本地事务处理失败,那么就会重试执行。
- 如果是业务层面的失败,给消息生产方发送一个业务补偿消息,通知进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
本地消息表优缺点:
优点:
- 本地消息表建设成本比较低,实现了可靠消息的传递确保了分布式事务的最终一致性。
- 无需提供回查方法,进一步减少的业务的侵入。
- 在某些场景下,还可以进一步利用注解等形式进行解耦,有可能实现无业务代码侵入式的实现。
缺点:
- 本地消息表与业务耦合在一起,难于做成通用性,不可独立伸缩。
- 本地消息表是基于数据库来做的,而数据库是要读写磁盘IO的,因此在高并发下是有性能瓶颈的
MQ事务消息 VS 本地消息表
二者的共性:
1、 事务消息都依赖MQ进行事务通知,所以都是异步的。
2、 事务消息在投递方都是存在重复投递的可能,需要有配套的机制去降低重复投递率,实现更友好的消息投递去重。
3、 事务消息的消费方,因为投递重复的无法避免,因此需要进行消费去重设计或者服务幂等设计。
MQ事务消息:
- 需要MQ支持半消息机制或者类似特性,在重复投递上具有比较好的去重处理;
- 具有比较大的业务侵入性,需要业务方进行改造,提供对应的本地操作成功的回查功能;
DB本地消息表:
- 使用了数据库来存储事务消息,降低了对MQ的要求,但是增加了存储成本;
- 事务消息使用了异步投递,增大了消息重复投递的可能性;
最大努力通知
最大努力通知方案的目标,就是发起通知通过一定的机制,最大努力将业务处理结果通知到接受方
最大努力通知型的最终一致性:
本质是通过引入定期校验机制实现最终一致性,对业务的侵入性较低,适合于对最终一致性敏感度比较低、业务链路较短的场景。
最大努力通知事务主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以只能尽最大努力去通知实现数据最终一致性,比如充值平台与运营商、支付对接、商户通知等等跨平台、跨企业的系统间业务交互场景;
而异步确保型事务主要适用于内部系统的数据最终一致性保障,因为内部相对比较可控,比如订单和购物车、收货与清算、支付与结算等等场景。
MQ的事务消息方案
要实现最大努力通知,可以采用 MQ 的 ACK 机制。
最大努力通知事务在投递之前,跟异步确保型流程都差不多,关键在于投递后的处理。
因为异步确保型在于内部的事务处理,所以MQ和系统是直连并且无需严格的权限、安全等方面的思路设计。最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:
- 业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
- 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
- 业务被动方提供幂等的服务接口,防止通知重复消费。
- 业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。
- 业务活动的主动方,在完成业务处理之后,向业务活动的被动方发送消息,允许消息丢失。
- 主动方可以设置时间阶梯型通知规则,在通知失败后按规则重复通知,直到通知N次后不再通知。
- 主动方提供校对查询接口给被动方按需校对查询,用于恢复丢失的业务消息。
- 业务活动的被动方如果正常接收了数据,就正常返回响应,并结束事务。
- 如果被动方没有正常接收,根据定时策略,向业务活动主动方查询,恢复丢失的业务消息
特点
- 用到的服务模式:可查询操作、幂等操作;
- 被动方的处理结果不影响主动方的处理结果;
- 适用于对业务最终一致性的时间敏感度低的系统;
- 适合跨企业的系统间的操作,或者企业内部比较独立的系统间的操作,比如银行通知、商户通知等;
本地消息表方案
要实现最大努力通知,可以采用 定期检查本地消息表的机制 。
发送消息方:
- 需要有一个消息表,记录着消息状态相关信息。
- 业务数据和消息表在同一个数据库,要保证它俩在同一个本地事务。直接利用本地事务,将业务数据和事务消息直接写入数据库。
- 在本地事务中处理完业务数据和写消息表操作后,通过写消息到 MQ 消息队列。使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务消息表记录
- 消息会发到消息消费方,如果发送失败,即进行重试。
- 生产方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:
- 业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
- 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
- 业务被动方提供幂等的服务接口,防止通知重复消费。
- 业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。
最大努力通知事务 VS 异步确保型事务
最大努力通知事务在我认知中,其实是基于异步确保型事务发展而来适用于外部对接的一种业务实现。他们主要有的是业务差别,如下:
• 从参与者来说:最大努力通知事务适用于跨平台、跨企业的系统间业务交互;异步确保型事务更适用于同网络体系的内部服务交付。
• 从消息层面说:最大努力通知事务需要主动推送并提供多档次时间的重试机制来保证数据的通知;而异步确保型事务只需要消息消费者主动去消费。
• 从数据层面说:最大努力通知事务还需额外的定期校验机制对数据进行兜底,保证数据的最终一致性;而异步确保型事务只需保证消息的可靠投递即可,自身无需对数据进行兜底处理
补偿型
TCC
TCC(Try-Confirm-Cancel)的概念来源于 Pat Helland 发表的一篇名为“Life beyond Distributed Transactions:an Apostate’s Opinion”的论文。
TCC 分布式事务模型包括三部分:
1.主业务服务:主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动。
2.从业务服务:从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用。
3.业务活动管理器:业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。
TCC 提出了一种新的事务模型,基于业务层面的事务定义,锁粒度完全由业务自己控制,目的是解决复杂业务中,跨表跨库等大颗粒度资源锁定的问题。
TCC 把事务运行过程分成 Try、Confirm / Cancel 两个阶段,每个阶段的逻辑由业务代码控制,避免了长事务,可以获取更高的性能。
TCC的工作流程
TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
因此,针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑:
初步操作 Try:完成所有业务检查,预留必须的业务资源。
确认操作 Confirm:真正执行的业务逻辑,不作任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务有且只能成功一次。
取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。
TCC 分布式事务模型包括三部分:
Try 阶段: 调用 Try 接口,尝试执行业务,完成所有业务检查,预留业务资源。
Confirm 或 Cancel 阶段: 两者是互斥的,只能进入其中一个,并且都满足幂等性,允许失败重试。
Confirm 操作: 对业务系统做确认提交,确认执行业务操作,不做其他业务检查,只使用 Try 阶段预留的业务资源。
Cancel 操作: 在业务执行错误,需要回滚的状态下执行业务取消,释放预留资源。
Try 阶段失败可以 Cancel,如果 Confirm 和 Cancel 阶段失败了怎么办?
TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。
TCC事务案例
然而基于补偿的事务形态也并非能实现所有的需求,如以下场景:某笔订单完成时,同时扣掉用户的现金,但交易未完成,也未被取消时,不能让客户看到钱变少了。
这时我们可以引入TCC,其流程如下:
- 订单服务创建订单
- 订单服务发送远程调用到现金服务,冻结客户的现金
- 提交订单服务数据
- 订单服务发送远程调用到现金服务,扣除客户冻结的现金
以上是正常完成的流程,若为异常流程,则需要发送远程调用请求到现金服务,撤销冻结的金额。
以上流程比基于补偿实现的事务的流程要复杂,同时开发的工作量也更多:
- 订单服务编写创建订单的逻辑
- 现金服务编写冻结现金的逻辑
- 现金服务编写扣除现金的逻辑
- 现金服务编写解冻现金的逻辑
TCC实际上是最为复杂的一种情况,其能处理所有的业务场景
SAGA
SAGA可以看做一个异步的、利用队列实现的补偿事务。
1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas,讲述的是如何处理long lived transaction(长活事务)。Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。
论文地址:sagas
Saga模型是把一个分布式事务拆分为多个本地事务,每个本地事务都有相应的执行模块和补偿模块(对应TCC中的Confirm和Cancel),当Saga事务中任意一个本地事务出错时,可以通过调用相关的补偿方法恢复之前的事务,达到事务最终一致性。
这样的SAGA事务模型,是牺牲了一定的隔离性和一致性的,但是提高了long-running事务的可用性。
Saga 模型由三部分组成:
- LLT(Long Live Transaction):由一个个本地事务组成的事务链。
- 本地事务:事务链由一个个子事务(本地事务)组成,LLT = T1+T2+T3+…+Ti。
- 补偿:每个本地事务 Ti 有对应的补偿 Ci。
Saga的执行顺序有两种:
- T1, T2, T3, …, Tn
- T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n
Saga 两种恢复策略:
- 向后恢复(Backward Recovery):撤销掉之前所有成功子事务。如果任意本地子事务失败,则补偿已完成的事务。如异常情况的执行顺序T1,T2,T3,…Ti,Ci,…C3,C2,C1。
- 向前恢复(Forward Recovery):即重试失败的事务,适用于必须要成功的场景,该情况下不需要Ci。执行顺序:T1,T2,…,Tj(失败),Tj(重试),…,Ti。
显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。
理论上补偿事务永不失败,然而,在分布式世界中,服务器可能会宕机,网络可能会失败,甚至数据中心也可能会停电。在这种情况下我们能做些什么? 最后的手段是提供回退措施,比如人工
Saga 模式的实现方式
- 基于编排器(Orchestrator)
概念:
- 编排器是一个集中式的控制器,负责管理 Saga 的执行流程。
- 它按照预定义的流程,依次调用各个局部事务。
- 处理错误和补偿逻辑。
优点:
- 集中控制,逻辑清晰,易于管理和监控。
- 补偿流程由编排器负责,简化了服务间的依赖。
缺点:
- 编排器可能成为单点瓶颈。
- 需要维护编排器的高可用性。
- 基于事件驱动的编排
概念:
- 利用消息队列或事件总线,服务之间通过事件进行通信。
- 编排器监听和发布事件,驱动 Saga 的执行。
优点:
- 解耦服务,提高系统的灵活性。
- 利用消息中间件的可靠性,保证消息的传递。
缺点:
- 事件链可能复杂,调试和追踪困难。
- 需要处理消息的幂等性和重复消费。
- 基于协同者(Choreography)
概念:
- 没有集中式的编排器,各个服务通过订阅和发布事件,自主完成事务和补偿。
- 每个服务都知道 Saga 的流程,自己决定下一步操作。
优点:
- 服务高度解耦,无需中央控制器。
- 扩展性好,易于添加新的服务。
缺点:
- 业务流程分散在各个服务中,逻辑复杂。
- 需要确保所有服务对 Saga 流程有一致的理解。
实用案例
- 订单处理系统
场景描述:
一个电商平台的订单处理流程,涉及以下服务:
- 订单服务:创建订单。
- 库存服务:扣减库存。
- 支付服务:处理付款。
- 物流服务:安排发货。
Saga 流程:
- T1:订单服务创建订单。
- T2:库存服务扣减库存。
- T3:支付服务处理付款。
- T4:物流服务安排发货。
补偿操作:
- C1:订单服务取消订单。
- C2:库存服务恢复库存。
- C3:支付服务退款。
- 实现示例
基于编排器的实现:
- 编排器负责按照顺序调用各个服务的接口。
- 如果某个服务调用失败,编排器按相反顺序调用补偿接口。
伪代码:
class OrderSagaOrchestrator {
public void executeSaga(Order order) {
try {
// T1: 创建订单
orderService.createOrder(order);
// T2: 扣减库存
inventoryService.reduceStock(order);
// T3: 处理付款
paymentService.processPayment(order);
// T4: 安排发货
shippingService.scheduleShipment(order);
// Saga 成功完成
} catch (Exception e) {
// 发生异常,执行补偿
compensate(order);
}
}
private void compensate(Order order) {
// 按相反顺序执行补偿操作
try {
// C3: 支付退款
paymentService.refund(order);
} catch (Exception e) { /* 日志记录 */ }
try {
// C2: 恢复库存
inventoryService.restoreStock(order);
} catch (Exception e) { /* 日志记录 */ }
try {
// C1: 取消订单
orderService.cancelOrder(order);
} catch (Exception e) { /* 日志记录 */ }
}
}