资讯动态

Seata分布式事务实战:XA与TCC模式落地、对比与踩坑指南

发布时间:2026/10/17 7:11:00 来源:尧图企业网站定制
做微服务的人都会撞上那道坎跨服务的数据一致性。我见过太多团队一开始靠“本地消息表 定时对账”硬扛账越对越多最后还是要回来老老实实引入分布式事务组件。Seata 这个名字做后端的基本都听过阿里开源的那个事务组件很多人只知道有个自动挡的 AT 模式但我这两年生产环境里真正用到刀刃上的是 XA 模式还有业务侵入性最高的 TCC 模式。这篇文章不绕理论直接讲清楚这两种模式怎么落地、何时选它们、以及那些文档不会写出来的坑。1. 先把Seata的事务骨架理清TC、TM、RM到底各管什么1.1 三个角色的分工决定了你排查问题的方向很多人一上来就看 XA、TCC 的代码结果出了问题还是懵。我的建议是先花十分钟把 Seata 的三个核心角色刻进脑子里后面所有的问题排查都会有方向。Seata 的事务模型脱胎于经典的全局事务概念但把参与者拆成了三个独立角色TCTransaction Coordinator事务协调者独立部署的服务端负责全局事务的开启、提交、回滚。你可以把它理解成“裁判”所有分支的最终裁决都由它下。TMTransaction Manager事务管理器嵌入在发起方应用里负责向 TC 申请开启全局事务并在业务执行结束后向 TC 发起全局提交或回滚的请求。它是“发起人”。RMResource Manager资源管理器管理分支事务的资源。数据库连接、本地事务的提交与回滚都由它向 TC 汇报。它是“干活的人”。举个例子你有一个下单接口同时调用了订单服务和库存服务。TM 在下单接口的开头向 TC 申请一个全局事务 IDXID然后这个 XID 会通过微服务调用链路传到库存服务。库存服务里的 RM 在执行本地 SQL 时把本地事务注册成 Seata 的分支事务并把自己的执行结果上报给 TC。等所有分支都执行完了TM 说一声“提交”TC 就根据所有分支的上报结果决定整体提交还是整体回滚。1.2 XA 和 TCC 在系统里的介入位置完全不同理解了三个角色再看模式就简单了。因为XA 和 TCC 最大的区别不在全局事务管理流程上而在 RM 如何去管“资源”。XA 模式里RM 直接使用数据库原生支持的 XA 协议。数据库自己就具备两阶段提交的能力Seata 只是做了统一牵引。它的介入点在数据源层业务代码几乎不需要改动无侵入。TCC 模式里RM 不做资源管理而是把“资源操作”的粒度拆成了三个业务方法Try、Confirm、Cancel由业务代码来模拟资源的预扣、确认扣减和释放。它介入点在业务方法层侵入性极强但换来的是不依赖数据库的全局锁性能和可用性上限更高。明白了这个区别你才能理解后面为什么 XA 代码可以那么清爽而 TCC 需要写一整套额外的状态字段和校验逻辑。2. XA模式落地数据库原生的两阶段提交代码最省心2.1 实战场景模拟电商下单库存不足必须全部回滚我用一个模拟电商的订单系统来做演示。系统分两个服务order-service负责创建订单inventory-service负责扣减库存。需求很简单用户下单时创建一条订单记录同时从商品库存里扣掉对应数量两个操作必须同生共死不能出现订单创建成功但库存没扣、或者库存扣了但订单没创建的情况。这个场景用 XA 模式最合适因为两个操作都是短事务数据库本身支持 XA业务逻辑不复杂我们不希望为了分布式事务去改业务表结构、加状态字段。2.2 数据源配置与核心代码一个注解搞定全局事务先看依赖配置。我用的是 Spring Boot 2.x MyBatis-Plus Seata 1.5.2注册中心用 Nacos。XA 模式对数据源的要求是必须支持 XA 协议MySQL 从 5.7 开始原生支持我用的是 MySQL 8.0问题不大。第一步配置数据源。XA 模式不能用普通的数据源必须用 Seata 提供的DataSourceProxyXA把原来的DataSource包一层Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { return new DruidDataSource(); } Bean public DataSource dataSourceProxyXA(DataSource dataSource) { return new DataSourceProxyXA(dataSource); } Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSourceProxyXA) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceProxyXA); // 省略 MyBatis 配置 return factoryBean.getObject(); } }第二步在发起方订单服务的接口上加GlobalTransactionalService public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private InventoryFeignClient inventoryFeignClient; Override GlobalTransactional(name create-order-xa, rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { // 1. 本地事务插入订单 Order order new Order(); order.setUserId(orderDTO.getUserId()); order.setCommodityCode(orderDTO.getCommodityCode()); order.setCount(orderDTO.getCount()); order.setAmount(orderDTO.getAmount()); orderMapper.insert(order); // 2. 远程调用库存服务扣减库存 inventoryFeignClient.deduct(orderDTO.getCommodityCode(), orderDTO.getCount()); } }库存服务的deduct接口就是一个普通的本地事务方法不需要加额外注解。因为 XID 已经通过调用链传递过去了库存服务的数据源已经被DataSourceProxyXA代理Seata 会自动把本地事务注册成全局事务的分支Service public class InventoryServiceImpl implements InventoryService { Autowired private InventoryMapper inventoryMapper; Override Transactional(rollbackFor Exception.class) public void deduct(String commodityCode, int count) { int updated inventoryMapper.deductStock(commodityCode, count); if (updated 0) { throw new RuntimeException(库存不足); } } }注意XA 模式下本地注解用普通Transactional就行Seata 会把本地事务的提交/回滚纳入全局事务的协调。如果库存不足deduct抛出异常后本地事务先回滚同时 Seata 的 RM 会向 TC 上报分支失败TC 就会通知订单服务回滚本地事务最终订单记录也不会落库。实测下来的效果就是代码和本地事务几乎一样只有一个注解的差别但跨服务的数据一致性已经保证了。2.3 XA模式最容易被忽略的代价全局锁与事务时长XA 模式看起来爽但它有一个非常现实的问题资源锁的持有时间被拉长了。这不是 Seata 的问题而是 XA 协议本身的特性。在 XA 的两阶段提交中第一阶段prepare执行后数据库的资源和行锁不会释放要等第二阶段commit 或 rollback的全局指令到达才真正落定。假如你的全局事务里有一个跨服务的 RPC 调用耗时 3 秒那这 3 秒内所有被涉及的数据行理论上都是锁住的。我在模拟系统中做了一个压测同时发起 50 个下单请求全部操作同一件商品。结果发现在 XA 模式下请求的响应时间会出现明显的尾部延迟后 20 个请求基本都在等锁甚至有两个因为超过了数据库innodb_lock_wait_timeout的默认 50 秒而直接超时失败。所以我的结论是XA 只适合短事务、低并发冲突的场景比如订单创建、支付回调、账户开户。如果事务里涉及多个长时间外部调用或者高频操作同一行数据请优先考虑 TCC。另外还有一个配置问题容易踩坑。如果你用的是 Druid 连接池并且开启了testWhileIdle和testOnReturn连接池可能会在事务执行期间对物理连接做探活这会导致 XA 连接状态被干扰。我建议 XA 模式下把连接池的testOnReturn关掉保持连接状态的稳定性。3. TCC模式落地把事务控制权交还给业务换回性能和灵活度3.1 Try、Confirm、Cancel三阶段到底在做什么如果说 XA 是把两阶段提交托管给数据库那 TCC 就是把两阶段提交的思想搬到了业务代码里Try 阶段只预检和冻结资源Confirm 阶段才真正执行Cancel 阶段负责释放预检时冻结的资源。我用转账场景来说明。假设账户 A 给账户 B 转 1000 元涉及两个账户服务或者同一个服务里的两张账户表。如果直接用 XA全程锁余额字段转账这种高频操作很容易锁冲突。用 TCC我们可以设计成这样Try检查 A 账户余额是否充足如果充足把 A 账户的 1000 元冻结起来在冻结金额字段加 1000但此时 A 的可用余额不变。Confirm真正扣减 A 的可用余额同时增加 B 的可用余额并把 A 的冻结金额减掉 1000。Cancel释放 Try 阶段冻结的金额把 A 账户的冻结金额减掉 1000可用余额保持不变。也就是说分布式事务的处理过程被拆解成了三个独立的业务方法每个方法都封装了自己的本地事务。这样数据库层面不需要持有长时间锁因为 Try 阶段结束时事务就提交了资源已经落库不会被 XA 的 prepare 状态卡住。这也是 TCC 号称“高性能分布式事务方案”的原因。但代价就是你必须接受大量额外的业务代码、状态表设计以及处理各种极端情况。3.2 转账案例的完整TCC实现冻结表是关键为了让上面的设计可落地我在账户表里加了一个frozen_amount字段表示冻结金额。同时为了保证幂等和防悬挂我加了一张tcc_transaction_log表记录每次分支事务的执行状态。账户表结构简化后CREATE TABLE account ( id bigint(20) NOT NULL, user_id varchar(64) NOT NULL, balance decimal(10,2) NOT NULL DEFAULT 0.00, frozen_amount decimal(10,2) NOT NULL DEFAULT 0.00, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB;分支事务日志表CREATE TABLE tcc_transaction_log ( id bigint(20) NOT NULL AUTO_INCREMENT, xid varchar(128) NOT NULL, branch_id bigint(20) NOT NULL, transfer_id varchar(64) NOT NULL, status tinyint(4) NOT NULL COMMENT 1-Try, 2-Confirm, 3-Cancel, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_xid_branch (xid, branch_id), UNIQUE KEY uk_transfer_id (transfer_id) ) ENGINEInnoDB;接着是核心的 TCC 业务方法。在 Seata 里TCC 分支事务用TwoPhaseBusinessAction注解定义Service public class TransferServiceImpl implements TransferService { Autowired private AccountMapper accountMapper; Autowired private TccLogMapper tccLogMapper; Override GlobalTransactional(name transfer-tcc, rollbackFor Exception.class) public void transfer(String sourceUserId, String targetUserId, BigDecimal amount) { // 这里只是发起全局事务真正的TCC动作由各自的资源服务执行 accountFeignClient.tryDeduct(sourceUserId, targetUserId, amount); } }账户冻结的 Try 方法Service public class AccountActionServiceImpl implements AccountActionService { Autowired private AccountMapper accountMapper; Autowired private TccLogMapper tccLogMapper; Override TwoPhaseBusinessAction(name accountTryDeduct, commitMethod confirmDeduct, rollbackMethod cancelDeduct) Transactional(rollbackFor Exception.class) public boolean tryDeduct(String sourceUserId, String targetUserId, BigDecimal amount) { // 1. 防悬挂如果Cancel已经在Try之前执行过了则直接拒绝本次Try TccLog log tccLogMapper.selectByTransferId(sourceUserId _ targetUserId _ amount); if (log ! null log.getStatus() 3) { return false; } // 2. 预检查余额是否充足充足则冻结 int updated accountMapper.tryFreeze(sourceUserId, amount); if (updated 0) { throw new RuntimeException(账户余额不足); } // 3. 记录分支事务状态 TccLog tccLog new TccLog(); tccLog.setXid(RootContext.getXID()); tccLog.setBranchId(RootContext.getBranchId()); tccLog.setTransferId(sourceUserId _ targetUserId _ amount); tccLog.setStatus(1); tccLogMapper.insert(tccLog); return true; } Override Transactional(rollbackFor Exception.class) public boolean confirmDeduct(String sourceUserId, String targetUserId, BigDecimal amount) { // 1. 幂等校验 TccLog log tccLogMapper.selectByTransferId(sourceUserId _ targetUserId _ amount); if (log null || log.getStatus() 2) { return true; } // 2. 转账源账户冻结金额减少目标账户余额增加 accountMapper.confirmTransfer(sourceUserId, targetUserId, amount); // 3. 更新日志状态 tccLogMapper.updateStatus(log.getId(), 2); return true; } Override Transactional(rollbackFor Exception.class) public boolean cancelDeduct(String sourceUserId, String targetUserId, BigDecimal amount) { // 1. 空回滚校验如果Try还没执行Cancel直接返回成功 TccLog log tccLogMapper.selectByTransferId(sourceUserId _ targetUserId _ amount); if (log null) { return true; } // 2. 幂等校验 if (log.getStatus() 3) { return true; } // 3. 释放冻结金额 accountMapper.cancelFreeze(sourceUserId, amount); // 4. 更新日志状态 tccLogMapper.updateStatus(log.getId(), 3); return true; } }这里的逻辑看着不复杂但它把 TCC 的三个经典疑难杂症都处理了空回滚、幂等、悬挂。后面我有专门一节详细说这三个坑这里先记住一个关键点tcc_transaction_log表是 TCC 的“防弹衣”没有它你在高并发和超时场景下必出问题。3.3 空回滚、幂等、悬挂TCC绕不开的三个拦路虎这三个问题不是理论概念我在模拟系统里都真实复现过每个都是线上事故级别的坑。先看空回滚。想象一下Try 因为网络超时没有到达账户服务但全局事务已经判了“死缓”TC 通知各分支执行 Cancel。Cancel 启动后发现冻结记录根本不存在。如果你的 Cancel 里直接执行“释放冻结金额”的 SQL因为没冻结过SQL 影响行数为 0看似没问题但万一你的释放 SQL 写得不严谨、用了负值冲销就会把账户余额越冲越少。空回滚的标准答案就是Cancel 必须先检查 Try 有没有执行没执行就直接返回成功这就是代码里第一步校验的来源。再看幂等。分布式环境下网络不可能保证只调用一次Confirm 和 Cancel 都可能被 TC 重试。比如 Confirm 第一次执行成功结果网络波动TC 没收到响应重新发了一次 Confirm。你的confirmTransferSQL 再执行一遍就会把目标账户余额加上两次 1000。这就是幂等控制缺失的典型事故。解决办法就是用日志表的status或唯一键挡住重复请求。最后是悬挂。这是最阴险的。场景是Try 请求因为网络拥堵迟迟没到Cancel 反而先到了Cancel 判断日志表里没有记录直接返回成功。然后那个拥堵的 Try 请求又到了它一看日志表也没有记录正常执行冻结结果就是资金被冻结了但永远不会有人来释放形成“悬挂”。我的代码里做了处理Try 执行前先查日志表如果 status 已经是 3Cancel直接拒绝执行。这样虽然没法让资金自动释放但至少不会多出一笔永远解不开的冻结。这三个问题需要反复测试我建议在所有 TCC 分支事务里都强制写这套防御逻辑不要相信“我们的网络很稳定”这种话。4. XA与TCC横评一张表看清各自的能力边界4.1 六维对比一致性、侵入性、性能、可用性、复杂度、适用场景很多人选型时纠结 XA 和 TCC其实把核心维度列成一张表答案就清晰了对比维度XA 模式TCC 模式一致性强度强一致数据库原生保证最终一致需要业务补偿业务侵入性低只改数据源配置加注解高每个分支都要写 Try/Confirm/Cancel性能表现较差全局锁持有时间长较好无全局锁资源操作即时提交数据库依赖强依赖数据库支持 XA 协议不依赖什么数据库都行甚至可以是非数据库资源实现复杂度低几乎和本地事务一样高需要设计冻结表、日志表、防悬挂逻辑适用场景短事务、强一致、低并发冲突长事务、高并发、跨服务资源种类多这个表格不是拍脑袋做的是从代码行数和压测数据里一格格填出来的。我模拟系统里同样一个“转账”接口XA 实现核心代码是 30 行TCC 实现核心代码是将近 150 行。但压测结果里TCC 的 TPS 是 XA 的 3 倍以上因为转账操作大量集中在同一个账户行上XA 的锁等待太致命了。4.2 我的选型判断逻辑和几句口诀选型没有银弹但有几个判断顺序可以帮你快速缩小范围第一步看业务能否接受最终一致。如果扣款、转账、清结算这类强一致场景优先 XA或者 AT但不在本文范围。如果只是积分、优惠券、活动库存这类允许短暂不一致的TCC 完全够用。第二步看事务时长。如果事务里只有几个本地 SQL 加上一次快速 RPC时长能控制在 1 秒以内XA 稳妥。如果事务里有第三方接口、人工审核、消息推送这种不可控耗时果断 TCC 或者放弃强一致方案改用异步对账。第三步看团队维护能力。TCC 代码量大后续每次改需求都要同步改三套方法很容易改出漏钢。如果团队流动率高、文档不全我宁可让他们用 XA 这种“傻瓜式”方案也不要为了性能给自己埋个深坑。我自己的判断口诀是“短平快上 XA长活多变上 TCC实在吃不准先 AT 兜底再演进。”注意这里的前提是你对业务有足够深的理解而不是按性能指标去选。5. 实测踩坑清单我在模拟订单系统里先后踩过的五个问题5.1 库存扣减的XA长事务把数据库连接池打满了我第一次在模拟系统里把下单接口和物流查询接口放在同一个全局事务里本来以为没什么事。结果物流查询的第三方 API 平均响应 2 秒下单接口一压测数据库连接池立刻被打满。原因是 XA 模式下每个分支事务的数据库连接在 prepare 之后不能释放必须等全局事务的 commit 指令而那个指令要等物流查询返回后才能发出。等于一个下单请求霸占着数据库连接长达 3 到 4 秒连接池当然撑不住。排查链路是先看监控发现连接池活跃数持续高位然后抓线程栈定位到prepare之后的等待方法再往上找业务代码发现物流查询被包进了全局事务。解决办法很简单把物流查询挪出全局事务让它只做普通调用事务只留订单创建和库存扣减。5.2 账户冻结超时Cancel 先到导致空回滚这是我在 TCC 转账场景里踩的第一个坑。当时为了模拟网络抖动故意给 Try 里的select ... for update加了 3 秒延迟但全局事务超时时间设的是 1 秒。结果 TC 判定超时后直接发 CancelCancel 执行到一半Try 的 SQL 才姗姗来迟。因为没有日志表Cancel 里的cancelFreeze明明影响行数是 0但 Try 又执行了tryFreeze导致冻结金额一直悬空。所以我在代码里补了日志表并且在 Cancel 和 Try 开头都加了状态判断。这里特别提醒日志表的查询和状态更新必须和业务 SQL 在同一个本地事务里否则判断和操作之间会出现新的窗口期。5.3 Confirm 被重试两次目标账户余额多了一倍当时压测 TCC 转账发现目标账户余额偶发性地多出 1000。查日志发现 Seata 的 TC 在 Confirm 执行后没有及时收到 RM 的确认响应自动重试了一次 Confirm。我的confirmDeduct方法里没有幂等判断confirmTransfer的 UPDATE 语句没有唯一约束保护直接多执行了一次。解决方案就是前面的日志表status判断。另外我建议在业务表里尽可能加业务唯一键比如转账批次号用数据库本身的约束去兜底双保险。5.4 数据源配置顺序不对XA 代理没有生效这个坑比较隐蔽。我最初在配置类里同时定义了DataSource和DataSourceProxyXA但SqlSessionFactory注入的时候使用的是原始DataSource导致 Seata 的数据源代理根本没生效GlobalTransactional只是个摆设。排查时发现全局事务确实创建了但没有对应的分支事务上报日志里出现“branch transaction register failed”之类的信息。后来把SqlSessionFactory的入参改成DataSourceProxyXA问题解决。这里提醒一个检查技巧看 Seata 的全局事务日志是否把分支事务 ID 关联到了具体 SQL 执行如果关联不上十有八九是数据源代理没接到 MyBatis 或 JPA 的链路里。5.5 Seata 版本太低部分 TCC 注解参数不兼容模拟系统一开始用的 Seata 1.3.0结果发现TwoPhaseBusinessAction里的commitMethod和rollbackMethod的参数列表必须和 Try 完全一致但在 1.3.0 版本下传BusinessActionContext进去偶尔会读取不到参数。后来升级到 1.5.2并且统一了三个方法的参数签名问题消失。我的经验是Seata 的版本迭代对 TCC 支持变化很大建议直接用 1.5.x 以上版本1.3 及以下的历史版本不要用于新项目。6. 最后想说的几点实际体会这套模拟系统我前后跑了小半年最大的感受是分布式事务的难点从来不在框架 API而在你对业务数据的掌控程度。XA 模式代码写得少但因为锁和事务时长都要收敛在可控范围内反而要求你把业务拆分得很干净长调用必须隔离出去。TCC 模式代码写得多但每个方法的语义都很清楚Try 是冻结、Confirm 是落账、Cancel 是补偿出了问题可以通过日志表一步步还原现场。如果让我提一个优先级最高的建议那就是不管选 XA 还是 TCC一定要在核心方法里加上完整的日志记录和埋点。分布式事务故障的典型特征就是“只在一台机器上发生、只有一个请求触发、复现概率极低”没有日志你根本无从下手。我在模拟系统里给每个分支事务都打了 XID、branchId、方法名、状态流转的记录排查问题时一条日志链就能贯穿完整链路。另外不要试图用一个分布式事务组件解决所有数据一致性问题。下单场景用全局事务没问题但像报表统计、数据同步、异步通知这类场景用本地消息表加定时对账反而更可靠、成本更低。分布式事务是工具不是信仰什么时候用它、什么时候绕开它比怎么用它更值得花时间思考。如果你正准备在自己的系统里用 Seata 的 XA 或 TCC建议先照着我这套模拟环境把基础流程跑通然后把事务超时、网络异常、重复请求这三种故障分别注入进去看看系统能不能守住正确性。能守住再谈上线守不住说明你的防御逻辑还差得远。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑