编程 TCC 的三种异常压在一张表上:tcc_fence_log 四个状态,useTCCFence 默认还是 false

2026-11-03 00:04:21

TCC 的三种异常压在一张表上:tcc_fence_log 四个状态,useTCCFence 默认还是 false

TCC 被反复讨论的三个问题——幂等、空回滚、资源悬挂——在实现层面是同一件事:同一条分支事务记录,被 Try 和 Cancel 以错序的方式写入。理解这一点后,就不需要三套机制。

这里只讲二阶段记录表如何用状态机拦截错序,不涉及幂等键设计(属于入口去重),也不涉及重试退避。

1. 前提:Try 里的一次本地写,必须和业务同一个事务

TCC 的资源必须落在本地事务里。账户 A 余额 100,要转 30:

  • Try:检查余额、冻结 30 到 trading_balance,余额不动
  • Confirm:把冻结的 30 真正扣掉(扣 balance、解冻 trading_balance)
  • Cancel:解冻,余额回到初始

Try 不真正扣款,这是 TCC 和 SAGA 的差别:用户不会看到「钱扣了但对方没收到」的中间态。代价是 Try 一定要提交本地事务——否则 Confirm 阶段才发现余额不够,就没救了。

记录表也必须在同一个本地事务里写。这不是风格问题:记录写进去了业务没写、或者业务写了记录没写,后面的状态机全部失效。

2. 三种异常,各自是哪种错序

幂等:TC 调 Confirm 超时,不知道结果,重试。分支侧的 Confirm 必须能被调多次,结果一致。

空回滚:Try 的包在网络里丢了或者超时,TC 判定失败、回滚,Cancel 先到。此时分支侧没有任何预留资源,Cancel 不能去「释放」它。

资源悬挂:更极端——Cancel 已经执行完(且是空回滚),那条迟到的 Try 才到,照样预留了资源。全局事务已是终态,再不会有 Confirm/Cancel 来处理这批预留,资源永久挂着。

后两者是连着的:悬挂的前置条件就是空回滚。所以处理悬挂不用另开机制,只要在空回滚那一刻留下痕迹。

3. Seata 的做法:一张 tcc_fence_log,四个状态

Seata 1.5.1 引入的这套机制叫 TCC Fence,开关是 @TwoPhaseBusinessAction 上的 useTCCFence,默认 false。没打开,框架不帮忙拦截,三个异常全靠业务代码自己处理。这是升级时最容易漏的一行。

建表语句(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 COMMENT 'create time',
    `gmt_modified`  DATETIME(3)   NOT NULL COMMENT 'update time',
    PRIMARY KEY (`xid`, `branch_id`),
    KEY `idx_gmt_modified` (`gmt_modified`),
    KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

主键 (xid, branch_id) 就是全部机制的核心:一条分支事务在整个生命周期里只有一行,所有「谁先谁后」都降级为对这行的 insert 和 update 是否成功。

status 四个值对应四次写入时机:

status值写入时机含义
tried1Try 开始时业务还没执行,先占位
committed2Confirm 成功后已提交,再调直接返回
rollbacked3Cancel 成功后已回滚
suspended4Cancel 时查不到记录悬挂标记,等迟到的 Try 来撞

三个异常分别对应三次判断:

幂等(Confirm):查这行,status 已是 committed 就直接返回 true,不再执行业务;查不到记录抛 TCC fence record not exists,说明 Try 根本没进来过,这时不该继续提交。

空回滚(Cancel):查不到记录 → 不执行业务(没什么可释放的)→ 插入一条 status=4 的记录 → 返回 true。返回 true 很重要:空回滚是正常情况,报错只会让 TC 无限重试。

悬挂(晚到的 Try):Try 的开头是插 status=1 的记录。既然 Cancel 那次空回滚已经插了 status=4,这次 insert 直接主键冲突。Seata 的处理是识别 DuplicateKeyException、记一条日志说明「分支已被回滚」、本地事务置为回滚——Try 的业务代码根本不会执行,也就不会有资源被预留。

「空回滚时必须写记录」是唯一能挡住悬挂的手段。Cancel 里「查不到就 return true 什么都不做」是最常见的错误写法:留不下痕迹,迟到的 Try 照样预留,资源就是真挂住了。

伪代码示意:

// Cancel:先查后写,查不到也要占位
public boolean cancel(BusinessActionContext ctx) {
    TCCFenceDO log = query(ctx.xid, ctx.branchId); // 实现里是 SELECT ... FOR UPDATE
    if (log == null) {
        insert(ctx.xid, ctx.branchId, ctx.actionName, STATUS_SUSPENDED);
        return true;                       // 空回滚,成功返回
    }
    if (log.getStatus() == STATUS_ROLLBACKED) {
        return true;                       // 幂等
    }
    doCancelBusiness(ctx);                 // 释放预留资源
    updateStatus(ctx, STATUS_ROLLBACKED);
    return true;
}

查询用 for update,是因为同一个 xid 的 Try 和 Cancel 可能真的并发到达,靠行锁把两次判断串起来。

4. 自建一张表的话,几条硬约束

不用 Seata 也行,但要守住同样的几条:

  1. 唯一键必须是(全局事务 ID,分支 ID),或者 biz_id + sub_biz_id 组合。没有唯一键,就没有「谁先到」的判定依据。
  2. 事务日志表必须和业务表在一个库、一个本地事务里。分库之后记录表和业务表跨库,状态机就退化成两条独立事务,中间失败一次就错位。
  3. 状态只能前进,不能后退:tried → committed 或 tried → rollbacked,suspended 只能被后来的 Try 撞死,不能被改写。
  4. 已 committed 收到 Cancel、已 rollbacked 收到 Confirm,都不能执行业务,用 version 字段做乐观锁更新,返回成功即可。
  5. Try 重复到达同样靠唯一键冲突处理,不需要额外的分布式锁。

Go 侧可以看 DTM 的子事务屏障(BranchBarrier)。它把这套判定封在 branchBarrier.call(conn, func(barrier) { ... }) 里,业务照内层写,重复请求、悬挂、空补偿三种情况都由屏障在同一个本地事务内拦掉。DTM 文档里明确写了:TCC 的分支编排留在应用端,所以应用崩溃或发版重启时会丢失编排信息,只能回滚——TCC 适合短事务,分钟级的长流程应该换 SAGA 或二阶段消息。

5. 上线前该看的三个指标

  • Try 的记录数 vs Confirm 的记录数:差值就是没走完的分支
  • status=4(suspended)的行:出现说明 Cancel 先到过,属于真空回滚,频繁出现要去查网络和超时设置
  • Confirm/Cancel 重试超过 3 次的事务:Seata 和 DTM 都会一直重试直到成功,程序 bug 导致的失败会变成无限重试,需要人工兜底

表本身也要清理。idx_gmt_modified 不是可选的装饰,按 gmt_modified 定期删掉终态记录,否则这张表会跟 undo_log 一样膨胀到影响写入。

项目信息

复制全文 生成海报 TCC 分布式事务 Seata DTM

推荐文章

程序员茄子在线接单