分布式事务
在微服务架构流行的今天,一次交易需要跨越多个服务和数据库来实现,而分布式事务是我们必须要面对的难点之一。面试时我也喜欢问候选人对分布式事务的理解及解决方案,有些候选人一上来就大谈特谈MQ,甚至连哪个MQ性能更好都谈到了。我说,除了MQ呢,候选人就回答不上来了。
分布式事务的产生
以下单扣钱扣库存为倒说明如下
服务拆分前(余额和库存扣减在一起)
//1、事务开始
//2、扣余额
//3、扣库存
//4、其它操作
//5、提交事务
服务化后(拆分为余额服务、库存服务)
余额服务:
//1、事务开始
//2、扣余额
//3、其它操作1
//4、提交事务
库存服务:
//1、事务开始
//2、扣库存
//3、其它操作2
//4、提交事务
可见,分布式事务产生的原因是原本只需要单个关系型数据库即可完成的事务操作,现在需要跨多个数据库操作才能完成,不拆分就不会有分布式事务问题,可以说它是我们“自找的麻烦”,而我们“自找麻烦”的本质在于(目前或将来)数据变多了。
避免分布式事务
是的,你没看错,解决分布式事务的第一个方法是避免产生分布式事务,有些场景下使用单体应用就足够了。微服务不是银弹,有些场景是不适合使用微服务架构的(后续另起文章介绍)。当架构师面对分布式事务时,首先想的不应该是如何从技术上解决,而要对引起分布式事务的原因,可接受的成本,项目发展,技术人员素质等等有个通盘清晰的考量,考虑清楚后再去解决具体的技术问题。
2PC
以下单扣钱扣库存示例伪代码如下
//1、锁定余额
//2、锁定库存
//3、扣减余额
//4、扣减库存
//5、释放锁定的余额
//6、释放锁定的库存
可以看出2PC由于是强一致性,资源需要在事务内部等待,性能不高,不适合用于高性能、高并发场景。虽然现实中很少采用2PC,但它却提供了一个良好的理论基础,现实中的分布式系统实现,往往是综合各种因素下妥协的结果。
CAP原理
同时满足“CAP原理”中的一致性、可用性和分区容错性三者是不可能的,只能同时满足其中二项。而在分布式环境中P几乎是不可避免的,那只剩下可用性和一致性中间二选一。在多数互联网应用场景中,选择AP,牺牲强一致性,实现最终一致性的较多。
好了,我们的妥协有了理论支持,现在的问题是如何解决分布式环境中数据最终一致性的问题,通常实现最终一致性有以下方案:
本地消息表和事件表
- 异步消息,即A服务业务上不依赖等待B服务结果的情况,如注册送券
对A服务
//1、开始事务
//2、A服务本地操作
//3、写入A服务本地消息表
//4、提交事务
//5、发送消息,如果发送失败,有后台任务定时重发
对B服务
//1、从消息队列消费消息
//2、开始事务
//3、处理消息,注意幂等处理
//4、提交事务
//5、删除消息队列中该条消息
注意:发送消息不一定非得用消息队列,甚至也可以是一个HTTP接口,但通常用MQ能更好的解耦。
以上实现成立的前提是:本地事务、重试、幂等
- 同步调用,即A服务依赖B服务结果,如电商下单交易(A扣钱,B扣库存,这里我们假设业务上要求扣钱和库存扣减需要实现最终一致)
//1、调用A本地扣钱操作,并记录接口调用事件记录到事件表(本地事务)
//2、远程调用B提供的库存扣减服务(本地事务)
//3.1 如果2调用成功,则完成,事务最终一致
//3.2 如果2调用失败,则回退事务1,向前端返回失败(如有必要)
//3.3 如果2调用超时,则回退事务1,记录相关事件,向前端返回失败(如有必要),定时任务根据
//事件记录到服务一直到B尝试回退,直至完成。
注意:不同的业务要求下,3.2 - 3.3 的处理会有所不同。
总体来说,本方案的本质在于将分布式事务分解成多个独立的本地事务,外加上重试机制(幂等)达到最终一致。
本方案优点在于:灵活,可按业务定制最优实现,实现相对简单,缺点在于消息表可能会对业务库造成侵入,也会对业务代码造成侵入。可以通过研发管理的手段,比如通过提供公共架构调用,消息表由公共框架提供,以减少对业务的侵入。
TCC
TCC即Try/Confirm/Cancel,TCC本质上是业务妥协的结果,比如单次扣款操作变成了:Try冻结,Confirm确认扣款,Cancel解冻。服务间通过互相TCC调用达到最终一致。TCC是一种模式,并不是具体的解决方案,具体还是要使用消息表或其它一些落地方案来实现。
本方案优点在于统一模式,代码风格实现统一,缺点是有些业务不适用,而且对参与分布式事务的操作都有T、C、C三个实现,编码量也多了不少。
普通MQ
既然采用消息表会对业务造成侵入,那我直接用消息队列来实现行不行?
//事务开始
//本地操作
//提交事务
//发送消息到消息队列?失败了呢?本地操作成功了,消息没发出去
//发送消息到消息队列
//事务开始
//本地操作
//提交事务,失败了呢?消息发出去了,本地操作没完成
看起来对于异步消息无论是本地事务后发MQ,还是先发MQ再做本地事务,都似乎无法达成最终一致。一种思路是正常情况下(绝大多数)使用MQ,异常情况下,还是使用异常消息表记录。
//事务开始
//本地操作
//提交事务
//发送消息到消息队列,当失败时,将消息记录到消息表,后台任务定时重试
由于写完DB后再写MQ出问题的概率要远远高于写完DB再写这个DB的概率,多数情况下这种方案是没问题的。但是由于写消息表跟本地事务始终不在同一个事务里,还是存在不一致的概率(虽然极低),所以还是需要有定期补偿机制找出最终不一致的记录进行补偿。故对最终一致性要求比较高的场景本方案其实比消息表的实现更加把问题复杂化了,但对一致性要求不那么高的场景,使用本方案带来的收益也是很明显的。
可靠消息服务
可靠消息服务是一个独立的服务,内部通常采用了MQ+消息表,对外提供消息创建、发送、取消服务
//事务开始
//本地操作
//调用消息服务创建消息
//提交事务,如果失败则调用消息服务取消消息
//调用消息服务发送消息
消息服务定时查询业务系统,将已创建但超过一定时间未发送/取消的消息查询最终的业务状态以判断是要发送还是取消。
可以看出可靠消息服务实现本质上是事件表实现的服务化后版本。
事务MQ
典型的代表是RocketMQ,其操作流程是这样的
//事务开始
//本地操作
//发送Prepare消息
//提交事务,如果失败则调用消息服务取消消息
//发送Confirm消息,如果失败了,RocketMQ会定期将Prepare消息询问发送方,是Confirm还是Cancel,这不就是典型的TCC操作模式嘛
可以看到采用RocketMQ与采用可靠消息服务的调用方式是很类似的,相比之下相当于RocketMQ内部实现了“定时扫描消息表”的功能,对业务系统来说,按某个惟一业务标识返回是否取消消息还是发送消息的工作量还是没少,另外RocketMQ事务消息部分的代码也并未开源,需要自己去实现。
本方案的特点是:直接使用RocketMQ的事务消息功能,通过业务系统与RocketMQ的深度绑定(二次开发),达到最终一致。笔者认为引入MQ很大一部分原因是为了解耦,而现在为了实现分布式事务又把我们的业务系统与特定的二次开发过的MQ产品做了耦合,这不得不说是个很矛盾的现实。我认为以下情况的团队不适用本方案:
1、开发运维团队对RocketMQ无知识储备的
2、不具备RocketMQ二次开发能力的
3、不想与RocketMQ做深度绑定的
其实说白了就一句话:你们团队研发的产品系列是否愿意与RocketMQ做绑定并愿意持续的技术投入。
总结: 我们结合系统的服务化拆分引出分布式事务的产生,然后分析了具有强一致性的二阶段提交协议(2PC)过程并得出了优缺点,继而从CAP原理,我们也得出多数情况下只能放弃强一致性而追求最终一致性。最终一致性的实现方案本质在于本地事务 + 重试(幂等)+ 取消,阿里提出的TCC模式可以说是这一思想的扩充(业务上加了Try)。当然我们也可以沿着这一思想加上服务化的方案一直前行,自行实现可靠消息服务。最后我们分析了传统MQ在最终一致性消息上的不足,而RocketMQ的事务消息则弥补了这一方面的空白,但在选型RocketMQ时也需慎重,根据团队和项目规划长远而慎重选择。
作业: 现有出入金服务、余额服务分别使用不同的数据库,现需要实现用户入金成功则余额增加(不允许多加),出金成功则余额扣减(不允许多扣)。不使用MQ,请结合业务实际使用消息表实现。
出入金分布式事务解决方案:
- 让我们看看当用户余额和出入金订单在同一个库时,入金和出金的代码是这样写的:
1.1 入金
[1] 插入一条入金订单,状态为处理中
[2] 调用渠道入金接口
[3] 收到渠道响应,如果渠道明确响应交易成功,则更新订单状态为成功并增加用户余额(同一事务)
1.2 出金
[1] 插入一条出金订单,状态为处理中
[2] 扣减用户余额
[3] 调用渠道出金接口
[4] 收到渠道响应,如果渠道明确为交易失败,则更新订单状态为失败并回退已扣减金额(同一事务) - 现在由于出入金服务和余额变更服务使用了不同的数据库,采用最终一致方案:
1.0 余额变更服务
[1] 提供余额变更服务,必须幂等;
[2] 余额变更状态查询服务,包括:成功/失败/404
1.1 入金
[1] 插入一条入金订单,状态为处理中
[2] 调用渠道入金接口
[3] 收到渠道响应,如果渠道明确响应交易成功,则更新订单状态为成功并新增增加用户余额事件(同一事务)
1.2 出金
[1] 插入一条出金订单,状态为处理中
[2] 远程调用服务扣减用户余额 可能的结果有[成功、失败、超时未知],只有成功时才往下走,否则向前端返回出金失败。
[3] 调用渠道出金接口
[4] 收到渠道响应,如果渠道明确为交易失败,则更新订单状态为失败并增加回退已扣减金额事件(同一事务)
1.3 定时程序
[1] 将消息表事件落地到余额变更服务
[2] 定时扫描出金余额扣减日志,状态为超时未知的记录,更新状态为实际状态,如果实际状态为成功还会更新记录余额扣减日志(事务)
说明:出金前阶段可认为是TCC的一种变形,即Try(尝试扣减用户余额),Confirm(渠道明确返回交易成功了,更新出金订单状态为成功),Cancel(渠道明确返回失败了,或者调用余额变更超时但实际成功了,回退)。