分布式事务的解决方案
相关概念
事务
事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。简单地说,事务提供一种“要么什么都不做,要么做全套”(All or Nothing)机制。
一致性
强一致性
任何一次读都能读到某个数据的最近一次写的数据。系统中的所有进程,看到的操作顺序,都和全局时钟下的顺序一致。简言之,在任意时刻,所有节点中的数据是一样的。
弱一致性
数据更新后,如果能容忍后续的访问只能访问到部分或者全部访问不到,则是弱一致性。
最终一致性
不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。简单说,就是在一段时间后,节点间的数据会最终达到一致状态。
幂等操作
在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,支付流程中第三方支付系统告知系统中某个订单支付成功,接收该支付回调接口在网络正常的情况下无论操作多少次都应该返回成功。
分布式事务
在了解分布式事务之前我们首先需要了解和明确什么是事务,下面是《分布式系统概念与设计》这本书的一些文字说明:
事务定义了一个服务器操作序列,由服务器保证这些操作序列在多个客户并发访问和服务器出现故障的情况下的原子性。
事务的目标是在多个事务访问对象以及服务器面临故障的情况下,保证所有由服务器管理的对象始终保持一个一致的状态。
事务是由客户定义的针对服务器对象的一组操作,它们组成一个不可分割的单元,由服务器执行。
从这些定义中我们可以了解到事务其实就是一组操作序列,它们是统一完整的,这些操作要么全部成功执行,要么失败后全部回滚不产生任何影响。
而所谓的分布式事务则是指这些操作涉及到多台服务器,也称为多机事务。分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
相对的,我们传统事务也称之为单机事务。在单机事务时代,我们通常可以使用数据库系统提供的事务操作来解决原子性问题;那么在微服务越来越流行的当下,我们应该如何保证这些分布在多台服务器上的操作的原子性呢?
分布式事务产生的原因
- service中有多个节点:一个公司之内,用户的资产可能分为好多个部分,比如余额,积分,优惠券等等。在公司内部有可能积分功能由一个微服务团队维护,优惠券又是另外的团队维护,这样的话就无法保证积分扣减了之后,优惠券能否扣减成功。
- resource中有多个节点:MySQL一般来说装千万级的数据就得进行分库分表,对于一个支付宝的转账业务来说,你给的朋友转钱,有可能你的数据库是在北京,而你的朋友的钱是存在上海,所以我们依然无法保证他们能同时成功。
分布式事务使用场景
转账
转账是最经典的分布式事务场景,假设用户 A 使用银行 app 发起一笔跨行转账给用户 B,银行系统首先扣掉用户 A 的钱,然后增加用户 B 账户中的余额。此时就会出现 2 种异常情况:1. 用户 A 的账户扣款成功,用户 B 账户余额增加失败 2. 用户 A 账户扣款失败,用户 B 账户余额增加成功。对于银行系统来说,以上 2 种情况都是不允许发生,此时就需要分布式事务来保证转账操作的成功。
下单扣库存
在电商系统中,下单是用户最常见操作。在下单接口中必定会涉及生成订单 id, 扣减库存等操作,对于微服务架构系统,订单 id 与库存服务一般都是独立的服务,此时就需要分布式事务来保证整个下单接口的成功。
同步超时
继续以电商系统为例,在微服务体系架构下,我们的支付与订单都是作为单独的系统存在。订单的支付状态依赖支付系统的通知,假设一个场景:我们的支付系统收到来自第三方支付的通知,告知某个订单支付成功,接收通知接口需要同步调用订单服务变更订单状态接口,更新订单状态为成功。流程图如下,从图中可以看出有两次调用,第三方支付调用支付服务,以及支付服务调用订单服务,这两步调用都可能出现调用超时的情况,此处如果没有分布式事务的保证,就会出现用户订单实际支付情况与最终用户看到的订单支付情况不一致的情况。
事务的ACID特性
在开始着手解决分布式事务的原型性问题之前,我们必须先对原子性这个概念有个比较深入的了解。从上面的原子事务的概念中我们首先可以感知到两个含义:全有和全无,以及隔离性。
全有和全无说的是事务的所有操作要么全部执行成功并记录到持久存储中,要么因为故障或有意中止而不产生任何效果,这里又有两层含义:故障原子性和持久性。
隔离性说的是每个事务的执行不受其它事务影响,换言之,事务在执行过程中的中间效果对其它事务不可见。
对事务的全有和全无以及隔离性的归纳和总结,便有了我们现在所熟知的事务的ACID特性:
- 原子性:Automatic, 事务的操作序列要么全部执行,要么都不执行。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:Consistency, 从一个一致性的状态迁移到另一个一致性的状态,即一个事务执行之前和执行之后数据库都必须处于一致性状态,不会存在中间状态的数据。
- 隔离性:Isolation, 多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的。
- 持久性:Duration, 事务一旦成功完成,它的所有影响将保存到持久存储中,之后的其他操作或故障都不会对事务的结果产生影响。
这便是事务的定义里面内含的特性,里面的核心是原子性,只有了解了事务的含义和构成,接下来在面对分布式事务问题的时候我们才能知道如何去进行着手。
分布式事务对ACID的遵循
- A-原子性:严格遵循
- C-一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽
- I-隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽
- D-持久性:严格遵循
CAP定理
CAP定理说的是一个分布式系统只能满足下列三个特性的两个,而不能同时满足:
- C:一致性,所有客户端看到都是同一份数据,即使在数据更新和删除之后,等同于在分布式系统中的所有数据备份,在同一时刻是否同样的值(强一致)。
- A:可用性,即使部分节点发生故障,所有客户端也能找到可用的数据备份,等同于集群整体面对客户端的读写请求,在合理的时间内返回合理的响应(不是错误和超时的响应),合理的时间指的是请求不能无限被阻塞,合理的响应指的是系统应该明确返回结果并且结果是正确的。
- P:分区容忍性,即使发生网络分区故障(如某台机器网络出现了问题),系统仍然能够按照预期正常工作。
CAP定理在分布式领域至关重要,在构建大型分布式系统的时候我们必须根据自己业务的独特性在三者之间进行权衡。由于在分布式系统中,网络无法100%可靠,在构建分布式应用的时候我们往往不得不考虑分区容忍性(P是必选),这个时候我们通常只能在一致性和可用性之间进行选择,即在CP和AP之间进行选择,要么选择一致性放弃可用性,要么放弃一致性选择可用性。
证明:
分布式节点之间通常存在一个数据拷贝的过程,在这一个过程中是只能满足AP或者CP的。举个例子,比如Redis分布式集群中,当一个写请求打到一个主节点上,几乎同时另一个读请求打到Redis这个主节点的对应从节点上,此时请问该从节点能返回刚才写在主节点的数据吗?若要保证CP,此时数据正在从主节点复制到从节点的路上,此时该节点的该数据是不可用的;若要保证AP,因为数据正在从主节点复制到从节点的路上,因此节点间的数据状态是不一致的。
- CP:放弃可用性,追求一致性和分区容错性,如ZooKeeper追求强一致性。
- AP:放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展。
BASE理论
根据CAP定理,如果要完整的实现事务的ACID特性,只能放弃可用性选择一致性,即CP模型。然而如今大多数的互联网应用中,可用性也同样至关重要。于是eBay架构师根据CAP定理进行妥协提出一种ACID替代性方案,从而来达到可用性和一致性之间的某种微妙的平衡,选择AP模型的同时最大限度的满足一致性:强一致性不可强求,退而求其次选择最终一致性。
分布式事务对BASE理论完全遵循。
BASE是下面三部分的英文缩写简称:
- BA:Basically Available ,基本可用性,分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用、整个系统的整体可用。
- S:Soft State,软状态,允许系统存在中间状态,而该中间状态不会影响系统整体可用性,这个中间状态最终会转化为最终状态。
- E:Eventually Consistency,最终一致性,在经过一定时间后,分布式集群节点间的数据拷贝能达到最终一致的状态。
- “基本可用”:相对CAP的“完全可用”而言的,即在部分节点出现故障的时候不要求整个系统完全可用,允许系统出现部分功能和性能上的损失:比如增加响应时间,引导用户到一个降级提示页面等等。
- “软状态”:相对CAP定理强一致性的“硬状态”而言,CAP定理的一致性要求数据变化要立即反映到所有的节点副本上去,是一种强一致性。“软状态”不要求数据变化立即反映到所有的服务器节点上,允许存在一个中间状态进行过渡,比如允许放大延时等。
- ”最终一致性“:相对强一致性而言,它不要求系统数据始终保持一致的状态,只要求系统经过一段时间后最终会达到一致状态即可。
解决方案概述
在了解分布式领域的CAP定理和BASE理论,以及事务的ACID特性后,要解决分布式事务一致性的问题我们首要关注的便是原子性问题,并且根据BASE理论提供的思路从一致性的强弱角度来梳理对应的解决方案。
强一致性方案
强一致性的方案便是前面提到的舍A保C的CP模型,即通过牺牲可用性来保证一致性,这种方案适用于对一致性要求很高的场景,比如金融交易等。
2PC——二阶段提交协议
二阶段提交协议(Two–Phase Commit protocol)是一种原子提交协议,用来协调参与分布式事务的所有进程是否提交/终止事务。2PC之所以称之为两阶段,是因为进行事务的提交需要经历两个阶段:准备阶段和提交阶段。每个阶段又由两步组成,准备阶段分为询问和投票两步,提交阶段分为决策和完成两步。
阶段一:Prepare(准备阶段、投票阶段,可理解成除了提交事务之外啥事都做完了)
- 询问:协调者询问每个参与者能够进行本地事务提交;
- 投票:参与者根据自身情况向协调者发送Yes/No消息;
阶段二:Commit/Cancel(提交阶段或回滚阶段,不一定就是提交)
- 决策:如果所有参与者回复Yes则协调者进行全局事务提交,否则全局中止事务;
- 完成:参与者根据协调者的结果进行事务的提交或回滚。
2PC协议是最基本的原子提交协议,是我们理解其它方案的基础,它最大的缺陷就是阻塞范围太广,协议的任何一方出现故障都会导致协议阻塞而不能进行下去。正是由于它同步大范围阻塞的特性,其性能不是很好,并不是很适用于那些追求性能的高并发场景,但理解它是我们理解后续其它变种协议的基础。
2PC存在的问题
- 同步阻塞:当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。
- 单点故障:一旦事务管理器出现故障,整个系统不可用。
- 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
- 不确定性:当协事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。
实战
目前支付宝使用两阶段提交思想实现了分布式事务服务 (Distributed Transaction Service, DTS) ,它是一个分布式事务框架,用来保障在大规模分布式环境下事务的最终一致性。具体可参考支付宝官方文档:https://tech.antfin.com/docs/2/46887
DTP/XA规范
DTP模型是国际X/Open联盟根据2PC协议定义的分布式事务处理模型(DTP: Distributed Transaction Process Model),它定义了三大组件,以及它们之间相互操作的 XA 接口:
- AP:应用程序,定义了事务以及对涉及到的资源(终端或数据库)的一系列操作,并在事务边界内访问资源
- RMs:资源管理器,计算机共享资源的一个特定部分,如数据库管理系统(DBMS)、打印服务等,即参与者
- TM:事务管理器,管理全局事务,协调事务的提交和回滚,并协助进行故障恢复,即协调者
XA 事务由一个或多个资源管理器(RM)、一个事务管理器(TM)和一个应用程序(AP)组成。
这里的RM、TM、AP三个角色是经典的角色划分,会贯穿后续Saga、TCC等事务模式。
分布式数据库通常会采用该方案来实现自己的分布式事务,比如MySQL就提供了对XA事务的支持。XA的特点是:
- 简单易理解,开发较容易
- 对资源进行了长时间的锁定,并发度低
实战
以转账作为例子,一个成功完成的XA事务时序图如下:
3PC——三阶段提交协议
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit
、PreCommit
和 DoCommit
。
CanCommit阶段
协调者向参与者发送commit请求(仅询问参与者的自身状况),参与者如果可以提交就返回Yes响应,否则返回No响应。
事务询问:协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
响应反馈:参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No。
PreCommit阶段
协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有以下两种可能:
- 假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行:
- 发送预提交请求:协调者向参与者发送PreCommit请求,并进入Prepared阶段。
- 事务预提交:参与者接收到PreCommit请求后,会执行事务操作。
- 响应反馈:如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
- 假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有收到参与者的响应,那么就执行事务的中断:
- 发送中断请求:协调者向所有参与者发送abort请求。
- 中断事务:参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
DoCommit阶段
该阶段进行真正的事务提交,也可以分为以下两种情况:
- 提交事务
- 发送提交请求:协调者接收到所有参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送DoCommit请求。
- 事务提交:参与者接收到DoCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
- 响应反馈:事务提交完之后,向协调者发送ACK响应。
- 完成事务:协调者接收到所有参与者的ACK响应之后,完成事务。
- 回滚事务:任一参与者没有发送ACK响应,有可能是参与者发送的不是ACK响应,也有可能是参与者响应超时,那么协调者就会执行中断事务:
- 发送中断请求:协调者向所有参与者发送abort请求。
- 事务回滚:参与者接收到abort请求之后,执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
- 反馈结果:参与者完成事务回滚之后,向协调者发送ACK消息。
- 中断事务:协调者接收到所有参与者的ACK响应之后,执行事务的中断。
相比于2PC的不同
- 准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
- 预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。(作为一位参与者,自己进入了预提交状态可以推断出其他参与者也都进入了预提交状态)
- 多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。
- 3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。
- 业界暂时较少具体的实现, 3PC 只是纯的理论上的东西,而且相比于 2PC 它是做了一些努力但是效果甚微。
最终一致性方案
基于2PC的强一致性方案的阻塞特性对性能的影响很大,在CAP定理中属于CP范畴。在互联网应用中为了提升性能和可用性,基于BASE理论,可以使用最终一致性来替代强一致性,通过牺牲部分一致性来换取性能和可用性的提升。
TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务。TCC是基于BASE理论的类2PC方案,根据业务的特性对2PC的流程进行了优化。
解决XA的缺点
- 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
- 同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
- 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性
业务实例
一个简化版的订销存交易流程:
用户在电商网站下订单后通知库存服务扣减粗存,最后通过积分服务给用户增加积分。整个交易操作应该具有原子性,这些交易步骤要么一起成功,要么一起失败,必须是一个整体性的事务。
假设用户下完订单通知库存服务扣减库存失败时,比如原本是10件商品卖了1件剩余9件,但由于库存DB操作失败,导致库存还是10件,这时就出现了数据不一致的情况,此时如果有其它用户也进行了购买操作,则可能出现超卖的问题。
如果采用2PC的解决方案,在整个交易成功完成或者失败回滚之前,其它用户的操作将会处于阻塞等待的状态,这会大大的降低系统的性能和用户体验。
执行流程
TCC的操作流程基本和2PC类似,区别在一些步骤的细节上,如下图:
- Try阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)。
- Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。
- Cancel阶段:取消执行,释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。
不同于2PC第一阶段的Prepare,TCC在Try阶段主要是对资源的预留操作这类的轻量级操作,比如冻结部分库存数量,它不需要像2PC在第二阶段完成之后才释放整个资源,也就是它不需要等待整个事务完成后才进行提交,这时其它用户的购买操作可以继续正常进行,因此它的阻塞范围小时间短暂,性能上比2PC方案要有很大的提升。
TCC的Confirm/Cancel阶段在业务逻辑上是不允许返回失败的,如果因为网络或者其他临时故障,导致不能返回成功,TM会不断地重试,直到Confirm/Cancel返回成功。
一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。以转账作为例子,通常会在Try里面冻结金额,但不扣款,Confirm里面扣款,Cancel里面解冻金额。
特点
- 并发度较高,无长期资源锁定。
- 开发量较大,会将原来只需要一个接口就可以实现的逻辑拆分为 Try、Confirm、Cancel 三个接口,所以代码实现复杂度相对较高,而且这对一些难以改动的老旧系统来说甚至是不可行的。
- TCC适用于订单类业务,对中间状态有约束、强隔离性、严格一致性要求、执行时间较短的业务。
- 一致性较好,不会发生SAGA已扣款最后又转账失败的情况。
- 可以跨数据库、跨不同的业务系统来实现事务。
实战
TCC依赖于业务方来配合提供这样的接口,推行难度大,所以一般不推荐使用这种方式。
本地事务状态表(本地消息表)
本地事务状态表的方案是在调用方调用分布式事务之前将待执行的事务流程及其状态信息存储到数据库中,依赖数据库DB本地事务的原子特性,这一步操作是原子完成的,这个存储事务执行状态信息的表称为本地事务状态表。
实质上利用了各系统本地的事务来实现分布式事务。
在将事务状态信息存储到DB后,调用方才会开始继续后面的的调用操作,每次调用成功时更新对应的事务状态,某一步失败时则中止执行。同时后台需要运行一个定时任务来定期扫描事务状态表,对于没有完成的事务操作重新发起调用,或者执行回滚,或者在失败重试指定次数后触发告警让人工介入进行修复。本地事务表的方案大概如下图所示:
执行流程
顾名思义会有一张存放本地消息的表,一般都是放在数据库中,以购买商品为例:
- 首先在扣钱服务器一端建立一个本地消息表,执行业务操作、往消息表中插入一条操作数据这两个操作发生在同一个事务中,依靠数据库本地事务保证一致性。
- 调用下一个操作,若下一个操作调用成功则消息表的消息状态可以直接改成已成功;若失败,不更改消息状态。
- 用一个定时任务去轮询这个本地事务表,把没有发送的消息(失败状态),扔给商品库存服务器(即之前还未成功执行的消息,去重试调用对应的服务,服务更新成功了再变更消息的状态),到达商品服务器之后需要先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。
- 商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,令扣钱服务器本地消息表进行状态更新。
实现条件
- 消费者与生成者的接口都要支持幂等
- 生产者需要额外的创建消息表
- 需要提供补偿逻辑,如果消费者业务失败,需要生产者支持回滚操作
特点
- 长事务仅需要分拆成多个任务,使用简单
- 生产者需要额外的创建消息表
- 每个本地消息表都需要进行轮询
- 消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作
适用范围
核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。适用于可异步执行的业务,且后续操作无需回滚的业务
实战
跨行转账可通过该方案实现:用户 A 向用户 B 发起转账,首先系统会扣掉用户 A 账户中的金额,将该转账消息写入消息表中,如果事务执行失败则转账失败,如果转账成功,系统中会有定时轮询消息表,往 mq 中写入转账消息,失败重试。mq 消息会被实时消费并往用户 B 中账户增加转账金额,执行失败会不断重试。
消息事务
在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上(对本地消息表的一个封装,将本地消息表移动到了MQ内部),解决生产端的消息发送与本地事务执行的原子性问题。
执行流程
- 先给 Broker 发送事务消息即半消息,半消息不是说一半消息,而是这个消息对消费者来说不可见。
- 服务端存储消息,并响应消息的写入结果。
- 发送成功后发送方再执行本地事务。
- 根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。
- RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内半消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
- 如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。
- 如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
特点
- 长事务仅需要分拆成多个任务,并提供一个反查接口,使用简单
- 消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作
- 适用于可异步执行的业务,且后续操作无需回滚的业务
尽最大努力通知
发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。
实现条件
- 消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
- 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
执行流程
- 系统 A 本地事务执行完之后,发送个消息到 MQ;
- 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口;
- 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。
与消息事务的不同
本地消息表和事务消息都属于可靠消息。
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
适用范围
适用于对时间不敏感的、通知类型业务,例如短信通知、微信交易的结果等,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口。
消息中间件
现在使用消息中间件来解耦系统架构设计的方案越来越普遍,基于消息中间件的分布式事务解决方案主要分两类,根据使用的消息中间件是否支持事务消息来划分。比如使用 Kafka(< 0.11.0) 这类的不支持事务消息的消息中间件,参与事务的系统需要在给消息中间件发送消息之前,把消息的信息和状态存储到本地的消息表中,方案如下图概述:
参与分布式事务的某个系统A接收到请求后,在执行本地事务的同时需要同时将待发送的消息同时记录到事务消息表里面去,将业务表和消息表放在一个数据库事务里,保证两者的原子性;执行完后系统A不直接给消息中间件发消息,而是通过后台的定时任务来扫描消息表来进行发送,定时任务会不断的失败重试,直到消息中间件成功返回 ack 消息并更细消息表状态,从而保证消息的不丢失。
消息中间件收到消息后会给后面的事务执行者系统B发送消息,只有系统B成功应答 Ack 消息后消息中间件才会将系统A发送的消息丢弃。由于消息会不断的重复发送,所以事务的所有参与者需要自行保证事务执行的幂等性,比如判重表等手段。
如果是基于RocketMQ或Kafka(>=0.11.0)这类的支持事务操作的消息中间件,上述的方案则可以简化,此时上面的的定时任务的工作将交给消息中间件来提供。
事务消息(或者说原子消息)的实现的基本原理是二阶段提交协议(2PC)。它将一个消息的发送操作分为两步,即 Prepare Message(准备消息)和 Confirm Message(确认消息)。如下面的时序图:
详细的操作流程这里不进行赘述,其本质就是将前面介绍的定时任务的工作挪到了消息中间件内部完成:消息中间件会对那些处于 Prepare 状态的消息不断进行询问是否可以进行提交投递。
弱一致性方案
上面基于最终一致性的方案可以很好的满足我们大多数的场景需要,让我们在可用性和一致性之间取得微妙的平衡。但是在一些场景下,我们对系统的性能和可用性反而会有更高的要求。
比如海量请求的高并发秒杀场景中,就连保证基本可用性也变的非常困难,除了对秒杀的非核心功能降级服务,增加响应时间等,根据CAP定理,我们不得不再次放低对一致性的要求,从最终一致性弱化到弱一致性,从而再次提高系统的性能和可用性。
基于状态的补偿
这是一个根据业务特性进行妥协的一种方案,根据实际的业务场景对立面的数据重要性进行划分,放弃传统的全局数据一致,允许其中个别数据出现不一致但不会对业务产生重大影响。比如在电商网站购物场景中,其中两个主要的步骤是创建订单和扣库存,这分别由两个服务进行处理:订单服务和库存服务。
如果采用前面基于消息的最终一致性方案,创建订单的消息通知库存服务扣除库存,由于异步消息的延迟则会导致超卖;如果采用TCC的方案,每次请求操作都需要Try、Confirm两次请求调用,性能又不能达标;如果采用本地事务状态表,则需要对海量的事务进行状态更新操作,性能和延迟也同样会是个问题。
但是我们可以依据实际的电商购物场景进行取舍:允许少卖,但不能超卖。于是我们可以先扣库存后提交订单,订单创建成功后再关联到库存,这样就不会出现超卖的问题了:
扣库存 | 提交订单 | 返回结果 | 可能结果 | |
---|---|---|---|---|
1 | √ | √ | √ | |
2 | √ | × | × | 多扣库存 |
3 | × | × | 多扣库存 |
这里的基于状态的补偿,则是一种事后处理机制,根据库存流水记录查找那些一段时间内未关联订单的库存记录进行撤销操作。比如我们我们在12306上的提交购买车票,那些30分钟内未支付的车票会进行释放。
重试(+回滚)+告警+人工修复
上面的方案对业务场景的要求比较多,对于那些业务流程复杂,需要维护的状态也很复杂,也就是很难根据状态进行自动补偿的时候,我们可以进一步简化操作:不做自动的状态补偿。
还是拿上面那个订单和库存的例子进行说明,比如先扣库存,然后创建订单,如果订单创建失败则重试,重试还是失败则回滚,回滚失败则触发告警,然后人工根据日志记录进行修复。
这个方案其实并没有什么特别的要求,甚至也说不上是什么方案,就是根据业务流程特性一步一步的操作,但里面的关键则是详细的操作日志记录和告警,至于是否需要尝试回滚也是可有可无的。
说白了这个方案就是放弃一致性的要求,也是成本最低最被动的方案。
事后处理-对账
所有的“过程”都会产生“结果”,对账属于事后处理关注的是“结果”,它根据结果(数据)来反推过程(事务)出了问题,从而对数据就行修补。比如每隔一段时间对订单进行扫描,对长时间未处理的订单进行告警。
对账的关键是“找出数据背后的数学规律”,有些好找,有些难。它的一个基本要求是数据记录起码是“完备”的,否则谈何“数学规律”。
严格上来说对账算不上什么方案,更多的是用来辅助人工对数据进行检查,发现其中存在的问题(比如异常、假账)等,然后触执行对应的动作,比如告警。但是我们可以在设计方案的时候根据对账的思路来设计一个自动对账流程来发现不一致的数据并自动修补。
END
这篇文章主要是对过去一段时间对分布式系统里面的分布式事务的学习和总结,简要概述了分布式事务的相关概念和分布式领域的CAP定理和BASE理论,最后从一致性的角度出发来学习和探索其中的几个解决方案并进行归类总结。
对数据一致性要求比较高的场景中(金融银行等)我们可以使用2PC一类的强一致性方案;在一些更普遍和常规的互联网应用中,我们需要同时关注可用性和一致性,这个时候可以采用基于BASE理论的最终一致性的几个方案;在一些极端的场景中根据业务特性我们可以退化使用弱一致性方案。
没有完美的方案,只有适合的方案。