问题描述

有一天正在搬砖,突然出现不断的seata补偿失败告警,如图1(敏感信息会打码,下同):
image.png
图1
我们使用的seata模式是saga,引申出几个问题

  • 什么原因导致触发了补偿?
  • 什么原因导致补偿失败?
  • 为什么补偿会一直重试?

接下来我们来一一剖析原因。

注:本文会涉及到saga模式相关的术语和原理,若对saga模式不熟悉,可以先看文末的seata相关知识介绍

什么原因导致触发了补偿?

对于触发补偿,正常思路是有分支事务失败了,找到案发时间点的相关日志,发现其中并没有error日志。

于是查看全局事务(对应seata_state_machine_inst表)和分支事务(对应seata_state_inst表)的状态,如图2、图3
image.png
图2
image.png
图3
图中可以看到,全局事务的status和compensation_status都是UN(UNKNOW),而分支事务状态都是SU(SUCCESS),seata文档对全局事务的两个状态的介绍如下:

  • 如果所有服务执行成功(事务提交成功)则status=SU, compensateStatus=null
  • 如果有服务执行失败且存在更新类服务执行成功且没有进行补偿(事务提交失败) 则status=UN, compensateStatus=null
  • 如果有服务执行失败且不存在更新类服务执行成功且没有进行补偿(事务提交失败) 则status=FA, compensateStatus=null
  • 如果补偿成功(事务回滚成功)则status=FA/UN, compensateStatus=SU
  • 发生补偿且有未补偿成功的服务(回滚失败)则status=FA/UN, compensateStatus=UN
  • 存在事务提交或回滚失败的情况Seata Sever都会不断发起重试

当前全局事务的两个状态都是UN,也就是发生了补偿且补偿失败,但我们观察到分支事务的状态都是成功,这就解释不通了,为什么分支事务都成功了,全局事务还会失败?
从分支事务都成功,且整个过程没有错误日志来看,我们可以认为业务代码本身是没有问题的,那么只能猜测是seata内部逻辑出错了,于是从INFO和WARN日志中看看能不能找到蛛丝马迹。

分析日志,发现其中有一行WARN日志很可疑,如图4:
image.png
图4
翻seata代码,找到如图5位置
image.png
图5
图5中可以看到,因为effect<1,代码执行到打印我们上面看到的warn日志,而下面的`reportTransactionFinished`方法就没有执行到,该方法正是上报全局事务成功结束的消息到TC。
到这里可以知道,因为没有上报事务成功结束,TC一直没收到消息,在半小时后就触发了超时补偿机制,开始进行补偿。

那问题点就聚焦到为什么effect<1,这里对应的是一条更新语句,SQL如图6
image.png
图6
这条SQL语句是在全局事务结束之后更新全局事务的状态及相关的上下文参数。
这条SQL会更新到gmt_updated,这个字段每次都会是个最新值,所以这行正常情况下effect不会<1,那么问题就来到了where语句,其中id是全局事务id(xid),通过图2的SQL查询,确认该xid对应的值是可以查到数据的,而gmt_updated是全局事务的最近更新时间,这个时间是在补偿流程开始的时候查数据库拿到的,除非中间对他进行修改,否则该时间不应该发生变化,那么在这里也应该能查到。
这么看现在有两种更新失败的可能性:

  • gmt_updated从获取到执行UPDATE的过程中发生了变化
  • 并发地有另外一个线程更新了该全局事务的gmt_updated,导致这里再去作为where条件查的时候查不到

对于第一种可能,看了整个补偿过程中,没有发现有对该值进行修改;
对于第二种可能,seata saga模式对所有的SQL语句都放在了StateLogStoreSqls类下,分析其中的sql,有对gmt_updated字段进行修改的,是以下两个场景:

  • 手动重启状态机时会去更新is_running字段。该场景需要手段触发,通过方法引用,并没有在项目中找到有调用的地方,因此该场景没有触发到。
  • 状态机结束时记录相关参数(如用于记录用户入参和中间产生的上下文参数的end_params等),该场景就是对应上面讲到的执行失败的语句。由于每次业务请求都是一次新的seata调用,不存在同时进行结束状态机,因此该场景也不存在并发修改。

经过这么一分析,gmt_updated是不可能中途被修改了,那么where语句就应该能匹配到记录,但从结果上来看并不是这样,那还会有什么可能呢?

如果不是值修改过的问题,换个思路,因为是时间戳,所以也可能是处理上的精度问题,突然想到DB代理层最近在升级时间戳支持毫秒,该升级是灰度升级,时间点上也吻合,细推一下,因此该SQL执行失败的原因就很明朗了:
在全局事务执行过程中,先创建状态机插入到表中,此时gmt_updated的精度是秒,但在事务完成时更新状态机状态时,执行UPDATE语句灰度到了支持毫秒的机器,where条件中的值就变成了毫秒,这样做等值判断的时候自然就不成立了。

什么原因导致补偿失败?

TC半小时后触发补偿,但一直补偿失败,异常信息如图7:
image.png
图7
该异常的意思是补偿方法的入参appId为空,补偿方法的配置如图8:
image.png
图8
异常信息对应的appId就是该补偿方法的第一个参数,按这么说可能是请求传入的appId是空的,但查看日志,是有传入appId的。

既然不是入参的问题,那再看看补偿流程。

由于补偿是异步流程,势必有段逻辑会去恢复当时的上下文,包括请求参数,这样需要看看恢复上下文的逻辑是怎样的。

image.png
图9
通过补偿失败的错误日志(图9),可以看到补偿的逻辑是在SagaResourceManager#branchRollback中,如图10
image.png
图10
image.png
图11
从图11可以看到,TC是下发了xid,也就是状态机实例id,然后在本地去重新加载状态机实例,其中就会包含相关的上下文参数。

image.png
图12
如图12,还是在compenstateInternal方法中,会看到从getStateMachineContextVariables方法拿到contextVariables,并设置到context中(通过图10知道replaceParams是null,所以replaceParams忽略掉),这个contextVariables就是在调用业务回滚方法时的业务参数来源,也就是说就是因为contextVariables中不包含reqBo.appId参数,导致了补偿失败。

那contextVariables的值从哪里来呢?getStateMachineContextVariables方法如下图13:
image.png
图13
可以看到当状态机endParams不为空时,就直接返回endParams了,那endParams的值是什么呢?

image.png
图14
通过查表(图14)可以看到endParams不为空,但发现并没有reqBo这个对象,那更没有appId了,所以现在问题就变成了endParams的保存逻辑是怎么样的?对于启动参数reqBo会不会塞入到endParams中?

我们回到全局事务的启动逻辑,会调用StateMachineEngine#startWithBusinessKey,启动参数reqBo通过startParams传入,如图15
image.png
图15
进入startWithBusinessKey方法中,如图16、图17,startParams会传入到withStateMachinedContextVariables方法,然后设置到ProcessContext中,对应的VAR_NAME_STATEMACHINE_CONTEXT就是执行业务方法时的参数来源。
image.png
图16
image.png
图17

而在全局事务流程结束时,会执行endStateMachine,其中会把VAR_NAME_STATEMACHINE_CONTEXT的内容放入endParams,其中就包含了startParams,如图18
image.png
图18
然后执行recordStateMachineFinished,该方法会将endParams持久化到数据库中,如下图19:
image.png
图19
看过图5的同学再看到这个方法是不是很熟悉?没错,红框就是导致全局事务失败的那行代码!这下子就明白了,因为这行SQL执行失败,导致了endParams没更新上去,间接就导致了补偿的时候找不到startParams的参数,从而导致补偿失败!
image.png
图20
如图20所示。该图的执行流程文末会介绍。

那这个补偿失败问题怎么解决呢?其实在这个场景下不用解决,因为当时接口实际上是返回成功的,所以补偿失败可以忽略。

为什么补偿会一直重试?

补偿的重试是可以配置的,默认情况下是无限重试,只有如图21配置了相关参数才会进行有限次数的重试。而当前就是因为没有配置重试参数,才会导致补偿无限重试。
image.png
图21

seata saga相关知识介绍

saga使用的时候需要实现事务逻辑和回滚补偿逻辑。
如图22是一个主事务,有n个分支事务,当T3失败的时候,会沿着C3(Compensation3)回滚补偿T1, T2, T3对应的补偿逻辑。
image.png
图22
内部实现执行流程如下图23:
image.png
图23

  • 图中的状态图是先执行 stateA, 再执行 stataB,然后执行 stateC;
  • “状态”的执行是基于事件驱动的模型,stataA 执行完成后,会产生路由消息放入 EventQueue,事件消费端从 EventQueue 取出消息,执行 stateB;
  • 在整个状态机启动时会调用 Seata Server (即TC)开启分布式事务,并生产 xid, 然后记录”状态机实例”启动事件到本地数据库;
  • 当执行到一个”状态”时会调用 Seata Server 注册分支事务,并生产 branchId, 然后记录”状态实例”开始执行事件到本地数据库;
  • 当一个”状态”执行完成后会记录”状态实例”执行结束事件到本地数据库, 然后调用 Seata Server 上报分支事务的状态;
  • 当整个状态机执行完成,会记录”状态机实例”执行完成事件到本地数据库, 然后调用 Seata Server 提交或回滚分布式事务;

参考链接