编程 TCC 库存 Cancel:空回滚、悬挂、幂等怎么用一张 tcc_fence_log 收口

2026-10-22 00:05:24

TCC 库存 Cancel:空回滚、悬挂、幂等怎么用一张 tcc_fence_log 收口

站内已经有两篇讲库存回滚的文章:一篇在 DB 侧围绕 available/locked 字段加 outbox 释放任务,另一篇在 Redis 缓存层用 Lua 处理回滚与乱序。这一篇只谈 TCC 分支事务中库存服务的 Cancel 是否该执行,以及 Try、Cancel、重试三者如何靠一张 tcc_fence_log 相互约束。

信源:

不能写成无条件 UPDATE

TCC 的 Try 阶段要把资源真正预留出来,并且提交本地事务;所有分支 Try 成功后由 TC 驱动 Confirm,任一分支失败则由 TC 驱动 Cancel 释放预留。

库存服务最容易写错的 Cancel:

UPDATE stock SET frozen = frozen - ? WHERE sku_id = ?;

这行 SQL 在正常情况下没问题,问题出在“正常”两个字。Try 可能压根没到,或者到了但本地事务回滚了,而 TC 仍然会对这个分支发起 Cancel。这时 frozen 本来就没加过,减一次等于凭空把库存加出来。这就是空回滚。

所以 Cancel 执行前必须先回答一个问题:这个 (xid, branch_id) 的 Try 到底有没有成功落库。

tcc_fence_log:主键就是幂等的地基

Seata 1.5.1 引入事务控制表 tcc_fence_log 来解决上述问题。建表语句(MySQL):

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;rollbacked:3;suspended:4)',
  `gmt_create` DATETIME(3) NOT NULL,
  `gmt_modified` DATETIME(3) NOT NULL,
  PRIMARY KEY (`xid`,`branch_id`),
  KEY `idx_gmt_modified` (`gmt_modified`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

状态取值:1 tried、2 committed、3 rollbacked、4 suspended。

主键 (xid, branch_id) 是整个机制的地基:同一个全局事务的同一个分支在表里只可能有一行,Try、Cancel、Confirm 的所有状态转移都是对同一行的条件更新,插入冲突本身就是一种信号。幂等、空回滚、悬挂三个问题最后都归到这一行的状态判断上。

Try:插入 tried,DuplicateKey 就拒绝

库存服务的 Try 先往 fence 表插一条 status=1,再执行预留。如果插入时撞主键,说明这个分支之前已经被 Cancel 处理过;Cancel 在查不到记录时会插入一条 status=4 的 suspended 记录,这条记录会挡住晚到的 Try。

try {
    fenceDao.insert(xid, branchId, actionName, STATUS_TRIED);
} catch (DuplicateKeyException e) {
    // Cancel 已经先到并写了 suspended,Try 不能再执行
    log.warn("Branch transaction has already rollbacked before, prepare fence failed. xid={}, branchId={}",
             xid, branchId);
    // 标记 rollbackOnly,让当前全局事务走回滚
    throw new UnexpectedRollbackException("fence conflict");
}

Seata 的 prepareFence 在捕获 DuplicateKeyException 时打的日志是 Branch transaction has already rollbacked before, prepare fence failed,随后把记录加入清理队列并标记 rollbackOnly。这段处理不能省,否则 Try 会在悬挂场景下把资源预留成功,而全局事务早已结束,没人再来释放。

Cancel:先锁行,再决定

Cancel 的第一步是 SELECT ... FOR UPDATE。这里用 for update 是为了把行锁住,避免和并发的 Try 或另一个 Cancel 之间出现“查的时候没有、写的时候已经有了”的窗口。

SELECT xid, branch_id, status FROM tcc_fence_log
WHERE xid = ? AND branch_id = ? FOR UPDATE;

按查到的状态分支:

查询结果动作
无记录插入 status=4(suspended),直接返回成功
status=3 或 4幂等返回成功
status=2(committed)打 WARN,返回失败
status=1(tried)条件更新为 rollbacked,成功后才回补库存

无记录这一支就是空回滚:Try 没落库,Cancel 不执行业务回补,但要把 suspended 记录写下去,让晚到的 Try 撞主键。status 已经是 rollbacked 或 suspended,说明这个分支处理过了,直接返回成功即可。status=committed 说明分支已经提交,此时再收到 Cancel 属于异常,Seata 的实现是打 Branch transaction status is unexpected 并返回 false,不能去回滚一个已提交的分支。

status=tried 时才真正回补库存。状态更新用条件更新:

UPDATE tcc_fence_log
SET status = 3, gmt_modified = NOW(3)
WHERE xid = ? AND branch_id = ? AND status = 1;

影响行数为 1 才执行库存回补;影响行数为 0 不能当作没事发生。可能是并发下另一个线程已经把它推到 rollbacked,也可能是状态被改成了别的值。处理方式是重新查一次这行记录:如果是 rollbacked,按幂等成功返回;如果是 committed 或记录消失,打告警并返回失败,交给 TC 重试或人工介入。Seata 的 updateStatusAndInvokeTargetMethod 就是把“改状态”和“调目标方法”绑在一起,改不成功就不执行。

fence 写入必须和库存变更在同一个本地事务

PR #3545 的讨论里有一条关键结论:只加状态表、没有代理数据源,无法保证本地事务与状态控制表的原子性和排他性,也就无法保证幂等和防悬挂。一阶段、二阶段的业务本地事务要和幂等、防悬挂的 SQL 处于同一个本地事务中。

所以不能只在方法上挂一个 @Transactional 就完事,要确认 fence 表的 DAO 和库存 DAO 走的是同一个物理连接、同一个本地事务。开事务的顺序大致是:

  1. 取连接,setAutoCommit(false),开启本地事务;
  2. 在同一个连接上执行 SELECT ... FOR UPDATE 查 fence 记录;
  3. 按状态分支,执行 fence 的条件 UPDATE;
  4. 在同一个连接上执行库存回补 SQL;
  5. 一起 commit。

如果 fence 操作和库存操作落到不同连接或不同数据源上,第 3 步成功了第 4 步失败,状态表说“已回滚”、库存却没回补,后面所有重试都会被幂等判断挡掉,库存永久少一块。Seata 侧需要数据源代理把业务连接和 fence 连接对齐,具体配置需按自己环境的 Seata 版本确认。

useTCCFence 默认是 false

@TwoPhaseBusinessAction 上的 useTCCFence 属性控制是否开启这套防护,Seata 里默认 false,1.5.1 才引入。也就是说,代码照抄了 fence 表、SQL 也写了,只要注解没显式打开,运行时就等于没防护,Try 和 Cancel 还是会按裸逻辑跑。

@TwoPhaseBusinessAction(name = "stockAction", commitMethod = "confirm",
        rollbackMethod = "cancel", useTCCFence = true)
public boolean tryReserve(BusinessActionContext ctx, @BusinessActionContextParameter(paramName = "skuId") Long skuId,
                          @BusinessActionContextParameter(paramName = "qty") Integer qty) {
    ...
}

升级 Seata 版本时这块容易漏配,尤其是从 1.5.1 之前的版本升上来、只加了表没加属性的情况。低于 1.5.1 的版本没有这个属性,需按自己环境的 Seata 版本确认。

Try 要真预留,Cancel 回补要带下界

fence 表只管“该不该执行”,不保证“执行了库存对不对”。库存服务的 Try 必须真正把资源预留出来:

UPDATE stock
SET frozen = frozen + ?, available = available - ?
WHERE sku_id = ? AND available >= ?;

只做检查、不做 frozen 增加,Confirm 阶段扣不动,等于把问题推到第二阶段。Confirm 时把 frozen 转成已售:

UPDATE stock
SET frozen = frozen - ?, sold = sold + ?
WHERE sku_id = ? AND frozen >= ?;

Cancel 回补同样要带下界条件,防止出现负库存:

UPDATE stock
SET frozen = frozen - ?, available = available + ?
WHERE sku_id = ? AND frozen >= ?;

影响行数为 0 说明账面 frozen 不够,属于数据不一致,要打告警,不能静默放过。

fence 表的清理

tcc_fence_log 会一直增长,需要按 gmt_modified 定期删除。idx_gmt_modified 就是给清理任务用的:

DELETE FROM tcc_fence_log
WHERE gmt_modified < DATE_SUB(NOW(3), INTERVAL 7 DAY)
LIMIT 1000;

保留窗口必须大于 TC 的最长重试窗口,包括重试次数乘以重试间隔,以及可能存在的延迟调度。具体数值需按自己环境的 TC 配置确认,7 天只是示例。删早了,晚到的重试分支查不到记录,会被当成空回滚或悬挂处理:Cancel 查不到就插 suspended,Try 查不到就重新插 tried,整套防护直接退化。分批带 LIMIT 是为了避免大事务锁表,循环执行直到影响行数为 0。

排查清单

  • suspended 记录增长:SELECT status, COUNT(*) FROM tcc_fence_log WHERE gmt_modified > NOW() - INTERVAL 1 HOUR GROUP BY status;,suspended 数量异常通常意味着 Try 大量没到达或网络乱序,值得看同一时间段的 TC 重试日志。
  • Cancel 返回失败次数:失败要么来自空回滚之外的异常,要么来自 status=committed 的分支被要求回滚,后者单独告警。
  • committed 后收到 Cancel:这条 WARN 日志配上 (xid, branch_id),基本都对应一次二阶段状态错乱,需要拿 xid 去 TC 侧对账。
  • fence 表膨胀:看表行数和清理任务的最后执行时间,清理任务停掉之后,状态判断和查询会逐渐变慢。

推荐文章

程序员茄子在线接单