个人随笔
目录
事务一致性的相关策略
2025-04-21 21:57:48

分布式事务解决⽅案汇总

1、2PC

2PC理论。在讲MySQL Binlog和Redo Log的⼀致性问题时,已经⽤到了2PC。当然,那个场景只是内部的分布式事务问题,只涉及单机的两个⽇志⽂件之间的数据⼀致性;2PC 是应⽤在两个数据库或两个系统之间。

2PC 有两个⾓⾊:事务协调者和事务参与者。具体到数据库的实现来说,每⼀个数据库就是⼀个参与者,调⽤⽅也就是协调者。2PC是指事务的提交分为两个阶段:
阶段 1:准备阶段。协调者向各个参与者发起询问,说要执⾏⼀个事务,各参与者可能回复YES、NO或超时。

阶段 2:提交阶段。如果所有参与者都回复的是 YES,则事务协调者向所有参与者发起事务提交操作,即Commit操作,所有参与者各⾃执⾏事务,然后发送ACK。如果有⼀个参与者回复的是 NO,或者超时了,则事务协调者向所有参与者发起事务回滚操作,所有参与者各⾃回滚事务,然后发送ACK.

2PC的实现。通过分析可以发现,要实现2PC,所有参与者都要实现三个接口:Prepare、Commit、Rollback,这也就是XA协议,在Java中对应的接口是javax.transaction.xa.XAResource,通常的数据库也都实现了这个协议。

2PC的问题。2PC在数据库领域⾮常常见,但它存在⼏个问题:
问题1:性能问题。在阶段1,锁定资源之后,要等所有节点返回,然后才能⼀起进⼊阶段2,不能很好地应对⾼并发场景。

问题2:阶段1完成之后,如果在阶段2事务协调者宕机,则所有的参与者接收不到Commit或Rollback指令,将处于“悬⽽不决”状态。

问题3:阶段1完成之后,在阶段2,事务协调者向所有的参与者发送
了Commit指令,但其中⼀个参与者超时或出错了(没有正确返回ACK),则其他参与者提交还是回滚呢?也不能确定。

为了解决2PC的问题,又引⼊了3PC。3PC存在类似宕机如何解决的问题,因此还是没能彻底解决问题.

2PC 除本⾝的算法局限外,还有⼀个使⽤上的限制,就是它主要⽤在两个数据库之间(数据库实现了XA协议)。但以⽀付宝的转账为例,是两个系统之间的转账,⽽不是底层两个数据库之间直接交互,所以没有办法使⽤2PC。

不仅⽀付宝,其他业务场景基本都采⽤了微服务架构,不会直接在底层的两个业务数据库之间做⼀致性,⽽是在两个服务上⾯实现⼀致性。正因为2PC有诸多问题和不便,在实践中⼀般很少使⽤,⽽是采⽤下⾯要讲的各种⽅案。

最终⼀致性(消息中间件)

⼀般的思路是通过消息中间件来实现“最终⼀致性”,如

系统A收到⽤户的转账请求,系统A先⾃⼰扣钱,也就是更新DB1;然后通过消息中间件给系统B发送⼀条加钱的消息,系统B收到此消息,对⾃⼰的账号进⾏加钱,也就是更新DB2。

这⾥⾯有⼀个关键的技术问题:

系统A给消息中间件发消息,是⼀次⽹络交互;更新DB1,也是⼀次⽹络交互。系统A是先更新DB1,后发送消息,还是先发送消息,后更新DB1?假设先更新DB1成功,发送消息⽹络失败,重发又失败,怎么办?又假设先发送消息成功,更新DB1失败。消息已经发出去了,又不能撤回,怎么办?或者消息中间件提供了消息撤回的接口失败怎么办?

因为这是两次⽹络调⽤,两个操作不是原⼦的,⽆论谁先谁后,都是有问题的。

1.最终⼀致性:错误的⽅案0

有⼈可能会想,可以把“发送加钱消息”这个⽹络调⽤和更新DB1放在同⼀个事务⾥⾯,如果发送消息失败,更新DB⾃动回滚。这样不就可以保证两个操作的原⼦性了吗?

这个⽅案看似正确,其实是错误的,原因有两点:
(1)⽹络的2将军问题:发送消息失败,发送⽅并不知道是消息中间件没有收到消息,还是消息已经收到了,只是返回response的时候失败了?

如果已经收到消息了,⽽发送端认为没有收到,执⾏update DB的回滚操作,则会导致账户A的钱没有扣,账户B的钱却被加了。

(2)把⽹络调⽤放在数据库事务⾥⾯,可能会因为⽹络的延时导致数据库长事务。严重的会阻塞整个数据库,风险很⼤。

2.最终⼀致性:第一种实现⽅式(业务⽅⾃⼰实现)

(1)系统A增加⼀张消息表,系统A不再直接给消息中间件发送消息,⽽是把消息写⼊到这张消息表中。把DB1的扣钱操作(表1)和写⼊消息表(表2)这两个操作放在⼀个数据库事务⾥,保证两者的原⼦性。

(2)系统A准备⼀个后台程序,源源不断地把消息表中的消息传送给消息中间件。如果失败了,也不断尝试重传。因为⽹络的2将军问题,系统A发送给消息中间件的消息⽹络超时了,消息中间件可能已经收到了消息,也可能没有收到。系统A会再次发送该消息,直到消息中间件返回成功。所以,系统A允许消息重复,但消息不会丢失,顺序也不会打乱。

(3)通过上⾯的两个步骤,系统A保证了消息不丢失,但消息可能重复。系统B对消息的消费要解决下⾯两个问题:

问题1:丢失消费。系统B从消息中间件取出消息(此时还在内存⾥⾯),如果处理了⼀半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办?

答案是通过消息中间件的ACK机制,凡是发送ACK的消息,系统B重启之后消息中间件不会再次推送;凡是没有发送ACK的消息,系统B重启之后消息中间件会再次推送。

但这又会引发⼀个新问题,就是下⾯问题2的重复消费:即使系统B把消息处理成功了,但是正要发送ACK的时候宕机了,消息中间件以为这条消息没有处理成功,系统B再次重启的时候又会收到这条消息,系统B就会重复消费这条消息(对应加钱类的场景,账号⾥⾯的钱就会加两次)

问题2:重复消费。除了ACK机制,可能会引起重复消费;系统A的后台任务也可能给消息中间件重复发送消息。为了解决重复消息的问题,系统B增加⼀个判重表。判重表记录了处理成功的消息ID 和消息中间件对应的offset(以Kafka为例),系统B宕机重启,可以定位到offset位置,从这之后开始继续消费。每次接收到新消息,先通过判重表进⾏判重,实现业务的幂等。同样,对DB2的加钱操作和消息写⼊判重表两个操作,要在⼀个DB的事务⾥⾯完成。

这⾥要补充的是,消息的判重不⽌判重表⼀种⽅法。如果业务本⾝就有业务数据,可以判断出消息是否重复了,就不需要判重表了。通过上⾯三步,实现了消息在发送⽅的不丢失、在接收⽅的不重复,联合起来就是消息的不漏不重,严格实现了系统A和系统B的最终⼀致性。

但这种⽅案有⼀个缺点:系统A需要增加消息表,同时还需要⼀个后台任务,不断扫描此消息表,会导致消息的处理和业务逻辑耦合,额外增加业务⽅的开发负担。

所以这里还有一种方案,提到Controller层等级消息,等级失败就插入一条补偿日志,补偿也补偿失败,旧打印错误信息(这种方案其实也有问题,不过概率很低,那就是提交事务成功,宕机,或者等级消息失败宕机怎么办,所以如果在一些很重的场景这个方法不太靠谱)。

3.最终⼀致性:第⼆种实现⽅式(基于RocketMQ事务消息)

RocketMQ不是提供⼀个单⼀的“发送”接口,⽽是把消息的发送拆成了两个阶段,Prepare阶段(消息预发送)和Confirm阶段(确认发送)。具体使⽤⽅法如下:

步骤1:系统A调⽤Prepare接口,预发送消息。此时消息保存在消息中间件⾥,但消息中间件不会把消息给消费⽅消费,消息只是暂存在那。

步骤2:系统A更新数据库,进⾏扣钱操作。

步骤3:系统A调⽤Comfirm接口,确认发送消息。此时消息中间件才会把消息给消费⽅进⾏消费。

显然,这⾥有两种异常场景:
场景1:步骤1成功,步骤2成功,步骤3失败或超时,怎么处理?
场景2:步骤1成功,步骤2失败或超时,步骤3不会执⾏。怎么处理?
这就涉及RocketMQ的关键点:RocketMQ会定期(默认是1min)扫描所有的预发送但还没有确认的消息,回调给发送⽅,询问这条消息是要发出去,还是取消。发送⽅根据⾃⼰的业务数据,知道这条消息是应该发出去(DB更新成功了),还是应该取消(DB更新失败)。

对⽐最终⼀致性的两种实现⽅案会发现,RocketMQ 最⼤的改变其实是把“扫描消息表”这件事不让业务⽅做,⽽是让消息中间件完成。⾄于消息表,其实还是没有省掉。因为消息中间件要询问发送⽅事物否执⾏成功,还需要⼀个“变相的本地消息表”,记录事务执⾏状态和消息发送状态。

同时对于消费⽅,还是没有解决系统重启可能导致的重复消费问题,这只能由消费⽅解决。需要设计判重机制,实现消息消费的幂等。

4、⼈⼯介⼊

⽆论⽅案1,还是⽅案2,发送端把消息成功放⼊了队列中,但如果消费端消费失败怎么办?
如果消费失败了,则可以重试,但还⼀直失败怎么办?是否要⾃动回滚整个流程?
答案是⼈⼯介⼊。从⼯程实践⾓度来讲,这种整个流程⾃动回滚的代价是⾮常巨⼤的,不但实现起来很复杂,还会引⼊新的问题。⽐如⾃动回滚失败,又如何处理?
对应这种发⽣概率极低的事件,采取⼈⼯处理会⽐实现⼀个⾼复杂的⾃动化回滚系统更加可靠,也更加简单。

TCC

2PC 通常⽤来解决两个数据库之间的分布式事务问题,⽐较局限。现在企业采⽤的是各式各样的SOA服务,更需要解决两个服务之间的分布式
事务问题。
为了解决SOA系统中的分布式事务问题,⽀付宝提出了TCC。TCC是
Try、Confirm、Cancel三个单词的缩写,其实是⼀个应⽤层⾯的2PC协议,
Confirm对应2PC中的事务提交操作,Cancel对应2PC中的事务回滚操作.

(1)准备阶段:调⽤⽅调⽤所有服务⽅提供的Try接口,该阶段各调⽤⽅做资源检查和资源锁定,为接下来的阶段2做准备。
(2)提交阶段:如果所有服务⽅都返回 YES,则进⼊提交阶段,调⽤⽅调⽤各服务⽅的Confirm接口,各服务⽅进⾏事务提交。如果有⼀个服务⽅在阶段1返回NO或者超时了,则调⽤⽅调⽤各服务⽅的Cancel接口。

这⾥有⼀个关键问题:TCC既然也借鉴2PC的思路,那么它是如何解决2PC的问题的呢?也就是说,在阶段2,调⽤⽅发⽣宕机,或者某个服务超时了,如何处理呢?

答案是:不断重试!不管是Confirm失败了,还是Cancel失败了,都不断重试。这就要求Confirm和Cancel都必须是幂等操作。注意,这⾥的重试是由TCC的框架来执⾏的,⽽不是让业务⽅⾃⼰去做。

事务状态表+调⽤⽅重试+接收⽅幂等

同样以转账为例,介绍⼀种类似于TCC的⽅法。TCC的⽅法通过TCC框架内部来做,下⾯介绍的⽅法是业务⽅⾃⼰实现的。

调⽤⽅维护⼀张事务状态表(或者说事务⽇志、⽇志流⽔),在每次调⽤之前,落盘⼀条事务流⽔,⽣成⼀个全局的事务ID。

初始是状态1,每调⽤成功1个服务则更新1次状态,最后所有系统调⽤成功,状态更新到状态4,状态2、3是中间状态。当然,也可以不保存中间状态,只设置两个状态:Begin和End。事务开始之前的状态是Begin,全部结束之后的状态是End。如果某个事务⼀直停留在Begin状态,则说明该事务没有执⾏完毕。

然后有⼀个后台任务,扫描状态表,在过了某段时间后(假设1次事务执⾏成功通常最多花费30s),状态没有变为最终的状态4,说明这条事务没有执⾏成功。于是重新调⽤系统A、B、C。保证这条流⽔的最终状态是状态4(或 End 状态)。当然,系统 A、B、C根据全局的事务ID做幂等操作,所以即使重复调⽤也没有关系。

补充说明:
(1)如果后台任务重试多次仍然不能成功,要为状态表加⼀个Error
状态,通过⼈⼯介⼊⼲预。
(2)对于调⽤⽅的同步调⽤,如果部分成功,此时给客户端返回什么呢?
答案是不确定,或者说暂时未知。只能告诉⽤户该笔钱转账超时,请稍后再来确认。
(3)对于同步调⽤,调⽤⽅调⽤A或B失败的时候,可以重试三次。如果重试三次还不成功,则放弃操作,再交由后台任务后续处理。

对账

岂⽌事务有状态,系统中的各种数据对象都有状态,或者说都有各⾃完整的⽣命周期,同时数据与数据之间存在着关联关系。我们可以很好地利⽤这种完整的⽣命周期和数据之间的关联关系,来实现系统的⼀致性,这就是“对账”。

在前⾯的⽅案中,⽆论最终⼀致性,还是TCC、事务状态表,都是为了保证“过程的原⼦性”,也就是多个系统操作(或系统调⽤),要么全部成功,要么全部失败。但所有的“过程”都必然产⽣“结果”,过程是我们所说的“事务”,结果就是业务数据。⼀个过程如果部分执⾏成功、部分执⾏失败,则意味着结果是不完整的。从结果也可以反推出过程出了问题,从⽽对数据进⾏修补,这就是“对账”的思路!

对账又分为全量对账和增量对账:
(1)全量对账。⽐如每天晚上运作⼀个定时任务,⽐对两个数据
库。
(2)增量对账。可以是⼀个定时任务,基于数据库的更新时间;也可以基于消息中间件,每⼀次业务操作都抛出⼀个消息到消息中间件,然后由⼀个消费者消费这条消息,对两个数据库中的数据进⾏⽐对(当然,消息可能丢失,⽆法百分之百地保证,还是需要全量对账来兜底)。总之,对账的关键是要找出“数据背后的数学规律”。

妥协⽅案:弱⼀致性+基于状态的补偿

可以发现:
· “最终⼀致性”是⼀种异步的⽅法,数据有⼀定延迟;
· TCC是⼀种同步⽅法,但TCC需要两个阶段,性能损耗较⼤;
· 事务状态表也是⼀种同步⽅法,但每次要记事务流⽔,要更新事务状态,很烦琐,性能也有损耗;
· “对账”也是⼀个事后过程。

如果需要⼀个同步的⽅案,既要让系统之间保持⼀致性,又要有很⾼的性能,⽀持⾼并发,应该怎么处理呢?

既要满⾜⾼并发,又要达到⼀致性,鱼和熊掌不能兼得。可以利⽤业务的特性,采⽤⼀种弱⼀致的⽅案。比如对于电商的购物来讲,允许少卖,但不能超卖。所以扣库存和创建订单不一定需要强一致性,只需要保证库存部门超卖即可。

库存每扣⼀次,都会⽣成⼀条流⽔记录。这条记录的初始状态是“占⽤”,等订单⽀付成功后,会把状态改成“释放”。对于那些过了很长时间⼀直是占⽤,⽽不释放的库存,要么是因为前⾯多扣造成的,要么是因为⽤户下了单但没有⽀付。通过⽐对,得到库存系统的“占⽤又没有释放的库存流⽔”与订单系统的未⽀付的订单,就可以回收这些库存,同时把对应的订单取消。类似12306⽹站,过⼀定时间不⽀付,订单会取消,将库存释放。

妥协⽅案:重试+回滚+报警+⼈⼯修复

基于订单的状态+库存流⽔的状态做补偿(或者说叫对账)。如果业务很复杂,状态的维护也很复杂,就可以采⽤下⾯这种更加妥协⽽简单的⽅法。

先扣库存,后创建订单。不做状态补偿,为库存系统提供⼀个回滚接口。创建订单如果失败了,先重试。如果重试还不成功,则回滚库存的扣减。如回滚也失败,则发报警,进⾏⼈⼯⼲预修复。

总之,根据业务逻辑,通过三次重试或回滚的⽅法,最⼤限度地保证⼀致。实在不⼀致,就发报警,让⼈⼯⼲预。只要⽇志流⽔记录得完整,⼈⼯肯定可以复!通常只要业务逻辑本⾝没问题,重试、回滚之后还失败的概率会⽐较低,所以这种办法虽然丑陋,但很实⽤。

总结了实践中⽐较可靠的七种⽅法:两种最终⼀致性的⽅案,两种妥协办法,两种基于状态+重试+幂等的⽅法(TCC,状态机+重试+幂等),还有⼀种对账⽅法。在实现层⾯,妥协和对账的办法最容易,最终⼀致性次之,TCC最复杂。

 7

啊!这个可能是世界上最丑的留言输入框功能~


当然,也是最丑的留言列表

有疑问发邮件到 : suibibk@qq.com 侵权立删
Copyright : 个人随笔   备案号 : 粤ICP备18099399号-2