【评论抽奖咯】分布式事务的5种解决方案!

2,922 阅读13分钟

花哥很荣幸参加了掘金送周边活动,感谢大家一直对我和掘金的支持,在下方评论区留言,随机送好礼~

抽奖软件:抽奖软件,按评论先后顺序进行录入

抽奖奖品:随机抽选两位小伙伴,送掘金徽章1枚

前言

距离上次发文已经有将近一个月的时间了,最近各种事情堆在一起也是比较忙,月初有幸获得掘金官方的【免费申请掘金周边】活动资格,这不今天给小伙伴们送福利了,毕竟你们的幸福就是我的动力,啊哈哈哈哈。

20210824774702_BpasYc.gif

思绪良久,最终决定今天和大家分享一些分布式相关的知识,用浅显的例子唠一唠分布式中事务的相关解决方案,这也是面试中的高频问题,学会本文你会对2PCTCC本地消息表最终一致性最大努力通知以及对应的场景有一个更好的认识。

单系统事务

这里先撇开分布式不说,单说事务,什么是事务呢?我想每个人都是能够说出来一些的,比如最常听到的原子性、一致性、隔离性、持久性,即ACID特性,那用官方的话怎么解释呢,维基百科中用转账的例子来说明,A账户转账给B账户100元,此业务包含A账户减100元、B账户加100元这两个操作,对支持事务的系统来说,无论什么情况,都要保证这两个操作都能完成,不能A账户扣减成功,但是B账户没有增加钱。

这里带着掘金等级一级及以下的小伙伴,回顾一下ACID:

  • 原子性(Atomicity): 事务作为一个整体被执行,事务中包含的操作要么不执行,要么全部执行。即转账场景中,A账户扣减、B账户增加这两个动作必须全部成功或者全部失败。
  • 一致性(Consistency): 数据必须满足完整性约束,从一个一致性状态转变到另一个一致性状态。即转账场景中不存在A账户扣减100元,但是B账户没有增加的现象。
  • 隔离性(Isolation): 多个事务并发执行时是互不干扰的。
  • 持久性(Durability): 一旦事务完成之后,对数据库的修改会永久保存下来,之后的其他操作不会对已提交的事务结果产生影响。

接下来用一个常见的支付场景来讲一下单机系统中的事务是怎样的。

image.png

现在的小伙伴,我相信大家都是经常网购的,那大家有没有想过,在我们完成支付后,商城系统内部是怎样运作,来完成订单修改、库存扣减、优惠券扣减、消息通知、日志记录等等这一堆操作的。上面这幅图就是在单机系统中我们常见的写法,在一个事务中完成如下几个操作:

  • 用户发起并完成支付
  • 接收到支付回调
  • 开启事务
  • 订单状态修改
  • 优惠券扣减
  • 日志记录
  • 消息通知
  • 提交事务

在这个过程中我们就用到了事务,一旦其中某个环节失败,系统会将已经完成的操作进行回滚,比如日志记录失败后,会将前两步(订单状态修改、优惠券扣减)操作回滚到修改前的状态,从而保证系统业务上的正确性。

上述的写法虽然逻辑清晰、实现简单,但是同样是缺点非常明显,代码耦合性高,比如后续新增一个积分系统,就必须在原有逻辑中继续追加;除此之外该模式也不适用高并发业务场景,那要怎样解决这一问题呢,也就是我们今天要讲的分布式事务了。

分布式事务

说到分布式系统,给人的第一印象就是:哇,好高大上啊,再看看自己**天天写的都是什么辣鸡需求。那对于分布式中重要的环节,分布式事务我们该怎么理解呢?对于单机系统我们都知道了,各个模块按顺序操作本地数据库,来完成数据的新增修改,在出现异常时对所有操作进行回滚。分布式事务其实也是类似的,先看下面这张图:

image.png

分布式系统中,会将原单机系统中各个模块拆解成一个个独立的系统,分别部署到不同的服务器中,每个子系统都可以单独运行,不依赖其他子系统。

那当用户完成支付后,其实整个流程和单机系统基本是一致的,订单系统首先收到支付成功通知,在对数据库操作的同时也会通知其他所有子系统开始工作,其他子系统收到消息后,会完成自己业务处理并最终写入到数据库中。如果大家所有子系统都顺利完成自身的工作,那整个流程也是完美收官,但是如果某个子系统由于缺陷或者网络超时,比如消息模块因为宕机,没有及时通知到用户,这就导致整个业务没有完整闭环,那这时我们该怎么办呢?其实这里就用到了分布式事务了。

接下来分析几种常用的分布式事务解决方案

2PC

2PCTwo-phase commit protocol的缩写,翻译过来就是两阶段提交,那什么是两阶段提交呢(一脸懵逼)。顾名思义,就是分两个阶段来控制事务的提交(准备阶段提交阶段),在2PC中,还引入了两个重要的角色,一个是事务协调者,另一个则是参与者。举个简单的例子,我们在业务中,应该会遇到为了完成某个需求,需要增加两条数据到两个数据库(主库、子库)中,这时候我们就要在同一个事务中完成这两次操作。

没有理解也不用着急,接下来花哥用图示来逐步拆解这个过程。

在准备阶段中,事务协调者会向所有参与者发出准备请求,询问是否能够执行提交操作,参与者收到请求并将准备结果反馈给协调者,完整流程图如下图。

  • 准备阶段

image.png

  • 提交阶段

当协调者收到所有参与者获得的反馈消息都是【准备成功】时,协调者就会通知各个参与者进入提交阶段;此时参与者节点完成操作,并释放整个事务期间占用的资源,并向协调者发起【提交成功】的反馈;最终由协调者完成事务。

image.png

这里还会出现另一种情况: 在准备阶段中,有一个参与者返回准备失败,协调者就会通知所有参与者,要求每个参与者进行回滚操作,参与者在回滚成功后,会告知协调者【回滚成功】。

  • 准备阶段

image.png

  • 提交阶段

image.png

看完上面几幅图后,我们知道,2PC能够保证第一阶段所有参与者都准备成功(失败)时,通过协调者完成对各个数据库(参与者)进行事务提交(回滚)的通知,最终各个角色合作完成整个分布式事务的提交。

认真的小伙伴会有一个疑问,如果在提交阶段有参与者提交失败了呢? 因为在准备阶段会出现两种情况,所以在提交阶段,就会分为两种情况讨论,也要分别讨论:

  • 如果第二阶段为提交事务:通过不断重试,直到所有参与者全部完成提交,如果最终还是不能成功执行,那只能通过人工主动干预......
  • 如果第二阶段为回滚事务:同样也会不断重试,直到所有参与者全部完成回滚,否则第一阶段中的参与者会一直处于阻塞状态。

TCC

TCC 的全程分为三个阶段,分别是 Try、Confirm、Cancel,

  • Try阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留
  • Confirm阶段:这个阶段是指确认操作,实际已经真正执行了
  • Cancel阶段:如果某个服务的业务方法执行出错,就会将已经执行成功的业务逻辑进行回滚操作

以转账的例子为例,在跨银行进行转账的时候,需要涉及到两个银行的分布式事务,从A 银行向 B 银行转100元,整个流程如下:

  • Try阶段:冻结A银行账户100元,B银行账户预增加100元;
  • Confirm阶段:执行实际的转账操作,A银行账户的资金扣减,B银行账户的资金增加;
  • Cancel阶段:如果任何一个银行的操作失败,那么就需要回滚进行补偿,就是比如A银行账户如果已经扣减了,但是B银行账户资金增加失败了,那么就得把A银行账户资金给加回去。

image.png

TCC 对业务的侵入较大并且是业务紧耦合,这种方案说实话几乎很少用人使用,但是也有它适用的场景。

比较适合的场景:对一致性要求极高,比如常见的就是资金类的场景,可以用TCC方案,通过自己编写大量的业务逻辑,自己判断一个事务中的各个环节是否正常执行,出现异常情况就执行回滚操作。

但是一般来说,这个方案中事务回滚严重依赖于手写代码来进行回滚和补偿,会造成补偿代码巨大,不建议轻易使用。

本地消息表

该机制是在每个系统的数据库中,增加一个消息表,在操作完本系统业务表(如步骤1/5)后,会新增一条与该业务相关的消息记录并保存到消息表中(如步骤2、步骤6),通过整个链路最终保证整个分布式事务的完整性。

如下图,为了保证A系统、B系统的全部操作在一个事务中,我们会在A系统插入完业务数据后,将该数据唯一识别信息(如ID)保存至消息表,注意此时消息表中记录为待确认状态,随后A系统通知MQ;然后B系统会获取MQ中的消息,开始自身的业务逻辑处理,即首先插入业务表数据,再插入消息表数据。

有一点需要注意:B系统插入消息表数据时,需要注意MQ的一些特点,即重复消费的问题,因此B系统在插入消息表时,要保证此次操作为首次执行,可以通过B系统业务表中唯一ID进行确定。

随后B系统回调A系统,告知其本系统操作成功,然后A系统收到消息后,将A系统消息表状态修改为已完成状态,整个分布式至此结束。

为了保证B系统能够正常接收消息,A系统可以增加轮询操作,对所有待确认的消息每隔1s轮询一次,查看是否超过指定时间(如1分钟)还未响应,根据自身业务可以选择重发或者回滚。

image.png

该方案的缺陷是严重依赖数据库的消息表,在并发场景中会有瓶颈也比较明显,而且需要系统容忍一定时间的数据不一致。

消息事务

该模式是通过消息中间件,如RocketMQ 来完成分布式事务。

首先A系统会发送一条prepared状态的消息到MQ中,该类型的消息对订阅者是不可见,因此也不会被消费;一旦发送成功,A系统会继续完成本地事务的执行,若执行正常,就会再发送一条确认消息,告知mq本地事务执行完成,可以通知B系统完成消费了。RocketMQ会轮询prepared状态的消息,一定时间内还未收到确认消息,就会主动进行反查,确认消息是否成功。

当步骤3执行完成后,B系统此时会收到MQ的消息,开始执行本地事务,执行完成后,就会将该消息消费;若B系统在执行本地事务时出现异常,可以通过几种方案进行解决,如协调MQ重发、告知A系统重发、增加其他中间件(如zk)等。

此外,还要注意B系统消费消息时,幂等性的问题,和本地消息表中类似,此处不再赘述。

image.png

最大努力通知

该方案用最直观的说法就是:我已经尽最大的努力去通知其他系统了,如果这样还是不能完成,那我也是没有办法了,此时只能通过人工进行干预。这种方案适用于对分布式事务要求不严格的情况,比如日志记录、购买成功短信通知这类。

这种方案对于A系统来说,压力是比较小的,它只要完成本地事务并向MQ中发送消息,就算是结束本次事务,其他的都会交给【最大努力通知服务】去协调,如果最大努力通知服务一直收不到B系统的反馈,可以进行一定阈值(如20次)的重试,当超过阈值后,可以通知人工进行干预或直接放弃。

流程如下:

  • 系统A执行完本地事务,发送个消息到MQ;
  • 最大努力通知服务消费MQ然后写入数据库中记录下来,或者是放入个内存队列中,接着调用系统B的接口;
  • 如果系统B执行成功,此事务正常结束;但如果系统B执行失败,最大努力通知服务会尝试重新调用系统B,反复N次,最后还是未能成功就直接放弃或通知人工。

image.png

总结

本小节对分布式事务处理中常见的几种方案进行了讲解,在实际应用中,要结合自身的业务来合理选用。

比如2PC适用于数据库层面,TCC属于补偿性事务思想,是在业务层面来完成事务,但是代码侵入性较大,慎重选用,而最后三种的本地消息、事务消息、最大努力通知,共同思想是想保证最终一致性,适用于对时间不敏感以及分布式要求不严格的场景中。

最后给小伙伴们留一个问题,你们在日常开发中用到了哪些方案呢,可以在评论区和大家分享一下其中的坑,最后会随机抽出两位小伙伴送出今日的掘金礼品哦。