分布式事务里TCC模式最难缠的三个问题,几乎每个做支付、订单、库存这类强一致场景的团队都会撞上:幂等、悬挂、空回滚。这三个词听起来像三座大山,实际上它们都源于同一个根源——TCC把"两阶段提交"的协调责任交给了业务代码,而网络是不可靠的,协调者会重试、会超时、会宕机,于是同一个Try可能被调两次,Cancel可能比Try先到,Cancel也可能对着一个根本不存在的Try执行。Apache Seata作为国内用得最广的分布式事务框架之一,在TCC模式下对这三个问题给出了框架级的解法,但很多人只知道"Seata能防悬挂",却说不清它到底在哪一层防、靠什么防、防到什么程度、哪些还得自己兜底。
我做过几个基于Seata TCC的落地项目,踩过的坑基本都集中在这三件事上。这篇就把Seata TCC模式下幂等、悬挂、空回滚的完整机制拆开讲清楚:框架替你做了什么、事务控制表长什么样、状态流转是怎么卡住异常调用的、以及哪些边界情况框架管不了必须业务自己补。不管你是刚接触TCC的新手,还是已经在用Seata但被重复回滚搞到头大的老手,都能从里面找到能直接抄的配置和代码思路。
1. 先把三个问题的病根挖出来,别急着上框架
在讲Seata怎么解之前,得先把这三个问题各自的触发路径讲透。很多人一上来就背"幂等就是重复调用结果一致",但真到线上排查时根本对不上号,因为不知道它是从哪条链路冒出来的。TCC的三个异常本质上都是调用时序错乱和网络重试的产物,理解了这个前提,后面看Seata的设计才会有"原来如此"的感觉。
1.1 幂等:为什么Try和Cancel都可能被重复执行
TCC的三个方法Try、Confirm、Cancel都是由事务协调者(在Seata里是TC,Transaction Coordinator)来驱动调用的。协调者发现某个分支事务超时没响应,会重试;Confirm阶段某个参与者没回执,会重试;甚至参与者自己处理完但回执丢了,协调者照样重试。这就意味着同一个Try、同一个Confirm、同一个Cancel都可能被执行多次。
Try重复执行相对好处理,因为Try一般是资源预留(比如冻结库存、预扣余额),重复冻结会导致资源被多扣,这是实打实的资损。Confirm重复执行问题不大,因为Confirm是"确认",本身就该设计成幂等。真正要命的是Cancel重复执行——退款退两次,用户直接多拿钱。所以幂等的核心战场在Try和Cancel,尤其是Cancel。
我见过一个典型事故:库存服务的Cancel方法里写的是update stock set frozen = frozen - 10 where id = ?,没有做任何判断。协调者因为网络抖动重试了一次Cancel,结果冻结库存被减了两次,账面对不上,最后靠人工对账才捞回来。这就是没做幂等的代价。
1.2 悬挂:Cancel跑到了Try前面
悬挂这个词很形象——一个事务的Cancel执行了,结果它的Try后来才到,这个Try就像被"悬挂"在空中,永远等不到属于自己的Confirm或Cancel,资源被白白冻结。触发场景是这样的:Try请求因为网络延迟卡在半路,协调者等超时了,判定这个分支失败,于是发起Cancel。Cancel先到达并执行成功(此时Try还没到,Cancel对着一个"空"执行,这就是空回滚,后面讲)。然后那个迟到的Try终于到达并执行,冻结了资源。可此时整个全局事务早就回滚结束了,不会再有Confirm或Cancel来释放这笔冻结——资源就泄漏了。
悬挂的危害是资源泄漏,不像幂等那样直接资损,但日积月累会把库存、额度这些池子耗干。而且它很隐蔽,因为每一步单独看都"成功"了,只有把时序拼起来才看得出问题。
1.3 空回滚:Cancel对着一个没执行过的Try
空回滚指的是Cancel执行时,对应的Try根本没执行(或者还没执行)。协调者为什么会发起一个没有Try的Cancel?还是超时判定:Try请求超时,协调者认为分支失败要回滚,于是发Cancel。但Try可能只是慢,不是没执行;也可能Try确实没到。无论哪种,Cancel都会被执行,而此时没有冻结过任何资源,Cancel里的"解冻""退款"操作如果无脑执行,就会把不属于这笔事务的资源给退了——比如把别人冻结的库存解冻了,或者给用户退了一笔没扣过的钱。
空回滚和悬挂是一对孪生问题,经常同时出现:Cancel先到(空回滚),Try后到(悬挂)。所以业内的标准解法是把它们放在一起处理,Seata也是这么设计的。
注意:这三个问题的判断依据都是"当前这笔分支事务处于什么状态",所以解法核心一定是持久化分支事务的执行状态,靠状态机来拦截非法调用。任何不落状态、只靠内存判断的方案,在分布式环境下都不可靠。
2. Seata TCC的事务控制表:状态机才是解法的地基
Seata解决这三个问题的核心思路,是在参与者侧维护一张分支事务状态记录表(业内常叫tcc_fence_log,Seata的TCC模式默认就叫这个名字),每次Try、Confirm、Cancel执行前都先查/写这张表,用状态流转来卡住重复和乱序调用。理解这张表的结构和状态机,是理解全部机制的前提。
2.1 tcc_fence_log表的结构与字段含义
Seata的TCC模式(准确说是TCC Fence模式)会在业务库中自动建一张tcc_fence_log表,核心字段大致如下(不同版本略有差异,以实际建表语句为准):
| 字段 | 含义 | 作用 |
|---|---|---|
| xid | 全局事务ID | 标识属于哪个全局事务 |
| branch_id | 分支事务ID | 标识全局事务下的哪个分支 |
| action_name | 动作名(try/confirm/cancel) | 区分当前是哪个阶段 |
| status | 状态 | 记录该分支当前进展,是拦截的核心 |
| gmt_create | 创建时间 | 用于超时清理和排查 |
| gmt_modified | 修改时间 | 同上 |
这张表的关键在于以(xid, branch_id)为唯一约束。也就是说,一个全局事务下的一个分支,在表里最多只有一行记录(或者按action分多行,取决于实现版本),所有状态判断都围绕这一行展开。为什么用唯一约束?因为并发重试时,两个请求可能同时想插入同一行,唯一约束能保证只有一个成功,另一个直接冲突失败——这本身就是一道幂等锁。
状态值一般有这么几个关键态:STATUS_TRY(已Try)、STATUS_COMMITTED(已Confirm)、STATUS_ROLLBACKED(已Cancel)。有的版本还会用suspended之类的中间态。状态机的流转规则是死的:Try只能从"无记录"流转到"已Try";Confirm只能从"已Try"流转到"已Confirm";Cancel则允许从"无记录"或"已Try"流转到"已Cancel"。
2.2 为什么状态要落在业务库而不是独立存储
这是个容易被忽略但很关键的设计选择。Seata把fence表建在业务数据源里,而不是单独搞一个存储,原因是:Try阶段往往要操作业务数据(冻结库存、预扣余额),这些操作和fence记录的写入必须在同一个本地事务里完成。如果fence表在另一个库,就没法保证"业务操作成功"和"状态记录成功"的原子性——可能出现业务冻结了但状态没记上,或者状态记上了业务没冻结,状态机就失真了。
所以正确做法是:Try方法内部,业务更新和fence插入放在同一个@Transactional本地事务里,要么都成,要么都败。这一点在写代码时特别要注意,如果你的Try里调了远程服务或者有跨库操作,本地事务就兜不住了,得重新设计。
2.3 状态机如何天然拦住重复调用
有了这张表和状态约束,三个问题在理论上就有了拦截点:
- 重复Try:第二次Try想插入(xid, branch_id)记录,撞唯一约束,直接失败,业务操作不执行。
- 重复Cancel:第一次Cancel把状态改成"已Cancel",第二次Cancel查到状态已是"已Cancel",直接返回成功,不再执行业务解冻。
- 空回滚:Cancel执行时查不到Try记录(或状态不是"已Try"),说明Try没执行过,Cancel直接标记为"已Cancel"但不执行业务逻辑。
- 悬挂:Cancel已经把状态置为"已Cancel",迟到的Try想插入记录时发现已存在"已Cancel"记录,拒绝执行Try。
你看,四个异常场景全部被状态机覆盖了。这就是为什么我说"状态机才是地基"——Seata的TCC Fence本质上就是把这张表和这套判断逻辑用框架的方式固化下来,让业务不用每次手写。
3. Try阶段的防悬挂:插入即占位,迟到者一律拒绝
Try是整条链路的起点,它的防悬挂逻辑决定了后面Cancel能不能正确判断。Seata在Try阶段的处理可以概括成一句话:先占位,再干活,占位失败就不干活。
3.1 Try执行的完整判断链路
Seata TCC Fence模式下,一个Try方法被调用时,框架(通过代理或拦截器)会按下面的顺序处理:
- 根据当前全局事务xid和分支branch_id,去
tcc_fence_log查记录。 - 如果查不到记录:说明这是第一次Try,插入一条status为"已Try"的记录,然后执行业务Try逻辑(冻结资源等),整个过程在一个本地事务里。
- 如果查到记录且状态是"已Try":说明Try重复了,直接返回成功(或按幂等语义返回),不重复执行业务。
- 如果查到记录且状态是"已Cancel":说明Cancel已经先执行过了,这就是典型的悬挂场景,拒绝执行Try,直接返回失败或按约定处理。
第4步是防悬挂的关键。因为Cancel先到意味着全局事务已经判定回滚,这个迟到的Try没有任何意义,执行了只会泄漏资源。框架在这里直接把它挡掉,业务代码根本不会被调用。
3.2 业务代码该怎么配合写
框架帮你做了判断,但业务代码得按规范写才能生效。核心要求是:Try方法必须是一个本地事务,业务操作和fence记录写入要原子。用Seata的注解方式大致是这样:
@TwoPhaseBusinessAction(name = "inventoryTccAction", commitMethod = "confirm", rollbackMethod = "cancel") public boolean try(BusinessActionContext context, @BusinessActionContextParameter(paramName = "skuId") Long skuId, @BusinessActionContextParameter(paramName = "count") Integer count) { // 业务操作:冻结库存 int affected = inventoryMapper.freeze(skuId, count); if (affected <= 0) { throw new RuntimeException("库存不足,冻结失败"); } return true; }上面这段是业务逻辑,fence的插入和状态判断由Seata的TCC切面自动完成(前提是开启了TCC Fence,即配置了tcc.fence.enabled=true,并且数据源被Seata代理)。这里有个容易踩的坑:fence表和业务表必须在同一个数据源,否则本地事务管不住两张表,状态机就失效了。我见过有人把fence表建在另一个库,结果重复Try照样穿透,排查了半天才发现是数据源配错了。
提示:如果你的Try里有调用外部服务(比如调另一个系统的接口),那这个Try就不是纯本地事务了,fence机制只能保证本地这部分的一致性。这种场景要么把外部调用挪到Confirm里,要么用可靠消息等方案补,别指望fence能兜住跨系统的悬挂。
3.3 悬挂被拦住之后,资源真的安全了吗
这里要泼一盆冷水:框架拦住迟到的Try,确实避免了"Try执行后资源泄漏",但有个前提——Cancel必须真的先执行过并且留下了"已Cancel"记录。如果Cancel因为某些原因没执行(比如协调者自己挂了),那迟到的Try照样能插入"已Try"记录并冻结资源,此时全局事务已经结束,没人来Confirm或Cancel,资源还是泄漏。
所以严格来说,Seata的fence机制解决的是"Cancel已执行情况下的悬挂",对于"Cancel压根没执行"的极端情况,得靠全局事务的超时补偿或者定时的对账清理来兜底。这一点很多资料不讲,但实际运维中必须考虑。我的做法是给fence表加一个定时任务,扫描超过一定时间还停留在"已Try"状态的记录,结合业务侧的对账逻辑做补偿释放。
4. Cancel阶段的双重防护:空回滚识别与幂等短路
Cancel是三个问题里最危险的一环,因为它直接涉及退款、解冻这类"往外吐资源"的操作。Seata在Cancel阶段的处理比Try更精细,要同时解决空回滚和重复Cancel两件事。
4.1 Cancel执行时如何识别空回滚
Cancel被调用时,框架同样先查fence记录,判断逻辑是这样的:
- 查不到Try记录:说明Try从没执行过,这就是空回滚。框架的处理是——插入一条status为"已Cancel"的记录,但不执行业务Cancel逻辑,直接返回成功。为什么返回成功?因为对协调者来说,回滚这个分支的目的已经达到了(本来就没做任何事,无需回滚),返回成功可以让全局事务顺利结束。
- 查到记录且状态是"已Try":正常情况,Try执行过,需要回滚。框架把状态更新为"已Cancel",然后执行业务Cancel逻辑(解冻资源)。
- 查到记录且状态是"已Cancel":说明Cancel重复了,直接返回成功,不重复执行业务。
第1步就是空回滚的标准解法:用"有没有Try记录"来判断该不该真正回滚。没有Try记录就只留痕不干活,既满足了协调者的回滚语义,又不会误伤资源。
4.2 重复Cancel的幂等短路是怎么实现的
重复Cancel的拦截靠的是状态判断加唯一约束的双保险。第一次Cancel把状态从"已Try"改成"已Cancel"并提交,第二次Cancel进来查到状态已是"已Cancel",直接短路返回。即使两个Cancel并发进来,状态更新那一步(update ... where status = '已Try')只有一个能成功,另一个更新影响行数为0,框架据此判定为重复,同样短路。
这里的状态更新语句带where status = '已Try'这个条件特别重要,它是乐观锁的写法。如果写成无条件的update ... set status = '已Cancel',并发下两个请求都能"成功",业务逻辑就可能被执行两次。Seata内部用的就是带条件的更新,这也是为什么它能扛住并发重试。
4.3 业务Cancel方法必须自己再兜一层
框架的fence机制很强,但我强烈建议业务Cancel方法内部再写一层幂等判断,作为最后一道防线。原因有两个:一是fence机制依赖配置正确(数据源代理、fence开关、表存在),配置一旦出问题框架就失灵;二是有些团队会用非fence的普通TCC模式,那就完全靠业务自己保证。
业务层的幂等写法通常是查业务状态再决定是否操作,比如:
public boolean cancel(BusinessActionContext context) { Long skuId = Long.valueOf(context.getActionContext("skuId").toString()); Integer count = Integer.valueOf(context.getActionContext("count").toString()); // 业务侧幂等:只有处于"已冻结"状态才解冻 int affected = inventoryMapper.unfreezeIfFrozen(skuId, count); // affected 为 0 说明已经解冻过或从未冻结,直接返回成功 return true; }对应的SQL类似update inventory set frozen = frozen - #{count}, available = available + #{count} where sku_id = #{skuId} and frozen >= #{count},用frozen >= count这个条件保证不会把冻结量减成负数。这种"条件更新+影响行数判断"是业务幂等的通用套路,配合fence机制形成双保险。
5. 状态流转全景:把Try、Confirm、Cancel串成一条线
单看每个阶段的处理还不够,得把三个阶段的状态流转拼成一张完整的图,才能理解为什么这套机制是自洽的。下面用文字把关键路径走一遍,重点看异常时序下状态怎么变。
5.1 正常路径下的状态演进
正常情况:Try插入"已Try"→Confirm把状态改成"已Confirm"→结束。或者Try插入"已Try"→Cancel把状态改成"已Cancel"→结束。两条路径都干净,没有歧义。
Confirm阶段其实也需要幂等,虽然它不涉及资源释放,但重复Confirm可能触发重复的后续动作(比如发通知、记账)。Seata对Confirm的处理和Cancel类似:查状态,已是"已Confirm"就短路,是"已Try"就更新并执行,查不到记录则视实现决定是否报错(正常不该出现Confirm先于Try)。
5.2 异常时序下的状态拦截效果
把几个经典异常时序列出来对比,能直观看到状态机的作用:
| 异常时序 | 无fence时的后果 | 有fence时的处理 |
|---|---|---|
| Try→Try(重复) | 资源重复冻结 | 第二次撞唯一约束,拒绝 |
| Cancel→Try(悬挂) | Try冻结后泄漏 | Try查到"已Cancel",拒绝执行 |
| Cancel(无Try,空回滚) | 误释放他人资源 | 查无Try记录,只留痕不执行 |
| Cancel→Cancel(重复) | 重复退款 | 第二次查到"已Cancel",短路 |
| Try→Cancel→Cancel | 重复退款 | 同上,短路 |
这张表基本就是TCC异常处理的"标准答案"。你会发现所有拦截都指向同一个动作:查状态、比状态、按状态决定干不干活。Seata把这套逻辑封装进框架,业务只需要按规范写三个方法、配好fence,剩下的交给状态机。
5.3 Confirm和Cancel的先后竞争怎么处理
理论上Confirm和Cancel不会同时发生(全局事务要么提交要么回滚),但极端情况下(比如协调者脑裂、消息乱序)可能出现Confirm和Cancel都到达参与者的情况。fence的状态机对此的处理是:谁先到谁改状态,后到的发现状态不匹配就拒绝。比如Cancel先到把状态改成"已Cancel",Confirm后到发现状态不是"已Try",就无法更新,框架会报错或按配置处理。这种冲突属于全局事务层面的异常,fence只能保证参与者侧不被重复操作,全局层面的冲突还得靠TC的幂等和超时机制。
6. 落地时的配置与代码清单,照着配就行
讲完原理,落到实操。这一节把Seata TCC Fence模式从依赖、配置到建表、代码的完整清单列出来,都是可以直接抄的。
6.1 依赖与fence开关配置
首先引入Seata的依赖(版本按你项目实际选,这里只示意):
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.7.0</version> </dependency>然后在配置文件里开启TCC Fence:
seata: enabled: true application-id: inventory-service tx-service-group: my_tx_group tcc: fence: enabled: true # 是否自动建表,生产环境建议关掉,手动建 auto-create-table: falseauto-create-table这个开关要特别注意:开发环境开着方便,它会自动帮你建tcc_fence_log表;生产环境建议关掉,手动执行建表脚本,避免启动时因权限或并发建表出问题。建表脚本Seata的仓库里有,核心就是前面说的那几个字段加唯一索引。
6.2 数据源代理这个坑必须提前排
fence机制依赖Seata对数据源做代理,才能拦截SQL、管理本地事务和fence记录。如果你用的是Spring Boot自动配置,通常Seata会帮你代理;但如果你自己手动配了多数据源、或者用了某些连接池的特殊配置,代理可能失效,表现就是fence表不写入、状态判断不生效。
排查方法:启动后看日志里有没有数据源被Seata代理的提示,或者直接跑一个Try看tcc_fence_log有没有记录。没有记录就是代理没生效,检查seata.enable-auto-data-source-proxy是否为true,以及数据源是不是被其他框架抢先代理了(比如某些分库分表中间件也会代理数据源,容易冲突)。
6.3 三个方法的完整代码骨架
把Try、Confirm、Cancel三个方法的骨架给出来,重点看参数传递和幂等写法:
@LocalTCC public interface InventoryTccAction { @TwoPhaseBusinessAction(name = "inventoryTccAction", commitMethod = "confirm", rollbackMethod = "cancel") boolean tryFreeze(BusinessActionContext context, @BusinessActionContextParameter(paramName = "skuId") Long skuId, @BusinessActionContextParameter(paramName = "count") Integer count); boolean confirm(BusinessActionContext context); boolean cancel(BusinessActionContext context); }几个要点:@LocalTCC表示这是本地TCC(fence模式要求),@TwoPhaseBusinessAction声明两阶段方法名,@BusinessActionContextParameter把业务参数塞进上下文,这样Confirm和Cancel才能从context里取到Try时的参数。参数传递是TCC最容易出错的地方之一——Cancel里取不到skuId,多半是忘了加这个注解或者参数名对不上。
6.4 参数传递与上下文使用的注意事项
BusinessActionContext里除了你显式塞的业务参数,还有xid、branchId这些框架参数。Cancel和Confirm里取业务参数一定要用context.getActionContext("参数名"),参数名必须和Try上注解里的paramName完全一致,大小写都不能错。我踩过一次坑:Try上写的是paramName = "skuId",Cancel里取的是"skuID",结果取出来是null,解冻逻辑直接空指针,排查了半天。
另外,参数如果是复杂对象,要确保它能被序列化,因为跨服务调用时上下文要经过网络传输。简单类型(Long、Integer、String)最稳,复杂对象建议拆成基本字段传。
7. 框架兜不住的边界,这些坑得自己填
Seata的fence机制覆盖了绝大多数场景,但它不是银弹。实际落地中有些边界情况框架管不了,必须业务侧补,这一节把我踩过的和见过的坑集中说一下。
7.1 fence表膨胀与清理策略
tcc_fence_log会随着事务量持续增长,如果不清理,几个月就能到千万级,查询变慢、影响Try的性能。Seata本身不自动清理历史记录,得自己搞。常见做法是加定时任务,删除gmt_create早于一定时间(比如7天)且状态为终态(已Confirm或已Cancel)的记录。注意别删还在"已Try"状态的记录,那可能是悬挂或未完成的事务,删了会破坏状态判断。
清理任务的频率和保留时长要结合业务量定。事务量大的系统,建议按天分区或者定期归档,别让单表无限涨。这个事看着琐碎,但不做迟早出问题。
7.2 跨服务调用场景下fence的局限
前面提过,fence依赖本地事务。如果你的Try里调了远程服务,那部分操作不在本地事务里,fence管不住。典型场景是:订单服务的Try里既要冻结自己的订单状态,又要调库存服务冻结库存。这时候订单侧的fence只能保证订单状态不被重复冻结,库存侧得靠库存服务自己的fence。两个服务各自管好自己的fence,通过全局事务xid关联,这是正确姿势。
但如果你在Try里调了一个没有实现TCC的普通服务,那这个调用就完全在事务保护之外了,失败或重复都没人管。这种要么改造那个服务支持TCC,要么用Saga/可靠消息替代,别硬塞进TCC里。
7.3 空回滚返回值的语义陷阱
空回滚时框架返回成功,这个语义要理解清楚:它表示"回滚这个分支这件事已经完成",不代表"业务上真的回滚了资源"。如果你的上层逻辑依赖Cancel的返回值做判断(比如根据返回值决定要不要发补偿消息),就会误判。正确做法是:Cancel的返回值只用于向协调者报告回滚是否成功,业务补偿逻辑应该基于业务状态判断,而不是基于Cancel的返回值。
7.4 高并发下唯一约束冲突的处理
高并发重试时,多个Try同时插入同一(xid, branch_id)记录,唯一约束会让其中一个成功、其余抛异常。Seata内部会捕获这个异常并转成"重复Try"的语义处理,但如果你自己也在业务代码里捕获了数据库异常,可能把框架的异常吞掉,导致状态判断错乱。所以业务代码里不要随意catch数据库层的DuplicateKeyException,让框架去处理。
8. 一套可复用的自检清单
最后给一份上线前可以逐条过的自检清单,都是血泪教训换来的:
- fence开关是否开启,数据源是否被正确代理(跑一个Try看表里有没有记录)。
tcc_fence_log表和业务表是否在同一个数据源。- Try方法是否是纯本地事务,有没有混入远程调用。
- 三个方法的
paramName是否完全一致,Cancel/Confirm能否正确取到参数。 - 业务Cancel是否加了条件更新做二次幂等。
- 是否有fence表的定时清理任务。
- 是否有针对"已Try"悬挂记录的定时对账补偿。
- 生产环境是否关闭了
auto-create-table,改为手动建表。
这套清单我在两个项目里用过,基本能把TCC的常见坑挡在发布前。TCC本身不复杂,复杂的是各种异常时序,而Seata的fence机制把最难的时序判断用状态机固化了下来。理解状态机、配好fence、业务侧再兜一层,这三件事做到位,幂等、悬挂、空回滚就不再是玄学问题,而是可以逐条验证的工程问题。