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 的地基。

空回滚的三个真实来源

  1. Try 超时但请求还在路上:协调者 500ms 超时判失败,Try 实际 800ms 后才到参与方。
  2. Try 本地事务失败:库存不足、账户冻结失败——参与方返回失败,但失败响应在网络上丢了,协调者只能按超时处理。
  3. 协调者自己崩了:恢复后按全局事务日志判定 “该回滚”,而某些分支的 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;

四个细节决定正确性:

  1. 主键 (tx_id, branch_id):并发的空回滚与重复 Cancel 只能有一个插入成功,其余走 ON DUPLICATE KEY,幂等交给数据库。
  2. WHERE status = 0:状态推进用条件更新,affected rows = 1 才算 “我赢了”,避免 “先查后改” 的竞态。
  3. amount / user_id 存在记录里:正常回滚时以 Try 记录为准,防止 Cancel 请求被篡改金额。
  4. 与业务表同库同事务:冻结和记账要么都成功,要么都失败。跨库就必须借助本地消息表或事务消息 (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
);

状态机的三条规则:

  1. 先落日志再发指令:TRYING → CONFIRMING 必须先写库,再逐个调 Confirm。否则协调者一崩,恢复后不知道该提交还是回滚。
  2. 定时扫描 deadline 过期且未完成的事务,继续推进。Confirm / Cancel 可以重试到天荒地老,但不能忘。
  3. 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 的那一方才执行解冻,另一方直接返回成功。幂等的裁判权必须交给数据库的唯一键或条件更新,不能交给应用层的 “先查后改”。