24.4 TCC (空回滚 / 幂等 / 悬挂)
一句话
TCC 让每个参与方用 Try (预留)/ Confirm (确认)/ Cancel (释放) 三个本地操作参与分布式事务,用 “业务冻结” 换掉 2PC 的 “数据库长锁”;代价是必须自己处理空回滚、幂等、悬挂这三类异常。
TCC 在做什么:把数据库锁换成业务预留
以 “下单扣库存 + 扣余额” 为例:
| 阶段 | 库存服务 | 账户服务 | 失败意味着 |
|---|---|---|---|
| Try | 冻结 1 件:frozen += 1 | 冻结 100 元:frozen += 100 | 整体失败,走 Cancel |
| Confirm | 扣掉冻结:stock -= 1; frozen -= 1 | 扣掉冻结:balance -= 100; frozen -= 100 | 不允许业务性失败,重试到成功 |
| Cancel | 解冻:frozen -= 1 | 解冻:frozen -= 100 | 不允许业务性失败,重试到成功 |
三个必须记住的性质:
- **Try 只预留,不落账。**真正的扣减在 Confirm。这样任何一步失败,资源都能原样退回。
- **Confirm / Cancel 里不能再做业务判断。**资源在 Try 阶段已经预留,到这一步还做 “余额够不够”,只会让分支永远结束不了。
- **Cancel 不是数据库回滚,是业务释放。**已经写进账的记录要么撤销 (冲正),要么保留原记录 + 反向记账。
🔑 TCC 的隔离性不靠数据库锁,靠预留字段:可用余额 = 总额 − 冻结额。别人看到的是 “可用的不够了”,而不是 “这行被锁住了”。
与 2PC / XA 的硬差别
| 维度 | 2PC / XA | TCC |
|---|---|---|
| 锁在哪 | 数据库资源层(行锁、表锁) | 业务表字段(冻结量、预留记录) |
| 锁多久 | prepare → commit 全程持有 | Try → Confirm 窗口,但不占数据库锁 |
| 业务侵入 | 几乎为零(换数据源 / 驱动) | 每个分支要写 3 个接口 + 一张状态表 |
| 中间态可见 | 其他事务看不到未提交数据 | 可见:冻结金额、PENDING 状态 |
| 隔离性 | 由数据库隔离级别保证 | 业务自己保证 |
| 吞吐 | 低:长事务 + 连接占用 | 高:无长事务,锁粒度是业务资源 |
| 典型场景 | 同库、短事务、支持 XA 的数据库 | 跨服务、资金 / 库存、不能长事务 |
💡 2PC 的 prepare 已经把 “能不能提交” 问完了,代价是资源一直锁着;TCC 把 “能不能” 提前到 Try,用业务可见的冻结量代替锁。中间态因此暴露给所有人——这就是 TCC 全部复杂度的来源。
三个异常:出事只有这三种情况
正常路径 Try → Confirm 和 Try → Cancel 都简单。故障全部来自 “网络不可靠 + 超时重试”:
① 空回滚:Try 没成功,Cancel 却来了
协调者 ──Try──✗(超时 / 失败)──▶ Cancel ──▶ 参与方从没预留过
② 幂等:Confirm / Cancel 来了不止一次
协调者 ──Confirm──▶ 超时 ──▶ Confirm ──▶ 超时 ──▶ Confirm
③ 悬挂:Cancel 先到,Try 迟到
协调者 ──Cancel──▶ 参与方(查无记录,按空回滚处理)
网络里迟到的 Try ──▶ 参与方(冻住资源,此后再没人管这笔事务)
| 异常 | 触发条件 | 不管会怎样 | 解法 | 落在哪个接口 |
|---|---|---|---|---|
| 空回滚 | Try 未执行 / 执行失败,Cancel 到达 | 无脑解冻 → 余额或库存凭空多出来 | 查无记录时插入 CANCELLED 记录,返回成功 | Cancel |
| 幂等 | Confirm / Cancel 被重复调用 | 重复解冻(多退)、重复扣减(多扣) | 状态机 CAS:UPDATE ... WHERE status='TRIED',看 affected rows | Confirm + Cancel |
| 悬挂 | Cancel 先于 Try 到达 | 迟到的 Try 冻结资源,事务已结束 → 资源永久冻结 | Try 先查记录,见到 CANCELLED 就空执行返回成功 | Try |
🔑 **空回滚与防悬挂是同一个动作的两面。**Cancel 在 “查不到 Try” 时写下的那条
CANCELLED,既是空回滚的证据,也是拦截迟到 Try 的栅栏。事务控制表不是可选项,是 TCC 的地基。
空回滚的三个真实来源
- Try 超时但请求还在路上:协调者 500ms 超时判失败,Try 实际 800ms 后才到参与方。
- Try 本地事务失败:库存不足、账户冻结失败——参与方返回失败,但失败响应在网络上丢了,协调者只能按超时处理。
- 协调者自己崩了:恢复后按全局事务日志判定 “该回滚”,而某些分支的 Try 根本没发出去。
为什么空回滚必须返回成功
Cancel 的语义是 “把这笔事务预留的东西还回去”。既然没预留,就没什么可还——返回成功才能让协调者把这一分支标记成已回滚,整个事务才能收敛。返回失败只会让协调者无限重试一条永远不可能成功的 Cancel,卡死全局事务。
为什么 Confirm 也要幂等
Confirm 同样会超时,协调者超时后必然重发。参与方第一次可能已经扣完款了,第二次再扣就是资损。
幂等不能靠 “我记得调过了”——进程会重启,内存不可信;只有事务控制表里的状态可信。
事务控制表:TCC 的唯一事实来源
CREATE TABLE tcc_branch (
tx_id VARCHAR(64) NOT NULL, -- 全局事务 ID
branch_id VARCHAR(64) NOT NULL, -- 分支 ID(一个参与方一个)
status TINYINT NOT NULL, -- 0=TRIED 1=CONFIRMED 2=CANCELLED
user_id VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL, -- 预留量;空回滚时由 Cancel 请求自带
updated_at DATETIME NOT NULL,
PRIMARY KEY (tx_id, branch_id)
);
-- Try:冻结 + 记账,必须与业务表更新在同一个本地事务
UPDATE account SET available = available - ?, frozen = frozen + ?
WHERE user_id = ? AND available >= ?;
INSERT INTO tcc_branch (tx_id, branch_id, status, user_id, amount, updated_at)
VALUES (?, ?, 0, ?, ?, NOW());
-- Cancel 的空回滚:抢占式插入,唯一键保证并发下只有一方写入
INSERT INTO tcc_branch (tx_id, branch_id, status, user_id, amount, updated_at)
VALUES (?, ?, 2, ?, ?, NOW())
ON DUPLICATE KEY UPDATE status = status; -- 已存在则什么也不做
-- Cancel 的正常回滚:只有 TRIED 才能推进
UPDATE tcc_branch SET status = 2, updated_at = NOW()
WHERE tx_id = ? AND branch_id = ? AND status = 0; -- affected=1 才有资格解冻
UPDATE account SET available = available + ?, frozen = frozen - ?
WHERE user_id = ?;
-- Confirm:同理,affected=0 说明别人先推进了,直接返回成功
UPDATE tcc_branch SET status = 1, updated_at = NOW()
WHERE tx_id = ? AND branch_id = ? AND status = 0;
四个细节决定正确性:
- 主键
(tx_id, branch_id):并发的空回滚与重复 Cancel 只能有一个插入成功,其余走ON DUPLICATE KEY,幂等交给数据库。 WHERE status = 0:状态推进用条件更新,affected rows = 1才算 “我赢了”,避免 “先查后改” 的竞态。amount / user_id存在记录里:正常回滚时以 Try 记录为准,防止 Cancel 请求被篡改金额。- 与业务表同库同事务:冻结和记账要么都成功,要么都失败。跨库就必须借助本地消息表或事务消息 (24.6 / 24.7)。
可运行的最小 TCC
下面用内存表模拟数据库,把空回滚、悬挂、幂等、空提交各跑一遍 (Go 1.21+,go run 直接可跑):
package main
import (
"errors"
"fmt"
"sync"
)
const (
statusTried = "TRIED"
statusConfirmed = "CONFIRMED"
statusCancelled = "CANCELLED"
)
type account struct {
available int64 // 可用余额
frozen int64 // 冻结余额:TCC 的业务锁
}
type branch struct {
user string
amount int64
status string
}
// participant 模拟一个参与方:业务表 + 事务控制表,
// 两者的更新在同一个本地事务里(要么都写,要么都不写)。
type participant struct {
mu sync.Mutex
accounts map[string]*account
branches map[string]*branch // key = txID + "/" + branchID
}
func newParticipant() *participant {
return &participant{
accounts: map[string]*account{"alice": {available: 1000}},
branches: make(map[string]*branch),
}
}
func (p *participant) key(txID, branchID string) string { return txID + "/" + branchID }
// Try:检查并预留。防悬挂的关键在这儿——先看有没有 CANCELLED 记录。
func (p *participant) Try(txID, branchID, user string, amount int64) error {
p.mu.Lock()
defer p.mu.Unlock()
k := p.key(txID, branchID)
if b, ok := p.branches[k]; ok {
if b.status == statusCancelled {
fmt.Println(" ↳ Try 发现 CANCELLED 记录,空执行(挡住悬挂)")
return nil // 悬挂:Cancel 已先到,本次 Try 直接空执行
}
fmt.Println(" ↳ Try 重复到达,按幂等直接成功")
return nil // 重复 Try:幂等
}
acc := p.accounts[user]
if acc == nil || acc.available < amount {
return errors.New("可用余额不足")
}
// 同一个本地事务:冻结余额 + 写分支记录
acc.available -= amount
acc.frozen += amount
p.branches[k] = &branch{user: user, amount: amount, status: statusTried}
return nil
}
// Confirm:把冻结变成真正扣减。业务上不允许失败,且必须幂等。
func (p *participant) Confirm(txID, branchID string) error {
p.mu.Lock()
defer p.mu.Unlock()
k := p.key(txID, branchID)
b, ok := p.branches[k]
if !ok {
// 空提交:Try 从未成功却收到 Confirm,说明协调者状态机出错了,必须告警。
p.branches[k] = &branch{status: statusConfirmed}
return errors.New("空提交:没有 Try 记录,需人工介入")
}
switch b.status {
case statusConfirmed:
fmt.Println(" ↳ Confirm 重复到达,幂等返回")
return nil
case statusCancelled:
return errors.New("已回滚的分支不能再 Confirm")
}
// 真实实现:UPDATE account SET frozen=frozen-? WHERE user_id=? 与
// UPDATE tcc_branch SET status=1 WHERE ... AND status=0 放在同一个本地事务。
acc := p.accounts[b.user]
acc.frozen -= b.amount
b.status = statusConfirmed
return nil
}
// Cancel:释放预留。空回滚 + 幂等都在这儿。
func (p *participant) Cancel(txID, branchID, user string, amount int64) error {
p.mu.Lock()
defer p.mu.Unlock()
k := p.key(txID, branchID)
b, ok := p.branches[k]
if !ok {
// 空回滚:没有 Try 可回滚,插入 CANCELLED 当栅栏,挡住迟到的 Try
p.branches[k] = &branch{user: user, amount: amount, status: statusCancelled}
fmt.Println(" ↳ Cancel 查无 Try 记录:写入 CANCELLED(空回滚 + 防悬挂)")
return nil
}
switch b.status {
case statusCancelled:
fmt.Println(" ↳ Cancel 重复到达,幂等返回")
return nil
case statusConfirmed:
return errors.New("已确认的分支不能回滚,只能冲正")
}
acc := p.accounts[b.user]
acc.frozen -= b.amount
acc.available += b.amount
b.status = statusCancelled
return nil
}
func (p *participant) show(tag string) {
p.mu.Lock()
defer p.mu.Unlock()
a := p.accounts["alice"]
fmt.Printf("[%s] alice 可用=%d 冻结=%d\n", tag, a.available, a.frozen)
}
func main() {
p := newParticipant()
fmt.Println("① 正常:Try → Confirm")
_ = p.Try("tx1", "b1", "alice", 100)
_ = p.Confirm("tx1", "b1")
p.show("tx1")
fmt.Println("② 空回滚:Try 从未执行,Cancel 到达")
_ = p.Cancel("tx2", "b1", "alice", 100)
p.show("tx2")
fmt.Println("③ 悬挂:Cancel 先到,Try 迟到")
_ = p.Cancel("tx3", "b1", "alice", 100)
_ = p.Try("tx3", "b1", "alice", 100) // 迟到的 Try 必须空执行
p.show("tx3")
fmt.Println("④ 幂等:Confirm / Cancel 各重复一次")
_ = p.Try("tx4", "b1", "alice", 200)
_ = p.Confirm("tx4", "b1")
_ = p.Confirm("tx4", "b1") // 重复 Confirm 不能多扣
_ = p.Try("tx5", "b1", "alice", 300)
_ = p.Cancel("tx5", "b1", "alice", 300)
_ = p.Cancel("tx5", "b1", "alice", 300) // 重复 Cancel 不能多退
p.show("tx4+tx5")
fmt.Println("⑤ 空提交:Confirm 先于 Try 到达")
fmt.Println(" 错误:", p.Confirm("tx6", "b1"))
p.show("tx6")
}
输出 (go run main.go):
① 正常:Try → Confirm
[tx1] alice 可用=900 冻结=0
② 空回滚:Try 从未执行,Cancel 到达
↳ Cancel 查无 Try 记录:写入 CANCELLED(空回滚 + 防悬挂)
[tx2] alice 可用=900 冻结=0
③ 悬挂:Cancel 先到,Try 迟到
↳ Cancel 查无 Try 记录:写入 CANCELLED(空回滚 + 防悬挂)
↳ Try 发现 CANCELLED 记录,空执行(挡住悬挂)
[tx3] alice 可用=900 冻结=0
④ 幂等:Confirm / Cancel 各重复一次
↳ Confirm 重复到达,幂等返回
↳ Cancel 重复到达,幂等返回
[tx4+tx5] alice 可用=700 冻结=0
⑤ 空提交:Confirm 先于 Try 到达
错误: 空提交:没有 Try 记录,需人工介入
[tx6] alice 可用=700 冻结=0
五组场景各看一个结果:②③ 余额没有变多 (空回滚与悬挂都被挡住),④ 只扣了 200 (不是 400),⑤ 空提交被拒绝并告警。
协调者也要持久化
参与方各自幂等,不等于全局事务能收敛。协调者 (事务管理器) 必须落一张全局事务表:
CREATE TABLE tcc_global (
tx_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(16) NOT NULL, -- TRYING / CONFIRMING / CANCELLING / DONE
deadline DATETIME NOT NULL, -- 全局超时时间
created_at DATETIME NOT NULL
);
状态机的三条规则:
- 先落日志再发指令:
TRYING → CONFIRMING必须先写库,再逐个调 Confirm。否则协调者一崩,恢复后不知道该提交还是回滚。 - 定时扫描
deadline过期且未完成的事务,继续推进。Confirm / Cancel 可以重试到天荒地老,但不能忘。 - Confirm 阶段出现 “已回滚”、Cancel 阶段出现 “已确认”,说明指令乱序或记录被改,直接告警走人工,不要自动修。
冻结窗口的成本,可以算出来
TCC 用 “预留” 换锁,预留期间资源对别人不可用。假设:
- 下单 QPS = 1000;
- Try 到 Confirm 平均耗时 200ms;
- 每单冻结 1 件库存。
在途事务数 ≈ 1000 × 0.2 = 200 笔,也就是任意时刻约有 200 件库存处于冻结态。一家只备 100 件货的店,会因为 “库存都在冻结中” 直接卖不动。
🔑 预留窗口越短,TCC 的吞吐越接近无锁;窗口越长,越像一把业务层的悲观锁。优化 TCC 的第一优先级是缩短 Try → Confirm 的耗时,不是调大重试次数。
什么时候用 TCC
| 你的情况 | 选择 |
|---|---|
| 跨服务扣库存 / 扣余额,要求「不能多扣、不能少扣」 | TCC:预留 + 确认,资金类最稳 |
| 参与方是第三方接口,无法改造出 Try/Confirm/Cancel | 不要用 TCC,用 Saga(24.5)或本地消息表(24.6) |
| 业务流程长、步骤多、能接受中间态可见 | Saga 更轻,TCC 的 3 倍接口量不划算 |
| 只需要「本地事务 + 通知下游」 | 本地消息表 / 事务消息,别上 TCC |
| 需要严格隔离(转账中间态不能被看到) | TCC 也做不到,回到单库事务或 2PC |
反模式
| 反模式 | 后果 |
|---|---|
| Cancel 不做空回滚判断,直接「解冻 / 加回」 | 余额、库存凭空增加,直接资损 |
| Confirm / Cancel 不幂等,靠内存标记去重 | 进程重启后标记丢失,重试即重复扣款 |
Try 不检查 CANCELLED 记录 | 悬挂:资源永久冻结,账对不上 |
| 用定时任务「超时自动解冻」代替状态机 | 解冻后 Confirm 又扣一次,双花 |
| Confirm 里做业务校验(余额不足则失败) | 分支永远结束不了,全局事务卡死 |
| 事务控制表与业务表分库 | 失去本地事务,冻结与记账可能只成功一半 |
| Confirm 失败只重试 3 次就放弃 | 已确认分支丢账,只能靠对账兜底 |
把 Cancel 当数据库回滚,直接 DELETE 业务数据 | 丢审计、丢流水,事后无法追责 |
自检
Q1 空回滚和悬挂的根因都是 “Try 没成功”,为什么一个要在 Cancel 里处理,一个要在 Try 里处理?
▸ 答案:两者处理的是不同时间点的同一个缺口。空回滚是 Cancel 先到,此时必须让 Cancel 不产生副作用,并留下 CANCELLED 记录;悬挂是 Try 后到,它必须先读这条记录才能知道自己已经作废。只做空回滚不写记录,迟到的 Try 照样冻结资源;只做 Try 检查而 Cancel 不写记录,检查永远查不到东西。所以两者是一套机制的两端:Cancel 写栅栏,Try 读栅栏。
Q2 有人把 Confirm 写成 “查一下订单状态,没支付就返回失败”。这在 TCC 里为什么是错的?
▸ 答案:Confirm 阶段资源已经在 Try 里预留,业务判断必须前置到 Try。到 Confirm 再做 “没支付就失败”,会让这个分支永远无法结束:协调者只能重试,而重试结果不变,全局事务卡死,资源一直冻着。Confirm / Cancel 只能做状态推进,不允许有业务性失败;技术性失败 (DB 超时) 靠幂等重试解决。
Q3 你的 Cancel 实现是 “先 SELECT 查记录,再判断、再 UPDATE”,压测时偶发多解冻。为什么?怎么改?
▸ 答案:查与改之间没有原子性,两个重复的 Cancel 可能同时读到 TRIED,然后各自解冻一次。改法是把判断塞进 SQL:UPDATE tcc_branch SET status=2 WHERE tx_id=? AND branch_id=? AND status=0,只有 affected rows = 1 的那一方才执行解冻,另一方直接返回成功。幂等的裁判权必须交给数据库的唯一键或条件更新,不能交给应用层的 “先查后改”。