• TCC分布式事务----以Hmily框架为例


    插曲: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

    这里小马哥讲的有明显两个问题

    1. 小马哥说每个服务的confirm是在try之后立马执行的,这其实是有问题的。真正的confirm是在所有的try都成功之后,发起者的try整个结束之后,由TxManager异步调用的
    2. undolog在TCC模式下根本就没用。小马哥一直在说什么undolog,但其实TCC模式下的补偿是由业务来实现的,而不是undolog。

    除此之外,我还有额外的一个困惑

    1. 如果某个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异常恢复逻辑

    在这里插入图片描述

    恢复思路大概是:

    1. 有最大重试次数,超过次数直接设置状态为DEATH
    2. 每次恢复,需要锁住该行,然后调用confirm或者cancel

    这种恢复逻辑的存在,就需要我们保证Confirm和Cancel操作的幂等性。

    https://dromara.org/zh/blog/hmily_introduction.html

    这里面还提到了很多,比如针对RPC集群场景下,如何保证TRY,和Confirm路由到不同节点时,仍然可以从缓存中找到HmilyParticipant对象。关键就在于
    这里的CacheLoader的逻辑是,如果不存在key,则调用load进行加载,而load则是从设置的日志库中读取。

  • 相关阅读:
    yum方式更新Jenkins
    在 openEuler 22.03 上安装 KubeSphere 实战教程
    Go Web——简单blog项目(一)
    TCP/IP协议专栏——ARP攻击原理与分类——网络入门和工程维护必看
    vue课程75 axios是只专注于网络请求的库
    MySQL 有这一篇就够(呕心狂敲37k字,只为博君一点赞!!!)
    14种主流的RTOS 单片机操作系统~来学!
    十四、Java SPI
    云原生数据库VS传统数据库
    C++ realloc()用法及代码示例
  • 原文地址:https://blog.csdn.net/weixin_52016782/article/details/134317013