插曲:RocketMQ的Half Message
先引入一个插曲,RocketMQ为什么要有Half Message
为什么不在本地事务提交之后,直接发一个commit消息不就行了,为什么还要先发一个可以撤回的、不能被消费的half message,再执行本地事务呢?这其实是一种状态转移:Producer把事务开始执行这个状态转移到了RocketMQ的Server,这样一来,即使Producer再执行完本地事务之后进行重启,Server由于已经根据halfMessage知道了这个事务执行的状态,所以会去主动轮询Producer。因此HalfMessage的使用需要配合一个可以提供事务状态检查的接口。
TCC业界实现
tcc-transaction
https://github.com/changmingxie/tcc-transaction
tx-lcn
https://github.com/codingapi/tx-lcn
hmily
https://github.com/dromara/hmily
Hmily
这里小马哥讲的有明显两个问题
- 小马哥说每个服务的confirm是在try之后立马执行的,这其实是有问题的。真正的confirm是在所有的try都成功之后,发起者的try整个结束之后,由TxManager异步调用的
- undolog在TCC模式下根本就没用。小马哥一直在说什么undolog,但其实TCC模式下的补偿是由业务来实现的,而不是undolog。
除此之外,我还有额外的一个困惑
- 如果某个confirm/cancel执行失败了会怎么办,会重复调用吗?但是为什么示例给的confirm并不是一个幂等操作?
源码分析
下面对Hmily的源码进行分析
makePayment方法执行的updateOrderStatus、accountService.payment、inventoryService.decrease其实是三个try操作,其中updateOrderStatus是本地服务调用,而剩下两个是RPC。本地的更新订单状态的try,对应的confirm和cancel通过@HmilyTCC这个注解进行指定,对应RPC调用的服务,服务提供者的方法上也有指定相应的Confirm和Cancel
accountService
inventoryService
ok,那到这里其实应该明朗了一些:多个分布式事务的Try被挨个调用,这些事务的Confirm和Cancel操作则通过注解被指定,我们很容易知道,框架一定会通过@HmilyTCC这个注解进行AOP,这样一来,在try操作的执行前后,就有相当大的发挥空间。
Hmily会怎么发挥呢?不妨先设想一下TCC面临的问题
问题一:Try失败
如果某个Try失败了,比如说,我accountService调用失败了,那此时会怎么样?按照TCC的思想,此时应该对orderService调用Cancel操作,因为orderService在accountService的Try之前已经Try过了。那么问题来了,accountService作为一个远程服务,应该如何通知orderService进行Cancel呢?因此,在accountService的切面中,afterThrowing一定要做的一件事情就是,通知已经Try过的服务进行Cancel。怎么通知呢?通知给谁呢?执行Cancel的线程和执行Try的是一个线程吗?从Hmily的官网可以看到,Hmily的Cancel和Confirm是由TxManager异步调用的,也就是说,TxManager是一个独立于这三个服务之外的一个线程或是进程,专门管理整个TCC的全局事务。所以,afterThrowing会通知TxManager,TxManager会调用Cancel
所以,下面的思路是,顺着刚刚的思路,找到AOP,分析TxManager
AOP的主要逻辑集中在 HmilyGlobalInterceptor#invoke
invoke先加载了事务的上下文,顾名思义,上下文指的是这几个分布式事务之间共享的一些信息,其通过RpcParameterLoader#load获得。从RpcParameterLoader可以看出,上下文应该是通过RPC框架,比如Dubbo,进行传递的。加载完上下文之后,会继续往下执行
从这里可以看出,先通过getRegistry获得一个注册表,然后从注册表中选出一个Handler,调用handleTransaction。
getRegistry的逻辑如下
也就是,通过注解从REGSTRY中选择一个注册表,REGISTRY是一个静态变量,已经被初始化过
我们是TCC注解,所以选择TCC的实现,这里的设计思路很像Dubbo 框架的SPI Loader
注册表中被放入了很多Handler,这些Handler通过角色来获取,事务发起者和事务参与者的Handler是不一样的。在我们的例子中,orderService就是一个事务发起者START,而accountService和inventoryService就是事务参与者PARTICIPANT。让我们回到invokeWithinTransaction方法,通过方法签名上的分布式事务注解、当前角色,选定Handler之后,调用具体的handleTransaction实现。那么选择的逻辑,也就是select是什么呢?
如果当前上下文为空,也就是RPC的源头,那么就是发起者,则返回发起者的Handler。如果上下文不为空,则从上下文中找到角色,返回对应角色的Handler。我们当前的角色是Start,那么就找Start的Handler
下面去分析 StarterHmilyTccTransactionHandler
这里有两个非常重要的角色:executor和disruptor
point.proceed()就是执行原本的方法逻辑,即调用3个try。一旦抛出异常,则会调用executor.globalCancel(currentTransaction),而顺利执行完毕的话,则会调用executor.globalConfirm(currentTransaction)。那么disruptor.getProvider().onData() 我理解是将cancel和confirm进行了异步化处理。
所以disruptor只是一个异步化手段,暂时不做深入分析,这里重点关注的还是executor
首先是preTry
preTry构建了上下文对象,因为现在是START,所以还没有上下文 。上下文中设置了当前的角色START,动作TRYING,类型TCC等信息,随后将上下文放入了HmilyContextHolder中了,这个HmilyContextHolder是一个ThreadLocal,方便后面RPC调用时随时获取。
preTry之后就是调用切点方法的proceed了, 为了符合时序,我们的分析思路最好不要从executor.globalCancel(currentTransaction)或者executor.globalConfirm(currentTransaction)开始。这是因为在执行这两步操作之前的proceed,其实是调用了三个try操作的,本地的try,即更新订单,是本地服务,而剩下的两个try都是rpc,也都被标注了@HmilyTCC注解,因此分析他们的Handler也是很有必要的。所以这就需要我们分析ParticipantHmilyTccTransactionHandler
这个还是挺有趣的。如果是TRYING阶段,则会执行具体的proceed,而如果是CONFIRM或者是CANCELING阶段,则不会去执行proceed了,而是调用participantConfirm/participantCancel。我们目前只是进行了Try操作,是TRYING阶段,第62行和69行可以看到,如果当前服务的Try执行成功了,则万事大吉,记录下日志之后就返回。而如果Try抛出异常,则会删除当前参与者的日志记录,并且将异常往外抛。这个往外抛异常的操作,毫无疑问会引起本次RPC调用的失败,最终会进入到StarterHmilyTccTransactionHandler的catch中。而这个日志记录我觉得也很关键,因为它可以用来判定,当前参与者是否完成了Try操作,这决定了一旦出错,是否要对它执行Cancel。
所以,下面分析的重点,就来到了事务发起者的globalCancel和globalConfirm
globalCancel会设置当前全局事务的状态为CANCELING,然后遍历事务的参与者,挨个执行cancel操作。这里有个问题,我作为事务的发起者,如何知道有哪些参与者呢?hmilyParticipants来自currentTransaction,而currentTransaction 在外层来自ThreadLocal,也就是说,事务参与者执行完逻辑之后,会更新全局事务currentTransaction,然后通过RPC返回给START方。具体操作在ParticipantHmilyTccTransactionHandler调用的executor.preTryParticipant中
那么回到cancel,这个cancel是怎么执行到远程服务的cancel方法的呢?
HmilyParticipant的cancelHmilyInvocation应该是指定了cancel方法的信息
executeRPC应该就是执行具体调用cancel的RPC逻辑了。
这里我有个疑问,为什么cancel的RPC需要我从START方去调用,难道不是远程自己调吗?如果是从START来调,那远程岂不还要导出cancel方法?
但其实我看到accountService的cancel方法并没有被作为接口导出
前面提到过,HmilyParticipant会在RPC调用链时拦截调用并被构建出来。
第83行,调用之前,在第77行就被构建出来了。调用之后,在 89行,被注册了,这个时候,START才得以感知到Participant的存在。
而在ParticipantHmilyTccTransactionHandler在TRYING阶段执行 executor.preTryParticipant 的时候,会对HmilyParticipant进行构建,此时指定了自己的cancel和confirm。不过这个是怎么传递给调用方的呢?
难绷。。终于知道了,这个根根不会传递给调用方,而是自己解析出来cancel和confirm的方法之后放到本地缓存里了
放完之后,在cancel和confirm逻辑中可以之间拿到并调用本地
而STRAT发起globalcancel时,是RPC,此时不管是cancelInvocation还是confirmInvocation,都是指向的try方法的,这点可以从RPC Filter的DubboHmilyTransactionFilter#buildParticipant中看出
ok,那没问题,不管是globalCancel还是globalConfrim,执行的RPC都是调用远程的Try,至于具体的Cancel和Confirm操作交给远程决定。
终于可以回答问题了:Try失败之后,遍历参与者列表(这个列表中只有已经Try成功的参与者),然后调用Cancel,调用逻辑是向参与者的try发起RPC,会被AOP拦截,不会执行try而是执行注解上的cancel。
问题二:Cancel失败或者Confirm 失败怎么办
一个很蛋疼的事情:如果Cancel 或者 Confirm 执行失败, Hmily不会对其进行重试或者补偿。
可以看到,Hmily将confirm和cancel丢入了异步任务中,并且没有对异常进行任何处理。
会这么草率吗?不是记得有日志吗?是不是应该另外启一个线程,然后重复Confirm或者Cancel,幂等性交给业务方保证。
哦哦哦,看我发现了什么
ok,说明还是有恢复服务的,浅找了一下
HmilyTransactionSelfRecoveryScheduled # selfTccRecovery,里面有详细的TCC异常恢复逻辑
恢复思路大概是:
- 有最大重试次数,超过次数直接设置状态为DEATH
- 每次恢复,需要锁住该行,然后调用confirm或者cancel
这种恢复逻辑的存在,就需要我们保证Confirm和Cancel操作的幂等性。
https://dromara.org/zh/blog/hmily_introduction.html
这里面还提到了很多,比如针对RPC集群场景下,如何保证TRY,和Confirm路由到不同节点时,仍然可以从缓存中找到HmilyParticipant对象。关键就在于
这里的CacheLoader的逻辑是,如果不存在key,则调用load进行加载,而load则是从设置的日志库中读取。