原文地址:梁桂钊的博客
欢迎关注公众号:「服务端思维」。一群同频者,一起成长,一起精进,打破认知的局限性。
从本地事务到分布式事务的演变
什么是事务?回答这个问题之前,我们先来看一个经典的场景:支付宝等交易平台的转账。假设小明需要用支付宝给小红转账 100000 元,此时,小明帐号会少 100000 元,而小红帐号会多 100000 元。如果在转账过程中系统崩溃了,小明帐号少 100000 元,而小红帐号金额不变,就会出大问题,因此这个时候我们就需要使用事务了。请参见图 6-1。
这里,体现了事务一个很重要的特性:原子性。事实上,事务有四个基本特性:原子性、一致性、隔离性、持久性。其中,原子性,即事务内的操作要么全部成功,要么全部失败,不会在中间的某个环节结束。一致性,即使数据库在一个事务执行之前和执行之后,数据库都必须处于一致性状态。如果事务执行失败,那么需要自动回滚到原始状态,换句话说,事务一旦提交,其他事务查看到的结果一致,事务一旦回滚,其他事务也只能看到回滚前的状态。隔离性,即在并发环境中,不同的事务同时修改相同的数据时,一个未完成事务不会影响另外一个未完成事务。持久性,即事务一旦提交,其修改的数据将永久保存到数据库中,其改变是永久性的。
本地事务通过 ACID 保证数据的强一致性。ACID是 Atomic(原子性)、Consistency(一致性)、 Isolation(隔离性)和 Durability(持久性)的缩写 。在实际开发过程中,我们或多或少都有使用到本地事务。例如,MySQL 事务处理使用到 begin 开始一个事务,rollback 事务回滚,commit 事务确认。这里,事务提交后,通过 redo log 记录变更,通过 undo log 在失败时进行回滚,保证事务的原子性。笔者补充下,使用 Java 语言的开发者都接触过 Spring。Spring 使用 @Transactional 注解就可以搞定事务功能。事实上,Spring 封装了这些细节,在生成相关的 Bean 的时候,在需要注入相关的带有 @Transactional 注解的 bean 时候用代理去注入,在代理中为我们开启提交/回滚事务。请参见图6-2。
随着业务的高速发展,面对海量数据,例如,上千万甚至上亿的数据,查询一次所花费的时间会变长,甚至会造成数据库的单点压力。因此,我们就要考虑分库与分表方案了。分库与分表的目的在于,减小数据库的单库单表负担,提高查询性能,缩短查询时间。这里,我们先来看下单库拆分的场景。事实上,分表策略可以归纳为垂直拆分和水平拆分。垂直拆分,把表的字段进行拆分,即一张字段比较多的表拆分为多张表,这样使得行数据变小。一方面,可以减少客户端程序和数据库之间的网络传输的字节数,因为生产环境共享同一个网络带宽,随着并发查询的增多,有可能造成带宽瓶颈从而造成阻塞。另一方面,一个数据块能存放更多的数据,在查询时就会减少 I/O 次数。水平拆分,把表的行进行拆分。因为表的行数超过几百万行时,就会变慢,这时可以把一张的表的数据拆成多张表来存放。水平拆分,有许多策略,例如,取模分表,时间维度分表等。这种场景下,虽然我们根据特定规则分表了,我们仍然可以使用本地事务。但是,库内分表,仅仅是解决了单表数据过大的问题,但并没有把单表的数据分散到不同的物理机上,因此并不能减轻 MySQL 服务器的压力,仍然存在同一个物理机上的资源竞争和瓶颈,包括 CPU、内存、磁盘 IO、网络带宽等。对于分库拆分的场景,它把一张表的数据划分到不同的数据库,多个数据库的表结构一样。此时,如果我们根据一定规则将我们需要使用事务的数据路由到相同的库中,可以通过本地事务保证其强一致性。但是,对于按照业务和功能划分的垂直拆分,它将把业务数据分别放到不同的数据库中。这里,拆分后的系统就会遇到数据的一致性问题,因为我们需要通过事务保证的数据分散在不同的数据库中,而每个数据库只能保证自己的数据可以满足 ACID 保证强一致性,但是在分布式系统中,它们可能部署在不同的服务器上,只能通过网络进行通信,因此无法准确的知道其他数据库中的事务执行情况。请参见图6-3。
此外,不仅仅在跨库调用存在本地事务无法解决的问题,随着微服务的落地中,每个服务都有自己的数据库,并且数据库是相互独立且透明的。那如果服务 A 需要获取服务 B 的数据,就存在跨服务调用,如果遇到服务宕机,或者网络连接异常、同步调用超时等场景就会导致数据的不一致,这个也是一种分布式场景下需要考虑数据一致性问题。请参见图6-4。
总结一下,当业务量级扩大之后的分库,以及微服务落地之后的业务服务化,都会产生分布式数据不一致的问题。既然本地事务无法满足需求,因此分布式事务就要登上舞台。什么是分布式事务?我们可以简单地理解,它就是为了保证不同数据库的数据一致性的事务解决方案。这里,我们有必要先来了解下 CAP 原则和 BASE 理论。CAP 原则是 Consistency(一致性)、Availablity(可用性)和 Partition-tolerance(分区容错性)的缩写,它是分布式系统中的平衡理论。在分布式系统中,一致性要求所有节点每次读操作都能保证获取到最新数据;可用性要求无论任何故障产生后都能保证服务仍然可用;分区容错性要求被分区的节点可以正常对外提供服务。事实上,任何系统只可同时满足其中二个,无法三者兼顾。对于分布式系统而言,分区容错性是一个最基本的要求。那么,如果选择了一致性和分区容错性,放弃可用性,那么网络问题会导致系统不可用。如果选择可用性和分区容错性,放弃一致性,不同的节点之间的数据不能及时同步数据而导致数据的不一致。请参见图 6-5。
此时,BASE 理论针对一致性和可用性提出了一个方案,BASE 是 Basically Available(基本可用)、Soft-state(软状态)和 Eventually Consistent(最终一致性)的缩写,它是最终一致性的理论支撑。简单地理解,在分布式系统中,允许损失部分可用性,并且不同节点进行数据同步的过程存在延时,但是在经过一段时间的修复后,最终能够达到数据的最终一致性。BASE 强调的是数据的最终一致性。相比于 ACID 而言,BASE 通过允许损失部分一致性来获得可用性。
现在,业内比较常用的分布式事务解决方案,包括强一致性的两阶段提交协议,三阶段提交协议,以及最终一致性的可靠事件模式、补偿模式,阿里的 TCC 模式。我们会在后面的章节中详细介绍与实战。
强一致性解决方案
二阶段提交协议
在分布式系统中,每个数据库只能保证自己的数据可以满足 ACID 保证强一致性,但是它们可能部署在不同的服务器上,只能通过网络进行通信,因此无法准确的知道其他数据库中的事务执行情况。因此,为了解决多个节点之间的协调问题,就需要引入一个协调者负责控制所有节点的操作结果,要么全部成功,要么全部失败。其中,XA 协议是一个分布式事务协议,它有两个角色:事务管理者和资源管理者。这里,我们可以把事务管理者理解为协调者,而资源管理者理解为参与者。
XA 协议通过二阶段提交协议保证强一致性。
二阶段提交协议,顾名思义,它具有两个阶段:第一阶段准备,第二阶段提交。这里,事务管理者(协调者)主要负责控制所有节点的操作结果,包括准备流程和提交流程。第一阶段,事务管理者(协调者)向资源管理者(参与者)发起准备指令,询问资源管理者(参与者)预提交是否成功。如果资源管理者(参与者)可以完成,就会执行操作,并不提交,最后给出自己响应结果,是预提交成功还是预提交失败。第二阶段,如果全部资源管理者(参与者)都回复预提交成功,资源管理者(参与者)正式提交命令。如果其中有一个资源管理者(参与者)回复预提交失败,则事务管理者(协调者)向所有的资源管理者(参与者)发起回滚命令。举个案例,现在我们有一个事务管理者(协调者),三个资源管理者(参与者),那么这个事务中我们需要保证这三个参与者在事务过程中的数据的强一致性。首先,事务管理者(协调者)发起准备指令预判它们是否已经预提交成功了,如果全部回复预提交成功,那么事务管理者(协调者)正式发起提交命令执行数据的变更。请参见图 6-6。
注意的是,虽然二阶段提交协议为保证强一致性提出了一套解决方案,但是仍然存在一些问题。其一,事务管理者(协调者)主要负责控制所有节点的操作结果,包括准备流程和提交流程,但是整个流程是同步的,所以事务管理者(协调者)必须等待每一个资源管理者(参与者)返回操作结果后才能进行下一步操作。这样就非常容易造成同步阻塞问题。其二,单点故障也是需要认真考虑的问题。事务管理者(协调者)和资源管理者(参与者)都可能出现宕机,如果资源管理者(参与者)出现故障则无法响应而一直等待,事务管理者(协调者)出现故障则事务流程就失去了控制者,换句话说,就是整个流程会一直阻塞,甚至极端的情况下,一部分资源管理者(参与者)数据执行提交,一部分没有执行提交,也会出现数据不一致性。此时,读者会提出疑问:这些问题应该都是小概率情况,一般是不会产生的?是的,但是对于分布式事务场景,我们不仅仅需要考虑正常逻辑流程,还需要关注小概率的异常场景,如果我们对异常场景缺乏处理方案,可能就会出现数据的不一致性,那么后期靠人工干预处理,会是一个成本非常大的任务,此外,对于交易的核心链路也许就不是数据问题,而是更加严重的资损问题。
三阶段提交协议
二阶段提交协议诸多问题,因此三阶段提交协议就要登上舞台了。三阶段提交协议是二阶段提交协议的改良版本,它与二阶段提交协议不同之处在于,引入了超时机制解决同步阻塞问题,此外加入了预备阶段尽可能提早发现无法执行的资源管理者(参与者)并且终止事务,如果全部资源管理者(参与者)都可以完成,才发起第二阶段的准备和第三阶段的提交。否则,其中任何一个资源管理者(参与者)回复执行,或者超时等待,那么就终止事务。总结一下,三阶段提交协议包括:第一阶段预备,第二阶段准备,第二阶段提交。请参见图 6-7。
三阶段提交协议很好的解决了二阶段提交协议带来的问题,是一个非常有参考意义的解决方案。但是,极小概率的场景下可能会出现数据的不一致性。因为三阶段提交协议引入了超时机制,如果出现资源管理者(参与者)超时场景会默认提交成功,但是如果其没有成功执行,或者其他资源管理者(参与者)出现回滚,那么就会出现数据的不一致性。
最终一致性解决方案
TCC 模式
二阶段提交协议和三阶段提交协议很好的解决了分布式事务的问题,但是在极端情况下仍然存在数据的不一致性,此外它对系统的开销会比较大,引入事务管理者(协调者)后,比较容易出现单点瓶颈,以及在业务规模不断变大的情况下,系统可伸缩性也会存在问题。注意的是,它是同步操作,因此引入事务后,直到全局事务结束才能释放资源,性能可能是一个很大的问题。因此,在高并发场景下很少使用。因此,阿里提出了另外一种解决方案:TCC 模式。注意的是,很多读者把二阶段提交等同于二阶段提交协议,这个是一个误区,事实上,TCC 模式也是一种二阶段提交。
TCC 模式将一个任务拆分三个操作:Try、Confirm、Cancel。假如,我们有一个 func() 方法,那么在 TCC 模式中,它就变成了 tryFunc()、confirmFunc()、cancelFunc() 三个方法。
tryFunc();
confirmFunc();
cancelFunc();
在 TCC 模式中,主业务服务负责发起流程,而从业务服务提供 TCC 模式的 Try、Confirm、Cancel 三个操作。其中,还有一个事务管理器的角色负责控制事务的一致性。例如,我们现在有三个业务服务:交易服务,库存服务,支付服务。用户选商品,下订单,紧接着选择支付方式进行付款,然后这笔请求,交易服务会先调用库存服务扣库存,然后交易服务再调用支付服务进行相关的支付操作,然后支付服务会请求第三方支付平台创建交易并扣款,这里,交易服务就是主业务服务,而库存服务和支付服务是从业务服务。请参见图 6-8。
我们再来梳理下,TCC 模式的流程。第一阶段主业务服务调用全部的从业务服务的 Try 操作,并且事务管理器记录操作日志。第二阶段,当全部从业务服务都成功时,再执行 Confirm 操作,否则会执行 Cancel 逆操作进行回滚。请参见图 6-9。
现在,我们针对 TCC 模式说说大致业务上的实现思路。首先,交易服务(主业务服务)会向事务管理器注册并启动事务。其实,事务管理器是一个概念上的全局事务管理机制,可以是一个内嵌于主业务服务的业务逻辑,或者抽离出的一个 TCC 框架。事实上,它会生成全局事务 ID 用于记录整个事务链路,并且实现了一套嵌套事务的处理逻辑。当主业务服务调用全部的从业务服务的 try 操作,事务管理器利用本地事务记录相关事务日志,这个案例中,它记录了调用库存服务的动作记录,以及调用支付服务的动作记录,并将其状态设置成“预提交”状态。这里,调用从业务服务的 Try 操作就是核心的业务代码。那么, Try 操作怎么和它相对应的 Confirm、Cancel 操作绑定呢?其实,我们可以编写配置文件建立绑定关系,或者通过 Spring 的注解添加 confirm 和 cancel 两个参数也是不错的选择。当全部从业务服务都成功时,由事务管理器通过 TCC 事务上下文切面执行 Confirm 操作,将其状态设置成“成功”状态,否则执行 Cancel 操作将其状态设置成“预提交”状态,然后进行重试。因此,TCC 模式通过补偿的方式保证其最终一致性。
TCC 的实现框架有很多成熟的开源项目,例如 tcc-transaction 框架。(关于 tcc-transaction 框架的细节,可以阅读:https://github.com/changmingxie/tcc-transaction)tcc-transaction 框架主要涉及 tcc-transaction-core、tcc-transaction-api、tcc-transaction-spring 三个模块。其中,tcc-transaction-core 是 tcc-transaction 的底层实现,tcc-transaction-api 是 tcc-transaction 使用的 API,tcc-transaction-spring 是 tcc-transaction 的 Spring 支持。 tcc-transaction 将每个业务操作抽象成事务参与者,每个事务可以包含多个参与者。参与者需要声明 try / confirm / cancel 三个类型的方法。这里,我们通过 @Compensable 注解标记在 try 方法上,并定义相应的 confirm / cancel 方法。
// try 方法
@Compensable(confirmMethod = "confirmRecord", cancelMethod = "cancelRecord", transactionContextEditor = MethodTransactionContextEditor.class)
@Transactional
public String record(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}
// confirm 方法
@Transactional
public void confirmRecord(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}
// cancel 方法
@Transactional
public void cancelRecord(TransactionContext transactionContext, CapitalTradeOrderDto tradeOrderDto) {}
对于 tcc-transaction 框架的实现,我们来了解一些核心思路。tcc-transaction 框架通过 @Compensable 切面进行拦截,可以透明化对参与者 confirm / cancel 方法调用,从而实现 TCC 模式。这里,tcc-transaction 有两个拦截器,请参见图 6-10。
org.mengyun.tcctransaction.interceptor.CompensableTransactionInterceptor,可补偿事务拦截器。
org.mengyun.tcctransaction.interceptor.ResourceCoordinatorInterceptor,资源协调者拦截器。
这里,需要特别关注 TransactionContext 事务上下文,因为我们需要远程调用服务的参与者时通过参数的形式传递事务给远程参与者。在 tcc-transaction 中,一个事务org.mengyun.tcctransaction.Transaction
可以有多个参与者org.mengyun.tcctransaction.Participant
参与业务活动。其中,事务编号 TransactionXid 用于唯一标识一个事务,它使用 UUID 算法生成,保证唯一性。当参与者进行远程调用时,远程的分支事务的事务编号等于该参与者的事务编号。通过事务编号的关联 TCC confirm / cancel 方法,使用参与者的事务编号和远程的分支事务进行关联,从而实现事务的提交和回滚。事务状态 TransactionStatus 包含 : 尝试中状态 TRYING(1)、确认中状态 CONFIRMING(2)、取消中状态 CANCELLING(3)。此外,事务类型 TransactionType 包含 : 根事务 ROOT(1)、分支事务 BRANCH(2)。当调用 TransactionManager#begin() 发起根事务时,类型为 MethodType.ROOT,并且事务 try 方法被调用。调用 TransactionManager#propagationNewBegin() 方法,传播发起分支事务。该方法在调用方法类型为 MethodType.PROVIDER 并且 事务 try 方法被调用。调用 TransactionManager#commit() 方法提交事务。该方法在事务处于 confirm / cancel 方法被调用。类似地,调用 TransactionManager#rollback() 方法,取消事务。
此外,对于事务恢复机制,tcc-transaction 框架基于 Quartz 实现调度,按照一定频率对事务进行重试,直到事务完成或超过最大重试次数。如果单个事务超过最大重试次数时,tcc-transaction 框架不再重试,此时需要手工介入解决。
这里,我们要特别注意操作的幂等性。幂等机制的核心是保证资源唯一性,例如重复提交或服务端的多次重试只会产生一份结果。支付场景、退款场景,涉及金钱的交易不能出现多次扣款等问题。事实上,查询接口用于获取资源,因为它只是查询数据而不会影响到资源的变化,因此不管调用多少次接口,资源都不会改变,所以是它是幂等的。而新增接口是非幂等的,因为调用接口多次,它都将会产生资源的变化。因此,我们需要在出现重复提交时进行幂等处理。那么,如何保证幂等机制呢?事实上,我们有很多实现方案。其中,一种方案就是常见的创建唯一索引。在数据库中针对我们需要约束的资源字段创建唯一索引,可以防止插入重复的数据。但是,遇到分库分表的情况是,唯一索引也就不那么好使了,此时,我们可以先查询一次数据库,然后判断是否约束的资源字段存在重复,没有的重复时再进行插入操作。注意的是,为了避免并发场景,我们可以通过锁机制,例如悲观锁与乐观锁保证数据的唯一性。这里,分布式锁是一种经常使用的方案,它通常情况下是一种悲观锁的实现。但是,很多人经常把悲观锁、乐观锁、分布式锁当作幂等机制的解决方案,这个是不正确的。除此之外,我们还可以引入状态机,通过状态机进行状态的约束以及状态跳转,确保同一个业务的流程化执行,从而实现数据幂等。
补偿模式
上节,我们提到了重试机制。事实上,它也是一种最终一致性的解决方案:我们需要通过最大努力不断重试,保证数据库的操作最终一定可以保证数据一致性,如果最终多次重试失败可以根据相关日志并主动通知开发人员进行手工介入。注意的是,被调用方需要保证其幂等性。重试机制可以是同步机制,例如主业务服务调用超时或者非异常的调用失败需要及时重新发起业务调用。重试机制可以大致分为固定次数的重试策略与固定时间的重试策略。除此之外,我们还可以借助消息队列和定时任务机制。消息队列的重试机制,即消息消费失败则进行重新投递,这样就可以避免消息没有被消费而被丢弃,例如 RocketMQ 可以默认允许每条消息最多重试 16 次,每次重试的间隔时间可以进行设置。定时任务的重试机制,我们可以创建一张任务执行表,并增加一个“重试次数”字段。这种设计方案中,我们可以在定时调用时,获取这个任务是否是执行失败的状态并且没有超过重试次数,如果是则进行失败重试。但是,当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。
除了重试机制之外,也可以在每次更新的时候进行修复。例如,对于社交互动的点赞数、收藏数、评论数等计数场景,也许因为网络抖动或者相关服务不可用,导致某段时间内的数据不一致,我们就可以在每次更新的时候进行修复,保证系统经过一段较短的时间的自我恢复和修正,数据最终达到一致。需要注意的是,使用这种解决方案的情况下,如果某条数据出现不一致性,但是又没有再次更新修复,那么其永远都会是异常数据。
定时校对也是一种非常重要的解决手段,它采取周期性的进行校验操作来保证。关于定时任务框架的选型上,业内比较常用的有单机场景下的 Quartz,以及分布式场景下 Elastic-Job、XXL-JOB、SchedulerX 等分布式定时任务中间件。关于定时校对可以分为两种场景,一种是未完成的定时重试,例如我们利用定时任务扫描还未完成的调用任务,并通过补偿机制来修复,实现数据最终达到一致。另一种是定时核对,它需要主业务服务提供相关查询接口给从业务服务核对查询,用于恢复丢失的业务数据。现在,我们来试想一下电商场景的退款业务。在这个退款业务中会存在一个退款基础服务和自动化退款服务。此时,自动化退款服务在退款基础服务的基础上实现退款能力的增强,实现基于多规则的自动化退款,并且通过消息队列接收到退款基础服务推送的退款快照信息。但是,由于退款基础服务发送消息丢失或者消息队列在多次失败重试后的主动丢弃,都很有可能造成数据的不一致性。因此,我们通过定时从退款基础服务查询核对,恢复丢失的业务数据就显得特别重要了。
可靠事件模式
在分布式系统中,消息队列在服务端的架构中的地位非常重要,主要解决异步处理、系统解耦、流量削峰等场景。多个系统之间如果同步通信很容易造成阻塞,同时会将这些系统会耦合在一起。因此,引入了消息队列,一方面解决了同步通信机制造成的阻塞,另一方面通过消息队列进行业务解耦。请参见图 6-12。
可靠事件模式,通过引入可靠的消息队列,只要保证当前的可靠事件投递并且消息队列确保事件传递至少一次,那么订阅这个事件的消费者保证事件能够在自己的业务内被消费即可。这里,请读者思考,是否只要引入了消息队列就可以解决问题了呢?事实上,只是引入消息队列并不能保证其最终的一致性,因为分布式部署环境下都是基于网络进行通信,而网络通信过程中,上下游可能因为各种原因而导致消息丢失。
其一,主业务服务发送消息时可能因为消息队列无法使用而发生失败。对于这种情况,我们可以让主业务服务(生产者)发送消息,再进行业务调用来确保。一般的做法是,主业务服务将要发送的消息持久化到本地数据库,设置标志状态为“待发送”状态,然后把消息发送给消息队列,消息队列收到消息后,也把消息持久化到其存储服务中,但并不是立即向从业务服务(消费者)投递消息,而是先向主业务服务(生产者)返回消息队列的响应结果,然后主业务服务判断响应结果执行之后的业务处理。如果响应失败,则放弃之后的业务处理,设置本地的持久化消息标志状态为“结束”状态。否则,执行后续的业务处理,设置本地的持久化消息标志状态为“已发送”状态。
public void doServer(){
// 发送消息
send();
// 执行业务
exec();
// 更新消息状态
updateMsg();
}
此外,消息队列发生消息后,也可能从业务服务(消费者)宕机而无法消费。绝大多数消息中间件对于这种情况,例如 RabbitMQ、RocketMQ 等引入了 ACK 机制。注意的是,默认的情况下,采用自动应答,这种方式中消息队列会发送消息后立即从消息队列中删除该消息。所以,为了确保消息的可靠投递,我们通过手动 ACK 方式,如果从业务服务(消费者)因宕机等原因没有发送 ACK,消息队列会将消息重新发送,保证消息的可靠性。从业务服务处理完相关业务后通过手动 ACK 通知消息队列,消息队列才从消息队列中删除该持久化消息。那么,消息队列如果一直重试失败而无法投递,就会出现消息主动丢弃的情况,我们需要如何解决呢?聪明的读者可能已经发现,我们在上个步骤中,主业务服务已经将要发送的消息持久化到本地数据库。因此,从业务服务消费成功后,它也会向消息队列发送一个通知消息,此时它是一个消息的生产者。主业务服务(消费者)接收到消息后,最终把本地的持久化消息标志状态为“完成”状态。说到这里,读者应该可以理解到我们使用“正反向消息机制”确保了消息队列可靠事件投递。当然,补偿机制也是必不可少的。定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。请参见图 6-13。
注意的是,因为从业务服务可能收到消息处理超时或者服务宕机,以及网络等原因导致而消息队列收不到消息的处理结果,因此可靠事件投递并且消息队列确保事件传递至少一次。这里,从业务服务(消费者)需要保证幂等性。如果从业务服务(消费者)没有保证接口的幂等性,将会导致重复提交等异常场景。此外,我们也可以独立消息服务,将消息服务独立部署,根据不同的业务场景共用该消息服务,降低重复开发服务的成本。
了解了“可靠事件模式”的方法论后,现在我们来看一个真实的案例来加深理解。首先,当用户发起退款后,自动化退款服务会收到一个退款的事件消息,此时,如果这笔退款符合自动化退款策略的话,自动化退款服务会先写入本地数据库持久化这笔退款快照,紧接着,发送一条执行退款的消息投递到给消息队列,消息队列接受到消息后返回响应成功结果,那么自动化退款服务就可以执行后续的业务逻辑。与此同时,消息队列异步地把消息投递给退款基础服务,然后退款基础服务执行自己业务相关的逻辑,执行失败与否由退款基础服务自我保证,如果执行成功则发送一条执行退款成功消息投递到给消息队列。最后,定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。这里,需要注意的是,自动化退款服务持久化的退款快照可以理解为需要确保投递成功的消息,由“正反向消息机制”和“定时任务”确保其成功投递。此外,真正的退款出账逻辑在退款基础服务来保证,因此它要保证幂等性,及出账逻辑的收敛。当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。请参见图 6-14。
总结一下,引入了消息队列并不能保证可靠事件投递,换句话说,由于网络等各种原因而导致消息丢失不能保证其最终的一致性,因此,我们需要通过“正反向消息机制”确保了消息队列可靠事件投递,并且使用补偿机制尽可能在一定时间内未完成的消息并重新投递。
开源项目的分布式事务实现解读
开源项目中对分布式事务的应用有很多值得我们学习与借鉴的地方。本节,我们就来对其实现进行解读。
RocketMQ
Apache RocketMQ 是阿里开源的一款高性能、高吞吐量的分布式消息中间件。在历年双 11 中,RocketMQ 都承担了阿里巴巴生产系统全部的消息流转,在核心交易链路有着稳定和出色的表现,是承载交易峰值的核心基础产品之一。RocketMQ 同时存在商用版 MQ 可在阿里云上购买(https://www.aliyun.com/product/ons),阿里巴巴对于开源版本和商业版本,主要区别在于:会开源分布式消息所有核心的特性,而在商业层面,尤其是云平台的搭建上面,将运维管控、安全授权、深度培训等纳入商业重中之重。
Apache RocketMQ 4.3 版本正式支持分布式事务消息。RocketMQ 事务消息设计主要解决了生产者端的消息发送与本地事务执行的原子性问题,换句话说,如果本地事务执行不成功,则不会进行 MQ 消息推送。那么,聪明的你可能就会存在疑问:我们可以先执行本地事务,执行成功了再发送 MQ 消息,这样不就可以保证事务性的?但是,请你再认真的思考下,如果 MQ 消息发送不成功怎么办呢?事实上,RocketMQ 对此提供一个很好的思路和解决方案。
RocketMQ 首先会发送预执行消息到 MQ,并且在发送预执行消息成功后执行本地事务。紧接着,它根据本地事务执行结果进行后续执行逻辑,如果本地事务执行结果是 commit,那么正式投递 MQ 消息,如果本地事务执行结果是 rollback,则 MQ 删除之前投递的预执行消息,不进行投递下发。注意的是,对于异常情况,例如执行本地事务过程中,服务器宕机或者超时,RocketMQ 将会不停的询问其同组的其他生产者端来获取状态。请参见图 6-15。
至此,我们已经了解了 RocketMQ 的实现思路,如果对源码实现感兴趣的读者,可以阅读org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#sendMessageInTransaction
。
ServiceComb
ServiceComb 基于华为内部的 CSE(Cloud Service Engine) 框架开源而来,它提供了一套包含代码框架生成,服务注册发现,负载均衡,服务可靠性(容错熔断,限流降级,调用链追踪)等功能的微服务框架。其中,ServiceComb Saga 是一个微服务应用的数据最终一致性解决方案。
Saga 拆分分布式事务为多个本地事务,然后由 Saga 引擎负责协调。如果整个流程正常结束,那么业务成功完成;如果在这过程中实现出现部分失败,那么Saga 引擎调用补偿操作。Saga 有两种恢复的策略 :向前恢复和向后恢复。其中,向前恢复对失败的节点采取最大努力不断重试,保证数据库的操作最终一定可以保证数据一致性,如果最终多次重试失败可以根据相关日志并主动通知开发人员进行手工介入。向后恢复对之前所有成功的节点执行回滚的事务操作,这样保证数据达到一致的效果。
Saga 与 TCC 不同之处在于,Saga 比 TCC 少了一个 Try 操作。因此,Saga 会直接提交到数据库,然后出现失败的时候,进行补偿操作。Saga 的设计可能导致在极端场景下的补偿动作比较麻烦,但是对于简单的业务逻辑侵入性更低,更轻量级,并且减少了通信次数,请参见图 6-16。
ServiceComb Saga 在其理论基础上进行了扩展,它包含两个组件: alpha 和 omega。alpha 充当协调者,主要负责对事务的事件进行持久化存储以及协调子事务的状态,使其得以最终与全局事务的状态保持一致。omega 是微服务中内嵌的一个 agent,负责对网络请求进行拦截并向 alpha 上报事务事件,并在异常情况下根据 alpha 下发的指令执行相应的补偿操作。在预处理阶段,alpha 会记录事务开始的事件;在后处理阶段,alpha 会记录事务结束的事件。因此,每个成功的子事务都有一一对应的开始及结束事件。在服务生产方,omega 会拦截请求中事务相关的 id 来提取事务的上下文。在服务消费方,omega 会在请求中注入事务相关的 id来传递事务的上下文。通过服务提供方和服务消费方的这种协作处理,子事务能连接起来形成一个完整的全局事务。注意的是,Saga 要求相关的子事务提供事务处理方法,并且提供补偿函数。这里,添加 @EnableOmega 的注解来初始化 omega 的配置并与 alpha 建立连接。在全局事务的起点添加 @SagaStart 的注解,在子事务添加 @Compensable 的注解指明其对应的补偿方法。
使用案例:https://github.com/apache/servicecomb-saga/tree/master/saga-demo
@EnableOmega
public class Application{
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@SagaStart
public void xxx() { }
@Compensable
public void transfer() { }
现在,我们来看一下它的业务流程图,请参见图 6-17。
更多精彩文章,尽在「服务端思维」!