167|TCC 模式:Try/Confirm/Cancel、空回滚、悬挂和幂等

分布式理论、事务与一致性约 9 分钟阅读4,814 字符
知识库文档 169 / 251

分类:分布式理论、事务与一致性(161–175) 阅读时间:约 45 分钟


一、为什么需要 TCC?

在单体应用中,数据库的 ACID 事务解决了绝大部分一致性问题。但进入分布式系统后,一次业务操作往往跨越多个服务、多个数据库,"一个本地事务"的假设不复存在。

以电商下单为例:

code
用户下单 → 扣库存(库存服务) → 扣余额(账户服务) → 生成订单(订单服务)

如果扣库存成功、扣余额失败,库存已经扣了,钱没扣——数据不一致。这就是分布式事务要解决的问题。

业界常见的分布式事务方案有:

方案 核心思路 一致性 性能 复杂度
2PC/XA 两阶段提交协议 强一致 低 中
TCC 资源预留 + 确认/取消 最终一致 高 高
Saga 正向补偿链条 最终一致 高 中
可靠消息 本地消息表 + 重试 最终一致 高 中
AT 模式(Seata) 自动回滚日志 最终一致 较高 低

TCC 的核心差异在于:它不是"锁住资源等人确认",而是"先预留资源,再确认或释放"。

文章配图
文章配图


二、TCC 三阶段模型

TCC 全称 Try-Confirm-Cancel,最初由 Pat Helland 在 2007 年提出,后来被蚂蚁金服等企业大规模实践并沉淀为分布式事务的标准模式之一。

文章配图
文章配图

2.1 Try:资源预留

Try 阶段不是"执行",而是"预留"——对资源做检查和冻结。

举个例子,转账 100 元:

code
Try:
  - 检查账户 A 余额是否 ≥ 100
  - 冻结 A 的 100 元(余额不减少,但不可用于其他交易)
  - 账户 B 不做任何操作

Confirm:
  - A 冻结的 100 元真正扣除
  - B 余额增加 100 元

Cancel:
  - A 冻结的 100 元解冻

Try 阶段的三个关键动作:

  1. 业务检查:校验是否满足业务规则(余额充足、库存足够、状态合法)
  2. 资源预留:对资源打下"预定"标记,防止被其他事务占用
  3. 预留记录:在业务表中记录本次预留,供 Confirm/Cancel 使用

Try 接口的设计原则:

code
Try 请求 → 检查业务条件 → 预留资源 → 返回预留 ID
         ↓ 不满足
         返回失败(不执行 Confirm/Cancel)

2.2 Confirm:确认提交

Confirm 阶段要求幂等——如果之前已经 Confirm 过,重复调用应该返回成功,而不是重复执行。

Confirm 的核心逻辑:

code
Confirm(预留ID):
  1. 查预留记录
  2. 如果已确认 → 幂等返回成功
  3. 如果已取消 → 异常(不应该出现)
  4. 执行业务提交:扣减冻结资源、更新状态
  5. 标记预留记录为"已确认"

Confirm 的设计约束:

  • 只做正向操作,不检查业务条件(Try 已经检查过了)
  • 必须幂等
  • 不能失败(如果 Confirm 失败,TCC 框架会不断重试)

2.3 Cancel:回滚取消

Cancel 是 Try 的逆操作——释放 Try 预留的资源。同样要求幂等。

code
Cancel(预留ID):
  1. 查预留记录
  2. 如果已取消 → 幂等返回成功
  3. 如果已确认 → 拒绝(不能取消已确认的资源)
  4. 释放预留资源
  5. 标记预留记录为"已取消"

Cancel 的设计约束:

  • 必须是 Try 的精确逆操作
  • 必须幂等
  • 必须允许"空回滚"(见下文)

三、TCC 的协调流程

TCC 需要一个**事务协调器(Transaction Coordinator)**来编排各参与方的 Try/Confirm/Cancel 调用。

3.1 正常流程

文章配图
文章配图

code
Coordinator                    Service A              Service B
    |                              |                      |
    |--- Try(A,预留ID_A) --------->|                      |
    |<-- 成功 ---------------------|                      |
    |--- Try(B,预留ID_B) -------------------------------->|
    |<-- 成功 --------------------------------------------|
    |                              |                      |
    |--- Confirm(A,预留ID_A) ----->|                      |
    |<-- 成功 ---------------------|                      |
    |--- Confirm(B,预留ID_B) ---------------------------->|
    |<-- 成功 --------------------------------------------|
    |                              |                      |
    |[事务完成]                     |                      |

3.2 异常流程(Try 失败)

文章配图
文章配图

code
Coordinator                    Service A              Service B
    |                              |                      |
    |--- Try(A) ------------------>|                      |
    |<-- 成功 ---------------------|                      |
    |--- Try(B) ---------------------------------------->|
    |<-- 失败 --------------------------------------------|
    |                              |                      |
    |--- Cancel(A) --------------->|  ← 取消 A 的预留      |
    |<-- 成功 ---------------------|                      |
    |                              |                      |
    |[事务回滚]                     |                      |

3.3 异常流程(Confirm 超时)

code
Coordinator                    Service A
    |                              |
    |--- Try(A) ------------------>|
    |<-- 成功 ---------------------|
    |--- Confirm(A) -------------->|
    |     (超时,未收到响应)          |
    |                              |
    |--- Confirm(A) [重试] ------->|
    |<-- 幂等返回成功 --------------|
    |                              |
    |[事务完成]                     |

四、空回滚(Empty Rollback)

4.1 什么是空回滚

空回滚:Try 阶段因为网络超时或服务宕机,Coordinator 不知道 Try 是否成功,直接发起 Cancel。此时服务端可能根本没有 Try 的记录,但仍需正确处理 Cancel 请求。

文章配图
文章配图

code
场景:
  1. Coordinator 发起 Try → 网络超时
  2. Coordinator 判定 Try 失败,发起 Cancel
  3. 但 Try 请求其实已经到达了服务端,只是响应丢失了
  4. 或者 Try 请求根本没到达服务端

4.2 为什么需要空回滚

如果没有空回滚机制,Cancel 找不到 Try 记录时会报错,导致 Coordinator 反复重试 Cancel,最终事务无法完成。

空回滚的正确处理:

code
处理 Cancel 时:
  1. 查预留记录
  2. 如果记录不存在 → 这是空回滚
  3. 创建一条"已取消"状态的预留记录(防悬挂,见下文)
  4. 返回成功

4.3 空回滚的实现方式

方式一:Try 先落记录

java
// Try 接口实现
public boolean tryReserve(String txId) {
    // 先插入预留记录(状态 = TRYING)
    Reservation record = new Reservation();
    record.setTxId(txId);
    record.setStatus("TRYING");
    reservationMapper.insert(record);
    
    // 再做业务操作
    freezeAmount(txId);
    return true;
}

// Cancel 接口实现
public boolean cancelReserve(String txId) {
    Reservation record = reservationMapper.selectByTxId(txId);
    if (record == null) {
        // 空回滚:没有 Try 记录,直接返回成功
        // 但要插入一条防悬挂记录
        Reservation antiSuspend = new Reservation();
        antiSuspend.setTxId(txId);
        antiSuspend.setStatus("CANCELLED"); // 标记为已取消
        reservationMapper.insert(antiSuspend);
        return true;
    }
    // 正常 Cancel 逻辑...
}

方式二:唯一键 + 防御性插入

java
public boolean tryReserve(String txId) {
    // 利用唯一索引防止重复 Try
    try {
        reservationMapper.insert(new Reservation(txId, "TRYING"));
    } catch (DuplicateKeyException e) {
        // 已存在,可能是重试
        return true;
    }
    freezeAmount(txId);
    return true;
}

五、悬挂(Suspension)

5.1 什么是悬挂

悬挂:Cancel 先于 Try 到达,Try 随后才到达,导致 Try 的预留操作"悬挂"在空中——对应的 Cancel 已经执行过了,Try 还能成功执行吗?

文章配图
文章配图

code
时间线:
  T1: Coordinator 发起 Try → 网络延迟/拥堵
  T2: Coordinator 等待超时,发起 Cancel
  T3: Cancel 到达服务端,执行空回滚,插入"CANCELLED"记录
  T4: Try 终于到达服务端
  T5: Try 如果执行成功 → 预留了资源但永远不会被 Confirm/Cancel
      → 资源泄漏!

5.2 悬挂的危害

悬挂会导致:

  • 资源被永久冻结(因为对应的 Cancel 已经执行过了,不会再调用)
  • 业务数据不一致
  • 如果有 Confirm,Confirm 会执行,但 Cancel 已经执行过了,数据混乱

5.3 防悬挂的实现

核心思路:Try 执行前,先检查是否已经被 Cancel 过。

java
public boolean tryReserve(String txId) {
    // 防悬挂检查:查是否有 Cancel 记录
    Reservation cancelRecord = reservationMapper.selectByTxId(txId);
    if (cancelRecord != null && "CANCELLED".equals(cancelRecord.getStatus())) {
        // 已经被 Cancel 过了,拒绝 Try
        log.warn("防悬挂:事务 {} 已被 Cancel,拒绝 Try", txId);
        return false;
    }
    
    // 正常 Try 逻辑
    Reservation record = new Reservation();
    record.setTxId(txId);
    record.setStatus("TRYING");
    reservationMapper.insert(record);
    
    freezeAmount(txId);
    return true;
}

核心要点:空回滚时插入的 Cancel 记录,同时也是防悬挂的"栅栏"。


六、幂等(Idempotency)

6.1 为什么 TCC 需要幂等

TCC 的 Confirm 和 Cancel 都可能被 Coordinator 重试(网络超时、进程重启等)。如果 Confirm/Cancel 不幂等,重复调用会导致:

文章配图
文章配图

  • Confirm 重复:资源被重复扣减(扣两次钱)
  • Cancel 重复:资源被重复释放(解冻两次,可能解冻了别人的)

6.2 幂等实现方案

方案一:状态机

java
public boolean confirm(String txId) {
    Reservation record = reservationMapper.selectByTxId(txId);
    if (record == null) {
        throw new IllegalStateException("Confirm without Try");
    }
    
    switch (record.getStatus()) {
        case "TRYING":
            // 执行确认
            doConfirm(record);
            reservationMapper.updateStatus(txId, "CONFIRMED");
            return true;
        case "CONFIRMED":
            // 已确认,幂等返回
            return true;
        case "CANCELLED":
            // 已取消,不能确认
            throw new IllegalStateException("Cannot confirm cancelled transaction");
        default:
            throw new IllegalStateException("Unknown status: " + record.getStatus());
    }
}

方案二:唯一键 + 插入防重

java
public boolean confirm(String txId) {
    try {
        // 插入确认记录,利用唯一键防重
        ConfirmRecord confirmRecord = new ConfirmRecord();
        confirmRecord.setTxId(txId);
        confirmRecord.setCreateTime(new Date());
        confirmMapper.insert(confirmRecord);
        
        // 执行确认业务
        doConfirm(txId);
        return true;
    } catch (DuplicateKeyException e) {
        // 已确认过,幂等返回
        return true;
    }
}

方案三:版本号(乐观锁)

sql
UPDATE reservation 
SET status = 'CONFIRMED', version = version + 1
WHERE tx_id = #{txId} AND status = 'TRYING' AND version = #{version}

如果 affected rows = 0,说明已经被更新过,幂等返回成功。

6.3 幂等的粒度

TCC 中的幂等分为两个层面:

  1. 接口级幂等:同一个 txId 的 Confirm/Cancel 多次调用,结果一致
  2. 业务级幂等:同一个 txId 的重复请求,不会产生重复的业务影响

七、TCC 的异常场景全景

7.1 场景矩阵

文章配图
文章配图

场景 Try Confirm Cancel 处理策略
正常 ✅ ✅ - 事务完成
Try 失败 ❌ - - 不调用 Confirm/Cancel
Try 超时 不确定 - ✅ 空回滚 + 防悬挂
Confirm 超时 ✅ 不确定 - 重试 Confirm(幂等)
Cancel 超时 ✅ - 不确定 重试 Cancel(幂等)
空回滚 未到达 - ✅ 记录 Cancel 防悬挂
悬挂 延迟到达 - 已执行 拒绝 Try
Confirm 重复 ✅ 重复 - 幂等返回

7.2 异常处理伪代码

java
public class TccParticipant {
    
    public boolean tryPhase(String txId, BusinessData data) {
        // 1. 防悬挂检查
        TccRecord existing = recordRepo.findByTxId(txId);
        if (existing != null && existing.getStatus() == Status.CANCELLED) {
            return false; // 已被 Cancel,拒绝 Try
        }
        if (existing != null) {
            return true;  // 重复 Try,幂等返回
        }
        
        // 2. 业务检查 + 资源预留
        if (!businessCheck(data)) {
            return false;
        }
        
        // 3. 创建预留记录
        TccRecord record = new TccRecord(txId, Status.TRYING, data);
        recordRepo.insert(record);
        
        // 4. 预留资源
        reserveResource(data);
        
        return true;
    }
    
    public boolean confirmPhase(String txId) {
        TccRecord record = recordRepo.findByTxId(txId);
        if (record == null) {
            throw new TccException("Confirm without Try");
        }
        
        if (record.getStatus() == Status.CONFIRMED) {
            return true; // 幂等
        }
        
        if (record.getStatus() == Status.CANCELLED) {
            throw new TccException("Cannot confirm cancelled transaction");
        }
        
        // 执行确认
        commitResource(record.getBusinessData());
        recordRepo.updateStatus(txId, Status.CONFIRMED);
        return true;
    }
    
    public boolean cancelPhase(String txId) {
        TccRecord record = recordRepo.findByTxId(txId);
        if (record == null) {
            // 空回滚:记录 Cancel 状态防悬挂
            TccRecord antiSuspend = new TccRecord(txId, Status.CANCELLED);
            recordRepo.insert(antiSuspend);
            return true;
        }
        
        if (record.getStatus() == Status.CANCELLED) {
            return true; // 幂等
        }
        
        if (record.getStatus() == Status.CONFIRMED) {
            throw new TccException("Cannot cancel confirmed transaction");
        }
        
        // 释放预留资源
        releaseResource(record.getBusinessData());
        recordRepo.updateStatus(txId, Status.CANCELLED);
        return true;
    }
}

八、TCC 的工程实践

8.1 框架选型

文章配图
文章配图

框架 特点 适用场景
Seata TCC 阿里开源,AT/TCC/Saga 三种模式,社区活跃 通用 Java 项目
ByteTCC 轻量级,基于 Spring 注解 中小型项目
Hmily 异步 Confirm/Cancel,高性能 高吞吐场景
自研 灵活可控 有特殊需求的大厂

8.2 Seata TCC 示例

java
@LocalTCC
public interface AccountService {
    
    @TwoPhaseBusinessAction(
        name = "deductAccount",
        commitMethod = "deductCommit",
        rollbackMethod = "deductRollback"
    )
    boolean deductPrepare(
        @BusinessActionContextParameter(paramName = "accountId") String accountId,
        @BusinessActionContextParameter(paramName = "amount") BigDecimal amount
    );
    
    boolean deductCommit(BusinessActionContext context);
    
    boolean deductRollback(BusinessActionContext context);
}

@Service
public class AccountServiceImpl implements AccountService {
    
    @Override
    public boolean deductPrepare(String accountId, BigDecimal amount) {
        // Try: 冻结金额
        Account account = accountMapper.selectForUpdate(accountId);
        if (account.getBalance().compareTo(amount) < 0) {
            throw new InsufficientBalanceException();
        }
        account.setFrozenAmount(account.getFrozenAmount().add(amount));
        account.setBalance(account.getBalance().subtract(amount));
        accountMapper.update(account);
        return true;
    }
    
    @Override
    public boolean deductCommit(BusinessActionContext context) {
        // Confirm: 确认扣减
        String accountId = context.getActionContext("accountId");
        // 从冻结金额中扣除
        accountMapper.deductFrozen(accountId, new BigDecimal(context.getActionContext("amount")));
        return true;
    }
    
    @Override
    public boolean deductRollback(BusinessActionContext context) {
        // Cancel: 解冻
        String accountId = context.getActionContext("accountId");
        BigDecimal amount = new BigDecimal(context.getActionContext("amount"));
        accountMapper.unfreeze(accountId, amount);
        return true;
    }
}

8.3 数据库表设计

sql
-- TCC 事务日志表(每个参与方各一张)
CREATE TABLE tcc_transaction (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    tx_id       VARCHAR(64) NOT NULL COMMENT '全局事务 ID',
    action_name VARCHAR(128) NOT NULL COMMENT 'TCC 参与者名称',
    status      VARCHAR(16) NOT NULL COMMENT 'TRYING/CONFIRMED/CANCELLED',
    biz_context JSON COMMENT '业务上下文',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tx_id (tx_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 业务数据表(以冻结金额为例)
CREATE TABLE account_frozen (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    tx_id       VARCHAR(64) NOT NULL,
    account_id  VARCHAR(32) NOT NULL,
    amount      DECIMAL(18,2) NOT NULL,
    status      VARCHAR(16) NOT NULL DEFAULT 'FROZEN',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tx_id (tx_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

九、TCC 的优缺点与适用场景

9.1 优点

  • 高性能:Try 阶段不持有数据库锁,资源只是"标记"而非"锁定"
  • 无全局锁:不像 2PC 在第一阶段就锁住资源
  • 业务语义清晰:Try/Confirm/Cancel 三阶段与业务操作天然对应
  • 最终一致性:通过重试机制保证

9.2 缺点

  • 代码侵入性强:每个业务接口都需要实现 Try/Confirm/Cancel 三个方法
  • 空回滚、悬挂、幂等:需要开发者自己处理这些边界情况
  • 业务改造成本高:需要设计"预留"语义,不是所有业务都容易改造
  • Confirm/Cancel 失败需要重试:需要补偿机制

9.3 适用场景

场景 是否适合 TCC 原因
资金交易(转账、支付) ✅ 非常适合 金额可以冻结/解冻,语义天然匹配
库存扣减 ✅ 适合 库存可以"预占"
积分/优惠券 ✅ 适合 可以冻结后确认/回滚
订单状态变更 ⚠️ 可以 状态机本身已有幂等性
简单 CRUD ❌ 不适合 改造 TCC 成本高于收益
非幂等的外部系统调用 ❌ 不适合 消息发送、邮件发送等

十、TCC vs 其他分布式事务方案

文章配图
文章配图

维度 TCC 2PC/XA Saga 可靠消息
一致性 最终一致 强一致 最终一致 最终一致
隔离性 业务层隔离 数据库锁隔离 无隔离 无隔离
性能 高 低 高 高
代码侵入 高 低 中 低
回滚方式 Cancel 自动回滚 补偿事务 无需回滚
适用场景 资金、库存 单体拆分 长事务 异步通知

总结

TCC 的核心思想是 "预留-确认-取消",它把分布式事务的复杂性从数据库层转移到了业务层。这让它获得了高性能,但代价是业务代码的复杂度显著上升。

三个关键防护机制:

  • 空回滚:Cancel 找不到 Try 记录时,不能报错,要记录 Cancel 状态
  • 防悬挂:Try 执行前检查是否已被 Cancel,防止延迟的 Try 造成资源泄漏
  • 幂等:Confirm/Cancel 必须支持重复调用,通过状态机或唯一键实现

🤔 面试官视角提问

以下问题模拟面试中可能被问到的深度问题,建议先尝试自己回答,再查看参考思路。

Q1:TCC 的 Try 阶段如果只做"检查"不做"预留",会有什么问题?

参考思路

如果 Try 只做检查不做预留,那 Try 成功通过后,到 Confirm 执行之间,资源状态可能已经变了(被其他事务修改)。这就是典型的 TOCTOU(Time-of-Check-Time-of-Use) 问题。

举例:Try 检查余额 100 元够用,返回成功。但另一个事务在 Confirm 之前扣了 50 元,Confirm 时余额只剩 50 元,不够扣了。

所以 Try 必须做"预留"——冻结资源,让其他事务无法使用,才是真正的隔离。

Q2:为什么 TCC 的 Confirm 阶段要求"不能失败"?如果真的失败了怎么办?

参考思路

Confirm 要求"不能失败"是因为 Try 已经预留了资源,如果 Confirm 失败,Coordinator 不知道该不该 Cancel——Cancel 了意味着业务撤消,不 Cancel 资源一直冻结。

设计上,Confirm 只做最简单的操作(改状态、扣减冻结资源),不应包含可能失败的业务校验。如果真的出现技术故障(如数据库宕机),TCC 框架会不断重试 Confirm,直到成功。

极端情况:Confirm 因为业务逻辑 Bug 永远失败(比如数据格式错误),那就需要人工介入——这也是 TCC 在实际运维中需要监控告警和人工补偿后台的原因。

Q3:空回滚和防悬挂是什么关系?为什么它们通常一起实现?

参考思路

空回滚和防悬挂是同一枚硬币的两面:

  • 空回滚处理的是 "Cancel 先到,Try 还没到" 的情况——Cancel 收到后,发现没有 Try 记录,插入一条 Cancel 记录。
  • 防悬挂处理的是 "Cancel 先到并处理了,Try 随后才到" 的情况——Try 收到后,检查发现已有 Cancel 记录,拒绝执行。

它们共享的是那条 Cancel 记录——空回滚时写入,防悬挂时检查。这就是为什么它们通常一起实现:空回滚产生的 Cancel 记录,天然就是防悬挂的栅栏。

Q4:如果用 Redis 实现 TCC 的预留,如何保证幂等和防悬挂?

参考思路

用 Redis 时,可以利用 SETNX(SET if Not eXists)实现:

java
// Try: 预留资源
String tryKey = "tcc:try:" + txId;
Boolean success = redis.setnx(tryKey, "TRYING");
if (!success) {
    // 已存在,检查状态
    String status = redis.get(tryKey);
    if ("CANCELLED".equals(status)) {
        return false; // 防悬挂:已被 Cancel
    }
    return true; // 幂等:重复 Try
}
redis.expire(tryKey, 3600); // 设置超时防止泄漏

// Cancel: 空回滚 + 防悬挂
String cancelKey = "tcc:cancel:" + txId;
redis.setnx(cancelKey, "CANCELLED"); // 空回滚记录
redis.set(tryKey, "CANCELLED"); // 标记 Try 为已取消(防悬挂)

关键点:

  • SETNX 保证幂等
  • Cancel 记录同时作为防悬挂的检查依据
  • 所有 Key 设置 TTL,防止 Redis 内存泄漏

Q5:TCC 在实际生产中,如果 Confirm 阶段涉及多个子操作,其中一个子操作成功了、另一个失败了,怎么办?

参考思路

这是一个经典陷阱。Confirm 内部不应该有"多个子操作"——或者说,Confirm 内部的所有子操作必须做成一个原子的整体。

具体做法:

  1. 数据库事务包裹:Confirm 的所有操作放在一个本地数据库事务中,利用 ACID 保证原子性
  2. 状态机驱动:Confirm 只做状态流转,实际业务操作由异步 Worker 消费状态变更执行
  3. 拆分成多个 TCC 参与者:如果 Confirm 确实跨越多个不可合并的数据源,那就应该把它们定义成多个独立的 TCC 参与者,由 Coordinator 协调

核心原则:一个 TCC 参与者的 Confirm 必须是一个原子的、不可再分的操作。如果不能做到,说明边界划分有问题。