看起来“无侵入”的Seata AT模式,真的能直接上生产吗?

侯乾 Lv4

背景

随着公司业务体量的增长,我们将核心交易系统从单体架构拆分为微服务架构。在拆分过程中,跨服务的数据库事务一致性成为了必须跨越的鸿沟。

经过对 XA、TCC、Saga 等主流方案的调研,鉴于团队目前的开发人力资源以及对旧系统的兼容需求,我们锁定了号称“零代码侵入”的 Seata AT 模式

官方文档看起来非常美好:只需要一个注解 @GlobalTransactional,就能像使用本地事务一样解决分布式事务。但在深入调研和压测后,我们发现:在复杂的生产环境下,”简单”往往意味着背后的代价。

s1: 理想的AT模型

首先我们来分析一下 Seata AT 模式的标准通讯模型。

Seata AT 的核心机制在于**“一阶段提交 + 二阶段异步释放”**。它通过代理 DataSource,拦截业务 SQL,在业务数据更新前,保存“前置镜像”和“后置镜像”到 Undo Log 中。

sequenceDiagram
    participant TM as Transaction Manager
    participant TC as Seata Server
    participant RM as Resource Manager (DB)

    TM->>TC: 开启全局事务
    TM->>RM: 执行业务SQL
    activate RM
    RM->>RM: 获取本地锁 -> 生成UndoLog -> 获取全局锁
    RM->>RM: 提交本地事务 (释放本地锁)
    deactivate RM
    TM->>TC: 发起全局提交
    TC->>RM: 异步清理UndoLog (释放全局锁)

在这个模型中,一阶段直接提交了本地事务,释放了数据库连接资源。这看起来兼顾了效率和一致性。

s2: 被忽视的“串行化”代价

然而,AT 模式为了实现写隔离(Write Isolation),引入了由 TC(服务端)维护的全局锁。

这带来了一个我们在调研初期容易忽视的问题:热点数据的并发瓶颈。

我们看看在高并发场景下,如果出现热点数据(如秒杀场景下扣减同一商品的库存),通讯模型会变成什么样:

sequenceDiagram
    participant T1 as 事务1 (持有全局锁)
    participant T2 as 事务2 (等待全局锁)
    participant DB as 数据库

    Note over T1: T1 完成了一阶段,本地已提交\n但持有全局锁 (Global Lock)
    
    T2->>DB: 执行 Update SQL
    DB-->>T2: 获取本地锁成功
    
    T2->>T2: 尝试注册分支 & 申请全局锁
    Note over T2: ❌ 冲突!T1 还没释放全局锁
    
    loop 自旋重试
        T2->>T2: 等待... (默认重试30次)
    end
    
    opt 超时
        T2->>T2: 抛出 GlobalLockWaitTimeoutException
        T2->>DB: 回滚本地事务
    end

分析:

  1. 虽然数据库的本地锁在一阶段释放了,但 全局锁 必须等到二阶段提交后才能释放。
  2. 如果有分支事务执行缓慢(RPC 延迟),或者 TC 负载过高,整个全局锁的持有时间 = 最慢分支的耗时 + RPC 通信耗时

这意味着,对于热点数据,AT 模式实际上是将并行处理强制降级为了串行处理。如果盲目上线,极有可能导致大量事务超时。

换个思路:TCC?

既然全局锁会阻塞并发,那是否应该换成 TCC(Try-Confirm-Cancel)模式?

TCC 不依赖全局锁,而是通过业务层的资源预留(比如冻结库存字段)来实现隔离。

  • 优点:性能极高,并发只受限于数据库行锁。
  • 缺点:代码侵入性极大。每个接口都要写 Try/Confirm/Cancel 三个方法,还需要处理“空回滚”、“悬挂”等复杂场景。

结论:对于我们的管理后台和非秒杀类业务(90%的场景),开发效率优先,AT 模式仍然是首选。但是,我们必须解决另一个更隐蔽的、可能导致资损的风险。

s3: 更隐蔽的“软成功”陷阱

解决了锁的认知问题,我们回到 AT 模式的实现原理。Seata TM 决定是“提交”还是“回滚”,完全依赖于是否捕获到异常

目前我们的微服务开发规范中,为了前端对接方便,下游服务(Provider)往往会进行全局异常拦截,返回一个 HTTP 200 的 Result 对象。

我们来看看这种场景下的通讯模型:

sequenceDiagram
    participant TM as OrderService (TM)
    participant RM as PointsService (RM)

    TM->>RM: RPC调用:扣减积分
    RM->>RM: ❌ 业务报错 (积分不足)
    RM-->>TM: 返回 HTTP 200 {code:500, msg:"积分不足"}
    
    Note over TM: AOP 切面未捕获异常\n认为业务执行成功
    
    TM->>TM: 发起 GlobalCommit
    Note over TM, RM: 🚨 事故:订单生成了,积分没扣!

问题一目了然:下游服务“吃掉”了异常,导致上游 TM 误判。这就是分布式事务中的**“软成功”(Soft Success)**。

尝试解决:人为约定

很直观的想法是:修改开发规范。

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

这种靠“人”来保证一致性的方案靠谱吗?

  • 如果新人不知道这个规范怎么办?
  • 上游业务代码里充斥着大量的 if (res.code != 200)NotifySender(业务方)职责太多了,既要处理业务,又要负责事务的防御性检查,严重违背单一职责原则
  • 如果将来 RPC 框架升级,或者 Result 结构变化,改动成本巨大。

小结

针对简单的“人为约定”是很难从根本上解决风险的,我们需要一个自动化的机制。

最终方案

事实上,我们需要将这种“异常识别”的逻辑从业务代码中剥离出来,下沉到基础设施层

我们对内部的 RPC Client SDK(基于 Feign)进行了改造,确立了以下三层治理体系:

  1. 交互契约(Protocol):重新定义内部服务调用标准,SDK 层面负责自动拆包检测。
  2. 异常治理(Infrastructure):在 SDK 的 Decoder 层拦截响应。如果 code != 200SDK 自动抛出自定义异常,打断 TM 的执行链路。
  3. 代码规范(Configuration):强制配置 @GlobalTransactional(rollbackFor = Exception.class) 作为兜底。

改造后的模型如下:

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

    TM->>SDK: 发起调用
    SDK->>RM: 网络请求
    RM-->>SDK: 返回 {code:500}
    
    Note over SDK: 🛑 基础设施层拦截
    SDK->>SDK: 检测到非200状态码
    SDK-->>TM: 抛出 RpcBusinessException
    
    Note over TM: Seata AOP 捕获异常 -> 回滚

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

看起来简单的分布式事务接入,真的简单吗?只有将基础设施治理做在前面,才能让技术真正的“无侵入”。

  • Title: 看起来“无侵入”的Seata AT模式,真的能直接上生产吗?
  • Author: 侯乾
  • Created at : 2022-08-01 17:18:17
  • Updated at : 2022-08-01 17:18:17
  • Link: http://houqian.github.io/2022/08/01/AT模式,真的能直接上生产吗?/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments