看起来“无侵入”的Seata AT模式,真的能直接上生产吗?
背景
随着公司业务体量的增长,我们将核心交易系统从单体架构拆分为微服务架构。在拆分过程中,跨服务的数据库事务一致性成为了必须跨越的鸿沟。
经过对 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
分析:
- 虽然数据库的本地锁在一阶段释放了,但 全局锁 必须等到二阶段提交后才能释放。
- 如果有分支事务执行缓慢(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)**。
尝试解决:人为约定
很直观的想法是:修改开发规范。
- 要求下游服务不要捕获异常,直接抛出。
- 或者要求上游服务在调用后,手动检查
if (res.code != 200) throw new RuntimeException()。
这种靠“人”来保证一致性的方案靠谱吗?
- 如果新人不知道这个规范怎么办?
- 上游业务代码里充斥着大量的
if (res.code != 200),NotifySender(业务方)职责太多了,既要处理业务,又要负责事务的防御性检查,严重违背单一职责原则。 - 如果将来 RPC 框架升级,或者 Result 结构变化,改动成本巨大。
小结
针对简单的“人为约定”是很难从根本上解决风险的,我们需要一个自动化的机制。
最终方案
事实上,我们需要将这种“异常识别”的逻辑从业务代码中剥离出来,下沉到基础设施层。
我们对内部的 RPC Client SDK(基于 Feign)进行了改造,确立了以下三层治理体系:
- 交互契约(Protocol):重新定义内部服务调用标准,SDK 层面负责自动拆包检测。
- 异常治理(Infrastructure):在 SDK 的 Decoder 层拦截响应。如果
code != 200,SDK 自动抛出自定义异常,打断 TM 的执行链路。 - 代码规范(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.