分布式事务这个坑,做微服务的人基本都绕不开。我第一次在订单链路里引入 Seata 是在一个交易系统的重构项目上,当时的诉求很朴素:下单要同时写订单库、扣库存库、扣账户余额,任何一个环节失败,前面写进去的数据都得干净地退回去。AT 模式上手快,但遇到库存表有热点行、账户余额涉及复杂的冲正规则时,AT 的全局锁和反向 SQL 补齐就开始让人不踏实。后来把链路拆成两套:强一致要求高的资金类走 XA,业务逻辑复杂、需要精细控制资源占用的走 TCC。这两种模式在 Seata 里都不是"加个注解就完事",配置、建表、幂等、空回滚、悬挂,每一块都有具体的落地细节。
这篇内容面向的是已经能跑起来 Seata 单机 Demo、准备把 XA 和 TCC 真正用进业务代码的同学。我会把两种模式的协议差异、工程配置的必改项、TCC 三大经典问题的处理代码、故障注入验证方法,以及我自己踩过的那些坑,一条条摊开讲。看完之后你应该能做到:知道自己的业务该选哪个模式,能独立把两种模式跑通,并且在 Confirm 重试、Cancel 先到这种异常时序下不出错。
1. 先把场景摆清楚:XA 和 TCC 的选择题从哪来
1.1 一个订单链路的真实约束
先假设我们有一个最典型的交易场景:用户下单,订单服务写一条订单记录,库存服务扣减库存,账户服务扣减余额。三个服务三个库,没有跨库事务,但业务上要求它们要么全成、要么全败。
AT 模式下,Seata 会自动生成 undo_log,用反向 SQL 把数据改回去。这条路在 90% 的场景里够用,但它有两个隐含前提:一是业务表能被反向 SQL 干净还原,二是业务对全局锁的等待时间不敏感。库存扣减如果写成了update stock set qty = qty - 5 where sku_id = 1 and qty >= 5,AT 能处理;可如果业务里还夹着"扣完库存要同步写一条流水、流水的 id 由库存表自增生成",反向 SQL 的适用性就开始变得微妙,回滚之后自增序列不会退回去,流水号就断了。
XA 模式的思路完全不同。它把提交和回滚的权力交给了数据库本身——分支事务在 prepare 阶段就完成了数据写入并持有了行锁,TC 说提交才真正 commit,TC 说回滚就直接 rollback。业务代码不用关心怎么补偿,数据库保证原子性。代价也很直接:从 prepare 到 commit 这段时间,连接和锁一直被占着。
TCC 则是把控制权完全交还给业务。Try 阶段做资源预留(冻结库存、冻结金额),Confirm 阶段确认扣减,Cancel 阶段释放预留。Seata 只负责在全局事务结束的时候,根据结果去调用你写好的 commit 或 cancel 方法,不碰你的数据。所以 TCC 的一致性上限取决于你代码写得对不对,反过来说,性能上限也取决于你预留的粒度设计得好不好。
这三个模式的差别,本质上是"一致性由谁保证"和"资源锁定时间由谁控制"两个维度的排列组合。选型的时候不要一上来就问哪个性能好,先问自己:这段业务的数据能不能被自动反向还原?不能,就别在 AT 上硬扛,直接考虑 TCC。
1.2 两种模式在协议层的分水岭
把 XA 和 TCC 放在一起对比,最容易混淆的是"它们看起来都是两阶段"。确实都是两阶段,但阶段里干的事情完全不同。
| 维度 | XA 模式 | TCC 模式 |
|---|---|---|
| 第一阶段做什么 | 数据库层面的 prepare,数据已写入但未提交,持锁 | 业务层面的 Try,做资源预留,不真正扣减 |
| 第二阶段做什么 | 数据库 commit 或 rollback | 调用业务写的 Confirm 或 Cancel |
| 谁保证原子性 | 数据库的 XA 协议 | 业务代码的正确性 |
| 是否需要 undo_log | 不需要 | 不需要 |
| 是否需要业务改造 | 几乎零改造 | Try/Confirm/Cancel 三个方法都要写 |
| 锁持有时间 | 长,从 prepare 到全局 commit | 短,Try 结束即释放数据库锁,只留业务资源占用 |
| 回滚的确定性 | 高,数据库直接回滚 | 取决于 Cancel 写得对不对 |
| 空回滚/悬挂风险 | 不存在 | 必须处理 |
| 典型适用场景 | 资金、账务、短链路的强一致场景 | 库存、券、额度这类需要预留的资源 |
注意:XA 的"不需要 undo_log"这一点经常被搞混。AT 模式必须建 undo_log 表(每个业务库都要建),XA 模式依赖数据库自身的 XA 能力和 Seata 的 XA 数据源代理,不需要 undo_log。我见过有人把两种模式的建表脚本混着执行,跑起来没问题,但排查问题时会被误导。
这里还要澄清一个常见误解:XA 并不等于"性能差到不能用"。MySQL 的 InnoDB 从很早的版本就支持 XA,prepare 阶段的写操作开销和普通事务差别不大,真正拖慢的是锁和连接的持有时间。如果你的链路只有两三个分支、每个分支的执行时间在毫秒级,XA 的表现完全可接受。真正需要警惕的是长链路、慢 SQL 混在里面的场景——一个分支卡住十秒,整条链路的连接池都会被拖垮。
2. XA 模式落地:把两阶段提交交给数据库
2.1 从 TM 到 RM 的完整链路
Seata 的 XA 模式里,角色分工是清晰的。发起全局事务的服务是 TM,它向 TC 注册一个全局事务拿到 XID,然后通过 RPC 把这个 XID 传给下游分支。下游服务作为 RM,在收到请求后通过数据源代理拿到 XID,向 TC 注册分支,然后执行本地的 prepare。全局事务结束时,TM 根据结果通知 TC,TC 再逐个通知 RM 执行 commit 或 rollback。
这个链路里最容易出问题的环节是 XID 的传递。Seata 通过拦截器在服务调用时把 XID 塞进请求头,Feign、RestTemplate、Dubbo 都有对应的适配。如果你用的是自研的 RPC 框架或者消息队列做异步调用,XID 大概率传不过去,表现就是下游分支拿不到全局事务上下文,于是它自己开了一个本地事务提交了,回滚的时候根本回不去。我遇到过一次线上事故就是这么来的:下单走同步调用,扣积分走的是消息队列,结果订单回滚了,积分却扣掉了。解决办法是在消息头里手动透传 XID,消费端用RootContext.bind(xid)把上下文恢复回来,消费完再 unbind。
另一个容易忽略的点是数据源代理的生效范围。Seata 的 XA 代理需要把你的 DataSource 包一层DataSourceProxyXA,如果你的项目里有多个数据源、或者手动 new 了 JdbcTemplate,被包住的只有 Spring 容器里的那个数据源。所以在 XA 模式下,任何绕过注入数据源的数据库操作都不会参与全局事务——这类代码在测试环境很难暴露,因为回滚路径不常走,等到生产上真的回滚了一次才发现数据没退,代价就大了。
2.2 工程配置的几个必改项
XA 模式的配置比 AT 多几个关键项,核心是告诉 Seata"用 XA 代理"。
seata: enabled: true application-id: order-service tx-service-group: trade_tx_group enable-auto-data-source-proxy: true >XA RECOVER;这条命令会列出所有处于 prepared 状态的 XA 事务,包含 formatID、gtrid、bplength 等信息。gtrid 里通常编码了 Seata 的 XID,用它能对应上是哪个全局事务。处理方式有两种:等 TC 重试成功自动清理,或者是在确认业务已经不需要这个分支之后手动XA ROLLBACK 'xid'。手动回滚是最后手段,一定要确认清楚,否则可能把本该提交的数据回滚掉。
提示:把
XA RECOVER做成定时巡检任务,输出到监控里。我在一个资金系统上就是这么干的,每天凌晨跑一次,有残留就告警,比等业务方反馈"数据被锁住了"要主动得多。
3. TCC 模式落地:用三段式代码换回来的控制权
3.1 Try/Confirm/Cancel 的职责边界
TCC 的核心是把"扣减"拆成"预留"和"确认"。以账户扣款为例,Try 阶段不是直接扣余额,而是冻结:balance不动,frozen加上去。Confirm 阶段把frozen减掉、balance减掉,完成真正的扣款。Cancel 阶段把frozen减回去,余额恢复。
这个拆法的好处非常直接:Try 只是一个普通的本地事务,执行完就提交,数据库行锁立刻释放,不会像 XA 那样一直持锁到全局事务结束。真正的资源占用变成了业务层面的"冻结金额",粒度和时长都由你控制。
但代价也跟着来了。三个方法都要你自己写,而且必须满足几个约束:
- Try 要能判断资源是否充足,预留失败要让全局事务回滚;
- Confirm 必须能成功,不能有业务校验失败的分支,否则全局事务会卡在重试上;
- Cancel 必须能释放 Try 预留的资源,而且要在 Try 没执行的情况下也能安全返回。
最后一条是新手上路最容易翻车的地方。Confirm 里如果有if (balance < amount) throw new Exception()这种校验,一旦余额被其他事务改动了,Confirm 就永远成功不了,TC 会一直重试直到超时,脏数据就留在那里了。正确做法是把校验全部放到 Try 里,Confirm 只做无条件的确认操作。
@LocalTCC public interface AccountTccAction { @TwoPhaseBusinessAction(name = "accountTccAction", commitMethod = "confirm", rollbackMethod = "cancel", useTCCFence = true) boolean tryFreeze(@BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "amount") BigDecimal amount, BusinessActionContext context); boolean confirm(BusinessActionContext context); boolean cancel(BusinessActionContext context); }@BusinessActionContextParameter是关键,它把参数序列化后存进 TC,这样 Confirm 和 Cancel 执行时能通过context.getActionContext("amount")拿回原始参数。没有这个注解的参数不会保存,Confirm 里就取不到值,会直接空指针。这个坑我在第一次写的时候踩了,现象是 Confirm 报 NPE,日志里看不出所以然,最后对着 context 里的 map 打出来才发现是参数没存。
3.2 幂等、空回滚、悬挂的工程化处理
这三个问题是 TCC 的"老三位",换任何框架都绕不过去,必须一个一个处理。
幂等:Confirm 和 Cancel 都可能被 TC 重复调用。原因很多——网络超时导致 TC 认为失败并重试、RM 执行成功但响应丢了、TC 侧状态没更新。所以两个方法都必须做成"执行一次和执行 N 次结果一样"。实现方式通常是靠事务状态表加唯一索引,或者靠数据库的幂等条件(比如where status = 1)。
空回滚:Cancel 先于 Try 到达。典型场景是 Try 阶段还没执行完就超时了,TC 判定失败直接下发 Cancel,结果 Cancel 执行的时候业务数据还没冻结,什么都不该释放。如果不做处理,Cancel 里的frozen = frozen - amount会把别人的冻结金额减掉,直接造成资金错账。
悬挂:Try 在 Cancel 之后到达。Try 一旦在空回滚之后执行成功,预留的资源就永远没人释放了。所以 Try 里必须先检查有没有空回滚记录,有的话直接拒绝执行。
三个问题的处理可以统一到一张事务状态表上,这张表同时也承担了幂等的职责:
CREATE TABLE IF NOT EXISTS tcc_branch_log ( xid VARCHAR(128) NOT NULL COMMENT '全局事务ID', branch_id BIGINT NOT NULL COMMENT '分支事务ID', action_name VARCHAR(64) NOT NULL COMMENT '动作名', status TINYINT NOT NULL COMMENT '1=tried 2=confirmed 3=cancelled 4=empty-rollback', amount DECIMAL(18,2) DEFAULT NULL, gmt_create DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), gmt_modified DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (xid, branch_id), KEY idx_gmt_modified (gmt_modified) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意主键是(xid, branch_id)的组合,这个唯一约束就是幂等的第一道防线。xid 在 Confirm 和 Cancel 执行时可以从context.getXid()拿到,branchId 从context.getBranchId()拿,两者组合唯一标识一个分支。
3.3 fence 表与本地事务的配合
Seata 从 1.5 之后给 TCC 提供了 fence 机制,本质上就是框架帮你把这套状态表的逻辑做了一层封装,官方给的表叫tcc_fence_log。开没开、用不用,会直接影响你需不需要自己写状态判断。
CREATE TABLE IF NOT EXISTS tcc_fence_log ( xid VARCHAR(128) NOT NULL COMMENT 'global id', branch_id BIGINT NOT NULL COMMENT 'branch id', action_name VARCHAR(64) NOT NULL COMMENT 'action name', status TINYINT NOT NULL COMMENT 'status(tried:1;committed:2;rolledback:3;rollbacked:4)', gmt_create DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), gmt_modified DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (xid, branch_id), KEY idx_gmt_modified (gmt_modified), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;使用方式是在@TwoPhaseBusinessAction上设置useTCCFence = true,同时 Try 方法必须加上@Transactional,让 Try 的本地事务和 fence 记录写入在同一个事务里。这样如果 Try 的业务操作成功但 fence 记录写失败,整个本地事务回滚,不会出现"资源冻结了但没有记录"的状态。
这里有个细节必须说清楚:fence 的拦截逻辑依赖本地事务的边界。如果 Try 方法内部又调了别的服务、开了新的数据库连接,事务传播行为一旦变成REQUIRES_NEW,fence 记录和业务写入就分属两个事务,原子性就断了。所以 TCC 的 Try 方法要保持简单的单库操作,跨库的事情交给全局事务去做,别在 Try 里面又嵌套一层。
我自己现在的做法是:业务简单、只需要防重和防悬挂的,直接用 fence,省掉手写状态表;业务里需要在 Cancel 时做一些额外动作(比如发通知、记审计日志)的,还是自己维护状态表,因为 fence 的状态机是封闭的,你没法在它的流程里插入业务逻辑。两种方式不冲突,同一个项目里混着用也没问题,只是要统一团队认知,别几个人写几种风格。
4. 两套代码从零跑通的顺序
4.1 环境与依赖的准备工作
先把 TC 拉起来。用官方的 seata-server 包,存储模式选 db 或者 raft。file 模式虽然开箱即用,但重启会丢事务状态,生产上不能用。db 模式需要建三张表:global_table、branch_table、lock_table,脚本在 server 包的script/server/db目录下。
# 启动 TC,指定存储模式和注册中心 sh bin/seata-server.sh -p 8091 -m db \ -Dstore.db.url="jdbc:mysql://127.0.0.1:3306/seata?useSSL=false&serverTimezone=Asia/Shanghai" \ -Dstore.db.user=root \ -Dstore.db.password=xxxserverTimezone这个参数别省,我见过因为时区不一致导致全局事务超时判断错乱的,现象是事务明明几秒就结束了,TC 却认为超时,日志里全是回滚。
业务侧的依赖就是seata-spring-boot-starter,版本要和 TC 保持一致。版本不一致的坑很隐蔽:客户端和服务端之间的 RPC 协议在跨大版本时可能有变化,表现是注册成功但通信报错。统一版本,或者至少保证在同一个大版本内,是最省事的做法。
数据库侧的准备分两套:
| 模式 | 需要的表 | 说明 |
|---|---|---|
| XA | 无额外表 | 只依赖数据库 XA 能力和 InnoDB |
| TCC | tcc_fence_log(用 fence 时)或自建状态表 | 必须在每个参与 TCC 的业务库里建 |
| AT(对照) | undo_log | 用 AT 的时候才需要 |
很多人第一次切 XA 的时候习惯性地去建 undo_log,建完发现没用到,其实是白折腾。
4.2 XA 侧的改造步骤
第一步,改配置,把>@Bean public DataSource dataSource(DruidDataSource druidDataSource) { return new DataSourceProxyXA(druidDataSource); }
第二步,在入口方法上加@GlobalTransactional。这个注解加在 TM 的入口上,别加在中间层,加多了会产生嵌套的全局事务。
@GlobalTransactional(name = "create-order-xa", timeoutMills = 30000, rollbackFor = Exception.class) public Long createOrder(OrderCreateCmd cmd) { Long orderId = orderService.save(cmd); inventoryClient.deduct(cmd.getSkuId(), cmd.getCount()); accountClient.debit(cmd.getUserId(), cmd.getAmount()); return orderId; }第三步,确认下游服务也被 Seata 代理了。下游服务不需要@GlobalTransactional,但必须能被 XID 拦截器注入上下文,且数据源被代理。验证方法是在下游打个断点看RootContext.getXID()有没有值,没有值就说明拦截器没生效。
第四步,做一次正常的回滚测试。让最后一个分支抛异常,看前两个分支的数据有没有被回滚。这个测试必须在联调阶段做完,上线后再测成本太高。
4.3 TCC 侧的改造步骤
TCC 的改造量大一些,顺序上建议从资源预留设计开始,而不是从写代码开始。
先想清楚每类资源怎么预留。库存的预留是"可用库存转冻结库存",账户的预留是"可用余额转冻结余额",优惠券的预留是"状态从可用改成锁定"。这个映射关系定下来,代码就顺了。
然后是接口定义,@LocalTCC加在接口上,三个方法分别标@TwoPhaseBusinessAction、commitMethod、rollbackMethod。参数里凡是 Cancel 和 Confirm 要用到的,都必须加@BusinessActionContextParameter。
@Override @Transactional(rollbackFor = Exception.class) public boolean tryFreeze(Long userId, BigDecimal amount, BusinessActionContext context) { String xid = context.getXid(); Long branchId = context.getBranchId(); // 防悬挂:Cancel 已经先执行过,直接拒绝 TccBranchLog existed = branchLogMapper.selectOne(xid, branchId); if (existed != null && existed.getStatus() == EMPTY_ROLLBACK) { throw new IllegalStateException("branch already rolled back: " + xid); } // 幂等:Try 重复执行直接返回 if (existed != null && existed.getStatus() == TRIED) { return true; } int updated = accountMapper.freeze(userId, amount); if (updated == 0) { throw new BizException("余额不足,冻结失败"); } branchLogMapper.insert(xid, branchId, "accountTccAction", TRIED, amount); return true; }Cancel 这边的顺序要反过来,先判断有没有 Try 记录:
@Override @Transactional(rollbackFor = Exception.class) public boolean cancel(BusinessActionContext context) { String xid = context.getXid(); Long branchId = context.getBranchId(); TccBranchLog log = branchLogMapper.selectOne(xid, branchId); // 空回滚:Try 从没执行过,记一笔空回滚标记后直接成功 if (log == null) { branchLogMapper.insert(xid, branchId, "accountTccAction", EMPTY_ROLLBACK, null); return true; } // 幂等:已经取消过或已确认,直接返回 if (log.getStatus() == CANCELLED || log.getStatus() == CONFIRMED) { return true; } accountMapper.unfreeze(log.getUserId(), log.getAmount()); branchLogMapper.updateStatus(xid, branchId, CANCELLED); return true; }注意 Cancel 里释放冻结用的金额是从状态表里读的,不是从 context 里读的。这样做的好处是即使 context 参数丢失,Cancel 也能正确释放;坏处是多一次查询。我倾向于读表,因为金额这类东西,多一次查询换确定性是值得的。
Confirm 的实现类似,只做无条件确认,不做任何业务校验,执行完把状态置为 CONFIRMED。
5. 故障注入验证:确认回滚真的发生了
5.1 三种故障点的注入方法
代码写完不代表能回滚。分布式事务最难验证的就是"异常路径真的走通了",所以我习惯在上线前做三轮故障注入。
第一轮,业务异常回滚。在链路的最后一个分支手动抛异常,检查前面分支的数据是否回滚。这一轮验证的是基础链路通不通。XA 模式下看分支表的数据是否恢复原值,TCC 模式下看冻结金额是否被释放。
第二轮,超时回滚。在某个分支里加Thread.sleep,把耗时拉到超过全局事务超时时间(默认 60 秒,我一般在注解上显式设成 30 秒以内)。这一轮验证的是 TC 的超时判定和回滚下发是否正常。
@GlobalTransactional(name = "create-order-tcc", timeoutMills = 15000, rollbackFor = Exception.class) public Long createOrder(OrderCreateCmd cmd) { // ... }超时回滚最容易暴露的问题就是空回滚和悬挂。因为超时的时候,有些分支可能还没开始执行,TC 就下发 Cancel 了,正好命中空回滚路径。跑完这一轮,去状态表里查status = 4的记录,有记录说明空回滚逻辑被触发了,再看后续 Try 有没有正确拒绝。
第三轮,进程中断。在 Try 执行完之后、全局事务提交之前,把 RM 所在的服务进程 kill 掉。这一轮验证的是 TC 的自动重试能力。重启之后观察日志,正常情况下 TC 会按配置的重试次数和间隔反复下发 Confirm,最终把所有分支推进到完成状态。重试次数和间隔在 TC 侧配置,server.recovery.committing-retry-period是提交重试间隔,rollbacking-retry-period是回滚重试间隔,超时时间server.recovery.timeout-retry-period决定多久判定为超时。
这一轮还有个附带检查项:TC 的重试只在事务状态处于 committing 或 rollbacking 时生效,如果事务因为超过最大重试次数被标记成 dead 状态,就不会再自动推进了。所以第三轮跑完一定要去global_table里看事务状态,出现 dead 就要靠人工介入。
5.2 观察日志与数据核对
验证不能只看接口有没有报错,数据必须逐个字段核对。我一般准备三张核对清单:
| 检查项 | XA 模式 | TCC 模式 |
|---|---|---|
| 分支表数据 | 原值未变 | 冻结字段归零 |
| 状态表 | 无残留 prepared 事务 | 状态为 cancelled 或 confirmed |
| 日志 | RM 收到 rollback 指令 | Cancel 方法有执行记录 |
| 连接池 | 无长时间占用 | 无异常增长 |
| 数据库锁 | XA RECOVER无记录 | 无行锁等待 |
核对的时候有个小技巧:给每次测试用不同的业务单号,测完按单号查所有相关表,一眼就能看出哪张表没退回去。用同一个单号反复测容易把数据搞混,尤其是 TCC 的冻结字段,同一个用户多测几轮,冻结余额的数值会对不上。
日志这边要在 Try、Confirm、Cancel 三个方法入口都打印 xid 和 branchId,最好用 MDC 串起来。出问题的时候按 xid 过滤一次,整条链路的执行顺序就出来了。TCC 的排查效率几乎完全取决于日志打得好不好,因为整个流程散落在三个方法里,没有完整日志就只能靠猜。
6. 踩坑清单与选型结论
6.1 高频报错与定位顺序
下面这几个报错我在不同项目里反复见过,按出现频率排一下。
no available service 'default' found:客户端找不到 TC。九成是vgroup-mapping配的和tx-service-group对不上,或者 TC 的地址填错了。如果是注册中心模式,还要确认客户端和服务端在同一个命名空间、同一个分组下。
Global transaction is not active:全局事务已经结束了,下游还在往这个事务里注册分支。常见于异步调用,或者全局事务超时之后下游才慢吞吞地执行。解决办法是把耗时操作挪出全局事务范围,或者适当调大超时时间——但调超时是治标,根子还是链路太长。
Branch transaction already committed:TCC 的 Confirm 被重复执行,而你的代码里没做幂等。要么状态表判断漏了分支,要么@BusinessActionContextParameter的参数在重复执行时变了导致状态查不到。按 xid 去状态表捞一下记录就能确认。
XAER_RMFAIL:XA 分支的数据库连接失效,通常是数据库重启或者连接超时。这类错误 TC 会重试,但如果数据库那边的 prepared 事务已经断了,重试也成功不了,得靠XA RECOVER兜底。
定位顺序我总结成一条:先看 xid 有没有正常传递(拦截器生效没),再看 TC 里这个事务的状态(active / committing / rollbacking / dead),最后看分支执行日志。这三步能覆盖 80% 的问题,剩下的基本是配置和版本不一致。
6.2 一张表看清怎么选
回到最初的问题,到底选哪个。我的判断顺序是这样的:
| 判断条件 | 倾向 XA | 倾向 TCC |
|---|---|---|
| 分支数量 | 2 到 3 个 | 4 个以上,链路长 |
| 单分支耗时 | 毫秒级 | 包含远程调用或复杂计算 |
| 业务能否改造 | 不想改造 | 可以投入改造 |
| 资源属性 | 直接扣减 | 需要预留(库存、额度、券) |
| 一致性要求 | 强一致 | 最终一致可接受 |
| 热点资源 | 少 | 多,需要控制锁粒度 |
我的实际做法是分层:资金扣减、账务记账这类不能出错的短链路走 XA,因为业务改造收益低、风险高;库存、优惠券、额度这类天生适合预留的资源走 TCC,因为预留本身就是业务上一个自然的动作。同一个系统里两种模式混用是完全正常的,Seata 支持同一个全局事务里混跑不同模式的分支。
混用的时候有一个必须注意的点:AT 的全局锁和 XA 的数据库锁作用范围不一样,如果同一个资源表有两个分支分别用 AT 和 XA 操作,锁的冲突行为会很混乱。我的原则是一张业务表只允许一种模式访问,跨表的事情交给全局事务串。
还有一种情况值得单独提一句:如果业务能接受最终一致、并且有天然的对账机制,那就不一定要上 TCC,本地消息表加重试可能更简单。分布式事务框架解决的是"过程一致性",不是"最终正确性",有对账兜底的业务,把复杂度压到对账链路上往往更划算。
我个人在实际操作中的体会是,XA 和 TCC 的代码量差距没有想象中那么大,真正的差距在排查成本上。XA 出问题时,绝大部分线索在数据库层面,XA RECOVER加上事务表就能定位个八九不离十;TCC 出问题时,线索散在三个方法的日志和状态表里,你得像拼图一样把执行顺序拼出来。所以如果团队的监控和日志基础比较薄弱,哪怕业务适合 TCC,也建议先用 XA 顶上,等可观测性建好了再迁。反过来,如果业务链路长、分支多、性能压测怎么都过不了,那该吃的改造代价就得吃,别指望在 AT 或 XA 上硬调参数能调出 TCC 的效果。
还有一个可以往后延展的方向:把 TCC 的 Confirm 和 Cancel 做成非阻塞的异步执行,Try 阶段返回之后就让全局事务尽快结束,Confirm 走独立线程池,这样能进一步压缩资源占用和响应时间,代价是对状态机的可靠性要求更高,需要考虑任务丢失的补偿。这个思路在小规模验证的时候效果不错,但真上生产之前,得先把状态表的清理策略想清楚,否则那个表会一直长下去。