【生产复盘】看起来完美的Seata分布式事务,为什么还是丢了数据?

侯乾 Lv4

背景

随着业务体量的增长,我们近期完成了交易核心链路的微服务拆分。为了在保证性能的同时维持数据一致性,我们经过调研,最终锁定了 Seata (AT模式) 作为分布式事务解决方案。

目前的业务场景是:用户下单(OrderService)-> 扣减积分(PointsService)。看似简单的链路,上线后却收到了客诉:订单创建成功了,但积分没扣。

首先我们来分析一下接入Seata后的理想通讯模型:

代码段

sequenceDiagram
    participant TM as OrderService (TM)
    participant TC as Seata Server
    participant RM as PointsService (RM)

    TM->>TC: 开启全局事务
    TM->>RM: RPC调用:扣减积分
    RM->>RM: 执行SQL (扣减成功)
    RM-->>TM: 返回成功
    TM->>TC: 发起全局提交
    TC->>RM: 异步清理UndoLog

注:这是理想情况下的Happy Path。

我们作为一个金融属性的系统,必然要确保资金流水的绝对一致。这个问题看起来一目了然,Seata AT模式本身就是为了解决这个问题而生的,为什么失效了?我们分析下异常场景。

s1: 正常回滚

在测试环境中,我们模拟了“积分不足”的场景,积分服务抛出异常,OrderService捕获异常,Seata成功回滚。一切看起来都很美好。

s2: 诡异的“软成功”

然而在生产环境中,我们捕捉到了这样一种交互序列:

sequenceDiagram
    participant TM as OrderService (TM)
    participant TC as Seata Server
    participant RM as PointsService (RM)

    TM->>TC: 1. 开启全局事务
    TM->>RM: 2. RPC调用:扣减积分
    RM->>RM: 3. 业务校验失败(积分不足/异常)
    RM-->>TM: 4. 返回 HTTP 200 {code:500, msg:"积分不足"}
    TM->>TM: 5. AOP切面未捕获异常
    TM->>TC: 6. 发起全局提交 (Commit)
    TC->>RM: 7. 释放锁

问题出现了:步骤3中积分服务确实报错了,但在步骤6中,OrderService却通知Seata提交了事务。

根因分析

深入排查代码后,发现问题出在RPC协议与事务协议的语义冲突

积分服务为了接口“优雅”,在Controller层加了全局异常处理(GlobalExceptionHandler),将所有异常捕获并封装成了 Result<T> 对象返回。

对于RPC框架(Feign/Dubbo)而言,HTTP 200意味着通信成功;对于Seata的TM切面而言,只要方法没有抛出Java Exception,就默认业务执行成功

这种“软成功”(Soft Success),直接击穿了分布式事务的防线。

尝试解决:人为约定

很直观的解决思路是:让开发人员修改代码。

  1. 要求下游服务不要捕获异常。
  2. 或者要求上游服务在调用后,手动检查 if (res.code != 200) throw new RuntimeException()

看起来好像解决了问题?然而这个方案真的靠谱吗?

  • 违背人性:在一个拥有上百个接口的系统中,依赖开发人员的自觉性去检查每一个返回值,这是不可靠的。
  • 职责不清:业务逻辑层混入了大量的防御性代码,如果将来RPC框架升级或更换返回结构,所有调用方都要改,显然是不合理的。

最终方案:基础设施治理

事实上,我们需要的是一个自动化的机制,它能抹平RPC“软成功”与Seata“硬回滚”之间的鸿沟。

实质上,对于“检测异常”这件事,其实可以换一个角度思考:我们只要确保在RPC调用的反序列化阶段,能够识别业务语义上的错误,并将其还原为异常即可。

ok,直接给出结论:SDK层面的防御性拦截。

我们改造了内部的RPC Client SDK(以Feign为例,自定义了Decoder):

sequenceDiagram
    participant Business as 业务代码
    participant SDK as RPC Client SDK (Decoder)
    participant Provider as 下游服务

    Business->>SDK: 发起调用
    SDK->>Provider: 网络请求
    Provider-->>SDK: 返回 {code:500, msg:"Error"}
    
    Note over SDK: 关键步骤:拦截解析
    SDK->>SDK: 检查 code != 200 ?
    
    alt 是错误码
        SDK-->>Business: 抛出 RpcBusinessException
        Note over Business: Seata AOP捕获异常 -> 回滚
    else 正常
        SDK-->>Business: 返回正常对象
    end

我们将这个逻辑封装在基础架构层:

  1. 交互契约上:规定内部服务调用,SDK会自动拆包。
  2. 异常治理上:如果下游返回非200状态码,SDK直接抛出异常,打断TM的执行链路。
  3. 代码规范上:强制配置 @GlobalTransactional(rollbackFor = Exception.class) 作为兜底。

这样一来,业务开发人员不需要编写任何额外的校验代码,NotifySender(业务方)不需要做任何改动,只负责处理业务逻辑即可,符合单一职责原则。

小结

通过对基础设施层的改造,我们解决了RPC异常封装导致的事务失效问题。

看起来简单的分布式事务接入,真的简单吗? 如果不去深入分析RPC协议和事务协议的交互细节,仅仅依赖中间件的能力,很容易陷入“看起来完美”的陷阱。技术的价值,往往体现在对这些细节的治理能力上。

  • Title: 【生产复盘】看起来完美的Seata分布式事务,为什么还是丢了数据?
  • Author: 侯乾
  • Created at : 2022-08-05 12:40:23
  • Updated at : 2022-08-05 12:40:23
  • Link: http://houqian.github.io/2022/08/05/【生产复盘】RPC接口返回200,Seata事务竟然没回滚/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
【生产复盘】看起来完美的Seata分布式事务,为什么还是丢了数据?