空回滚的判定:查不到 Try 记录,不等于 Try 没执行
站内 #8125 已经拆解过 Seata tcc_fence_log 表及相关日志的完整处理流程,#8355 讲过二阶段记录表的状态机。这篇不再铺开 TCC 三阶段,只讨论其中一个问题:一次 Cancel 进来,怎么判定它属于空回滚。
判错在哪个方向都会出问题,而且两个方向的后果不对称:
- 该补偿却判成空回滚、直接返回成功:Try 之后仍然提交,那笔已经冻结的资源再也没有人去解冻,形成资源悬挂。
- 不该补偿却执行了补偿:Cancel 又扣了一遍,可用库存/余额凭空增加,账面对不上,且无法通过重试自愈。
相关项目与文档:
- DTM: ,子事务屏障:
- Seata: ,TCC fence 说明:
SELECT 查不到 Try 记录,说明不了 Try 没执行
RPC 调用失败时,调用方看到的是同一个 error,但底层有两种完全不同的情况:
- 请求丢了:对端根本没收到,Try 确实没执行。
- 响应丢了:对端执行完、本地事务已提交,只是结果没回来。
这两种情况在调用方侧无法区分。这是两将军问题在业务层的表现:你拿不到可靠的「未执行」信号,只有「不确定」。
所以「Cancel 里查流水表没有这条 Try 记录」这个观察,只能证明「此刻这行不存在」,不能证明「Try 不会再来」。Try 可能还在网络上、还停在一个被挂起的进程里、还在等一把行锁。把一次读的返回值当成最终结论,等于把「不确定」当成了「没有」。
「先查后改」的窗口,以及 FOR UPDATE 为什么也挡不住
DTM 文档里描述过这个竞态,时序拆开是这样:
- Try 分支在本地事务里
SELECT空补偿记录,结果为空。 - 进程暂停 P;或者事务内部有网络请求拥塞,本地事务长时间等待。
- 全局事务超时,TC 发起回滚,Cancel 到达。
- Cancel
SELECT业务主键,没查到,判定为空补偿,写一条空补偿标记,返回成功。 - P 结束,Try 恢复,提交本地事务。
- 全局事务已回滚完成,Try 写入的业务数据没人回滚,形成悬挂。
问题出在「读」和「写」之间没有原子性,而不是 SQL 写错了。只要判据是「读到的结果」,这个窗口就永远存在。
换 SELECT ... FOR UPDATE 也解决不了,因为此时 Try 那行还不存在。行锁锁的是已存在的行,没有行就没有可锁对象;间隙锁能不能覆盖、覆盖的是不是同一个间隙,取决于隔离级别和索引结构,拿它当正确性保证太不可靠。锁不住一个「将要发生的写入意图」。
结论是:判据必须落在「写」上,由数据库的唯一索引来裁决谁先到,而不是由一次 SELECT 的返回值为空得出结论。
dtm_barrier:把判据做成一条 insert ignore
DTM 的做法是在业务库建一张 dtm_barrier 表,唯一键是 (gid, branch_id, op),op 取 try | confirm | cancel。算法四步:
- 开启本地事务。
- 对当前操作
op执行insert ignore (gid, branchid, op);插入不成功说明这个操作已经来过,提交事务、返回成功(幂等)。 - 若当前操作是
cancel,额外再insert ignore一条(gid, branchid, try);插入成功说明 Try 没来过,提交事务、直接返回成功(空回滚控制)。 - 过了屏障才调用业务逻辑,业务成功则提交,失败则回滚。
注意第 3 步的判定方向:是「插入成功」才返回。这条被 Cancel 代插的 try 记录,本质是一条持久化断言——「截至此刻,Try 没有来过」。插入成功即断言成立;唯一键冲突则说明 Try 已经插过,那就走正常补偿逻辑。
反过来也成立:Try 执行时第 2 步要插 (gid, branchid, try),如果 Cancel 已经代插过这条,Try 插入失败,直接拒绝执行业务逻辑——悬挂就是在这里被挡住的。
Go 侧入口长这样:
bb := &barrier.BranchBarrier{
TransType: "tcc",
Gid: gid,
BranchID: branchID,
Op: "cancel",
}
// busiCall 的签名以所用 SDK 版本为准,常见形式为 func(*sql.Tx) (interface{}, error)
err := bb.CallWithDB(db, func(tx *sql.Tx) (interface{}, error) {
// 只有确认 Try 来过,才会走到这里
return nil, nil
})
Try 与 Cancel 并发时,两者都会插同一条 gid-branchid-try,唯一索引让只有一个能成功,另一个阻塞等持锁事务结束后返回。分三种情况:
- Try 插失败、Cancel 插成功:Cancel 赢,说明 Try 尚未落库,判空回滚直接返回;Try 侧插入失败,拒绝执行业务。两边都正确。
- Try 插成功、Cancel 插失败:顺序确定为 Try 在前,Cancel 走正常补偿。
- 重叠期间宕机:至少 Cancel 会被 DTM 重试,重试后最终落到情况 1 或 2。
一句话概括:谁先插到 try 那条记录,谁定义顺序;宕机只是把「谁先」的结论推迟到重试之后。
Seata 那边的编码不同,断言是同一个
Seata TCC fence 的路径是「查 + SELECT ... FOR UPDATE + 查不到就插一条 status=4(suspended)的记录」。完整流程 #8125 已经写过,这里不复述。
要对照的只有一点:tcc_fence_log 里那条 status=4 的记录,和 DTM 里 Cancel 代插的 try 记录,是同一种东西——一条「Try 一定没来」的持久化断言。DTM 用唯一键冲突来表达,Seata 用一个状态值来表达;前者把裁决交给索引,后者把裁决交给「查 + 锁 + 插」这一串动作。判空回滚的依据都是「写成功了」,而不是「读不到」。
两侧怎么查
Seata 侧先看分布,status 取值含义见 #8355:
SELECT status, COUNT(*) AS cnt
FROM tcc_fence_log
WHERE gmt_create >= NOW() - INTERVAL 1 DAY
GROUP BY status;
status=4 的数量就是这一天判出的空回滚次数。这个数突然抬高,通常是全局超时配置太紧,或者 Try 侧有慢 SQL 把分支拖过了超时点。
再做 xid 级对账:日志里「分支已经 committed、却又收到 Cancel」的 WARN,把带 xid / branchId 的那几条捞出来,按 xid 去 TC 核对分支最终状态,再回业务表确认这笔业务到底扣没扣。这类 WARN 说明走到 fence 时状态与二阶段请求冲突,属于需要人工确认的信号,不能当成噪声刷掉。
DTM 侧查某个 gid 的全部 op:
SELECT gid, branch_id, op, create_time
FROM dtm_barrier
WHERE gid = ?
ORDER BY branch_id, create_time;
这里有个容易看错的语义:空回滚的 Cancel 会代插一条 try,所以事后表里 (gid, branch_id) 往往 try、cancel 两条都在,且 create_time 落在同一个本地事务里、几乎一致。判断「有 cancel 无 try」不能只看行是否存在,要看时间先后——代插的那条 try 时间不会早于 cancel。
按这个口径找「只有 Cancel、没有更早的 Try」:
SELECT c.gid, c.branch_id, c.create_time
FROM dtm_barrier c
WHERE c.op = 'cancel'
AND NOT EXISTS (
SELECT 1 FROM dtm_barrier t
WHERE t.gid = c.gid
AND t.branch_id = c.branch_id
AND t.op = 'try'
AND t.create_time < c.create_time
);
时间精度是秒级时,同事务写入的 try 和 cancel 可能落在同一秒,比较会漏,实际用的时候按表的时间精度放宽。字段名以实际建表语句为准。
清理窗口:最容易把防护悄悄关掉的运维动作
fence / barrier 表通常有按时间定期删除的清理任务。如果保留窗口短于 TC 的最长重试窗口,会出现这样一条路径:
一个正常的、早就 Try 过的老分支,Cancel 重试时记录已经被清掉,空回滚判定重新成立,Cancel 直接返回成功 —— 该补偿的没补偿,资源悬挂。
这个过程不会报任何错,只会表现为「偶尔有几笔冻结资金对不上」。所以清理保留时长必须 ≥ TC 配置的最长回滚重试时长并留出余量,清理任务上线时要和重试配置一起 review,不能当独立的小脚本随手改。
对账口径
空回滚留下的那条记录(DTM 的 try 断言 / Seata 的 status=4)本身就是「该分支从未发生过」的证据,要保留到超过重试窗口,不能当成垃圾尽快删。
对账时按分支维度比两个数:空回滚次数不应大于业务侧实际扣减次数。定期跑上面那条「只有 Cancel、没有更早 Try」的扫描,把结果落进对账报表,比等客诉再排查要省事得多。
还没答案的一点
如果 Try 操作的资源不在同一个库——冻结落在 Redis、扣款落在外部账户——唯一索引裁决该落在哪一侧?
现在的做法是在本地库放一条 barrier 记录作为裁决点,但这条记录与 Redis、外部账户的写入之间没有原子性,最终仍要靠补偿和对账兜底。这块暂时没有一个既简单又正确的答案。