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 走的是同一个物理连接、同一个本地事务。开事务的顺序大致是:
- 取连接,
setAutoCommit(false),开启本地事务; - 在同一个连接上执行
SELECT ... FOR UPDATE查 fence 记录; - 按状态分支,执行 fence 的条件 UPDATE;
- 在同一个连接上执行库存回补 SQL;
- 一起 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 表膨胀:看表行数和清理任务的最后执行时间,清理任务停掉之后,状态判断和查询会逐渐变慢。