分布式事务科普(终结篇)
Posted 阿飞的博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分布式事务科普(终结篇)相关的知识,希望对你有一定的参考价值。
封面:重庆洪崖洞
《分布式事务科普》是一篇科普型文章,内容共计两万五千字左右,应该算是涵盖了这个领域的大多数知识点。篇幅较长,遂分为上下两篇发出。上篇为《》:ACID、事务隔离级别、mysql事务实现原理、CAP、BASE、2PC、3PC等。下篇为《分布式事务科普——终结篇》,即今天这篇,详细讲解分布式事务的解决方案:XA、AT、TCC、Saga、本地消息表、消息事务、最大努力通知等。
分布式事务科普
随着业务的快速发展、业务复杂度越来越高,传统单体应用逐渐暴露出了一些问题,例如开发效率低、可维护性差、架构扩展性差、部署不灵活、健壮性差等等。而微服务架构是将单个服务拆分成一系列小服务,且这些小服务都拥有独立的进程,彼此独立,很好地解决了传统单体应用的上述问题,但是在微服务架构下如何保证事务的一致性呢?本文首先从事务的概念出来,带大家先回顾一下ACID、事务隔离级别、CAP、BASE、2PC、3PC等基本理论,然后再详细讲解分布式事务的解决方案:XA、AT、TCC、Saga、本地消息表、消息事务、最大努力通知等。
分布式事务解决方案
在引入分布式事务前,我们最好先明确一下我们是否真的需要分布式事务。有可能因为过度设计致使微服务过多,从而不得不引入分布式事务,这个时候就不建议你采用下面的任何一种方案,而是把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务。因为不论任何一种方案都会增加你系统的复杂度,这样的成本实在是太高了,千万不要因为追求某些设计,而引入不必要的成本和复杂度。
常见的分布式事务方案有:XA、AT、TCC、Saga、本地消息表、MQ消息事务、最大努力通知等。
X/Open DTP模型与XA
DTP全称是Distributed Transaction Process,即分布式事务模型。在DTP本地模型实例中包含3个部分:AP、TM和RM,如下图所示。其中,AP 可以和TM 以及 RM 通信,TM 和 RM 互相之间可以通信。
-
AP(Application Program,应用程序):AP定义事务边界(定义事务开始和结束)并访问事务边界内的资源。
-
RM(Resource Manager,资源管理器):RM管理着某些共享资源的自治域,比如说一个MySQL数据库实例。在DTP里面还有两个要求,一是RM自身必须是支持事务的,二是RM能够根据全局(分布式)事务标识(GTID之类的)定位到自己内部的对应事务。
-
TM(Transaction Manager,事务管理器):TM能与AP和RM直接通信,协调AP和RM来实现分布式事务的完整性。负责管理全局事务,分配全局事务标识,监控事务的执行进度,并负责事务的提交、回滚、失败恢复等。
AP和RM之间则通过RM提供的Native API 进行资源控制,这个没有进行约API和规范,各个厂商自己实现自己的资源控制,比如Oracle自己的数据库驱动程序。
DTP模型里面定义了XA接口,TM 和 RM 通过XA接口进行双向通信(这也是XA的主要作用, 除此之外,XA还对两阶段提交协议进行了部分优化),例如:TM通知RM提交事务或者回滚事务,RM把提交结果通知给TM。XA 的全称是eXtended Architecture,它是一个分布式事务协议,它通过二阶段提交协议保证强一致性。
其过程大致如下:
-
第一阶段:TM请求所有RM进行准备,并告知它们各自需要做的局部事务(Transaction Branch)。RM收到请求后,如果判断可以完成自己的局部事务,那就持久化局部事务的工作内容,再给TM肯定答复;要是发生了其他情况,那给TM的都是否定答复。在发送了否定答复并回滚了局部事务之后,RM才能丢弃持久化了的局部事务信息。
-
第二阶段:TM根据情况(比如说所有RM Prepare成功,或者,AP通知它要Rollback等),先持久化它对这个全局事务的处理决定和所涉及的RM清单,然后通知所有涉及的RM去提交或者回滚它们的局部事务。RM们处理完自己的局部事务后,将返回值告诉TM之后,TM才可以清除掉包括刚才持久化的处理决定和RM清单在内的这个全局事务的信息。
基于XA协议实现的分布式事务是强一致性的分布式事务,典型应用场景如JAVA中有关分布式事务的规范如JTA(Java Transaction API)和JTS(Java Transaction Service)中就涉及到了XA。
XA 协议通常实现在数据库资源层,直接作用于资源管理器上。因此,基于 XA 协议实现的分布式事务产品,无论是分布式数据库还是分布式事务框架,对业务几乎都没有侵入,就像使用普通数据库一样。
不过XA的使用并不广泛,究其原因主要有以下几类:
-
性能,如:阻塞性协议,增加响应时间、锁时间、死锁等因素的存在,在高并发场景下并不适用。 -
支持程度,并不是所有的资源都支持XA协议;在数据库中支持完善度也有待考验,比如MySQL 5.7之前都有缺陷(MySQL 5.0版本开始支持XA,只有当隔离级别为SERIALIZABLE的时候才能使用分布式事务)。 -
运维复杂。
Seata与AT模式
AT(Automatic Transaction)模式是基于XA事务演进而来,核心是对业务无侵入,是一种改进后的两阶段提交,需要数据库支持。AT最早出现在阿里巴巴开源的分布式事务框架Seata中,我们不妨先简单了解下Seata。
Seata简介
Seata(Simple Extensible Autonomous Transaction Architecture,一站式分布式事务解决方案)是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。Seata 的设计思路是将一个分布式事务可以理解成一个全局事务,下面挂了若干个分支事务,而一个分支事务是一个满足 ACID 的本地事务,因此我们可以操作分布式事务像操作本地事务一样。
Seata 内部定义了 3个模块来处理全局事务和分支事务的关系和处理过程,如上图所示,分别是 TM、RM 和 TC。其中 TM 和 RM 是作为 Seata 的客户端与业务系统集成在一起,TC 作为 Seata 的服务端独立部署。 Transaction Coordinator(TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。 Transaction Manager(TM):控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。 Resource Manager(RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚
-
Transaction Coordinator(TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。 -
Transaction Manager(TM):控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。 -
Resource Manager(RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
参照上图,简要概括整个事务的处理流程为:
-
TM 向 TC 申请开启一个全局事务,TC 创建全局事务后返回全局唯一的 XID,XID 会在全局事务的上下文中传播; -
RM 向 TC 注册分支事务,该分支事务归属于拥有相同 XID 的全局事务; -
TM要求TC提交或回滚XID的相应全局事务。 -
TC在XID的相应全局事务下驱动所有分支事务以完成分支提交或回滚。
Seata 会有 4 种分布式事务解决方案,分别是 AT 模式、TCC 模式、Saga 模式和 XA 模式。这个小节我们主要来讲述一下AT模式的实现方式,TCC和Saga模式在后面会继续介绍。
AT模式
Seata 的事务提交方式跟 XA 协议的两段式提交在总体上来说基本是一致的,那它们之间有什么不同呢?
我们都知道 XA 协议它依赖的是数据库层面来保障事务的一致性,也即是说 XA 的各个分支事务是在数据库层面上驱动的,由于 XA 的各个分支事务需要有 XA 的驱动程序,一方面会导致数据库与 XA 驱动耦合,另一方面它会导致各个分支的事务资源锁定周期长,这也是它没有在互联网公司流行的重要因素。
基于 XA 协议以上的问题,Seata 另辟蹊径,既然在依赖数据库层会导致这么多问题,那我们就从应用层做手脚,这还得从 Seata 的 RM 模块说起,前面也说过 RM 的主要作用了,其实 RM 在内部做了对数据库操作的代理层。如上图所示,在使用 Seata 时,我们使用的数据源实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,主要是解析 SQL,把业务数据在更新前后的数据镜像组织成回滚日志,并将 undo log 日志插入 undo_log 表中,保证每条更新数据的业务 SQL都有对应的回滚日志存在。
这样做的好处就是,本地事务执行完可以立即释放本地事务锁定的资源,然后向 TC 上报分支状态。当 TM 决议全局提交时,就不需要同步协调处理了,TC 会异步调度各个 RM 分支事务删除对应的 undo log 日志即可,这个步骤非常快速地可以完成;当 TM 决议全局回滚时,RM 收到 TC 发送的回滚请求,RM 通过 XID 找到对应的 undo log 回滚日志,然后执行回滚日志完成回滚操作。
如上图(左),XA 方案的 RM 是放在数据库层的,它依赖了数据库的 XA 驱动程序。而上图(右),Seata 的 RM 实际上是已中间件的形式放在应用层,不用依赖数据库对协议的支持,完全剥离了分布式事务方案对数据库在协议支持上的要求。
AT模式下是如何做到对业务无侵入,又是如何执行提交和回滚的呢?
第一阶段
参照下图,Seata 的 JDBC 数据源代理通过对业务 SQL 的解析,把业务数据在更新前后的数据镜像组织成回滚日志(undo log),利用本地事务的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交。这样可以保证任何提交的业务数据的更新一定有相应的回滚日志存在,最后对分支事务状态向 TC 进行上报。基于这样的机制,分支的本地事务便可以在全局事务的第一阶段提交,马上释放本地事务锁定的资源。
第二阶段
如果决议是全局提交,此时分支事务此时已经完成提交,不需要同步协调处理(只需要异步清理回滚日志),第二阶段可以非常快速地结束,参考下图。
如果决议是全局回滚,RM收到协调器发来的回滚请求,通过XID和Branch ID找到相应的回滚日志记录,通过回滚记录生成反向的更新SQL并执行,以完成分支的回滚,参考下图。
讲到这里,关于AT模式大部分问题我们应该都清楚了,但总结起来,核心也只解决了一件事情,就是ACID中最基本、最重要的 A(原子性)。但是,光解决A显然是不够的:既然本地事务已经提交,那么如果数据在全局事务结束前被修改了,回滚时怎么处理?ACID 的 I(隔离性)在Seata的AT模式是如何处理的呢?
Seata AT 模式引入全局锁机制来实现隔离。全局锁是由 Seata 的 TC 维护的,事务中涉及的数据的锁。
写隔离
参考官网(https://seata.io/en-us/docs/overview/what-is-seata.html)的资料,写隔离的要领如下:
-
第一阶段本地事务提交前,需要确保先拿到全局锁 。 -
拿不到全局锁,不能提交本地事务。 -
拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明。两个全局事务tx1和tx2,分别对a表的m字段进行更新操作,m的初始值1000。tx1先开始,开启本地事务拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁,本地提交释放本地锁。tx2后开始,开启本地事务拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1全局提交前,该记录的全局锁被 tx1持有,tx2需要重试等待全局锁 。
tx1 第二阶段全局提交,释放全局锁 。tx2拿到全局锁提交本地事务。
如果tx1的第二阶段全局回滚,则tx1需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
参考下图,此时如果tx2仍在等待该数据的全局锁,同时持有本地锁,则tx1的分支回滚会失败。分支的回滚会一直重试,直到tx2的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程全局锁在tx1结束前一直是被tx1持有的,所以不会发生脏写的问题。
读隔离
在数据库本地事务隔离级别为读已提交(READ COMMITTED)或以上的基础上,Seata(AT模式)的默认全局隔离级别是读未提交(READ UNCOMMITTED)。如果应用在特定场景下,必须要求全局的读已提交,目前Seata的方式是通过SELECT FOR UPDATE语句的代理。
SELECT FOR UPDATE语句的执行会申请全局锁 ,如果全局锁被其他事务持有,则释放本地锁(回滚SELECT FOR UPDATE语句的本地执行)并重试。这个过程中,查询是被阻塞 住的,直到全局锁拿到,即读取的相关数据是已提交的,才返回。
全局锁是由 TC 也就是服务端来集中维护,而不是在数据库维护的。这样做有两点好处:一方面,锁的释放非常快,尤其是在全局提交的情况下收到全局提交的请求,锁马上就释放掉了,不需要与 RM 或数据库进行一轮交互;另外一方面,因为锁不是数据库维护的,从数据库层面看数据没有锁定。这也就是给极端情况下,业务降级提供了方便,事务协调器异常导致的一部分异常事务,不会阻塞后面业务的继续进行。
AT模式基于本地事务的特性,通过拦截并解析 SQL 的方式,记录自定义的回滚日志,从而打破 XA 协议阻塞性的制约,在一致性、性能、易用性三个方面取得一定的平衡:在达到确定一致性(非最终一致)的前提下,即保障一定的性能,又能完全不侵入业务。在很多应用场景下,Seata的AT模式都能很好地发挥作用,把应用的分布式事务支持成本降到极低的水平。
不过AT模式也并非银弹,在使用之前最好权衡好以下几个方面:
-
隔离性。隔离性不高,目前只能支持到接近读已提交的程度,更高的隔离级别,实现成本将非常高。
-
性能损耗。一条Update的SQL,则需要全局事务XID获取(与TC通讯)、before image(解析SQL,查询一次数据库)、after image(查询一次数据库)、insert undo log(写一次数据库)、before commit(与TC通讯,判断锁冲突),这些操作都需要一次远程通讯RPC,而且是同步的。另外undo log写入时blob字段的插入性能也是不高的。每条写SQL都会增加这么多开销,粗略估计会增加5倍响应时间(二阶段虽然是异步的,但其实也会占用系统资源,网络、线程、数据库)。
-
全局锁。Seata在每个分支事务中会携带对应的锁信息,在before commit阶段会依次获取锁(因为需要将所有SQL执行完才能拿到所有锁信息,所以放在commit前判断)。相比XA,Seata 虽然在一阶段成功后会释放数据库锁,但一阶段在commit前全局锁的判定也拉长了对数据锁的占有时间,这个开销比XA的prepare低多少需要根据实际业务场景进行测试。全局锁的引入实现了隔离性,但带来的问题就是阻塞,降低并发性,尤其是热点数据,这个问题会更加严重。Seata在回滚时,需要先删除各节点的undo log,然后才能释放TC内存中的锁,所以如果第二阶段是回滚,释放锁的时间会更长。Seata的引入全局锁会额外增加死锁的风险,但如果实现死锁,会不断进行重试,最后靠等待全局锁超时,这种方式并不优雅,也延长了对数据库锁的占有时间。
TCC
关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。在该论文中,TCC还是以Tentative-Confirmation-Cancellation命名。正式以Try-Confirm-Cancel作为名称的是Atomikos公司,其注册了TCC商标。
TCC分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
因此,针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑:
-
Try:完成所有业务检查,预留必须的业务资源。 -
Confirm:真正执行的业务逻辑,不作任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要Try操作成功,Confirm必须能成功。另外,Confirm操作需满足幂等性,保证分布式事务有且只能成功一次。 -
Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。
TCC分布式事务模型包括三部分:
-
主业务服务(Main Server):主业务服务为整个业务活动的发起方、服务的编排者,负责发起并完成整个业务活动。
-
从业务服务(Service):从业务服务是整个业务活动的参与方,负责提供TCC业务操作,实现Try、Confirm、Cancel三个接口,供主业务服务调用。
-
事务管理器(Transaction Manager):事务管理器管理控制整个业务活动,包括记录维护TCC全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的Confirm操作,在业务活动取消时调用所有从业务服务的Cancel操作。
上图所展示的是TCC事务模型与DTP事务模型的对比图,看上去这两者差别很大。聪明的读者应该可以从图中的着色上猜出些端倪,其实这两者基本一致:TCC模型中的主业务服务相当于DTP模型中AP,从业务服务相当于DTP模型中的RM,两者也都有一个事务管理器;TCC模型中从业务服务器所提供的Try/Commit/Cancel接口相当于DTP模型中RM提供的Prepare/Commit/Rollback接口。
所不同的是DTP模型中Prepare/Commit/Rollback都是由事务管理器调用,TCC模型中的Try接口是由主业务服务调用的,二阶段的Commit/Cancel才是由事务管理器调用。这就是TCC事务模型的二阶段异步化功能,从业务服务的第一阶段执行成功,主业务服务就可以提交完成,然后再由事务管理器框架异步的执行各从业务服务的第二阶段。这里牺牲了一定的隔离性和一致性的,但是提高了长事务的可用性。
下面我们再来了解一下一个完整的TCC分布式事务流程:
-
主业务服务首先开启本地事务。 -
主业务服务向事务管理器申请启动分布式事务主业务活动。 -
然后针对要调用的从业务服务,主业务活动先向事务管理器注册从业务活动,然后调用从业务服务的 Try 接口。 -
当所有从业务服务的 Try 接口调用成功,主业务服务提交本地事务;若调用失败,主业务服务回滚本地事务。 -
若主业务服务提交本地事务,则TCC模型分别调用所有从业务服务的Confirm接口;若主业务服务回滚本地事务,则分别调用 Cancel 接口; -
所有从业务服务的Confirm或Cancel操作完成后,全局事务结束。
用户接入TCC,最重要的是考虑如何将自己的业务模型拆成两阶段来实现。下面,我们从一个简答的例子来熟悉一下TCC的具体用法。
以“扣钱”场景为例,在接入TCC前,对A账户的扣钱,只需一条更新账户余额的 SQL 便能完成;但是在接入TCC之后,用户就需要考虑如何将原来一步就能完成的扣钱操作拆成两阶段,实现成三个方法,并且保证Try成功Confirm一定能成功。
如下图所示,一阶段Try方法需要做资源的检查和预留。在扣钱场景下,Try要做的事情是就是检查账户余额是否充足,预留转账资金,预留的方式就是冻结A账户的转账资金。Try方法执行之后,账号A余额虽然还是100,但是其中30元已经被冻结了,不能被其他事务使用。
二阶段Confirm执行真正的扣钱操作。Confirm会使用Try阶段冻结的资金,执行账号扣款。Confirm执行之后,账号A在一阶段中冻结的30元已经被扣除,账号A余额变成 70 元 。
如果二阶段是回滚的话,就需要在Cancel方法内释放一阶段Try冻结的30元,使账号A的回到初始状态,100元全部可用。
在TCC模型中,事务的隔离交给业务逻辑来实现。其隔离性思想就是通过业务的改造,在第一阶段结束之后,从底层数据库资源层面的加锁过渡为上层业务层面的加锁,从而释放底层数据库锁资源,放宽分布式事务锁协议,将锁的粒度降到最低,以最大限度提高业务并发性能。
以上面的例子举例,账户A上有100元,事务tx1要扣除其中的30元,事务tx2也要扣除30元,出现并发。在第一阶段的Try操作中,需要先利用数据库资源层面的加锁,检查账户可用余额,如果余额充足,则预留业务资源,扣除本次交易金额。一阶段结束后,虽然数据库层面资源锁被释放了,但这笔资金被业务隔离,不允许除本事务之外的其它并发事务动用。
补偿性事务
TCC第一阶段的Try或者第二阶段的Confirm/Cancel在执行过程中,一般都会开启各自的本地事务,来保证方法内部业务逻辑的ACID特性。这里Confirm/Cancel执行的本地事务是补偿性事务。
补偿性事务是一个独立的支持ACID特性的本地事务,用于在逻辑上取消服务提供者上一个ACID事务造成的影响,对于一个长事务(long-running transaction),与其实现一个巨大的分布式ACID事务,不如使用基于补偿性的方案,把每一次服务调用当做一个较短的本地ACID事务来处理,执行完就立即提交。
TCC第二阶段Confirm/Cancel执行的补偿性事务用于取消Try阶段本地事务造成的影响。因为第一阶段Try只是预留资源,之后必须要明确的告诉服务提供者,这个资源到底要还需不需要。下一节中所要讲述的Saga也是一种补偿性的事务。
TCC异常控制
在有了一套完备的 TCC 接口之后,是不是就真的高枕无忧了呢?答案是否定的。在微服务架构下,很有可能出现网络超时、重发,机器宕机等一系列的异常情况。一旦遇到这些 情况,就会导致我们的分布式事务执行过程出现异常,最常见的主要是空回滚、幂等、悬挂。因此,在TCC接口设计中还需要处理好这三个问题。
Cancel接口设计时需要允许空回滚。在Try接口因为丢包时没有收到,事务管理器会触发回滚,这时会触发Cancel接口,这时Cancel执行时发现没有对应的事务 XID或主键时,需要返回回滚成功。让事务服务管理器认为已回滚,否则会不断重试,而Cancel又没有对应的业务数据可以进行回滚。
幂等性的意思是对同一个系统使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,通常我们可以用事务XID或业务主键判重来控制。
悬挂的意思是Cancel比Try接口先执行,出现的原因是Try由于网络拥堵而超时,事务管理器生成回滚,触发Cancel接口,而最终又收到了Try接口调用,但是Cancel比Try先到。按照前面允许空回滚的逻辑,回滚会返回成功,事务管理器认为事务已回滚成功,则此时的Try接口不应该执行,否则会产生数据不一致,所以我们在Cancel空回滚返回成功之前先记录该条事务 XID或业务主键,标识这条记录已经回滚过,Try接口先检查这条事务XID或业务主键如果已经标记为回滚成功过,则不执行Try的业务操作。
总结
XA两阶段提交是资源层面的,而TCC实际上把资源层面二阶段提交上提到了业务层面来实现,有效了的避免了XA两阶段提交占用资源锁时间过长导致的性能低下问题。TCC也没有AT模式中的全局行锁,所以性能也会比AT模式高很多。不过,TCC模式对业务代码有很大的侵入性,主业务服务和从业务服务都需要进行改造,从业务方改造成本更高。
Saga
Saga 算法(https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf)于 1987 年提出,是一种异步的分布式事务解决方案。其理论基础在于,其假设所有事件按照顺序推进,总能达到系统的最终一致性,因此 Saga需要服务分别定义提交接口以及补偿接口,当某个事务分支失败时,调用其它的分支的补偿接口来进行回滚。
在Saga模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga模式下分布式事务通常是由事件驱动的,各个参与者之间是异步执行的,Saga 模式是一种长事务解决方案。
Saga模式不保证事务的隔离性,在极端情况下可能出现脏写。比如在分布式事务未提交的情况下,前一个服务的数据被修改了,而后面的服务发生了异常需要进行回滚,可能由于前面服务的数据被修改后无法进行补偿操作。一种处理办法可以是“重试”继续往前完成这个分布式事务。由于整个业务流程是由状态机编排的,即使是事后恢复也可以继续往前重试。所以用户可以根据业务特点配置该流程的事务处理策略是优先“回滚”还是“重试”,当事务超时的时候,服务端会根据这个策略不断进行重试。
由于Saga不保证隔离性,所以我们在业务设计的时候需要做到“宁可长款,不可短款”的原则,长款是指在出现差错的时候站在我方的角度钱多了的情况,钱少了则是短款,因为如果长款可以给客户退款,而短款则可能钱追不回来了,也就是说在业务设计的时候,一定是先扣客户帐再入帐,如果因为隔离性问题造成覆盖更新,也不会出现钱少了的情况。
Saga模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能。事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供TCC要求的接口,也可以使用Saga模式。
Saga模式所具备的优势有:一阶段提交本地数据库事务,无锁,高性能;参与者可以采用事务驱动异步执行,高吞吐;补偿服务即正向服务的“反向”,易于理解、易于实现;不过,Saga 模式由于一阶段已经提交本地数据库事务,且没有进行“预留”动作,所以不能保证隔离性。
一个好的分布式事务应用应该尽可能满足:
-
提高易用性、即降低业务改造成本。 -
性能损耗低。 -
隔离性保证完整。但如同CAP,这三个特性是相互制衡的,往往只能满足其中两个,我们可以搭配AT、TCC和Saga来画一个三角约束:
本地消息表
本地消息表最初是由eBay架构师Dan Pritchett在一篇解释 BASE 原理的论文《Base:An Acid Alternative》(https://queue.acm.org/detail.cfm?id=1394128)中提及的,业界目前使用这种方案是比较多的,其核心思想是将分布式事务拆分成本地事务进行处理。
方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
下面把分布式事务最先开始处理的事务方称为事务主动方,在事务主动方之后处理的业务内的其他事务称为事务被动方。事务的主动方需要额外新建事务消息表,用于记录分布式事务的消息的发生、处理状态。
参考上图,我们不妨来聊一聊本地消息表的事务处理流程。
事务主动方处理好相关的业务逻辑之后,先将业务数据写入数据库中的业务表(图中步骤1),然后将所要发送的消息写入到数据库中的消息表(步骤2)。注意:写入业务表的逻辑和写入消息表的逻辑在同一个事务中,这样通过本地事务保证了一致性。
之后,事务主动方将所要发送的消息发送到消息中间件中(步骤3)。消息在发送过程中丢失了怎么办?这里就体现出消息表的用处了。在上一步中,在消息表中记录的消息状态是“发送中”,事务主动方可以定时扫描消息表,然后将其中状态为“发送中”的消息重新投递到消息中间件即可。只有当最后事务被动方消费完之后,消息的状态才会被设置为“已完成”。
重新投递的过程中也可能会再次失败,此时我们一般会指定最大重试次数,重试间隔时间根据重试次数而指数或者线性增长。若达到最大重试次数后记录日志,我们可以根据记录的日志来通过邮件或短信来发送告警通知,接收到告警通知后及时介入人工处理即可。
前面3个步骤可以避免“业务处理成功,消息发送失败”或者“消息发送成功,业务处理失败”这种棘手情况的出现,并且也可以保证消息不会丢失。
事务被动方监听并消费消息中间件中的消息(步骤4),然后处理相应的业务逻辑,并把业务数据写入到自己的业务表中(步骤5),随后将处理结果返回给消息中间件(步骤6)。
步骤4-6中可能会出现各种异常情况,事务被动方可以在处理完步骤6之后再向消息中间件ACK在步骤4中读取的消息。这样,如果步骤4-6中间出现任何异常了都可以重试消费消息中间件中的那条消息。这里不可避免的会出现重复消费的现象,并且在前面的步骤3中也会出现重复投递的现象,因此事务被动方的业务逻辑需要能够保证幂等性。
最后事务主动方也会监听并读取消息中间件中的消息(步骤7)来更新消息表中消息的状态(步骤8)。
步骤6和步骤7是为了将事务被动方的处理结果反馈给事务主动方,这里也可以使用RPC的方式代替。如果在事务被动方处理业务逻辑的过程中发现整个业务流程失败,那么事务被动方也可以发送消息(或者RPC)来通知事务主动方进行回滚。
基于本地消息表的分布式事务方案就介绍到这里了,本地消息表的方案的优点是建设成本比较低,其虽然实现了可靠消息的传递确保了分布式事务的最终一致性,其实它也有一些缺陷:
-
本地消息表与业务耦合在一起,难以做成通用性,不可独立伸缩。 -
本地消息表是基于数据库来做的,而数据库是要读写磁盘IO的,因此在高并发下是有性能瓶颈的。
消息事务
消息事务作为一种异步确保型事务,其核心原理是将两个事务通过消息中间件进行异步解耦。
消息事务的一种实现思路是通过保证多条消息的同时可见性来保证事务一致性。但是此类消息事务实现机制更多的是用在 consume-transform-produce(Kafka支持)场景中,其本质上还是用来保证消息自身事务,并没有把外部事务包含进来。
还有一种思路是依赖于 AMQP 协议(RabbitMQ支持)来确保消息发送成功。AMQP需要在发送事务消息时进行两阶段提交,首先进行 tx_select 开启事务,然后再进行消息发送,最后执行 tx_commit 或tx_rollback。这个过程可以保证在消息发送成功的同时,本地事务也一定成功执行。但事务粒度不好控制,而且会导致性能急剧下降,同时也无法解决本地事务执行与消息发送的原子性问题。
不过,RocketMQ事务消息设计解决了上述的本地事务执行与消息发送的原子性问题。在RocketMQ的设计中,broker和producer的双向通信能力使得broker天生可以作为一个事务协调者存在。而RocketMQ本身提供的存储机制,则为事务消息提供了持久化能力。RocketMQ 的高可用机制以及可靠消息设计,则为事务消息在系统在发生异常时,依然能够保证事务的最终一致性达成。
RocketMQ 事务消息的设计流程同样借鉴了两阶段提交理论,整体交互流程如下图所示:
下面我们来了解一下这个设计的整体流程。
首先,事务发起方发送一个Prepare消息到MQ Server中(对应于上图中Step 1和Step 2),如果这个Prepare消息发送失败,那么就直接取消操作,后续的操作也都不再执行。如果这个Prepare消息发送成功了,那么接着执行自身的本地事务(Step 3)。
如果本地事务执行失败,那么通知MQ Server回滚(Step 4 - Rollback),后续操作都不再执行。如果本地事务执行成功,就通知MQ Server发送确认消息(Step 4 - Commit)。
倘若 Step 4中的Commit/Rollback消息迟迟未送达到MQ Server中呢?MQ Server会自动定时轮询所有的 Prepare 消息,然后调用事务发起方事先提供的接口(Step 5),通过这个接口反查事务发起方的上次本地事务是否执行成功(Step 6)。
如果成功,就发送确认消息给 MQ Server;失败则告诉 MQ Server回滚消息(Step 7)。
事务被动方会接收到确认消息,然后执行本地的事务,如果本地事务执行成功则事务正常完成。如果事务被动方本地事务执行失败了咋办?基于 MQ 来进行不断重试,如果实在是不行,可以发送报警由人工来手工回滚和补偿。
上图是采用本地消息表方案和采用RocketMQ事务消息方案的对比图,其实,我们不难发现RocketMQ的这种事务方案就是对本地消息表的封装,其MQ内部实现了本地消息表的功能,其他方面的协议基本与本地消息表一致。
RocketMQ 事务消息较好的解决了事务的最终一致性问题,事务发起方仅需要关注本地事务执行以及实现回查接口给出事务状态判定等实现,而且在上游事务峰值高时,可以通过消息队列,避免对下游服务产生过大压力。
事务消息不仅适用于上游事务对下游事务无依赖的场景,还可以与一些传统分布式事务架构相结合,而 MQ 的服务端作为天生的具有高可用能力的协调者,使得我们未来可以基于MQ提供一站式轻量级分布式事务解决方案,用以满足各种场景下的分布式事务需求。
最大努力通知
最大努力通知型(Best-effort Delivery)是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如支付通知、短信通知等。
以支付通知为例,业务系统调用支付平台进行支付,支付平台进行支付,进行操作支付之后支付平台会尽量去通知业务系统支付操作是否成功,但是会有一个最大通知次数。如果超过这个次数后还是通知失败,就不再通知,业务系统自行调用支付平台提供一个查询接口,供业务系统进行查询支付操作是否成功。
最大努力通知方案可以借助MQ(消息中间件)来实现,参考下图。
发起通知方将通知发给MQ,接收通知方监听 MQ 消息。接收通知方收到消息后,处理完业务回应ACK。接收通知方若没有回应ACK,则 MQ 会间隔 1min、5min、10min 等重复通知。接受通知方可调用消息校对接口,保证消息的一致性。
分布式事务的取舍
严格的ACID事务对隔离性的要求很高,在事务执行中必须将所有的资源锁定,对于长事务来说,整个事务期间对数据的独占,将严重影响系统并发性能。因此,在高并发场景中,对ACID的部分特性进行放松从而提高性能,这便产生了BASE柔性事务。柔性事务的理念则是通过业务逻辑将互斥锁操作从资源层面上移至业务层面。通过放宽对强一致性要求,来换取系统吞吐量的提升。另外提供自动的异常恢复机制,可以在发生异常后也能确保事务的最终一致。
柔性事务需要应用层进行参与,因此这类分布式事务框架一个首要的功能就是怎么最大程度降低业务改造成本,然后就是尽可能提高性能(响应时间、吞吐),最好是保证隔离性。
当然如果我们要自己设计一个分布式事务框架,还需要考虑很多其它特性,在明确目标场景偏好后进行权衡取舍,这些特性包括但不限于以下:
-
业务侵入性(基于注解、XML,补偿逻辑); -
隔离性(写隔离/读隔离/读未提交,业务隔离/技术隔离); -
TM/TC部署形态(单独部署、与应用部署一起); -
错误恢复(自动恢复、手动恢复); -
性能(回滚的概率、付出的代价,响应时间、吞吐); -
高可用(注册中心、数据库); -
持久化(数据库、文件、多副本一致算法); -
同步/异步(2PC执行方式); -
日志清理(自动、手动); -
......
分布式事务一直是业界难题,难在于CAP定理,在于分布式系统8大错误假设 ,在于FLP不可能原理 ,在于我们习惯于单机事务ACID做对比。无论是数据库领域XA,还是微服务下AT、TCC、Saga、本地消息表、事务消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。
以上是关于分布式事务科普(终结篇)的主要内容,如果未能解决你的问题,请参考以下文章