1. 分布式事务问题
谈起分布式事务,首先需要明白,分布式事务是一个业务问题,不能脱离具体的场景。
复制代码
- 分布式事务的引入会增加架构的复杂度也会导致性能的下降
- 应该通过合理的服务划分尽可能地避免分布式事务的使用
- 分布式事务所面对的场景注定无法做到像本地事务一样健壮,需要考虑人工补偿、定时校对等流程
2.分布式事务的几种常见解决方案
- 可靠消息MQ的解决方案(自己实现本地消息表、消息中间件自带事务实现)
- TCC编程式解决方案
- 基于数据库保证的解决方案 (阿里seata的AT模式)
- 不保证、通过对账或异步校对数据解决极端场景
- 无论是哪种分布式事务,都需要类似有事务协调器这一服务,都需要确保以下几个内容:
- 事务协调器与各参与者(业务服务)内的调用必须幂等,即重试不会导致数据异常
- 事务必须能确保发送到事务协调器,这是大前提
3.分布式事务之基于ROCKETMQ的可靠消息
在实际系统的开发过程中,可能服务间的调用是异步的。也就是说,一个服务发送一个消息给 MQ,那么针对这种异步调用,需要保证各个服务间的分布式事务,也就是说,基于 MQ 实现异步调用的多个服务的业务逻辑,要么一起成功,要么一起失败。
这个时候,就要用上可靠消息最终一致性方案,来实现分布式事务。
复制代码
3.1 场景引入
这一天客服给技术团队反馈了一个问题,有用户反馈说,按照规则应该是在支付之后可以拿到一个现金红包的,但是他在支付了一个订单之后,却并没有收到这个现金红包,于是就反馈给了客服,按理来说,订单系统在完成支付之后,会推送一条消息到RocketMQ里去,然后红包系统会从RocketMQ里接收那条消息去给用户发现金红包,我们看下图。
但是从订单系统和红包系统当天那个时间段的日志来看,居然只看到了订单系统有推送消息到RocketMQ的日志,但是并没有看到红包系统从RocketMQ中接收消息以及发现金红包的日志。于是大家推测,问题可能就出在这儿了,是不是支付订单消息在传输的过程中丢失了?导致现金红包没有派发出去!
3.2 场景分析( 哪些地方会存在消息丢失、坑点)
从 RocketMQ 全链路分析一下为什么用户支付后没收到红包?
- 订单系统推送消息到MQ就失败了,压根儿就没推送过去;
- 消息确实推送到MQ了,但是结果MQ自己机器故障,把消息搞丢了;
- 或者是红包系统拿到了消息,但是他把消息搞丢了,结果红包还没来得及发。
如果真的在生产环境里要搞明白这个问题,就必须要打更多的日志去一点点分析消息到底是在哪个环节丢失了?
3.3 发送消息零丢失方案:RocketMQ事务消息的实现流程分析
3.3.1解决订单系统推送消息问题
我们首先第一件事,不是先让订单系统做一些增删改操作,而是先发一个half消息给MQ以及收到他的成功的响应,初步先跟MQ做个联系和沟通
问题:
1、half消息写入失败了怎么办?订单系统应该执行一系列的回滚操作,比如对订单状态做一个更新,让状态变成“关闭交易”,同时通知支付系统自动进行退款。
2、half消息发送成功,没收到响应怎么办?RocketMQ这里有一个补偿流程,他会去扫描自己处于half状态的消息,如果我们一直没有对这个消息执行commit/rollback操作,超过了一定的时间,他就会回调你的订单系统的一个接口
3、本地事务执行失败怎么办?这个时候其实也很简单,直接就是让订单系统发送一个rollback请求给MQ就可以了。这个意思就是说,你可以把之前我发给你的half消息给删除掉了,因为我自己这里都出问题了,已经无力跟你继续后续的流程了。
4、提交本地事务之后应该怎么做?如果订单系统成功完成了本地的事务操作,比如把订单状态都更新为“已完成”了,此时你就可以发送一个commit请求给MQ,要求让MQ对之前的half消息进行commit操作,让红包系统可以看见这个订单支付成功消息
5、如果rollback或者commit发送失败了呢?这个时候其实也很简单,因为MQ里的消息一直是half状态,所以说他过了一定的超时时间会发现这个half消息有问题,他会回调你的订单系统的接口 你此时要判断一下,这个订单的状态如果更新为了“已完成”,那你就得再次执行commit请求,反之则再次执行rollback请求。
3.3.4 事务消息方案完成流程图
3.3.5 代码实战
发送half事务消息出去
half消息发送失败,或者没收到half消息响应
half消息成功了,执行订单本地事务
没有返回commit或者rollback的回调处理
4.分布式事务之基于TCC编程式解决方案
TCC 的全称是:Try、Confirm、Cancel。
Try 阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留。
Confirm 阶段:这个阶段说的是在各个服务中执行实际的操作。
Cancel 阶段:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功的业务逻辑的回滚操作。(把那些执行成功的回滚)
复制代码
4.1 场景引入
假设你现在有一个电商系统,里面有一个支付订单的场景。
那对一个订单支付之后,我们需要做下面的步骤:
- 更改订单的状态为“已支付”
- 扣减商品库存
- 给会员增加积分
- 创建销售出库单通知仓库发货
4.2 场景分析
[1] 订单服务-修改订单状态,[2] 库存服务-扣减库存,[3] 积分服务-增加积分,[4] 仓储服务-创建销售出库单。
上述这几个步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
举个例子,现在订单的状态都修改为“已支付”了,结果库存服务扣减库存失败。那个商品的库存原来是 100 件,现在卖掉了 2 件,本来应该是 98 件了。
结果呢?由于库存服务操作数据库异常,导致库存数量还是 100。这不是在坑人么,当然不能允许这种情况发生了!
我们来看看下面的这个图,直观的表达了上述的过程:
所以说,我们有必要使用 TCC 分布式事务机制来保证各个服务形成一个整体性的事务。
上面那几个步骤,要么全部成功,如果任何一个服务的操作失败了,就全部一起回滚,撤销已经完成的操作。
比如说库存服务要是扣减库存失败了,那么订单服务就得撤销那个修改订单状态的操作,然后得停止执行增加积分和通知出库两个操作。
4.3 落地实现TCC分布式事务
TCC 实现阶段一:Try
首先,上面那个订单服务先把自己的状态修改为:OrderStatus.UPDATING。
这是啥意思呢?也就是说,在 pay() 那个方法里,你别直接把订单状态修改为已支付啊!你先把订单状态修改为 UPDATING,也就是修改中的意思。这个状态是个没有任何含义的这么一个状态,代表有人正在修改这个状态罢了。
然后呢,库存服务直接提供的那个 reduceStock() 接口里,也别直接扣减库存啊,你可以是冻结掉库存。
积分服务的 addCredit() 接口也是同理,别直接给用户增加会员积分。你可以先在积分表里的一个预增加积分字段加入积分。比如:用户积分原本是 1190,现在要增加 10 个积分,别直接 1190 + 10 = 1200 个积分啊!你可以保持积分为 1190 不变,在一个预增加字段里,比如说 prepare_add_credit 字段,设置一个 10,表示有 10 个积分准备增加。
咱们来一起看看下面这张图,结合上面的文字,再来捋一捋整个过程:
TCC 实现阶段二:Confirm
这个时候,就需要依靠 TCC 分布式事务框架来推动后续的执行了,用来感知各个阶段的执行情况以及推进执行下一个阶段的这些事情,订单服务里内嵌的那个 TCC 分布式事务框架可以感知到,各个服务的 Try 操作都成功了。此时,TCC 分布式事务框架会控制进入 TCC 下一个阶段,第一个 C 阶段,也就是 Confirm 阶段。
为了实现这个阶段,你需要在各个服务里再加入一些代码。比如说,订单服务里,你可以加入一个 Confirm 的逻辑,就是正式把订单的状态设置为“已支付”了
订单服务内的 TCC 事务框架会负责跟其他各个服务内的 TCC 事务框架进行通信,依次调用各个服务的 Confirm 逻辑。然后,正式完成各个服务的所有业务逻辑的执行。
库存服务也是类似的,你可以有一个 InventoryServiceConfirm 类,里面提供一个 reduceStock() 接口的 Confirm 逻辑,这里就是将之前冻结库存字段的 2 个库存扣掉变为 0。
TCC 实现阶段三:Cancel
好,这是比较正常的一种情况,那如果是异常的一种情况呢?
举个例子:在 Try 阶段,比如积分服务吧,它执行出错了,此时会怎么样?
单服务的 TCC 分布式事务框架只要感知到了任何一个服务的 Try 逻辑失败了,就会跟各个服务内的 TCC 分布式事务框架进行通信
也就是说,会执行各个服务的第二个 C 阶段,Cancel 阶段。同样,为了实现这个 Cancel 阶段,各个服务还得加一些代码。
首先订单服务,它得提供一个 OrderServiceCancel 的类,在里面有一个 pay() 接口的 Cancel 逻辑,就是可以将订单的状态设置为“CANCELED”,也就是这个订单的状态是已取消。
库存服务也是同理,可以提供 reduceStock() 的 Cancel 逻辑,就是将冻结库存扣减掉 2,加回到可销售库存里去,98 + 2 = 100。
总结与思考
总结一下,你要玩儿 TCC 分布式事务的话:首先需要选择某种 TCC 分布式事务框架,各个服务里就会有这个 TCC 分布式事务框架在运行。
然后你原本的一个接口,要改造为 3 个逻辑,Try-Confirm-Cancel:
- 先是服务调用链路依次执行 Try 逻辑。
- 如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务。
- 如果某个服务的 Try 逻辑有问题,TCC 分布式事务框架感知到之后就会推进执行各个服务的 Cancel 逻辑,撤销之前执行的各种操作。
万一某个服务的 Cancel 或者 Confirm 逻辑执行一直失败怎么办呢?
那也很简单,TCC 事务框架会通过活动日志记录各个服务的状态。举个例子,比如发现某个服务的 Cancel 或者 Confirm 一直没成功,会不停的重试调用它的 Cancel 或者 Confirm 逻辑,务必要它成功!
我们的业务,加上分布式事务之后的整个执行流程
4.4 TCC的异常场景及应对机制
在分布式系统中,随时随地都需要面对网络超时,网络重发和服务器宕机等问题。所以分布式事务框架作为搭载在分布式系统之上的一个框架型应用也绕不开这些问题。具体而言,有以下常见问题:
- 幂等处理
- 空回滚
- 资源悬挂
这些异常的应对需要TCC框架的支持和解决方案。
幂等处理
产生原因
因为网络抖动等原因,分布式事务框架可能会重复调用同一个分布式事务中的一个分支事务的二阶段接口。所以分支事务的二阶段接口Confirm/Cancel需要能够保证幂等性。如果二阶段接口不能保证幂等性,则会产生严重的问题,造成资源的重复使用或者重复释放,进而导致业务故障。
解决
事务状态控制记录作为控制手段,只有存在INIT记录时才执行,存在CONFIRMED/ROLLBACKED记录时不再执行
空回滚
产生原因
先来说定义,当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的是正常的回滚逻辑。
解决
事务状态控制记录作为控制手段,二阶段发现无记录时插入记录,一阶段执行时检查记录是否存在
资源悬挂
产生原因
悬挂,顾名思义,是有一些资源被悬挂起来后续无法处理了。那么什么场景下才会出现这种现象呢?
上一节中提到过空回滚,指的是当一阶段Try未执行成功,而二阶段Cancel就因TC回滚整个分布式事务而被调用。
但是考虑一种极端情况,当分布式事务到终态后,参与者的一阶段Try才被执行,此时参与者会根据业务需求预留相关资源。预留资源只有当前事务才能使用,然而此时分布式事务已经走到终态,后续再没有任何手段能够处理这些预留资源。至此,就形成了资源悬挂。
这种一阶段比二阶段执行的还晚的情况看似不可能,但是仔细考虑RPC调用的时序,其实这种情况在复杂多变的网络中是完全可能的,下面的时序展示了这种可能性:
- 发起方通过RPC调用参与者一阶段Try,但是发生网络阻塞导致RPC超时
- RPC超时后,TC会回滚分布式事务(可能是发起方主动通知TC回滚或者是TC发现事务超时后回滚),调用已注册的各个参与方的二阶段Cancel
- 参与方空回滚后,发起方对参与者的一阶段Try才开始执行,进行资源预留从而形成悬挂
解决:事务状态控制记录作为控制手段,二阶段发现无记录时插入记录,一阶段执行时检查记录是否存在
4.5 代码分析
5.分布式事务之基于数据库保证(AT模式)
AT 模式是一种无侵入的分布式事务解决方案。
阿里Seata框架,实现了该模式。 在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
参考文献
《终于有人把“TCC分布式事务”实现原理讲明白了!》
《从 0 开始带你成为消息中间件实战高手》
《Seata 官方文档》