1、支付宝蚂蚁金服怎么在分布式架构下保证转账业务数据的一致性?
作者:肖钦 2019-03-27 15:02
概述
本文以分布式架构下的转账服务为业务场景,先阐述分布式架构下跨数据库转账遇到的数据一致性问题;再详细介绍如何使用行业常见的分布式事务解决方案(消息事务、冲正补偿、JTA/XA
),以及蚂蚁的分布式事务(DTX)
,解决跨库转账的数据一致性问题,并列举了各种解决方案的优劣势。
通过对比各种分布式事务解决方案,您会发现,分布式事务有丰富的接入模式,能应对各种复杂的业务场景,接入维护简单,性能优异,行业优势明显。
需求背景和技术问题
需求描述
转账是金融机构日常业务中的常见场景。假设用户 A为转账发起方,从自己的账户余额中转出一笔资金至用户 B 的账户中。此操作涉及两部分:“A 账户的扣钱”和“B 账户的加钱”,这两个操作要都成功才算转账成功。
▲ 用户 A 向用户 B 转账
如果一个操作成功而另一个操作失败(比如:B 账户加钱成功,A 账户扣钱失败),则会出现总体资金数据不一致,造成资金损失;故转账服务的中的“加钱”和“扣钱”操作必须在一个事务内,要么都成功,要么都失败。
在非分布式架构下,用户 A、用户 B 的账户数据都在同一个数据库中,可以使用数据库事务来保证“加钱”和“扣钱”操作的在一个事务内。
但是在分布式架构下,用户 A 的账户数据、用户 B 的账户数据会分别存储在不同数据库中,此时便无法再使用数据库事务来保证“加钱”和“扣钱”操作的原子性,需要考虑能保证数据一致性的解决方案。
需求分析
▲ 用户 A 发起转账业务
如上图所示,在分布式架构下,用户 A 发起转账操作,向用户 B 转账 100 元。
转账过程中,首先是在数据库 A 中扣除账号 A 的 100 元,紧接着是在数据库 B 中给账户 B 加 100 元。
“数据库 A 上账户 A 扣款操作”、“数据库 B 上账户 B 加钱操作”都成功才算转账成功;如果一个操作成功,另一操作失败(比如:账户 B 加钱成功,账户 A 扣钱失败),则会出现资金数据不一致,造成资金损失;如果加钱操作和扣钱操作丢失败,那么转账是失败的,但是不会有资金损失。
故需要保证数据库 A、数据库 B 上的更新操作都成功或者都失败;整个资金数据最终是一致的。
技术问题
分布式架构下,用户 A、用户 B 的账户数据存储在不同的数据库中,需要引入能保证跨数据库的多个操作在一个事务内的解决方案,以保证转账操作的原子性,保障跨库转账时的资金安全。
下文将介绍目前常见的分布式事务解决方案,并将其应用到转账场景,以解决跨库转账时的数据一致性问题。
行业分布式事务解决方案
分布式事务是指事务中资源分布于网络中的多个不同节点的事务。
▲ 分布式事务中的事务管理器与资源管理器
如上图所示,分布式事务有一个事务管理器(Transaction Manager)和多个资源管理器(Resource Manager)组成:
事务管理器:通常被称为事务发起方,负责发起方分布式事务,编排、协调所有资源管理器完成业务活动。
资源管理器:也被称为事务参与者,对应单个业务动作,由事务管理器协调、编排。
在本文的转账案例中,转账服务便是事务管理器(发起方),转账服务内部执行“扣钱”、“加钱”动作是事务的参与者。
二阶段提交协议(2PC)是分布式事务的基础协议
,在此协议中,事务管理器分两个阶段协调资源管理器。
▲ 二阶段提交
如上图所示,在第一阶段,事务管理器向所有资源管理器发生准备请求,如果所有资源管理返回准备成功,那么在第二阶段事务管理器向所有资源发生提交请求,完成所有资源的提交。
如果有任一资源管理一阶段准备失败,那么在第二阶段事务管理器向所有资源发生回滚请求,完成所有资源的回滚。
目前常见的分布式事务解决方案均采用二阶段提交协议实现。
下文将以转账场景为例,分别介绍非事务解决方案和常见的分布式事务解决方案,了解它们是如何保障分布式架构下跨库转账的原子性,保障资金数据的一致性。
0、非事务解决方案
▲ 转账操作流程
如上图所示,在非分布式事务解决方案中,转账操作会依次执行“A 账户扣除 100 元”、“B 账户增加 100 元”,最后完成转账操作。整个转账操作内部有两个操作,分别是:“A 账户扣钱”、“B 账户加钱”,我们把这两个操作抽象成扣钱服务(MinusAction)和加钱服务(AddAction):
扣钱服务:负责在 A 账户余额上扣除转账资金。
加钱服务:负责向 B 账户加钱。
▲ 转账服务流程
如上图所示,转账服务会依次调用 MinusAction 服务的 minus 方法完成 A 账户的扣款,AddAction 服务的 add 方法完成 B 账户的加钱。整个转账过程中,未引入任何分布式事务解决方案来保证转账操作(加钱操作、扣钱操作)的原子性。正常情况下,加钱操作和扣钱操作都执行成功,这种实现方式不会有问题。但是在异常情况下(比如:A 账户扣钱成功,但是 B 账户加钱失败,此时 A 账户扣掉的钱将无法恢复),会出现资金数据不一致,给用户造成资金损失。因此,在金融行业,这种解决方案是不可取的,必需引入分布式事务解决方案来保障转账服务的原子性。
一、消息事务解决方案
▲ 消息事务业务流程
消息事务是一种比较常见的分布式事务解决方案。如上图所示,引入了消息事务解决方案来解决转账操作的事务问题。
转账服务内部使用消息事务功能,发送加钱消息给加钱服务,同时在消息事务回调方法内调用扣钱服务完成 A 账户的扣钱;A 账户扣钱成功则加钱消息发送成功,否则加钱消息发送失败;消息发送完成之后,转账服务返回结果,消息事务一阶段完成。
在第二阶段订阅消息,消息消费时,调用加钱服务完成 B 账户的加钱;B 账户加钱成功则消息消费成功;否则消息消费失败,消息队列下个周期重试投递消息。
消息事务解决方案分析
消息事务的转账操作分为两个阶段,在一阶段执行的操作是:“A 账户扣除 100 元钱”和发送“B 账户加钱”消息至消息队列,这两个操作要么都成功,要么都失败。
在一阶段结束之后,整个转账操作便结束,用户会收到转账结果;此时用户会认为转账完成。
二阶段“B 账户加钱”消息的消费是异步的,由消息队列将“B 账户加钱”消息发送至“B 账户扣钱”服务,此服务消费消息并完成账户 B 的加钱;消息队列会一直重复投递消息,直到“B 账户加钱”成功为止。
消息事务解决方案问题
消息消费延迟。一阶段“A 账户扣款”之后,转账操作便结束,此时用户认为转账操作已经完成;但实际上“B 账户加钱操作”未执行;需要等待消息队列投递“B 账户加钱”消息方可执行,消息投递延迟时间是不确定的,造成“B 账户加钱操作”执行实际不确定。
要求二阶段的消息消费必须 100% 成功。一阶段“账户 A 扣除 100 元钱”成功之后,如果二阶段“账户 B 加钱 100 元钱操作”无法成功(比如:账户 B 不存在、B账户被冻结等原因导致账户 B 加钱永远不会成功),此时整个资金就处于不一致状态,账户 A 扣除的 100 元钱将永远无法得到补偿;所以使用消息事务必须保证二阶段的消息消费一定能成功。
引入“消息队列”风险点。消息事务可用的前提是消息队列可用。消息队列宕机会导致整个转账操作完全不可用;消息队列出现消息积压会导致二阶段延迟更加严重。因此,消息队列可能成为消息事务解决方案的一个潜在瓶颈。
二、冲正补偿解决方案
冲正补偿也是分布式事务比较常用的一种解决方案,冲正补偿可以解决消息事务二阶段不可逆的问题。
各个业务参与者(加钱、扣钱动作)需要分别实现正向业务操作,以及其逆向回滚操作。
事务协调者先执行所有参与者正向业务操作,如果所有参与者正向操作均成功,那么整个业务就算成功;如果任意参与者正向操作执行失败,那么协调者会去执行所有参与者的逆向操作,让事务回滚。转账的冲正补偿实现如下图所示:
▲ 冲正补偿业务流程
冲正补偿解决方案中,每一个业务操作均需要实现正向和逆向两个操作;对于扣钱服务,除了扣钱操作外,还需要实现其方法的回滚操作;对于加钱服务,除了加钱操作外,还需要实现其回滚操作。
冲正补偿解决方案分析
在冲正补偿下,各服务均需要用户设计和实现“正向”和“逆向”两个操作。
转账操作开始之后,先执行 A 账户的正向操作“A 账户扣钱操作”,如果执行失败,则执行逆向操作“A 账户扣钱回滚操作”,最终转账操作失败。
如果 A 账户正向操作成功,则执行 B 账户的正向操作“B账户加钱操作”;如果“B 账户加钱”执行失败,则会执行 B 账户逆向操作“B账户加钱回滚操作”,以及 A 账户的逆向操作“A 账户扣钱回滚操作”,最终转账失败。
如果 A 账户正向和 B 账户的正向操作均成功,那么转账成功。
冲正补偿解决方案问题
接入成本高。冲正补偿需要用户设计实现各服务的正向和逆向操作,用户在设计正向操作时,需要同时考虑逆向操作该如何执行;需要在正向操作中保存一些中间数据,供逆向操作运行时使用,系统设计实现较复杂。
资金安全问题。假如在某些场景下,对 B 账户“加钱”(正向)成功之后,出现一些其他异常导致整个转账操作需要回滚,此时会触发“加钱”操作的逆向操作去扣除 B 账户上的资金;但是如果 B 用户在此之前已经把账户上的资金全部转走,“扣除 B 账户上的资金”这个逆向操作可能永远不会成功,此时就出现资金无法追回的问题。为了解决资金安全问题,编排冲正补偿各动作时,需要考虑如何保障资金安全;系统设计的方方面面均需考虑资金安全,无疑系统设计会复杂繁琐,日后的代码维护也需谨慎。
维护成本高。正向、逆向操作执行过程中,可能出现服务器宕机、重启等异常情况导致转账流程中断(比如正向操作中,A账户扣钱成功之后,B 账户加钱还未开始,执行流程中断),此时就需要用户维护一个恢复程序,不断找到这种未完成的转账任务,执行该笔转账剩余的未完成的操作,使转账成功或者转账回滚,以保障数据的最终一致。但目前冲正补偿并没有标准的恢复程序可用,这个恢复程序就需要用户自己设计实现,成本较高。
JTA/XA 解决方案
JTA/XA 解决方案通过 JTA API 调用数据库的 XA 接口,协调各个数据库上的 XA 事务的提交和回滚。
▲ JTA/XA 业务流程
如上图所示,加钱操作、扣钱操作分别调用数据库 A、数据库 B 的 JTA/XA API。JTA/XA API 能帮助用户分二阶段协调各个数据库上 XA 事务的同步提交和回滚。
JTA/XA 解决方案分析
用户编写 JTA 接口,内部分别开启“A 数据库”、“B 数据库”上的 XA 事务。
开启 XA 事务之后,分别在“A 数据库”XA 事务上执行 A 账户扣钱任务,在“B 数据库”XA 事务中执行 B 账号加钱任务;并结束 XA 事务。
执行 XA 事务一阶段的预提交。
如果“A 数据库”、“B 数据库”上的 XA 事务预提交均成功,则提交 XA 事务。
如果“A 数据库”、“B 数据库”上的 XA 事务预提交出现失败,则回滚 XA 事务。
JTA/XA 解决方法下,数据库 XA 事务作为资源管理器,用户自己作为事务协调者,调用 JTA 接口操作 XA 事务。
JTA/XA 解决方案的问题
XA 并发性能受限。XA 事务内访问的数据都会被数据库加锁,直到 XA 事务提交或者回滚,这些数据锁才会被释放。这个数据库层的全局锁限制了 XA 事务的并发性,极大影响了 XA 事务的性能。
运维成本高。与冲正补偿一样,XA 解决方案下,事务协调者执行转账操作的任意阶段,都可能出现服务器宕机、重启等异常情况导致转账流程中断,此时就需要用户维护一个恢复程序,不断找到这种未完成的转账任务,执行该笔转账剩余的未完成的操作,使转账成功或者转账回滚,以保障数据的最终一致。
同样的,JTA/XA 解决方案并没有标准的恢复程序可用,这个恢复程序就需要用户自己设计实现,成本较高。
蚂蚁金服分布式事务解决方案
前文介绍了消息事务、冲正补偿等解决方案及其问题,接下来我们将介绍使用蚂蚁金服的分布式事务(DTX)解决方案来实现转账操作。
分布式事务有两种模式:TCC 模式和 FMT 模式:
TCC 模式由用户实现 TCC 参与者,供事务发起方协调。
FMT 模式无需用户实现 TCC 参与者,用户的业务将作为一阶段操作,每一个业务的二阶段操作由分布式事务框架自动生成。
下面我们将分别介绍如何使用 TCC 模式、FMT 模式实现转账操作。
TCC 模式解决方案
TCC 即 Try-Confirm-Cancel 的缩写,是服务化的二阶段提交(2PC)编程模型:
Try:资源检查和预留
Confirm:发生实际的业务操作;要求 Try 成功 Confirm 一定能成功
Cancel:预留资源的释放,Try 阶段的逆向操作
TCC 模式需要用户将“A 账户扣钱”、“B 账户加钱”均分成二阶段实现,在第一阶段检查预留资源,在二阶段提交时执行实际的扣钱、加钱操作,二阶段回滚时释放预留资源。
使用分布式事务的 TCC 模式,需要用户设计、实现个业务动作的 TCC 服务,此 TCC 服务有 3 个方法,分别是一阶段的准备方法(Try)、二阶段的提交方法(Confirm)和二阶段的回滚方法(Cancel)。
▲ 转账 TCC 业务流程
如上图所示,TCC 模式的转账操作描述:
转账操作内,首先执行各 TCC 参与者的一阶段方法,做转账准备操作;一阶段准备成功,则二阶段的提交一定能成功。
如果所有一阶段参与者方法均执行成功,那么二阶段转账操作会去执行所有 TCC 参与者的提交方法,执行 A 账户的扣钱和 B 账户的加钱,完成真正用户余额的转账。
如果一阶段有任一参与者出现失败,那么二阶段便会执行所有 TCC参与者的回滚方法,使得各账户恢复至转账前的状态。
在转账方法内部,用户只需要显示调用各个参与者(A 账户扣钱参与者、B 账户加钱参与者)的一阶段方法,无需关注参与者的二阶段方法调用(参与者二阶段方法由分布式事务框架来调用,分布式事务框架会根据转账方法返回结果是成功还是失败,来决定是去调用各参与者二阶段的提交方法还是回滚方法)。
TCC 模式优点
无资金安全风险。一阶段只会冻结转账资金,不会发生真正的资金转账。一阶段成功之后才回去执行二阶段的提交操作,完成真正的资金转账;与冲正补偿相比,TCC 模式资金更加安全。
运维成本低;在转账操作执行过程中出现异常中断时,TCC 模式无需用户自己维护异常事务恢复程序,分布式事务提供了标准的统一的恢复服务帮助用户恢复异常事务;这个恢复服务用户完全无感知。
性能优越。TCC 参与者各阶段方法由用户实现;相对于 XA 这种数据库层的全局锁,用户可自定义其数据库的操作粒度,使数据库层面的锁冲突最小、最大限度的提高吞吐量。
TCC 模式问题
接入成本较高。用户接入过程中,需要考虑如何将业务动作分成二阶段完成,需要在第一阶段预留资源,以保证第二阶段的提交一定能成功。此外,TCC 参与者实现时还需要考虑幂等控制、防悬挂等,这些都增加了 TCC 模式的接入成本。
FMT 模式解决方案
针对 TCC 模式接入成本高的问题,分布式事务开发了 FMT 模式。FMT 模式的接入成本极低,用户无需实现 TCC 参与者,只需要极少的改动业务代码即可接入分布式事务。
FMT 模式优点
接入简单、快捷;对业务代码改动少。
FMT 模式问题
在实现上,为了防止数据的无效读写等问题,添加了行锁;相对于几乎无锁的 TCC 模式,性能稍弱。
分布式事务与行业解决方案对比
与消息事务相比
消息事务存在消息消费延迟的问题;分布式事务所有操作均是同步调用,无任何延迟。
消息事务在一阶段成功后,要求二阶段的消息消费必须成功。分布式事务的 TCC 模式也是二阶段操作,但在一阶段准备操作后,可以保证二阶段一定成功;而 FMT 模式在一阶段准备操作后,会自动生成二阶段操作,可以保证操作 100% 成功。
消息事务依赖消息队列服务;分布式事务不依赖任何第三方服务。
与冲正补偿相比
冲正补偿要求各个业务动作均实现“正向”、“逆向”2 个操作;TCC 要求业务动作分 2 阶段实现,分别是一阶段的准备,和二阶段的提交/回滚。对于接入成本来说,二者可以说的等价的。但是FMT 模式下,无需用户实现这些复杂操作,用户只需按自身业务逻辑实现其代码。
冲正补偿在正向操作中就完成用户账户资金的修改,存在资金安全风险;TCC 模式一阶段只是冻结资金,二阶段才完成真正的资金变更,无资金安全问题;FMT 模式有全局行锁对用户数据进行加锁,用户在转账事务未完成前,无法动用账户资金,同样无资金安全问题。
冲正补偿需要用户维护一个事务的恢复服务;而分布式事务提高了统一的标准的异常恢复程序,分布式事务维护成本更低。
与 JTA/XA 相比
TCC 模式可以认为是无任何数据库层面的全局数据锁,性能比 XA 高很多;FMT 模式虽然有类 XA 的全局数据锁,但是我们对 FMT 的行锁做了大量优化,引入乐观锁、自旋锁、控制行锁粒度等优化策略,FMT 的行锁性能比 XA 稍高。
JTA/XA 解决方案需要用户维护一个事务的恢复服务;而分布式事务提高了统一的标准的异常恢复程序,维护成本更低。
总结
相较于消息事务、冲正补偿、JTA/XA 等解决方案,分布式事务提供了多种模式和解决方案,在性能、易用性、运维成本等方面均有较出色的表现,是目前行业内领先的分布式事务解决方案。
分布式事务提供了两种模式:TCC 模式、FMT 模式。如果比较看重易用性,可以选择使用 FMT 模式;如果业务逻辑比较复杂,对性能要求比较高,可以选择 TCC 模式。
原创日期:2018-04-04
原文作者:绍辉
2、一次给女朋友转账引发我对分布式事务的思考
本文在个人技术博客不同步发布,详情可用力戳 亦可扫描屏幕右侧二维码关注个人公众号,公众号内有个人联系方式,等你来撩...
前两天发了工资,第一反应是想着要给远方的女朋友一点惊喜!于是打开了平安银行的APP给女朋友转点钱!填写上对方招商银行卡的卡号、开户名,一键转账!搞定!在我点击的那瞬间,就收到了app的账户变动的提醒,并且出现了图一所示的提示界面:“处理中,正在等待对方银行返回结果…”。嗯!毕竟是跨行转账嘛,等个几秒也正常!脑海开始浮现出女朋友收到转账后惊喜与感动的画面!
然而,一切并没有那么顺利,刚过一会儿,app却如图二所示的提示我“由于收款人户名不符”导致转账失败!!!
刚刚都已经从我卡里扣过钱了,现在却提示我转账失败,银行会不会把我的钱给吞了?转账失败的钱还能退换给我吗?正在我紧张、焦虑、坐立不安之时又收到一条app冲正的消息,刚刚转账失败的钱已经退还给我了,看来我多虑了……这也证明咱平安银行的app还是比较安全靠谱的!
为啥从我卡里扣钱那么迅速,而对方却要几秒才能到账?并且转账失败后,扣除的钱还能及时的返还到我的卡里?万一钱返还失败怎么办?又或者我转一次钱,对方却收到了两次转账的申请又该如何?带着这些问题,我脑海中浮现出“事务”二字!
在我们还在“牙牙学语”的时候,老师经常会通过转账的栗子来跟我们讲解事务,但跟这里场景不一样的是,老师讲的是本地事务,而这里面对的是分布式事务!我们先来简单回顾一下本地事务!
本地事务
谈到本地事务,大家可能都很熟悉,因为这个数据库引擎层面能支持的!所以也称数据库事务,数据库事务四大特征:原子性(A),一致性(C),隔离性(I)和持久性(D),而在这四大特性中,我认为一致性是最基本的特性,其它的三个特性都为了保证一致性而存在的!
回到学生时代老师给我们举的经典栗子,A账户给B账户转账100元(A、B处于同一个库中),如果A的账户发生扣款,B的账户却没有到账,这就出现了数据的不一致!为了保证数据的一致性,数据库的事务机制会让A账户扣款和B在账户到账的两个操作要么同时成功,如果有一个操作失败,则多个操作同时回滚,这就是事务的原子性,为了保证事务操作的原子性,就必须实现基于日志的REDO/UNDO机制!但是,仅有原子性还不够,因为我们的系统是运行在多线程环境下,如果多个事务并行,即使保证了每一个事务的原子性,仍然会出现数据不一致的情况。例如A账户原来有200元的余额, A账户给B账户转账100元,先读取A账户的余额,然后在这个值上减去100元,但是在这两个操作之间,A账户又给C账户转账100元,那么最后的结果应该是A减去了200元。但事实上,A账户给B账户最终完成转账后,A账户只减掉了100元,因为A账户向C账户转账减掉的100元被覆盖了!所以为了保证并发情况下的一致性,又引入的隔离性,即多个事务并发执行后的状态,和它们串行执行后的状态是等价的!隔离性又有多种隔离级别,为了实现隔离性(最终都是为了保证一致性)数据库又引入了悲观锁、乐观锁等等……本文的主题是分布式事务,所以本地事务就只是简单回顾一下,需要记住的一点是,事务是为了保证数据的一致性!
分布式理论
还记得刚毕业那年,带着满腔的热血就去到了一家互联网公司,领导给我的第一个任务就是在列表上增加一个修改数据的功能。这能难倒我?我分分钟给你搞出来!不就是在列表上增加了一个“修改”按钮,点击按钮弹出框修改后保存就好了么。然而一切不像我想象的那么顺利,点击保存并刷新列表后,页面上的数据还是显示的修改之前的内容,像没有修改成功一样!过一会儿再刷新列表,数据就能正常显示了!测试多次之后都是这样!没见过什么大场面的我开始有点慌了,是我哪里写得不对么?最终,我不得不求助组内经验比较丰富的前辈!他深吸了一口气告诉我说:“毕竟是刚毕业的小伙子啊!我来跟你讲讲原因吧!我们的数据库是做了读写分离的,部分读库与写库在不同的网络分区。你的数据更新到了写库,而读数据的时候是从读库读取的。更新到写库的数据同步到读库是有一定的延迟的,也就是说读库与写库会有短暂的数据不一致”! “这样不会体验不好么?为什么不能做到写入的数据立马能读出来?那我这个功能该怎么实现呢?” 面对我的一堆问题,同事有些不耐烦的说:“听说过CAP理论吗?你先自己去了解一下吧”!是我开始查阅各种资料去了解这个陌生的词背后的秘密!
CAP理论
是由加州大学Eric Brewer教授提出来的,这个理论告诉我们,一个分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)这三个基本需求,最多只能同时满足其中两项。
一致性:这里的一致性是指数据的强一致,也称为线性一致性。是指在分布式环境中,数据在多个副本之间是否能够保持一致的特性。也就是说对某个数据进行写操作后立马执行读操作,必须能读取到刚刚写入的值。(any read operation that begins after a write operation completes must return that value, or the result of a later write operation)
可用性:任意被无故障节点接收到的请求,必须能够在有限的时间内响应结果。(every request received by a non-failing node in the system must result in a response)
分区容错性:如果集群中的机器被分成了两部分,这两部分不能互相通信,系统是否能继续正常工作。(the network will be allowed to lose arbitrarily many messages sent from one node to another)
在分布式系统中,分区容错性是基本要保证的。也就是说只能在一致性和可用性之间进行取舍。一致性和可用性,为什么不可能同时成立?回到之前修改列表的例子,由于数据会分布在不同的网络分区,必然会存在数据同步的问题,而同步会存在网络延迟、异常等问题,所以会出现数据的不一致!如果要保证数据的一致性,那么就必须在对写库进行操作时,锁定其他读库的操作。只有写入成功且完成数据同步后,才能重新放开读写,而这样在锁定期间,系统丧失了可用性。更详细关于CAP理论可以参考这篇文章,该文章讲得比较通俗易懂!
分布式事务
分布式事务就是在分布式的场景下,需要满足事务的需求!上篇文章我们聊过了消息中间件,那这篇文章我们要聊的是分布式事务,把两者一结合,便有了基于消息中间件的分布式事务解决方案!不管是本地事务,还是分布式事务,都是为了解决数据的一致性问题!一致性这个词咱们前面多次提及!与本地事务不同的是,分布式事务需要保证的是分布式环境下,不同数据库表中的数据的一致性问题。分布式事务的解决方案有多种,如XA协议、TCC三阶段提交、基于消息队列等等,本文只会涉及基于消息队列的解决方案!
本地事务讲到了一致性,分布式事务不可避免的面临着一致性的问题!回到最开始跨行转账的例子,如果A银行用户向B银行用户转账,正常流程应该是:
1、A银行对转出账户执行检查校验,进行金额扣减。 2、A银行同步调用B银行转账接口。 3、B银行对转入账户进行检查校验,进行金额增加。 4、B银行返回处理结果给A银行。
在正常情况对一致性要求不高的场景,这样的设计是可以满足需求的。但是像银行这样的系统,如果这样实现大概早就破产了吧。我们先看看这样的设计最主要的问题:
1、同步调用远程接口,如果接口比较耗时,会导致主线程阻塞时间较长。 2、流量不能很好控制,A银行系统的流量高峰可能压垮B银行系统(当然B银行肯定会有自己的限流机制)。 3、如果“第1步”刚执行完,系统由于某种原因宕机了,那会导致A银行账户扣款了,但是B银行没有收到接口的调用,这就出现了两个系统数据的不一致。 4、如果在执行“第3步”后,B银行由于某种原因宕机了而无法正确回应请求(实际上转账操作在B银行系统已经执行且入库),这时候A银行等待接口响应会异常,误以为转账失败而回滚“第1步”操作,这也会出现了两个系统数据的不一致。
对于问题的1、2都很好解决,如果对消息队列熟悉的朋友应该很快能想到可以引入消息中间件进行异步和削峰处理,于是又重新设计了一个方案,流程如下:
1、A银行对账户进行检查校验,进行金额扣减。 2、将对B银行的请求异步写入队列,主线程返回。 3、启动后台程序从队列获取待处理数据。 4、后台程序对B银行接口进行远程调用。 5、B银行对转入账户进行检查校验,进行金额增加。 6、B银行处理完成回调A银行接口通知处理结果。
通过上面的图我们能看到,引入消息队列后,系统的复杂性瞬间提升了,虽然弥补了我们第一种方案的几个不足点,但也带来了更多的问题,比如消息队列系统本身的可用性、消息队列的延迟等等!并且,这样的设计依然没有解决我们面临的核心问题-数据的一致性!
1、如果“第1步”刚执行完,系统由于某种原因宕机了,那会导致A银行账户扣款了,但是写入消息队列失败,无法进行B银行接口调用,从而导致数据不一致。 2、如果B银行在执行“第5步”时由于校验失败而未能成功转账,在回调A银行接口通知回滚时网络异常或者宕机,会导致A银行转账无法完成回滚,从而导致数据不一致。
面对上述问题,我们不得不对系统再次进行升级改造。为了解决“A银行账户扣款了,但是写入消息队列失败”的问题,我们需要借助一个转账日志表,或者叫转账流水表,该表简单的设计如下:
字段名称 | 字段描述 |
---|---|
tId | 交易流水id |
accountNo | 转出账户卡号 |
targetBankNo | 目标银行编码 |
targetAccountNo | 目标银行卡号 |
amount | 交易金额 |
status | 交易状态(待处理、处理成功、处理失败) |
lastUpdateTime | 最后更新时间 |
这个流水表需要怎么用呢?我们在“第1步”进行扣款时,同时往流水表写入一条操作流水,状态为“待处理”,并且这两个操作必须是原子的,也就是说必须通过本地事务保证这两个操作要么同时成功,要么同时失败!这就保证了只要转账扣款成功,必定会记录一条状态为“待处理”的转账流水。如果在这一步失败了,那自然就是转账失败,没有后续操作了。如果这步操作后系统宕机了导致没有将消息成功写入消息队列(也就是“第2步”)也没关系,因为我们的流水数据已经持久化了!这时候我们只需要加入一个后台线程进行补偿,定期的从转账流水表中读取状态为“待处理”且最后更新的时间距当前时间大于某个阈值的数据,重新放入消息队列进行补偿。这样,就保证了消息即使丢失,也会有补偿机制!B银行在处理完转账请求后会回调A银行的接口通知转账的状态,从而更新A银行流水表中的状态字段!这样就完美解决了上一个方案中的两个不足点。系统设计图如下:
到目前为止,我们很好的解决了消息丢失的问题,保证了只要A银行转账操作成功,转账的请求就一定能发送到B银行!但是该方案又引入了一个问题,通过后台线程轮询将消息放入消息队列处理,同一次转账请求可能会出现多次放入消息队列而多次消费的情况,这样B银行会对同一转账多次处理导致数据出现不一致!那怎么保证B银行转账接口的幂等性呢?
同样的,我们可以在B银行系统中需要增加一个转账日志表,或者叫转账流水表,B银行每次接收到转账请求,在对账户进行操作的时候同时往转账日志表中插入一条转账日志记录,同样这两个操作也必须是原子的!在接收到转账请求后,首先根据唯一转账流水Id在日志表中查找判断该转账是否已经处理过,如果未处理过则进行处理,否则直接回调返回! 最终的架构图如下:
所以,我们这里最核心的就是A银行通过本地事务保证日志记录+后台线程轮询保证消息不丢失。B银行通过本地事务保证日志记录从而保证消息不重复消费!B银行在回调A银行的接口时会通知处理结果,如果转账失败,A银行会根据处理结果进行回滚。
3、分布式事务的解决方案
来源:优知学院 原文地址:https://youzhixueyuan.com/solution-and-summary-of-distributed-transaction.html
分布式事务是企业集成中的一个技术难点,也是每一个分布式系统架构中都会涉及到的一个东西,特别是在这几年越来越火的微服务架构中,几乎可以说是无法避免,本文就围绕分布式事务各方面与大家进行介绍。
事务
1.1 什么是事务
数据库事务(简称:事务,Transaction)是指数据库执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。
事务拥有以下四个特性,习惯上被称为ACID特性:
- 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
- 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态是指数据库中的数据应满足完整性约束。除此之外,一致性还有另外一层语义,就是事务的中间状态不能被观察到(这层语义也有说应该属于原子性)。
- 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行,如同只有这一个操作在被数据库所执行一样。
- 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。在事务结束时,此操作将不可逆转。
1.2 本地事务
起初,事务仅限于对单一数据库资源的访问控制:
架构服务化以后,事务的概念延伸到了服务中。倘若将一个单一的服务操作作为一个事务,那么整个服务操作只能涉及一个单一的数据库资源:
这类基于单个服务单一数据库资源访问的事务,被称为本地事务(Local Transaction)。
分布式事务
本地事务主要限制在单个会话内,不涉及多个数据库资源。但是在基于SOA(Service-Oriented Architecture,面向服务架构)的分布式应用环境下,越来越多的应用要求对多个数据库资源,多个服务的访问都能纳入到同一个事务当中,分布式事务应运而生。
最早的分布式事务应用架构很简单,不涉及服务间的访问调用,仅仅是服务内操作涉及到对多个数据库资源的访问。
当一个服务操作访问不同的数据库资源,又希望对它们的访问具有事务特性时,就需要采用分布式事务来协调所有的事务参与者。
对于上面介绍的分布式事务应用架构,尽管一个服务操作会访问多个数据库资源,但是毕竟整个事务还是控制在单一服务的内部。如果一个服务操作需要调用另外一个服务,这时的事务就需要跨越多个服务了。在这种情况下,起始于某个服务的事务在调用另外一个服务的时候,需要以某种机制流转到另外一个服务,从而使被调用的服务访问的资源也自动加入到该事务当中来。下图反映了这样一个跨越多个服务的分布式事务:
如果将上面这两种场景(一个服务可以调用多个数据库资源,也可以调用其他服务)结合在一起,对此进行延伸,整个分布式事务的参与者将会组成如下图所示的树形拓扑结构。在一个跨服务的分布式事务中,事务的发起者和提交均系同一个,它可以是整个调用的客户端,也可以是客户端最先调用的那个服务。
较之基于单一数据库资源访问的本地事务,分布式事务的应用架构更为复杂。
在不同的分布式应用架构下,实现一个分布式事务要考虑的问题并不完全一样,比如对多资源的协调、事务的跨服务传播等,实现机制也是复杂多变。尽管有这么多工程细节需要考虑,但分布式事务最核心的还是其 ACID 特性。因此,想要了解一个分布式事务,就先从了解它是怎么实现事务 ACID 特性开始。
下文将从两个最常见的分布式事务模型入手,着重分析分布式事务的基础共通点,即如何保证分布式事务的 ACID 特性。
分布式理论
想保证集群的ACID几乎是很难达到,或者即使能达到那么效率和性能会大幅下降,这时我们就需要引入一个新的理论原则来适应这种集群的情况,就是 CAP 原则或者叫CAP定理
CAP定理
CAP定理是由加州大学伯克利分校Eric Brewer教授提出来的,他指出WEB服务无法同时满足一下3个属性:
- 一致性:分布式环境下多个节点的数据是否强一致。
- 可用性:分布式服务能一直保证可用状态。当用户发出一个请求后,服务能在有限时间内返回结果。
- 分区容忍性:特指对网络分区的容忍性。
具体地讲在分布式系统中,在任何数据库设计中,一个Web应用至多只能同时支持上面的两个属性。显然,任何横向扩展策略都要依赖于数据分区。因此,设计人员必须在一致性与可用性之间做出选择。
BASE理论
是基于CAP定理演化而来,是对CAP中一致性和可用性权衡的结果。核心思想:即使无法做到强一致性,但每个业务根据自身的特点,采用适当的方式来使系统达到最终一致性。BASE理论指的是:
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致性)
分布式事务的解决方案
分布式事务的解决方案有如下几种:
- 全局消息
- 基于可靠消息服务的分布式事务
- TCC
- 最大努力通知
方案1:全局事务(DTP模型)
全局事务基于DTP模型实现。DTP是由X/Open组织提出的一种分布式事务模型——X/Open Distributed Transaction Processing Reference Model。它规定了要实现分布式事务,需要三种角色:
- AP:Application 应用系统 它就是我们开发的业务系统,在我们开发的过程中,可以使用资源管理器提供的事务接口来实现分布式事务。
- TM:Transaction Manager 事务管理器
- 分布式事务的实现由事务管理器来完成,它会提供分布式事务的操作接口供我们的业务系统调用。这些接口称为TX接口。
- 事务管理器还管理着所有的资源管理器,通过它们提供的XA接口来同一调度这些资源管理器,以实现分布式事务。
- DTP只是一套实现分布式事务的规范,并没有定义具体如何实现分布式事务,TM可以采用2PC、3PC、Paxos等协议实现分布式事务。
- RM:Resource Manager 资源管理器
- 能够提供数据服务的对象都可以是资源管理器,比如:数据库、消息中间件、缓存等。大部分场景下,数据库即为分布式事务中的资源管理器。
- 资源管理器能够提供单数据库的事务能力,它们通过XA接口,将本数据库的提交、回滚等能力提供给事务管理器调用,以帮助事务管理器实现分布式的事务管理。
- XA是DTP模型定义的接口,用于向事务管理器提供该资源管理器(该数据库)的提交、回滚等能力。
- DTP只是一套实现分布式事务的规范,RM具体的实现是由数据库厂商来完成的。
- 有没有基于DTP模型的分布式事务中间件?
- DTP模型有啥优缺点?
方案2:基于可靠消息服务的分布式事务
这种实现分布式事务的方式需要通过消息中间件来实现。假设有A和B两个系统,分别可以处理任务A和任务B。此时系统A中存在一个业务流程,需要将任务A和任务B在同一个事务中处理。下面来介绍基于消息中间件来实现这种分布式事务。
- 在系统A处理任务A前,首先向消息中间件发送一条消息
- 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在。
- 消息中间件持久化成功后,便向系统A返回一个确认应答;
- 系统A收到确认应答后,则可以开始处理任务A;
- 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,此时它可以处理别的任务了。 但commit消息可能会在传输途中丢失,从而消息中间件并不会向系统B投递这条消息,从而系统就会出现不一致性。这个问题由消息中间件的事务回查机制完成,下文会介绍。
- 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行;
- 当任务B执行完成后,系统B向消息中间件返回一个确认应答,告诉消息中间件该消息已经成功消费,此时,这个分布式事务完成。
上述过程可以得出如下几个结论:
- 消息中间件扮演者分布式事务协调者的角色。
- 系统A完成任务A后,到任务B执行完成之间,会存在一定的时间差。在这个时间差内,整个系统处于数据不一致的状态,但这短暂的不一致性是可以接受的,因为经过短暂的时间后,系统又可以保持数据一致性,满足BASE理论。
上述过程中,如果任务A处理失败,那么需要进入回滚流程,如下图所示:
- 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。和发送Commit请求一样,系统A发完之后便可以认为回滚已经完成,它便可以去做其他的事情。
- 消息中间件收到回滚请求后,直接将该消息丢弃,而不投递给系统B,从而不会触发系统B的任务B。
此时系统又处于一致性状态,因为任务A和任务B都没有执行。
上面所介绍的Commit和Rollback都属于理想情况,但在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制。
系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:
- 提交 若获得的状态是“提交”,则将该消息投递给系统B。
- 回滚 若获得的状态是“回滚”,则直接将条消息丢弃。
- 处理中 若获得的状态是“处理中”,则继续等待。
消息中间件的超时询问机制能够防止上游系统因在传输过程中丢失Commit/Rollback指令而导致的系统不一致情况,而且能降低上游系统的阻塞时间,上游系统只要发出Commit/Rollback指令后便可以处理其他任务,无需等待确认应答。而Commit/Rollback指令丢失的情况通过超时询问机制来弥补,这样大大降低上游系统的阻塞时间,提升系统的并发度。
下面来说一说消息投递过程的可靠性保证。 当上游系统执行完任务并向消息中间件提交了Commit指令后,便可以处理其他任务了,此时它可以认为事务已经完成,接下来消息中间件一定会保证消息被下游系统成功消费掉!那么这是怎么做到的呢?这由消息中间件的投递流程来保证。
消息中间件向下游系统投递完消息后便进入阻塞等待状态,下游系统便立即进行任务的处理,任务处理完成后便向消息中间件返回应答。消息中间件收到确认应答后便认为该事务处理完毕!
如果消息在投递过程中丢失,或消息的确认应答在返回途中丢失,那么消息中间件在等待确认应答超时之后就会重新投递,直到下游消费者返回消费成功响应为止。当然,一般消息中间件可以设置消息重试的次数和时间间隔,比如:当第一次投递失败后,每隔五分钟重试一次,一共重试3次。如果重试3次之后仍然投递失败,那么这条消息就需要人工干预。
有的同学可能要问:消息投递失败后为什么不回滚消息,而是不断尝试重新投递?
这就涉及到整套分布式事务系统的实现成本问题。 我们知道,当系统A将向消息中间件发送Commit指令后,它便去做别的事情了。如果此时消息投递失败,需要回滚的话,就需要让系统A事先提供回滚接口,这无疑增加了额外的开发成本,业务系统的复杂度也将提高。对于一个业务系统的设计目标是,在保证性能的前提下,最大限度地降低系统复杂度,从而能够降低系统的运维成本。
不知大家是否发现,上游系统A向消息中间件提交Commit/Rollback消息采用的是异步方式,也就是当上游系统提交完消息后便可以去做别的事情,接下来提交、回滚就完全交给消息中间件来完成,并且完全信任消息中间件,认为它一定能正确地完成事务的提交或回滚。然而,消息中间件向下游系统投递消息的过程是同步的。也就是消息中间件将消息投递给下游系统后,它会阻塞等待,等下游系统成功处理完任务返回确认应答后才取消阻塞等待。为什么这两者在设计上是不一致的呢?
首先,上游系统和消息中间件之间采用异步通信是为了提高系统并发度。业务系统直接和用户打交道,用户体验尤为重要,因此这种异步通信方式能够极大程度地降低用户等待时间。此外,异步通信相对于同步通信而言,没有了长时间的阻塞等待,因此系统的并发性也大大增加。但异步通信可能会引起Commit/Rollback指令丢失的问题,这就由消息中间件的超时询问机制来弥补。
那么,消息中间件和下游系统之间为什么要采用同步通信呢?
异步能提升系统性能,但随之会增加系统复杂度;而同步虽然降低系统并发度,但实现成本较低。因此,在对并发度要求不是很高的情况下,或者服务器资源较为充裕的情况下,我们可以选择同步来降低系统的复杂度。 我们知道,消息中间件是一个独立于业务系统的第三方中间件,它不和任何业务系统产生直接的耦合,它也不和用户产生直接的关联,它一般部署在独立的服务器集群上,具有良好的可扩展性,所以不必太过于担心它的性能,如果处理速度无法满足我们的要求,可以增加机器来解决。而且,即使消息中间件处理速度有一定的延迟那也是可以接受的,因为前面所介绍的BASE理论就告诉我们了,我们追求的是最终一致性,而非实时一致性,因此消息中间件产生的时延导致事务短暂的不一致是可以接受的。
方案3:最大努力通知(定期校对)
最大努力通知也被称为定期校对,其实在方案二中已经包含,这里再单独介绍,主要是为了知识体系的完整性。这种方案也需要消息中间件的参与,其过程如下:
- 上游系统在完成任务后,向消息中间件同步地发送一条消息,确保消息中间件成功持久化这条消息,然后上游系统可以去做别的事情了;
- 消息中间件收到消息后负责将该消息同步投递给相应的下游系统,并触发下游系统的任务执行;
- 当下游系统处理成功后,向消息中间件反馈确认应答,消息中间件便可以将该条消息删除,从而该事务完成。
上面是一个理想化的过程,但在实际场景中,往往会出现如下几种意外情况:
- 消息中间件向下游系统投递消息失败
- 上游系统向消息中间件发送消息失败
对于第一种情况,消息中间件具有重试机制,我们可以在消息中间件中设置消息的重试次数和重试时间间隔,对于网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。
如果重复投递和定期校对都不能解决问题,往往是因为下游系统出现了严重的错误,此时就需要人工干预。
对于第二种情况,需要在上游系统中建立消息重发机制。可以在上游系统建立一张本地消息表,并将 任务处理过程 和 向本地消息表中插入消息 这两个步骤放在一个本地事务中完成。如果向本地消息表插入消息失败,那么就会触发回滚,之前的任务处理结果就会被取消。如果这量步都执行成功,那么该本地事务就完成了。接下来会有一个专门的消息发送者不断地发送本地消息表中的消息,如果发送失败它会返回重试。当然,也要给消息发送者设置重试的上限,一般而言,达到重试上限仍然发送失败,那就意味着消息中间件出现严重的问题,此时也只有人工干预才能解决问题。
对于不支持事务型消息的消息中间件,如果要实现分布式事务的话,就可以采用这种方式。它能够通过重试机制+定期校对实现分布式事务,但相比于第二种方案,它达到数据一致性的周期较长,而且还需要在上游系统中实现消息重试发布机制,以确保消息成功发布给消息中间件,这无疑增加了业务系统的开发成本,使得业务系统不够纯粹,并且这些额外的业务逻辑无疑会占用业务系统的硬件资源,从而影响性能。
因此,尽量选择支持事务型消息的消息中间件来实现分布式事务,如RocketMQ。
方案4:TCC(两阶段型、补偿型)
TCC即为Try Confirm Cancel,它属于补偿型分布式事务。顾名思义,TCC实现分布式事务一共有三个步骤:
- Try:尝试待执行的业务
- 这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源
- Confirm:执行业务
- 这个过程真正开始执行业务,由于Try阶段已经完成了一致性检查,因此本过程直接执行,而不做任何检查。并且在执行的过程中,会使用到Try阶段预留的业务资源。
- Cancel:取消执行的业务
- 若业务执行失败,则进入Cancel阶段,它会释放所有占用的业务资源,并回滚Confirm阶段执行的操作。
下面以一个转账的例子来解释下TCC实现分布式事务的过程。
假设用户A用他的账户余额给用户B发一个100元的红包,并且余额系统和红包系统是两个独立的系统。
- Try
- 创建一条转账流水,并将流水的状态设为交易中
- 将用户A的账户中扣除100元(预留业务资源)
- Try成功之后,便进入Confirm阶段
- Try过程发生任何异常,均进入Cancel阶段
- Confirm
- 向B用户的红包账户中增加100元
- 将流水的状态设为交易已完成
- Confirm过程发生任何异常,均进入Cancel阶段
- Confirm过程执行成功,则该事务结束
- Cancel
- 将用户A的账户增加100元
- 将流水的状态设为交易失败
在传统事务机制中,业务逻辑的执行和事务的处理,是在不同的阶段由不同的部件来完成的:业务逻辑部分访问资源实现数据存储,其处理是由业务系统负责;事务处理部分通过协调资源管理器以实现事务管理,其处理由事务管理器来负责。二者没有太多交互的地方,所以,传统事务管理器的事务处理逻辑,仅需要着眼于事务完成(commit/rollback)阶段,而不必关注业务执行阶段。
TCC全局事务必须基于RM本地事务来实现全局事务
TCC服务是由Try/Confirm/Cancel业务构成的, 其Try/Confirm/Cancel业务在执行时,会访问资源管理器(Resource Manager,下文简称RM)来存取数据。这些存取操作,必须要参与RM本地事务,以使其更改的数据要么都commit,要么都rollback。
这一点不难理解,考虑一下如下场景:
假设图中的服务B没有基于RM本地事务(以RDBS为例,可通过设置auto-commit为true来模拟),那么一旦[B:Try]操作中途执行失败,TCC事务框架后续决定回滚全局事务时,该[B:Cancel]则需要判断[B:Try]中哪些操作已经写到DB、哪些操作还没有写到DB:假设[B:Try]业务有5个写库操作,[B:Cancel]业务则需要逐个判断这5个操作是否生效,并将生效的操作执行反向操作。
不幸的是,由于[B:Cancel]业务也有n(0<=n<=5)个反向的写库操作,此时一旦[B:Cancel]也中途出错,则后续的[B:Cancel]执行任务更加繁重。因为,相比第一次[B:Cancel]操作,后续的[B:Cancel]操作还需要判断先前的[B:Cancel]操作的n(0<=n<=5)个写库中哪几个已经执行、哪几个还没有执行,这就涉及到了幂等性问题。而对幂等性的保障,又很可能还需要涉及额外的写库操作,该写库操作又会因为没有RM本地事务的支持而存在类似问题。。。可想而知,如果不基于RM本地事务,TCC事务框架是无法有效的管理TCC全局事务的。
反之,基于RM本地事务的TCC事务,这种情况则会很容易处理:[B:Try]操作中途执行失败,TCC事务框架将其参与RM本地事务直接rollback即可。后续TCC事务框架决定回滚全局事务时,在知道“[B:Try]操作涉及的RM本地事务已经rollback”的情况下,根本无需执行[B:Cancel]操作。
换句话说,基于RM本地事务实现TCC事务框架时,一个TCC型服务的cancel业务要么执行,要么不执行,不需要考虑部分执行的情况。
TCC事务框架应该提供Confirm/Cancel服务的幂等性保障
一般认为,服务的幂等性,是指针对同一个服务的多次(n>1)请求和对它的单次(n=1)请求,二者具有相同的副作用。
在TCC事务模型中,Confirm/Cancel业务可能会被重复调用,其原因很多。比如,全局事务在提交/回滚时会调用各TCC服务的Confirm/Cancel业务逻辑。执行这些Confirm/Cancel业务时,可能会出现如网络中断的故障而使得全局事务不能完成。因此,故障恢复机制后续仍然会重新提交/回滚这些未完成的全局事务,这样就会再次调用参与该全局事务的各TCC服务的Confirm/Cancel业务逻辑。
既然Confirm/Cancel业务可能会被多次调用,就需要保障其幂等性。 那么,应该由TCC事务框架来提供幂等性保障?还是应该由业务系统自行来保障幂等性呢? 个人认为,应该是由TCC事务框架来提供幂等性保障。如果仅仅只是极个别服务存在这个问题的话,那么由业务系统来负责也是可以的;然而,这是一类公共问题,毫无疑问,所有TCC服务的Confirm/Cancel业务存在幂等性问题。TCC服务的公共问题应该由TCC事务框架来解决;而且,考虑一下由业务系统来负责幂等性需要考虑的问题,就会发现,这无疑增大了业务系统的复杂度。
作者:大闲人柴毛毛 链接:https://juejin.im/post/5aa3c7736fb9a028bb189bca 来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
下面我们分别来看4种模式(AT、TCC、Saga、XA)的分布式事务实现。
AT模式
AT 模式是一种无侵入的分布式事务解决方案。 阿里seata框架,实现了该模式。
在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
AT 模式如何做到对业务的无侵入 :
- 一阶段: 在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
- 二阶段提交: 二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
- 二阶段回滚: 二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。
TCC 模式
TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;事务发起方在一阶段执行 Try 方式,在二阶段提交执行 Confirm 方法,二阶段回滚执行 Cancel 方法。
TCC 三个方法描述:
- Try:资源的检测和预留;
- Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功;
- Cancel:预留资源释放;
TCC 的实践经验
蚂蚁金服TCC实践,总结以下注意事项:
➢业务模型分2阶段设计 ➢并发控制 ➢允许空回滚 ➢防悬挂控制 ➢幂等控制
1 TCC 设计 - 业务模型分 2 阶段设计: 用户接入 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 模式,最重要的事情就是考虑如何将业务模型拆成 2 阶段,实现成 TCC 的 3 个方法,并且保证 Try 成功 Confirm 一定能成功。相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模式高很多。
2 TCC 设计 - 允许空回滚:
Cancel 接口设计时需要允许空回滚。在 Try 接口因为丢包时没有收到,事务管理器会触发回滚,这时会触发 Cancel 接口,这时 Cancel 执行时发现没有对应的事务 xid 或主键时,需要返回回滚成功。让事务服务管理器认为已回滚,否则会不断重试,而 Cancel 又没有对应的业务数据可以进行回滚。
3 TCC 设计 - 防悬挂控制:
悬挂的意思是:Cancel 比 Try 接口先执行,出现的原因是 Try 由于网络拥堵而超时,事务管理器生成回滚,触发 Cancel 接口,而最终又收到了 Try 接口调用,但是 Cancel 比 Try 先到。按照前面允许空回滚的逻辑,回滚会返回成功,事务管理器认为事务已回滚成功,则此时的 Try 接口不应该执行,否则会产生数据不一致,所以我们在 Cancel 空回滚返回成功之前先记录该条事务 xid 或业务主键,标识这条记录已经回滚过,Try 接口先检查这条事务xid或业务主键如果已经标记为回滚成功过,则不执行 Try 的业务操作。
4 TCC 设计 - 幂等控制:
幂等性的意思是:对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,通常我们可以用事务 xid 或业务主键判重来控制。
saga模式
Saga 理论出自 Hector & Kenneth 1987发表的论文 Sagas。 saga模式的实现,是长事务解决方案。
Saga 是一种补偿协议,在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
如图:T1~T3都是正向的业务流程,都对应着一个冲正逆向操作C1~C3
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 正向服务与补偿服务也需要业务开发者实现。因此是业务入侵的。
Saga 模式下分布式事务通常是由事件驱动的,各个参与者之间是异步执行的,Saga 模式是一种长事务解决方案。
Saga 模式使用场景
Saga 模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能。
事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,可以使用 Saga 模式。
Saga模式的优势是:
- 一阶段提交本地数据库事务,无锁,高性能;
- 参与者可以采用事务驱动异步执行,高吞吐;
- 补偿服务即正向服务的“反向”,易于理解,易于实现;
缺点:Saga 模式由于一阶段已经提交本地数据库事务,且没有进行“预留”动作,所以不能保证隔离性。后续会讲到对于缺乏隔离性的应对措施。
与TCC实践经验相同的是,Saga 模式中,每个事务参与者的冲正、逆向操作,需要支持:
- 空补偿:逆向操作早于正向操作时;
- 防悬挂控制:空补偿后要拒绝正向操作
- 幂等
XA模式
XA是X/Open DTP组织(X/Open DTP group)定义的两阶段提交协议,XA被许多数据库(如Oracle、DB2、SQL Server、MySQL)和中间件等工具(如CICS 和 Tuxedo)本地支持 。 X/Open DTP模型(1994)包括应用程序(AP)、事务管理器(TM)、资源管理器(RM)。
XA接口函数由数据库厂商提供。XA规范的基础是两阶段提交协议2PC。
JTA(Java Transaction API) 是Java实现的XA规范的增强版 接口。
在XA模式下,需要有一个[全局]协调器,每一个数据库事务完成后,进行第一阶段预提交,并通知协调器,把结果给协调器。协调器等所有分支事务操作完成、都预提交后,进行第二步;第二步:协调器通知每个数据库进行逐个commit/rollback。 其中,这个全局协调器就是XA模型中的TM角色,每个分支事务各自的数据库就是RM。
MySQL 提供的XA实现(https://dev.mysql.com/doc/refman/5.7/en/xa.html )
XA模式下的 开源框架有atomikos,其开发公司也有商业版本。 XA模式缺点:事务粒度大。高并发下,系统可用性低。因此很少使用。
AT、TCC、Saga、XA模式分析
四种分布式事务模式,分别在不同的时间被提出,每种模式都有它的适用场景
- AT 模式是无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎0学习成本。
- TCC 模式是高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
- Saga 模式是长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁,长流程情况下可以保证性能,多用于渠道层、集成层业务系统。事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,也可以使用 Saga 模式。
- XA模式是分布式强一致性的解决方案,但性能低而使用较少。
常见的分布式事务解决方案
1.基于XA协议的两阶段提交
XA是一个分布式事务协议,由Tuxedo提出。XA中大致分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。XA实现分布式事务的原理如下:
总的来说,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。但是,XA也有致命的缺点,那就是性能不理想,特别是在交易下单链路,往往并发量很高,XA无法满足高并发场景。XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。许多nosql也没有支持XA,这让XA的应用场景变得非常狭隘。
2.消息事务+最终一致性
所谓的消息事务就是基于消息中间件的两阶段提交,本质上是对消息中间件的一种特殊利用,它是将本地事务和发消息放在了一个分布式事务里,保证要么本地操作成功成功并且对外发消息成功,要么两者都失败,开源的RocketMQ就支持这一特性,具体原理如下:
1、A系统向消息中间件发送一条预备消息
2、消息中间件保存预备消息并返回成功
3、A执行本地事务
4、A发送提交消息给消息中间件
通过以上4步完成了一个消息事务。对于以上的4个步骤,每个步骤都可能产生错误,下面一一分析:
- 步骤一出错,则整个事务失败,不会执行A的本地操作
- 步骤二出错,则整个事务失败,不会执行A的本地操作
- 步骤三出错,这时候需要回滚预备消息,怎么回滚?答案是A系统实现一个消息中间件的回调接口,消息中间件会去不断执行回调接口,检查A事务执行是否执行成功,如果失败则回滚预备消息
- 步骤四出错,这时候A的本地事务是成功的,那么消息中间件要回滚A吗?答案是不需要,其实通过回调接口,消息中间件能够检查到A执行成功了,这时候其实不需要A发提交消息了,消息中间件可以自己对消息进行提交,从而完成整个消息事务
基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。原理如下:
虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险。
3.TCC 模型
TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
因此,针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑:
初步操作 Try:完成所有业务检查,预留必须的业务资源。
确认操作 Confirm:真正执行的业务逻辑,不作任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务有且只能成功一次。
取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。
TCC 分布式事务模型包括三部分:
1.主业务服务:主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动。
2.从业务服务:从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用。
3.业务活动管理器:业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。
一个完整的 TCC 分布式事务流程如下:
- 主业务服务首先开启本地事务;
- 主业务服务向业务活动管理器申请启动分布式事务主业务活动;
- 然后针对要调用的从业务服务,主业务活动先向业务活动管理器注册从业务活动,然后调用从业务服务的 Try 接口;
- 当所有从业务服务的 Try 接口调用成功,主业务服务提交本地事务;若调用失败,主业务服务回滚本地事务;
- 若主业务服务提交本地事务,则 TCC 模型分别调用所有从业务服务的 Confirm 接口;若主业务服务回滚本地事务,则分别调用 Cancel 接口;
- 所有从业务服务的 Confirm 或 Cancel 操作完成后,全局事务结束。
TCC模型小结
所谓的TCC编程模式,也是两阶段提交的一个变种。TCC提供了一个编程框架,将整个业务逻辑分为三块:Try、Confirm和Cancel三个操作。以在线下单为例,Try阶段会去扣库存,Confirm阶段则是去更新订单状态,如果更新订单失败,则进入Cancel阶段,会去恢复库存。总之,TCC就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,复杂度也不一样,因此,这种模式并不能很好地被复用。
消息事务+最终一致性
所谓的消息事务就是基于消息中间件的两阶段提交,本质上是对消息中间件的一种特殊利用,它是将本地事务和发消息放在了一个分布式事务里,保证要么本地操作成功成功并且对外发消息成功,要么两者都失败,开源的RocketMQ就支持这一特性,具体原理如下:
1、A系统向消息中间件发送一条预备消息 2、消息中间件保存预备消息并返回成功 3、A执行本地事务 4、A发送提交消息给消息中间件
通过以上4步完成了一个消息事务。对于以上的4个步骤,每个步骤都可能产生错误,下面一一分析:
- 步骤一出错,则整个事务失败,不会执行A的本地操作
- 步骤二出错,则整个事务失败,不会执行A的本地操作
- 步骤三出错,这时候需要回滚预备消息,怎么回滚?答案是A系统实现一个消息中间件的回调接口,消息中间件会去不断执行回调接口,检查A事务执行是否执行成功,如果失败则回滚预备消息
- 步骤四出错,这时候A的本地事务是成功的,那么消息中间件要回滚A吗?答案是不需要,其实通过回调接口,消息中间件能够检查到A执行成功了,这时候其实不需要A发提交消息了,消息中间件可以自己对消息进行提交,从而完成整个消息事务
基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务。原理如下:
虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险。
分布式事务总结
分布式事务,本质上是对多个数据库的事务进行统一控制,按照控制力度可以分为:不控制、部分控制和完全控制。不控制就是不引入分布式事务,部分控制就是各种变种的两阶段提交,包括上面提到的消息事务+最终一致性、TCC模式,而完全控制就是完全实现两阶段提交。部分控制的好处是并发量和性能很好,缺点是数据一致性减弱了,完全控制则是牺牲了性能,保障了一致性,体用哪种方式,最终还是取决于业务场景。作为技术人员,一定不能忘了技术是为业务服务的,不要为了技术而技术,针对不同业务进行技术选型也是一种很重要的能力!
参考地址
分布式事务?No, 最终一致性: https://zhuanlan.zhihu.com/p/25933039
聊聊分布式事务,再说说解决方案: https://www.cnblogs.com/savorboard/p/distributed-system-transaction-consistency.html
分布式事务的4种模式: https://zhuanlan.zhihu.com/p/78599954
如何通过事务消息保障抢购业务的分布式一致性?
前言
在电商领域,抢购和秒杀是非常普遍业务模式,抢购类业务在快速拉升用户流量并为消息者带来实惠的同时,也给电商系统带来了巨大考验。在高并发、大流量的冲击下,系统的性能和稳定性至关重要,任何一个环节出现故障,都会影响整体的购物体验,甚至造成电商系统的大面积崩溃。和电商领域抢购场景极为类似的业务模式还有很多,比如大型赛事和在线教育的报名系统,以及各类购票系统等。
针对抢购类业务在技术上带来的挑战,业界有一系列解决方案,通过不同维度来提升系统的性能与稳定性,包括动静分离、定时上架、异步处理、令牌队列、多级缓存、作弊行为侦测、流量防护、全链路压测等。
本文重点聚焦在如何确保抢购类业务的一致性上,分布式事务一直是IT界老大难的问题,而抢购业务所具备的高并发、大流量特征,更是成倍增加了分布式一致性的实现难度。以下将介绍如何通过事务消息构建满足抢购类业务要求的高性能高可用分布式一致性机制。
事务一致性原理回顾
事务是应用程序中一系列严密的操作,这一系列操作是一个不可分割的工作单位,它们要么全部执行成功,要么一个都不做。事务具有四个特征:原子性( Atomicity )、一致性( Consistency )、隔离性( Isolation )和持续性( Durability ),这四个特性简称为 ACID 特性。
在非并发状态下,保证事务的ACID特性是轻而易举的事情,如果某一个操作执行不成功,把前面的操作全部回滚就OK了。而在并发状态下,由于有多个事务同时操作同一个资源,对于事务ACID特性的保证就会困难一些,如果考虑得不周全,就会遇到如下几个问题:
- 脏读:事务A读到了事务B还没有提交的数据。
- 不可重复读:在一个事务里面对某个数据读取了两次,读出来的数据不一致。
- 幻读: 在一个事务对某个数据集用同样的方式读取了两次,数据集的条目数量不一致。
为了应对上述并发情况下出现的问题,就需要通过一定的事务隔离级别来解决。当事务的隔离级别越高的时候,上述问题发生的机会就越小,但是性能消耗也会越大。所以在实际生产过程中,要根据实际需求去确定隔离级别:
- READ_UNCOMMITTED(读未提交):最低的隔离级别,可以读到未提交的数据,无法解决脏读、不可重复读、幻读中的任何一种。
- READ_COMMITED (读已提交):能够防止脏读,但是无法解决不可重复读和幻读的问题。
- REPEATABLE_READ (重复读取):对同一条数据的多次重复读取能保持一致,解决了脏读、不可重复读的问题,但是幻读的问题还是无法解决。
- SERLALIZABLE ( 串行化):最高的事务隔离级别,避免了事务的并行执行,解决了脏读、不可重复读和幻读的问题,但性能最低。
关系型数据库提供了对于事务的支持,能够通过不同隔离级别的设置,确保并发状态下事务的ACID特性。但关系型数据库提供的能力是基于单机事务的,一旦遇到分布式事务场景,就需要通过更多其他技术手段来解决问题。
抢购业务中的分布式事务
有如下三种情况可能会产生分布式事务:
1.一个事务操作包含对两个数据库的操作:数据库所提供的事务保证仅能局限在对于自身的操作上,无法跨越到其他数据库。
2.一个事务包含对多个数据分片的操作:具体的分片规则由分库分表中间件或者分库分表SDK来实现,有可能跨越多个数据库或同一个数据库的多个表。对于业务逻辑而言,底层的数据分片情况是不透明的,因此也没有办法依赖于数据库提供的单机事务机制。
3.一个事务包括对多个服务的调用:在微服务领域,这是极为常见的场景,不同的服务使用不同的数据资源,甚至涉及到更为复杂的调用链路。在这种情况下,数据库提供的单机事务机制,仅仅能保证其中单一环节的ACID特性,没有办法延伸到全局。
微服务技术在电商领域的普及程度是非常高的,比较大型的电商应用还会通过中台思想将共性业务能力进行沉淀,因此抢购业务中的很多环境都属于跨服务的分布式调用,会涉及到上述第三种分布式事务形态。比如在订单支付成功后,交易中心会调用订单中心的服务把订单状态更新,并调用物流中心的服务通知商品发货,同时还要调用积分中心的服务为用户增加相应的积分。如何保障分布式事务一致性,成为了确保抢购业务稳定运行的核心诉求之一。
分布式事务的实现方式
传统分布式事务
传统的分布式事务通过XA模型实现,通过一个事务协调者,站在全局的角度将多个子事务合并成一个分布式事务。XA模型之所以能在分布式事务领域得到广泛使用,是因为其具有如下两个方面的优势:
- 提供了强一致性保证,在业务执行的任何时间点都能确保事务一致性。
- 使用简单。常见的关系型数据库都提供了对XA协议的支持,通过引入事务协调器,业务代码跟使用单机事务相比基本上没有差别。
但是在互联网领域,XA模型的分布式事务实现存在很多局限性,在抢购业务这样的高并发大流量场景中更是被完全弃用。我们拿XA分布式协议中最普遍的两阶式提交方案,来说明为什么XA模型并不适合互联网场景。
- 性能问题。在两段式提交的执行过程中,所有参与节点都是事务阻塞型的,需要长时间锁定资源。这会导致系统整体的并发吞吐量变低,在抢购业务中是不可接受的。
- 单点故障问题。事务协调者在链路中有着至关重要的作用,一旦协调者发生故障,参与者会一直阻塞下去,整个系统将无法工作,因此需要投入巨大的精力来保障事务协调者的高可用性。
- 数据不一致问题。在阶段二中,如果协调者向参与者发送commit请求之后,发生了网络异常,会导致只有一部分参与者接收到了commit请求,没有接收到commit请求的参与者最终会执行回滚操作,从而造成数据不一致现象。在抢购业务中,这样的数据不一致有可能会对企业或消费者造成巨大的经济损失。
因此XA模型是一个理想化的分布式事务模型,并没有考虑到高并发、网络故障等实际因素,即便是在两阶段提交的基础上,诞生了三阶段提交这样的实现方式,也没有办法从根本上解决性能和数据不一致的问题。
柔性事务
针对传统分布式事务方案在互联网领域的局限性,业界提出了CAP理论以及BASE理论,在此基础上诞生了在大型互联网应用中广泛使用的柔性事务。柔性事务的核心思想是放弃传统分布式事务中对于严格一致性的要求,允许在事务执行过程中存在数据不一致的中间状态,在业务上需要容忍中间状态的存在。柔性事务会提供完善的机制,保证在一段时间的中间状态后,系统能走向最终一致状态。
遵循BASE理论的柔性事务放弃了隔离性,减小了事务中锁的粒度,使得应用能够更好的利用数据库的并发性能,实现吞吐量的线性扩展。异步执行方式可以更好地适应分布式环境,在网络抖动、节点故障的情况下能够尽量保障服务的可用性。因此在高并发、大流量的抢购业务中,柔性事务是最佳的选择。
柔性事务有多种实现方式,包括TCC、Saga、事务消息、最大努力通知等,本文将重点介绍通过事务消息实现柔性事务。
事务消息原理分析
抢购业务场景拆解
我们结合抢购业务的真实场景,分析如何通过事务消息实现分布式一致性。在抢购业务中,有两个非常关键的阶段,需要引入分布式事务机制,分别是订单创建阶段和付款成功阶段。
从字面含义来看,抢购业务就隐含了一个重要的前提:库存是有限的。因此在订单创建的时候,需要预先检查库存情况,并相对应的库存进行锁定,以防止商品超卖。如果库存锁定操作失败,代表库存不足,必须确保订单不能被成功创建。在锁定库存后,如果因为某种异常情况导致订单创建失败,必须及时将之前锁定的库存进行释放操作,以便让其他用户可以重新争夺对应的商品。
如果抢购系统实现了购物车机制,在订单创建的同时,则需要从购买车中将相应的条目删除。
基于微服务架构的业务拆分,订单创建阶段的3个行为很有可能通过3个不同的微服务应用完成,因此需要通过分布式事务来保证数据一致性。
订单创建完成后,会等待用户付款,一旦付款成功,就会触发付款成功阶段的执行逻辑。这个阶段同样是通过分布式事务来完成,包含修改订单状态、扣减库存、通知发货、增加积分这4个子事务,它们要么全部不执行,要么全部执行成功。
当然,在真实的抢购业务中,情况有可能会更加的复杂,本文列出的只是其中最具代表性的几类业务行为。
引入消息异步通知机制
传统的分布式事务存在一个很大的弊端是参与节点都是事务阻塞型的,需要长时间锁定资源。以锁定库存 ->创建订单这个流程为例,借助于Redis等缓存系统,单纯锁定库存的操作只需要花费毫秒级的时间,可以承载非常高的并发量。但如果把创建订单的操作也考虑进来,加上不同微服务应用之间相互通讯的时候,整体耗时有可能超过1秒,导致性能急剧下降。
假设存在一种异步消息机制,让分布式事务的第一个参与方在执行完本地事务后,通过触发一笔消息通知事务的其他参与方完成后续的操作,就能将大事务拆解为多个本地子事务来分开执行。在这种模式下,事务的多个参与方之间之间并不需要彼此阻塞和等待,就能极大程度地提升并发吞吐能力。对于库存中心而言,在高并发场景下,只需要不断的执行锁定库存记录操作,并不断通过异步消息通知订单中心创建订单,只要异步消息机制能确保消息一定送达,并得到正确处理,就能够实现分布式最终一致性。
先执行本地事务,还是先发送异步消息?
在这个模型中,异步消息的发送交给了分布式事务的第一个参与方来完成,这个参与方就拥有了两个职责:执行本地事务和发送异步消息。到底应该先执行本地事务,还是先发异步消息呢?
第一种方案是先发送异步消息,再执行本地事务。这样做肯定是不行的,如果本地事务没有执行成功,异步消息已经发出去了,其他事务参与方收到消息后会执行对应的远程事务,造成数据不一致。
第二种方案是先执行本地事务,再发送异步消息。这样做能够解决本地事务执行失败导致的数据不一致问题,因为只有在本地事务执行成功的情况下,才会发送异步消息。但如果事务的参与方在执行本地事务成功后,自己宕机了,就再有没有机会发送异步消息了,因此这样做同样会造成数据不一致的问题。记住:在真实场景中,任何一个应用节点都不是100%可靠的,都存在宕机的可能性。
一个可行的方案是引入可以处理事务消息的消息队列集群,用于异步消息的中转。一个事务消息包含两种形态:首先,事务的参与方发送一笔半事务消息到消息队列,表示自己即将执行本地事务,消息队列集群在收到这个半事务消息后,不会马上进行投递,而是进行暂存。在执行完本地事务后,事务的参考方再发送一笔确认消息到消息队列集群,告知本地事务的执行状态。如果本地事务执行成功,消息队列集群会将之前收到的半事务消息进行投递;如果本地事务执行失败,消息队列集群直接删除之前收到的半事务消息,这样远程事务就不会被执行,从而保证了最终一致性。
同样,如果事务参与方在执行完本地事务后宕机了怎么办呢?这就需要消息队列集群具备回查机制:如果收到半事务消息后,在特定时间内没有再收到确认消息,会反过来请求事务参与方查询本地事务的执行状态,并给予反馈。这样,即便错过了确认消息,消息队列集群也有能力了解到本地事务的执行状态,从而决定是否将消息进行投递。在一个微服务应用中,会存在多个对等的应用实例,这也就代表着即便一个事务参与方的实例在执行完本地事务后宕机了,消息队列集群依然可以通过这个实例的兄弟实例了解到本地事务执行的最终状态。
如何确保远程事务能执行成功?
如果一切本地事务的执行,以及异步消息的投递都一切顺利的话,接下来还会存在另外两种数据不一致的可能性:
- 消息队列集群在将异步消息投递到远程事务参与方的时候,由于网络不稳定,消息没能投递成功。
- 消息投递成功了,但远程事务参与方还没来得及执行远程事务,就宕机了。
这两种情况都会导致远程事务执行失败,所以需要建立一种消息重试机制,让远程事务参与方在完成任务后(实际上对远程事务参与方而言,这个任务是它要执行的本地任务),给予消息队列集群一个反馈,告知异步消息已经得到了正确的处理。否则,消息队列会在一定时间后,周期性的重复投递消息,直到它收到了来自远程事务参与方的反馈,以确保远程事务一定能执行成功。
和事务回查机制类似,远程事务参与方也有多个对等的微服务实例,即便某个实例在没来得及执行远程事务的时候宕机,消息队列也可以将任务交给这个实例的兄弟实例来完成。
完整流程
事务消息实战
了解到事务消息的原理后,我们不难得出一个结论:消息队列集群在整个流程中起着至关重要的作用,如果消息队列集群不可用,所有涉及到分布式事务的业务都将中止!因此,我们需要一个高可用的消息队列集群,能够始终保持在工作状态,即便其某个组件出现故障,也能够在短时间内自动恢复,不会影响业务,还能确保接收到的消息不丢失。
消息队列RocketMQ
消息队列 RocketMQ 版是阿里云基于 Apache RocketMQ 构建的低延迟、高并发、高可用、高可靠的分布式消息中间件。该产品最初由阿里巴巴自研并捐赠给 Apache 基金会,服务于阿里集团 13 年,覆盖全集团所有业务,包括种类金融级场景。作为双十一交易核心链路的官方指定产品,支撑千万级并发、万亿级数据洪峰,历年刷新全球最大的交易消息流转记录。
阿里云消息队列RocketMQ提供了对于事务消息机制最完整实现,包括半事务消息、确认消息、事务回查机制、消息重试等重要功能。此外,消息队列RocketMQ还提供了极强的高可用能力以及数据可靠性,可以确保在各种极端场景下都能提供稳定的服务,并确保消息不丢失。
对于开发者而言,使用云上的消息队列RocketMQ,可以免除消息队列集群的搭建和维护工作,将更多的精力投入到实现业务逻辑的工作中。当消息队列集群的性能不能满足要求时,还可以非常方便的进行集群一键扩容,以获得更高的并发吞吐量。
开通RocketMQ服务
在阿里云官方网站开通消息队列服务后方可开始使用消息队列RocketMQ,如果使用RAM用户访问RocketMQ,还必须先为RAM用户进行授权。在完成阿里云账户注册以及实名认证后,打开消息队列RocketMQ版产品页,点击免费开通,页面跳转至消息队列RocketMQ版控制台,在弹出的提示对话框中,完成RocketMQ服务的开通。
接下来,登录RAM控制台,在左侧导航栏选择人员管理 > 用户,在用户页面,单击目标RAM用户操作列的添加权限,在添加权限面板,单击需要授予RAM用户的权限策略,单击确定。消息队列RocketMQ提供多种系统策略,可以根据权限范围为RAM用户授予相关权限。为了简单起见,我们先开通AliyunMQFullAccess权限策略,授予该RAM用户所有消息收发权限和控制台所有功能操作权限。
创建资源
在调用SDK收发消息前,需在消息队列RocketMQ控制台创建相关资源,在调用SDK时需填写这些资源信息。首先,我们要创建RocketMQ实例,实例是用于消息队列RocketMQ服务的虚拟机资源,相当于一个独立的消息队列集群,会存储消息Topic和客户端Group ID信息。我们还需要注意,只有在同一个地域下的同一个实例中的Topic和Group ID才能互通,例如,某Topic创建在华东1(杭州)地域的实例A中,那么该Topic只能被在华东1(杭州)地域的实例A中创建的Group ID对应的生产端和消费端访问。
登录到消息队列RocketMQ控制台,在左侧导航栏,单击实例列表,在顶部菜单栏,选择地域,如华东1(杭州),在实例列表页面,单击创建实例,在创建 RocketMQ 实例面板,完成实例的创建。
接下来,在实例所在页面的左侧导航栏,单击Topic 管理。在Topic 管理页面,单击创建 Topic,在创建 Topic面板,输入名称和描述,选择该Topic的消息类型为事务消息,完成Topic的创建。
Topic是消息队列RocketMQ版里对消息的一级归类,例如创建Topic_Trade这一Topic来识别交易类消息,消息生产者将消息发送到Topic_Trade,而消息消费者则通过订阅该Topic来获取和消费消息。
创建完实例和Topic后,需要为消息的消费者和或生产者创建客户端ID,即Group ID作为标识。在事务消息的场景中,需要创建2个不同的Group ID,分别代表本地事务参与方和远程事务参与方。在实例所在页面的左侧导航栏,单击Group 管理,在Group 管理页面,选择TCP 协议 > 创建 Group ID,在创建可用于 TCP 协议的 Group面板,完成本地事务客户端Group ID的创建。重复此操作,完成远程事务参与方Group ID的创建。
本地事务参与方的业务代码
本文将通过Java代码介绍如何实现事务消息相关的业务逻辑,为了简化业务逻辑,我们继续基于锁定库存 - > 创建订单这个流程来演示,在这个流程中,仅有2个事务参与方。
初始化TransactionProducer
我们先通过Maven引入消息队列RocketMQ的SDK,优先使用阿里云官方提供的TCP版SDK。
<dependency>
<groupId>com.aliyun.openservices</groupId>
<artifactId>ons-client</artifactId>
<version>1.8.7.2.Final</version>
</dependency>
顺利引入Log4j2用于日志输出。
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.7</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.13.1</version>
</dependency>
在库存中心的代码中,我们需要初始化一个TransactionProducer
,用于异步消息的发送,需要填入如下信息:
- Group ID:之前创建的用于本地事务参与方的Group ID。
- Access key和Secret Key:RAM用户对应的密钥信息,从RAM用户控制台获得。
- Nameserver Address:RocketMQ实例的接入点信息,从RocketMQ控制台获得。
Properties properties = new Properties();
// 您在控制台创建的Group ID。注意:事务消息的Group ID不能与其他类型消息的Group ID共用。
properties.put(PropertyKeyConst.GROUP_ID, "XXX");
// AccessKey ID阿里云身份验证,在阿里云RAM控制台创建。
properties.put(PropertyKeyConst.AccessKey, "XXX");
// AccessKey Secret阿里云身份验证,在阿里云RAM控制台创建。
properties.put(PropertyKeyConst.SecretKey, "XXX");
// 设置TCP接入域名,进入消息队列RocketMQ版控制台的实例详情页面的TCP协议客户端接入点区域查看。
properties.put(PropertyKeyConst.NAMESRV_ADDR, "XXX");
// LocalTransactionCheckerImpl本地事务回查类的实现
TransactionProducer producer = ONSFactory.createTransactionProducer(properties,
new LocalTransactionCheckerImpl());
producer.start();
TransactionProducer是线程安全的,启动后能在多线程环境中复用。
获取全局唯一的交易流水号
在发送半事务消息以及执行本地事务之前,我们需要先获取一个全局唯一的交易流水号,订单与交易流水号一一对应,接下来的事务消息机制都会依赖于这个这个交易流水号。我们可以通过引入第三方ID生成组件,或者在本地通过Snowflake算法实现。
实现本地事务回查逻辑
创建一个实现了LocalTransactionChecker
接口的LocalTransactionCheckerImpl
类,实现其中的check(Message)
方法,该方法返回本地事务的最终状态。至于具体的业务逻辑如何实现,不在本文讨论的范围之前,我们将其封装在BusinessService
类中。
package transaction;
import com.aliyun.openservices.ons.api.Message;
import com.aliyun.openservices.ons.api.transaction.LocalTransactionChecker;
import com.aliyun.openservices.ons.api.transaction.TransactionStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LocalTransactionCheckerImpl implements LocalTransactionChecker {
private static Logger LOGGER = LoggerFactory.getLogger(LocalTransactionCheckerImpl.class);
private static BusinessService businessService = new BusinessService();
@Override
public TransactionStatus check(Message msg) {
// 从消息体中获得的交易ID
String transactionKey = msg.getKey();
TransactionStatus transactionStatus = TransactionStatus.Unknow;
try {
boolean isCommit = businessService.checkbusinessService(transactionKey);
if (isCommit) {
transactionStatus = TransactionStatus.CommitTransaction;
} else {
transactionStatus = TransactionStatus.RollbackTransaction;
}
} catch (Exception e) {
LOGGER.error("Transaction Key:{}", transactionKey, e);
}
LOGGER.warn("Transaction Key:{}transactionStatus:{}", transactionKey, transactionStatus.name());
return transactionStatus;
}
}
执行本地事务并发送异步消息
我们先组装一条异步消息,其中包含了全局交易ID,消息将要发往的Topic,以及消息体。远程事务参与方将通过这个消息体中获取执行远程事务所必须的数据信息。
接下来,将这条异步消息连同一个实现了LocalTransactionExecuter
接口的匿名类,通过send
方法进行发送,这就是本地事务参与方所需要实现的所有业务代码了。当然,这个匿名类实现了TransactionStatus execute.execute()
方法,其中包含了对于本地事务的执行。完整代码如下:
package transaction;
import com.aliyun.openservices.ons.api.Message;
import com.aliyun.openservices.ons.api.ONSFactory;
import com.aliyun.openservices.ons.api.PropertyKeyConst;
import com.aliyun.openservices.ons.api.SendResult;
import com.aliyun.openservices.ons.api.transaction.TransactionProducer;
import com.aliyun.openservices.ons.api.transaction.TransactionStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
public class TransactionProducerClient {
private static Logger LOGGER = LoggerFactory.getLogger(TransactionProducerClient.class);
private static final BusinessService businessService = new BusinessService();
private static final String TOPIC = "create_order";
private static final TransactionProducer producer = null;
static {
Properties properties = new Properties();
// 您在控制台创建的Group ID。注意:事务消息的Group ID不能与其他类型消息的Group ID共用。
properties.put(PropertyKeyConst.GROUP_ID, "XXX");
// AccessKey ID阿里云身份验证,在阿里云RAM控制台创建。
properties.put(PropertyKeyConst.AccessKey, "XXX");
// AccessKey Secret阿里云身份验证,在阿里云RAM控制台创建。
properties.put(PropertyKeyConst.SecretKey, "XXX");
// 设置TCP接入域名,进入消息队列RocketMQ版控制台的实例详情页面的TCP协议客户端接入点区域查看。
properties.put(PropertyKeyConst.NAMESRV_ADDR, "XXX");
// LocalTransactionCheckerImpl本地事务回查类的实现
TransactionProducer producer = ONSFactory.createTransactionProducer(properties,
new LocalTransactionCheckerImpl());
producer.start();
}
public static void main(String[] args) throws InterruptedException {
String transactionKey = getGlobalTransactionKey();
String messageContent = String.format("lock inventory for: %s", transactionKey);
Message message = new Message(TOPIC, null, transactionKey, messageContent.getBytes());
try {
SendResult sendResult = producer.send(message, (msg, arg) -> {
// 此处用Lambda表示,实际是实现TransactionStatus execute(final Message msg, final Object arg)方法
TransactionStatus transactionStatus = TransactionStatus.Unknow;
try {
boolean localTransactionOK = businessService.execbusinessService(transactionKey);
if (localTransactionOK) {
transactionStatus = TransactionStatus.CommitTransaction;
} else {
transactionStatus = TransactionStatus.RollbackTransaction;
}
} catch (Exception e) {
LOGGER.error("Transaction Key:{}", transactionKey, e);
}
LOGGER.warn("Transaction Key:{}", transactionKey);
return transactionStatus;
}, null);
LOGGER.info("send message OK, Transaction Key:{}, result:{}", message.getKey(), sendResult);
} catch (Exception e) {
LOGGER.info("send message failed, Transaction Key:{}", message.getKey());
}
// demo example防止进程退出
TimeUnit.MILLISECONDS.sleep(Integer.MAX_VALUE);
}
private static String getGlobalTransactionKey() {
// TODO
return "";
}
}
得益于RocketMQ SDK优秀的封装,发送半事务消息、发送确认消息、事务回查等重要步骤都已经完整实现,不需要开发者再编写代码了,这将为用户带来特别顺畅开发体验。
远程事务参与方的业务代码
相对本地事务参与方而言,远程事务参与方的代码更加简单,只需要从异步消息中提取出对应信息,完成对远程事务的执行即可。
package transaction;
import com.aliyun.openservices.ons.api.Action;
import com.aliyun.openservices.ons.api.Consumer;
import com.aliyun.openservices.ons.api.ONSFactory;
import com.aliyun.openservices.ons.api.PropertyKeyConst;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Properties;
public class TransactionConsumerClient {
private static Logger LOGGER = LoggerFactory.getLogger(TransactionProducerClient.class);
private static final BusinessService businessService = new BusinessService();
private static final String TOPIC = "create_order";
private static final Consumer consumer = null;
static {
Properties properties = new Properties();
// 在控制台创建的Group ID,不同于本地事务参与方使用的Group ID
properties.put(PropertyKeyConst.GROUP_ID, "XXX");
// AccessKey ID阿里云身份验证,在阿里云RAM控制台创建。
properties.put(PropertyKeyConst.AccessKey, "XXX");
// Accesskey Secret阿里云身份验证,在阿里云服RAM控制台创建。
properties.put(PropertyKeyConst.SecretKey, "XXX");
// 设置TCP接入域名,进入控制台的实例详情页面的TCP协议客户端接入点区域查看。
properties.put(PropertyKeyConst.NAMESRV_ADDR, "XXX");
Consumer consumer = ONSFactory.createConsumer(properties);
consumer.start();
}
public static void main(String[] args) {
consumer.subscribe(TOPIC, "*", (message, context) -> {
LOGGER.info("Receive: " + message);
businessService.doBusiness(message);
// 返回CommitMessage,代表给予消息队列集群异步消息已经得到正常处理的回馈
return Action.CommitMessage;
}
);
}
}
事务回滚
是否存在这样的情况:当本地事务执行成功后,因为远程事务没有办法执行,而导致本地事务需要进行回滚操作呢?在事务消息原理分析一节,我们已经介绍过如何通过消息重试,确保远程事务能够执行成功,这是不是已经说明只要异步消息被确认,远程事务就一定可以执行成功,从而不存在对本地事务的回滚呢?
实际生产情况下,确实存在远程事务无法正常执行的情况。比如在付款成功阶段,当本地事务“修改订单状态”执行完成后,在执行远程事务“通知发货”的时,因为订单地址有误而被物流公司拒绝,这种情况下就必须对订单状态进行回退操作,并发起退款流程。
所以在执行远程事务的时候,我们有必要区分如下两种完全不同的异常:
- 技术异常:远程事务参与方宕机、网络故障、数据库故障等。
- 业务异常:远程逻辑在业务上无法执行、代码业务逻辑错误等。
简单来讲,当远程事务执行失败的时候,能够通过消息重试的方式解决问题的,属于技术异常;否则,属于业务异常。基于事务消息的分布式事务机制不能实现自动回滚,当业务异常发生的时候,必须通过回退流程确保已经完成的本地事务得到恢复。比如在修改订单状态 -> 通知发货这个场景中,如果由于业务异常导致无法发货的时候,需要通过额外的回退流程,将订单状态设置为“已取消”,并执行退款流程。
在事务消息机制中,回退流程相当于远程事务参与方和本地事务参与方调换了角色,和正常流程一样,同样也可以通过事务消息来完成分布式事务。由于正常流程和回退流程的业务逻辑是完全不一样的,所以最理想的方式是建立另外一个Topic来实现。这也就说明,我们在创建事务消息Topic的时候,要充分考虑到这个Topic背后的业务含义,并在Topic命名上尽可能的与真实业务相匹配。
多个事务参与方
本节展示的示例中,都只涉及到2个事务参与方,但在真实世界中,分布式事务往往涉及到更多的事务参与方,比如之前提到的付款成功环节,有修改订单状态->扣减库存->通知发货->增加积分这4个需要同时进行的操作,涉及到4个事务参与方。这种情况下如何通过事务消息来实现分布式事务呢?
我们依然可以继续使用之前的架构,只需要加入多个远程事务参与方就行了。可以通过RocketMQ的多消费组关联多个远程事务参与方,每一个参与方对应一个Group ID,在这种情况下,同一个异步消息会复制成多份投递给不同的事务参与方。
需要特别引起注意的是,当某个远程事务参与方遇到业务异常的时候,需要通知其他所有事务参与方执行回退流程,这无疑会增加业务逻辑的整体复杂度。为了简化事务消息的执行流程,我们可以对业务逻辑预先进行梳理,将子事务分为如下两类:
- 有可能发生业务异常的:比如锁定库存的操作,有可能因为库存不足而执行失败。又比如扣除积分的操作,有可能因为用户积分不足而无法扣除。
- 不太可能发生业务异常的:比如删除购物车条目的操作,除非是技术类故障,一定可以执行成功,即便对应的条目并不存在,也没有关系。又比如积分增加的操作,只要对应的用户没有注销,是不可能遇到业务异常的。
我们尽量将第一类事务作为本地事务而实现,将第二类事务作为远程事务而实现,这样就可以最大程度避免回退流程。
其他注意事项
消息幂等
RocketMQ能保证消息不丢失,但不能保证消息不重复,所以消费者在接收到消息后,有必要根据业务上的唯一Key对消息做幂等处理。在抢购业务中,唯一Key当然就是全局唯一的交易流水号,具体幂等处理方法在互联网上有很多文章供读者参考。当然,不是每一种业务远程事务都需要确保消息的幂等性,比如删除购物车指定条目这样的操作,在业务上是可以容忍多次反复执行的,就没有必要引入额外的幂等处理了。
每日对账
不同于传统事务的强一致性保证,柔性事务需要经历一个中间状态,才到达成事务的最终一致性。有某些特殊情况下,这个中间状态会持续非常长的时间,甚至需要人工主动介入才能实现最终一致性:
- 消息重试多次后,依然不成功:当消费者完全无法正常工作的时候,RocketMQ不可能永无止境地重试消息,事实上,如果16次重试后异步消息依然没有办法被正常处理,RocketMQ会停止尝试,将消息放到一个特殊的队列中。
- 未处理的业务异常:比如给某个账号加积分的时候,发现此账号被注销了,这是一个非常罕见的业务现象,有可能事先对此并没有健壮的处理机制。
- 幂等校验失败:处理幂等所依赖的系统比如Redis发生了故障,导致某些消息被重复处理。
- 其他严重的系统故障:比如网络长时间中断,留下了大量执行到一半的事务。
- 其他漏网之鱼。
这些情况下,我们都有需要通过定期对账机制来进行排查,在必要的时候发起人工主动介入流程,修复不一致的数据。事实上,在任何柔性事务的实现中,每日对账都是必不可少的数据安全保障性手段。
总结
在柔性事务的多种实现中,事务消息是最为优雅易用的一种。基于阿里云RocketMQ高性能、高可用的特点,完全可以胜任抢购业务这类高并发大流量的场景。在阿里巴巴自身的业务中,事务消息也广泛使用于双11这样的大型营销活动中,有着非常高的通用性。
但在IT领域,没有任何一种技术是银弹,引入事务消息机制需要针对性的修改业务逻辑,还需要借助于每日对账等额外的手段确保数据安全,在实现高性能的同时,也增加了整体的业务复杂度。我们需要对业务场景进行充分评估,对比多种不同的技术实现方案,从中挑选与自身业务特点最为匹配的一种,才能更好地发挥柔性事务的价值。