在微服务拆分之后,本地事务的边界被彻底打破。一笔订单创建往往要跨订单库、库存库、账户库,任何一个环节失败,前面已提交的操作都必须回滚——这是分布式事务最原始也最棘手的诉求。Seata 正是在这个背景下被广泛采用的方案,但它不是一个“加上 @GlobalTransactional 就万事大吉”的银弹。用错模式、选错场景,Seata 本身就会成为新的故障源。本文从架构与实现原理出发,逐一拆解四种模式的取舍,最后落到生产环境中最容易踩的几类问题及其排查与解决路径。
一、Seata 的架构:一个中心,两个端点
Seata 的架构可以用“1+3”来概括:一个全局事务标识 XID,三个核心角色 TC、TM、RM。
TC(Transaction Coordinator,事务协调者) 是独立部署的 Seata Server,维护全局事务和分支事务的状态,负责驱动全局事务的提交或回滚。它是整个体系的中枢,所有分支的注册、状态上报、二阶段指令的下发都经过 TC。
TM(Transaction Manager,事务管理器) 嵌入在业务发起方服务中,负责定义全局事务的边界——开启全局事务、获取 XID,并在业务逻辑结束后向 TC 发起全局提交或回滚的决议。标了 @GlobalTransactional 的那个服务,扮演的就是 TM 角色。
RM(Resource Manager,资源管理器) 嵌入在每一个参与分支事务的微服务中,管理本地数据源资源,向 TC 注册分支事务,并在二阶段接收 TC 的指令完成分支的提交或回滚。
一次全局事务的完整链路如下:TM 向 TC 申请开启全局事务,TC 生成全局唯一的 XID;XID 在微服务调用链的上下文中透传;各服务 RM 向 TC 注册分支事务,将本地操作纳入该 XID 的管辖;业务执行完毕后,TM 向 TC 发起全局提交或回滚决议;TC 根据全部分支的状态,调度各 RM 完成二阶段的提交或回滚。
理解这个架构之后,一个关键设计取舍就浮现出来了:XID 的透传和分支的同步注册,意味着 TC 的可用性与网络延迟会直接嵌入到每一个分支事务的本地提交路径中。后面很多生产问题的根因,都绕不开这一点。
二、实现原理:两阶段提交的“变体”,不是银弹
Seata 的底层基因是两阶段提交(2PC),但四种模式对“一阶段是否释放资源”“二阶段如何回滚”做了截然不同的选择,这直接决定了它们的性能、隔离性和适用场景。
AT 模式:一阶段提交,靠 undo_log 反向补偿
AT 是 Seata 默认、使用最广的模式。一阶段中,业务 SQL 和 undo_log 在同一个本地事务中提交,本地锁和连接资源随之释放;二阶段提交时异步删除 undo_log,回滚时则根据 undo_log 中记录的前后镜像做反向补偿。
写隔离靠全局锁实现:本地事务提交前必须先拿到全局锁,拿不到就不能提交,从而防止脏写。读隔离方面,AT 的默认全局隔离级别是读未提交,只有代理 SELECT ... FOR UPDATE 才能升级到读已提交。这意味着 AT 在性能上做了很大的让步——一阶段就提交,但提交前要跨网络向 TC 申请全局锁。
TCC 模式:业务层的手动两阶段
TCC 完全不依赖底层数据库,Try 阶段检查并预留资源,Confirm 执行真正的业务,Cancel 释放预留资源。它的灵活性极高,可以跨数据库、跨中间件管理资源,性能也最好,因为没有全局锁的阻塞。代价是业务侵入性很强,每个参与方都要实现三个方法,而且必须自行处理幂等、空回滚和悬挂这三个经典问题。
Saga 模式:长流程的正向执行 + 反向补偿
Saga 把长事务拆成一系列本地短事务,一阶段提交本地事务、不加锁,某个参与者失败时按相反顺序执行补偿操作。它天然适合流程长、参与者多、甚至包含外部遗留系统的场景,补偿服务也相对容易实现。但 Saga 不保证隔离性,中间状态对外可见,补偿逻辑的正确性需要严格论证。
XA 模式:数据库原生 2PC,强一致但代价明确
XA 利用数据库对 XA 协议的原生支持来管理分支事务,业务无侵入,主流关系型数据库广泛支持。但 XA prepare 之后分支事务进入阻塞阶段,必须等待 XA commit 或 rollback 才能释放资源,锁定周期长,应用层无法干预,性能较差。
三、四种模式的优缺点与选型
把四种模式放在同一个坐标系里看,核心权衡在于侵入性、隔离性、性能三者之间的取舍。
选型建议没有“最好”,只有“最匹配” :通用业务优先 AT,零侵入、上手快;资金类高并发核心链路考虑 TCC,用业务复杂度换取无全局锁的性能;跨公司、长流程编排选 Saga,但要接受隔离性缺失并设计好补偿;老系统迁移或 AT 未适配的数据库场景走 XA,换取强一致性。一个常见的误区是在 C 端高并发链路上无脑使用 AT——全局锁与本地行锁的双重绞杀在流量面前会迅速耗尽连接池,这一点后面会展开。
四、生产环境高频问题与解决办法
问题一:全局锁等待超时(Global lock wait timeout)
这是 Seata AT 模式最高频的报错。客户端在提交本地事务前向 TC 申请全局锁,如果该资源已被另一个未结束的全局事务持有,LockRetryController 默认重试 30 次、每次间隔 10ms,总计约 300ms 仍拿不到锁就抛出超时。
根因通常不在 Seata 本身,而在业务 SQL 的锁竞争模式。热点行更新(如库存扣减、账户余额变更)天然会成为全局锁瓶颈;如果调用方还并行发起多个更新同一资源的请求,冲突概率会被进一步放大。
排查路径:先看 Seata Client 日志中的 lockKeys,确认到底是哪条业务主键在争抢;同时查 MySQL 的 information_schema.INNODB_LOCK_WAITS 和 SHOW ENGINE INNODB STATUS,判断底层是否已经存在数据库层面的锁等待链——Seata 的全局锁超时很多时候只是表象,真正卡住的是未释放的数据库行锁。
解决手段:最根本的是调整事务边界,不要把远程调用包裹在 Spring 本地事务中,@GlobalTransactional 只包裹业务编排,本地事务尽量短小;将热点资源的更新串行化,避免并行争抢;在配置层面,如果业务可以接受更长的等待,调整 client.rm.lock.retryTimes 和 retryInterval,如果希望快速失败而不是长时间阻塞,可以设置 lockRetryPolicyBranchRollbackOnConflict=false 走标准重试,或在注解上使用 LockRetryPolicy.FAST_FAIL。
问题二:全局锁与本地锁的死锁环
AT 模式下存在一个经典死锁场景:服务 A 持有本地行锁,等待向 TC 申请全局锁;服务 B 持有全局锁,却在等待数据库本地行锁。两者互相等待,形成闭环,表现为大量 Lock wait timeout exceeded 和 TPS 断崖式下跌。
根因是加锁顺序不一致。当 @Transactional 和 @GlobalTransactional 嵌套不当,或者一个服务的本地事务范围过宽时,本地锁的生命周期被人为拉长,与全局锁的获取时机形成交叉。
解决办法:将内部本地事务的传播级别改为 Propagation.REQUIRES_NEW,让本地锁在子事务中尽快释放,避免长时间持有本地锁去等待全局锁;更推荐的做法是收窄事务粒度,远程 Feign 调用不要包在 @Transactional 中,让全局事务注解只负责编排,本地事务只针对单表操作。在上线前用压测验证加锁顺序,比事后排查死锁日志的成本低得多。
问题三:TCC 的空回滚、悬挂与幂等
TCC 的三个经典问题在生产中出现的频率远高于很多团队的预期。空回滚指 Try 根本没执行(或尚未执行),Cancel 却被调用,导致“无资源可释放”;悬挂指 Cancel 先于 Try 执行,之后迟到的 Try 又预留了资源,形成资源被锁定却无人释放的局面;幂等则是因为 TC 的重试机制,Confirm 或 Cancel 可能被重复调用。
Seata 1.5.1 之后通过 TCC Fence 机制在内置事务状态表中解决了这三类问题,核心思路是在 Try/Confirm/Cancel 操作前后记录分支事务状态,利用数据库唯一约束和行锁来拒绝空回滚、拦截悬挂、保证幂等。使用上需要开启 useTCCFence = true,并在业务库中创建 tcc_fence_log 表。如果仍在使用旧版本或自行实现 TCC,必须手动在业务层建立防悬挂和幂等控制,常见做法是建立独立的 TCC 事务控制表,用唯一索引做防御。一个容易忽略的点是:TCC 的 Try 阶段必须真实提交本地事务(比如真的从账户扣掉钱转入中间态),否则 Confirm 阶段可能因资源不足而失败。
问题四:RM 与 TC 通信异常(RmTransactionException / 分支注册失败)
这类异常通常出现在分支注册或二阶段指令下发环节,表现为 branch commit request failed、resource manager communication error 或 BranchRegister timeout。触发原因包括网络抖动、TC 负载过高导致 RPC 阻塞、RM 心跳丢失被 TC 误判宕机,以及数据库连接池耗尽导致 RM 无法及时响应 TC 的指令。
一个容易被低估的连锁反应是:AT 模式下 RM 在本地事务提交前需要同步向 TC 申请全局锁,如果这一步 RPC 阻塞,数据库连接会一直被持有而无法释放,高并发下迅速耗尽 HikariCP 连接池,进而引发整个服务的雪崩。
解决方向:TC 必须集群化部署,通过负载均衡让 RM 就近接入,并启用 TCP keep-alive 防止长连接被中间设备静默断开;合理设置 client.tm.commit.retry.count 等重试参数,避免因瞬时抖动导致不必要的全局回滚;对数据库连接池的 active 连接数和 TC 交互超时做关联监控,一旦发现连接数飙升且伴随 lock_table 等待,立即触发降级或熔断,而不是任由请求堆积。
问题五:Undo Log 膨胀与二阶段回滚失败
AT 模式依赖 undo_log 表做反向补偿,长时间未提交的全局事务或频繁回滚会持续积累 undo_log 记录。如果 server.undo.log.save.days 配置过小,可能在二阶段回滚需要读取日志时发现已被清理;配置过大则造成表膨胀和查询变慢。
另一类问题是二阶段回滚本身失败:回滚时 RM 需要重新获取本地锁来执行反向 SQL,如果此时有另一个事务正持有本地锁等待全局锁,回滚会不断重试,直到对方全局锁超时放弃,回滚才能成功。这本质上还是锁竞争问题,解决思路与“问题二”一致——缩短事务、减少锁持有时间,并在业务设计上尽量避免“回滚路径上还存在激烈锁竞争”的结构。
评论