编程 空回滚的判定:查不到 Try 记录,不等于 Try 没执行

2026-11-09 00:05:03

空回滚的判定:查不到 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 文档里描述过这个竞态,时序拆开是这样:

  1. Try 分支在本地事务里 SELECT 空补偿记录,结果为空。
  2. 进程暂停 P;或者事务内部有网络请求拥塞,本地事务长时间等待。
  3. 全局事务超时,TC 发起回滚,Cancel 到达。
  4. Cancel SELECT 业务主键,没查到,判定为空补偿,写一条空补偿标记,返回成功。
  5. P 结束,Try 恢复,提交本地事务。
  6. 全局事务已回滚完成,Try 写入的业务数据没人回滚,形成悬挂。

问题出在「读」和「写」之间没有原子性,而不是 SQL 写错了。只要判据是「读到的结果」,这个窗口就永远存在。

换 SELECT ... FOR UPDATE 也解决不了,因为此时 Try 那行还不存在。行锁锁的是已存在的行,没有行就没有可锁对象;间隙锁能不能覆盖、覆盖的是不是同一个间隙,取决于隔离级别和索引结构,拿它当正确性保证太不可靠。锁不住一个「将要发生的写入意图」。

结论是:判据必须落在「写」上,由数据库的唯一索引来裁决谁先到,而不是由一次 SELECT 的返回值为空得出结论。

dtm_barrier:把判据做成一条 insert ignore

DTM 的做法是在业务库建一张 dtm_barrier 表,唯一键是 (gid, branch_id, op),op 取 try | confirm | cancel。算法四步:

  1. 开启本地事务。
  2. 对当前操作 op 执行 insert ignore (gid, branchid, op);插入不成功说明这个操作已经来过,提交事务、返回成功(幂等)。
  3. 若当前操作是 cancel,额外再 insert ignore 一条 (gid, branchid, try);插入成功说明 Try 没来过,提交事务、直接返回成功(空回滚控制)。
  4. 过了屏障才调用业务逻辑,业务成功则提交,失败则回滚。

注意第 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,唯一索引让只有一个能成功,另一个阻塞等持锁事务结束后返回。分三种情况:

  1. Try 插失败、Cancel 插成功:Cancel 赢,说明 Try 尚未落库,判空回滚直接返回;Try 侧插入失败,拒绝执行业务。两边都正确。
  2. Try 插成功、Cancel 插失败:顺序确定为 Try 在前,Cancel 走正常补偿。
  3. 重叠期间宕机:至少 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、外部账户的写入之间没有原子性,最终仍要靠补偿和对账兜底。这块暂时没有一个既简单又正确的答案。

复制全文 生成海报 空回滚 TCC 分布式事务 DTM Seata 悬挂 幂等

推荐文章

程序员茄子在线接单