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 | 值 | 写入时机 | 含义 |
|---|---|---|---|
| tried | 1 | Try 开始时 | 业务还没执行,先占位 |
| committed | 2 | Confirm 成功后 | 已提交,再调直接返回 |
| rollbacked | 3 | Cancel 成功后 | 已回滚 |
| suspended | 4 | Cancel 时查不到记录 | 悬挂标记,等迟到的 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 也行,但要守住同样的几条:
- 唯一键必须是(全局事务 ID,分支 ID),或者
biz_id + sub_biz_id组合。没有唯一键,就没有「谁先到」的判定依据。 - 事务日志表必须和业务表在一个库、一个本地事务里。分库之后记录表和业务表跨库,状态机就退化成两条独立事务,中间失败一次就错位。
- 状态只能前进,不能后退:tried → committed 或 tried → rollbacked,suspended 只能被后来的 Try 撞死,不能被改写。
- 已 committed 收到 Cancel、已 rollbacked 收到 Confirm,都不能执行业务,用
version字段做乐观锁更新,返回成功即可。 - 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 一样膨胀到影响写入。
项目信息
- Seata(Apache 孵化):https://github.com/apache/incubator-seata
- Seata 官方站点:https://seata.apache.org/
- DTM(Go,子事务屏障):https://github.com/dtm-labs/dtm
- DTM 文档 · TCC:https://dtm.pub/practice/tcc
- Seata v1.5.1 TCC Fence 机制说明:https://www.alibabacloud.com/blog/seata-v1-5-1-solves-the-idempotence-suspension-and-empty-rollback-problems-of-tcc-mode_599647
- 阿里云 GTS 防悬挂与幂等接入(dtx_tcc_action 方案):https://help.aliyun.com/zh/document_detail/132915.html
- SOFAStack TCC 理论及设计实现:https://www.sofastack.tech/blog/seata-tcc-theory-design-realization/