分布式事务这块,TCC 模式几乎是绕不开的一个话题。但凡你用过 Seata 的 TCC 模式,大概率都碰到过这三个让人头疼的问题:幂等、悬挂、空回滚。这三个词听起来像是某种玄学黑话,但只要你真正在生产环境跑过 TCC 业务,就会明白它们每一个都能让你的数据一致性直接崩盘。我接触过不少团队,业务逻辑写得漂漂亮亮,结果一上压力测试,账户余额对不上、订单状态错乱、库存扣成负数,追根溯源最后都落到这三个坑上。Apache Seata 作为国内落地最广的分布式事务框架之一,它对 TCC 模式这三个问题的处理方案值得好好拆一拆。这篇内容适合正在用或准备用 Seata TCC 模式的后端开发、架构师,也适合那些被分布式事务折磨过、想搞清楚底层到底怎么兜底的同学。我会从问题本质讲起,把 Seata 的解决思路、核心机制、实操配置和踩坑经验一次讲透,让你看完能直接对照自己的业务代码做检查。
1. 先把三个问题掰开揉碎:它们到底在说什么
很多人一上来就背“幂等就是重复调用结果一致、悬挂就是 Try 比 Cancel 晚到、空回滚就是 Cancel 先于 Try”,背得滚瓜烂熟,但一到写代码还是不知道怎么防。问题出在没有真正理解这三个场景是怎么产生的。TCC 模式把一个业务操作拆成 Try(资源预留)、Confirm(确认提交)、Cancel(取消释放)三个阶段,这三个阶段由事务协调器分别发起调用,而网络是不可靠的,调用顺序和调用次数都不由你控制。三个问题本质上都是“调用时序异常 + 重试机制”共同作用的结果,下面逐个拆。
1.1 幂等:为什么重复调用是常态而不是意外
TCC 的 Confirm 和 Cancel 阶段,Seata 的事务协调器在没收到明确响应时会不断重试,这是框架保证最终一致性的基本手段。也就是说,同一个 Confirm 或 Cancel 被调用两次、三次甚至更多次,是完全正常的事情,不是异常。如果你的 Confirm 逻辑是“给账户加 100 元”,重复执行就变成加 200、加 300,数据直接错乱。幂等的核心诉求就是:无论同一个阶段被调用多少次,业务数据的最终状态和调用一次完全一致。
这里有个容易混淆的点:幂等不只是 Confirm 和 Cancel 要保证,Try 阶段理论上也可能因为重试被重复调用(虽然 Seata 对 Try 的重试控制相对保守),所以严谨的做法是三个阶段都做幂等。但实践中优先级最高的是 Confirm 和 Cancel,因为它们的重试频率最高、最不可控。
1.2 悬挂:Try 迟到引发的“幽灵资源”
悬挂这个场景稍微绕一点。正常时序是 Try 先执行、预留资源,然后 Confirm 或 Cancel 来收尾。但网络抖动或者超时可能导致一种情况:Cancel 先到了,执行完把资源释放了,结果 Try 请求姗姗来迟,又把资源预留上了。这时候事务其实已经结束了,但资源被一个“已死”的事务占着,没人来 Confirm 也没人来 Cancel,这部分资源就永远悬在半空,所以叫悬挂。
举个具体例子:用户下单,Try 阶段要冻结库存 10 件。Cancel 先到,发现没有冻结记录,直接返回成功(这就是空回滚,后面讲)。然后 Try 迟到,真的冻结了 10 件库存。这笔订单早就取消了,但这 10 件库存被永久冻结,其他订单用不了,这就是悬挂造成的资源泄漏。悬挂的危害在于它是“静默”的,不会报错,但资源在慢慢泄漏,时间一长就是事故。
1.3 空回滚:Cancel 来了,但 Try 根本没执行
空回滚指的是 Cancel 阶段被调用时,对应的 Try 阶段压根没执行过(比如 Try 请求在网络中丢了,或者 Try 还没到)。这时候 Cancel 逻辑如果无脑执行“释放资源”,就会去释放一个根本不存在的预留,轻则报错,重则把别的正常事务预留的资源给误释放了。
空回滚和悬挂是一对孪生问题,经常同时出现。空回滚本身不可怕,可怕的是处理不当引发连锁反应。比如 Cancel 里做“库存加回 10 件”,如果 Try 没执行,你加回 10 件就等于凭空多出 10 件库存,超卖风险直接拉满。所以空回滚的正确处理是:识别出这是空回滚,直接返回成功,什么都不做。
把这三个问题放在一起看,你会发现它们的共同根源是:TCC 三阶段的执行顺序和次数都不受业务代码控制,而业务代码却默认了“顺序执行、只执行一次”。Seata 的解决思路,本质上就是在框架层和业务层之间建立一套“事务状态记录 + 状态判断”的机制,让业务代码能够感知到当前调用处于什么状态,从而做出正确决策。
2. Seata 的解题思路:一张事务状态表撑起半边天
理解了问题本质,再看 Seata 的方案就顺理成章了。Seata 处理 TCC 三大问题的核心思想可以用一句话概括:用一张事务控制表记录每个分支事务的执行状态,让 Confirm 和 Cancel 在执行前先查状态,再决定做什么。这套机制在 Seata 里叫Fence(围栏),也叫 TCC 防悬挂控制。下面把它的设计逻辑和关键表结构讲清楚。
2.1 Fence 机制的整体设计:先记录,再判断,后执行
Fence 的核心是一张tcc_fence_log表(默认表名,可配置),每个分支事务在 Try 阶段执行时,会往这张表里插入一条记录,标记这个分支事务的状态。后续 Confirm 或 Cancel 进来时,先根据事务 ID 和分支 ID 查这张表,根据查到的状态决定是正常执行、跳过还是拒绝。
这套设计的精妙之处在于把“是否执行过”这个信息持久化了。因为 TCC 的三个阶段可能落在不同的服务实例、不同的时间点,甚至跨机房,靠内存状态根本不可靠,只有落库才能保证任何实例、任何时刻都能查到准确状态。这也是为什么 Seata 的 Fence 方案强依赖数据库——它需要一个所有参与者都能访问的共享存储来充当“状态裁判”。
具体来说,tcc_fence_log表的关键字段包括:分支事务 ID(branch_id)、事务 ID(xid)、分支状态(status)、业务操作标识等。状态值主要有几个:TRIED(Try 已执行)、COMMITTED(Confirm 已执行)、ROLLBACKED(Cancel 已执行)。Try 阶段插入TRIED记录,Confirm 阶段把状态更新为COMMITTED,Cancel 阶段更新为ROLLBACKED。每次操作前先查当前状态,就能判断出这次调用是否合法。
2.2 三种状态判断如何对应三大问题
把状态判断逻辑和三大问题对应起来看,逻辑非常清晰:
| 当前调用 | 查到状态 | 判断结论 | 处理动作 |
|---|---|---|---|
| Confirm | TRIED | 正常,Try 已执行待确认 | 执行业务 Confirm,更新为 COMMITTED |
| Confirm | COMMITTED | 重复调用(幂等场景) | 直接返回成功,不重复执行 |
| Confirm | 无记录 | 异常,Try 未执行 | 拒绝或按空回滚处理 |
| Cancel | TRIED | 正常,Try 已执行待回滚 | 执行业务 Cancel,更新为 ROLLBACKED |
| Cancel | ROLLBACKED | 重复调用(幂等场景) | 直接返回成功,不重复执行 |
| Cancel | 无记录 | 空回滚场景 | 插入 ROLLBACKED 记录,返回成功,不执行业务 |
这张表基本就是 Seata Fence 的判断全貌。幂等靠“状态已是终态则跳过”解决;空回滚靠“Cancel 查不到 Try 记录则插入回滚记录并直接返回”解决;悬挂则靠“Try 执行前检查是否已存在回滚记录,存在则拒绝执行”解决。三个问题,一套状态机,逻辑自洽。
2.3 悬挂的拦截点为什么在 Try 阶段
这里要特别说一下悬挂的处理,因为它的拦截点和另外两个不一样。幂等和空回滚都是在 Confirm/Cancel 阶段做判断,而悬挂的根源是 Try 迟到,所以拦截必须放在 Try 阶段:Try 执行前,先查tcc_fence_log里有没有这个分支的回滚记录(ROLLBACKED),如果有,说明 Cancel 已经先执行过了,这个 Try 是迟到的幽灵,直接拒绝执行并返回。
这个设计很关键。如果只在 Cancel 阶段做空回滚处理而不在 Try 阶段拦截,那么“Cancel 先到、Try 后到”的悬挂场景依然会发生——Cancel 空回滚返回成功了,但迟到的 Try 照样会把资源预留上。所以悬挂的防护是“Try 阶段拦截 + Cancel 阶段空回滚记录”两者配合才能闭环:Cancel 空回滚时插入 ROLLBACKED 记录,就是为了给迟到的 Try 留一个“此路不通”的标志。
3. 落地实操:从建表到代码,一步步配起来
理论讲完,该动手了。Seata 的 TCC Fence 方案落地涉及建表、依赖引入、注解配置、业务代码改造几个环节。我按实际项目里的顺序走一遍,把关键配置和容易出错的点都标出来。需要说明的是,下面涉及的表结构和配置项基于 Seata 官方常见实践整理,不同版本可能有细微差异,实际以你使用的版本为准。
3.1 建表:tcc_fence_log 的字段设计与索引考量
第一步是把tcc_fence_log表建好。这张表是所有状态判断的基础,字段设计要覆盖事务标识、分支标识、状态、时间等关键信息。一个典型的建表语句大致如下(以常见关系型数据库为例):
CREATE TABLE tcc_fence_log ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', 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-COMMITTED,3-ROLLBACKED', gmt_create DATETIME(3) NOT NULL COMMENT '创建时间', gmt_modified DATETIME(3) NOT NULL COMMENT '修改时间', PRIMARY KEY (id), UNIQUE KEY uk_xid_branch (xid, branch_id) ) COMMENT 'TCC事务防悬挂控制表';几个设计要点值得说清楚。唯一索引uk_xid_branch是灵魂,它保证了同一个分支事务在表里只有一条记录,这样并发场景下重复插入会直接触发唯一键冲突,天然实现了幂等的第一道防线。status 用数字而非字符串,是为了查询效率和存储空间考虑,状态值少且固定,数字足够。时间字段用毫秒精度,方便排查时序问题。另外这张表的数据会持续增长,生产环境一定要配定期清理策略,比如保留 7 天或 30 天的数据,否则表会越来越大,查询性能下降。
注意:这张表必须和业务数据在同一个数据库里,或者至少在同一个事务能覆盖的范围内。因为 Try 阶段插入 fence 记录和业务资源预留需要在同一个本地事务里,否则会出现 fence 记录写了但业务没执行、或者业务执行了但 fence 记录没写的中间态,状态判断就失真了。
3.2 依赖与配置:把 Fence 能力接进来
建完表,接下来是把 Seata 的 TCC Fence 能力引入项目。核心是引入 Seata 的相关依赖,并在配置里开启 TCC 相关选项。依赖方面,通常需要引入 Seata 的 spring-boot-starter 以及 TCC 相关的模块。配置上,重点确认几个参数:TCC 模式是否启用、fence 表名是否与建表一致、数据源配置是否正确指向业务库。
配置项里最容易出问题的是数据源绑定。Fence 的记录写入依赖数据源,如果数据源配错或者多数据源场景下没指定对,就会出现 fence 记录写到 A 库、业务数据写到 B 库的情况,状态判断直接失效。多数据源场景下,要确保 TCC 分支所在的服务使用的数据源和 fence 表所在库一致。另外,事务分组和注册中心配置也要和 TC(事务协调器)对齐,否则分支注册不上,Confirm/Cancel 根本不会发起,问题都暴露不出来。
3.3 注解与业务改造:Try/Confirm/Cancel 三方法的写法
Seata TCC 模式通过注解来标识三个阶段的方法。通常用@TwoPhaseBusinessAction标注 Try 方法,并在注解里指定 Confirm 和 Cancel 的方法名,同时声明该分支的动作名(name),这个 name 会和 fence 表里的action_name对应。Confirm 和 Cancel 方法则用@BusinessActionContextParameter来接收 Try 阶段传递的业务参数。
业务改造的关键原则是:Try 做资源检查和预留,Confirm 做真正的确认提交,Cancel 做资源释放。三个方法都要考虑“被重复调用”和“状态不对”的情况。虽然 Fence 在框架层做了拦截,但业务代码本身也要有防御意识,比如 Confirm 里更新业务状态时带上“当前状态必须是待确认”的条件,Cancel 里释放资源时校验预留记录是否存在。框架兜底和业务防御双保险,才是生产级的做法。
这里有个实操细节:Try 方法里预留资源的操作(比如冻结库存)和 fence 记录的插入,Seata 会尽量保证在同一个本地事务里,但业务代码要避免在 Try 里做不可回滚的副作用操作,比如发消息、调外部接口。这些操作应该放到 Confirm 阶段,因为 Try 可能被 Cancel,而 Confirm 才是最终确定的。
4. 踩坑实录:那些文档里不会写的经验
配置跑通只是开始,真正的问题都在生产环境里。这一节我把实际项目中遇到的典型问题和排查思路整理出来,都是文档里不太会提但极其要命的经验。
4.1 常见问题速查表
先上一张速查表,把高频问题和对应排查方向列清楚,遇到问题时可以快速定位。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Confirm 重复执行导致数据翻倍 | 幂等判断失效,fence 状态未正确更新 | 检查 Confirm 后状态是否更新为 COMMITTED,唯一索引是否生效 |
| 库存被永久冻结无法释放 | 悬挂发生,Try 迟到未被拦截 | 检查 Try 阶段是否查询了 ROLLBACKED 记录,Cancel 空回滚是否插入了记录 |
| Cancel 报“资源不存在”错误 | 空回滚未正确处理 | 检查 Cancel 查不到 Try 记录时是否直接返回成功而非报错 |
| fence 表数据暴涨 | 未配置清理策略 | 增加定时清理任务,按时间归档或删除历史数据 |
| 状态判断总是查不到记录 | 数据源不一致或事务未提交 | 检查 fence 表与业务库是否同源,Try 的本地事务是否正常提交 |
| 并发下唯一键冲突频繁 | 同一分支并发调用 | 属正常防护,捕获冲突后按幂等逻辑返回成功即可 |
这张表里,“状态判断总是查不到记录”是最隐蔽的坑。表面看是 fence 机制没生效,实际往往是数据源配错或者 Try 的本地事务因为异常回滚了,导致 fence 记录压根没落库。排查时先确认 Try 方法执行后表里有没有记录,没有的话就往事务和数据源方向查。
4.2 幂等实现的两个层次:框架拦截与业务兜底
很多人以为配了 Fence 就万事大吉,其实框架拦截和业务兜底是两个层次,缺一不可。框架层的 Fence 拦截能挡住大部分重复调用,但它依赖 fence 表的状态准确。如果因为某些异常导致状态更新失败(比如 Confirm 业务执行成功但状态更新那步失败),下次重试时框架看到状态还是 TRIED,就会再执行一次 Confirm,这时候就得靠业务层的幂等兜底。
业务层幂等的常见做法是给业务操作加唯一约束或状态机约束。比如账户变动记录表加一个“事务ID+分支ID”的唯一索引,重复插入直接冲突;或者更新订单状态时带上WHERE status = '待确认'条件,更新影响行数为 0 就说明已经处理过,直接返回成功。这两种方式我都用过,唯一约束适合流水类操作,状态机约束适合状态流转类操作,按业务特点选。
提示:业务层幂等判断和 fence 状态判断不要混在一起写,保持职责分离。fence 管“这个分支该不该执行”,业务幂等管“这个业务操作是否已生效”,两层各管各的,出问题时排查边界清晰。
4.3 空回滚与悬挂的联合防御:顺序不能乱
空回滚和悬挂必须联合防御,单独防一个都会漏。正确的防御顺序是:Cancel 阶段先查 Try 记录,查不到就插入 ROLLBACKED 记录并返回成功(空回滚处理);Try 阶段执行前先查有没有 ROLLBACKED 记录,有就拒绝执行(悬挂拦截)。这两步的顺序和配合是闭环的关键。
我见过一种错误实现:Cancel 空回滚时直接返回成功但不插入 ROLLBACKED 记录。这样看似处理了空回滚,但迟到的 Try 进来时查不到任何记录,照样会执行资源预留,悬挂问题原封不动。所以空回滚时插入 ROLLBACKED 记录这一步绝对不能省,它就是给悬挂拦截留的“路障”。
另一个坑是并发场景下的时序问题。Cancel 空回滚插入记录和 Try 查询记录如果并发执行,可能出现 Try 查询时记录还没插入、查询通过后记录才插入的情况,悬挂拦截失效。这种极端并发场景下,唯一索引和数据库行锁能提供一定保护,但更稳妥的做法是在 Try 的资源预留操作上加乐观锁或版本号,确保并发下不会重复预留。
4.4 性能与运维:fence 表不是建完就不管了
fence 表带来的性能开销主要在每次 Try/Confirm/Cancel 都要多一次数据库读写。单看一次开销不大,但高并发下这张表会成为热点。优化方向有几个:给 xid 和 branch_id 建好索引(唯一索引已经覆盖)、定期清理历史数据控制表大小、考虑分表应对超大规模。另外 fence 表的写入和业务操作在同一个本地事务里,会拉长事务时间,业务逻辑要尽量精简,避免在 Try 里做耗时操作。
运维层面,fence 表要纳入监控,重点关注表数据量增长趋势和慢查询。我遇到过因为没配清理策略,fence 表涨到几千万行,状态查询开始走慢查询,拖垮了整个 TCC 链路的情况。后来加了按天分区和定时清理,问题才解决。这个教训就是:任何会持续增长的表,上线前就要想好清理方案。
5. 几个容易被忽略的边界场景
除了三大经典问题,实际项目里还有几个边界场景值得单独拎出来说,它们不算高频,但一旦踩中排查成本极高。
5.1 Try 成功但 Confirm 一直失败怎么办
这种情况属于 Confirm 阶段持续异常,Seata 会不断重试,但业务上可能因为数据问题永远成功不了。比如 Confirm 要更新的目标记录被别的操作删了。这时候事务会一直卡在重试状态,需要人工介入。处理思路是:Confirm 逻辑要尽量设计成“幂等且最终能成功”,避免依赖易变的外部状态;同时要有告警机制,Confirm 重试超过阈值就报警,人工排查根因。不要指望框架无限重试能自愈,有些问题必须人来兜底。
5.2 跨库场景下 fence 表放哪
如果 TCC 分支涉及多个库,fence 表应该放在哪个库?原则是fence 表和该分支的业务数据同库,每个涉及 TCC 的库都建自己的 fence 表。因为 fence 记录的写入要和业务操作在同一个本地事务里,跨库就破坏了本地事务的原子性。多库场景下,每个库独立维护自己的 fence 表,互不干扰,这是最稳妥的做法。
5.3 历史数据迁移时的状态兼容
系统做数据迁移或者 fence 表结构升级时,要特别注意历史状态数据的兼容。比如状态值从字符串改成数字,迁移脚本要保证映射正确,否则状态判断全乱。升级前先在测试环境用真实数据量跑一遍迁移和回滚,确认状态判断逻辑在新旧数据上都正确,再上生产。这类操作一旦出错,影响的是整个 TCC 链路的正确性,容不得半点侥幸。
我个人在实际项目里的体会是,TCC 的幂等、悬挂、空回滚这三个问题,框架能帮你挡住大部分,但永远要假设“框架的防护可能失效”,在业务层留好兜底。Seata 的 Fence 机制设计得很巧妙,一张状态表加一套状态机就把三个问题统一处理了,但它不是银弹,数据源配置、事务边界、清理策略这些工程细节才是决定它能不能在生产环境稳住的关键。最后分享一个小技巧:上线前用混沌测试工具模拟网络延迟和重复调用,专门验证 Confirm 重复、Cancel 先到、Try 迟到这几个场景,比看多少文档都管用。