4. TCC 模式详解:Try-Confirm-Cancel 三阶段详解、空回滚与悬挂问题、幂等性设计

说到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在生产环境里很少直接用 2PC——它太“重”了,而且对资源锁定时间太长。后来接触了 TCC,才觉得这才是真正适合业务系统的柔性事务方案。

TCC 全称是 Try-Confirm-Cancel,说白了就是把一个分布式事务拆成三个阶段。我最早在电商支付场景里用 TCC 时,觉得它跟 2PC 有点像,但用久了才发现,它俩完全是两码事。

核心区别一句话:2PC 是数据库层面的锁,TCC 是业务层面的预留。

4.1 TCC 三阶段详解

咱们先看一个典型的场景:用户下单,扣库存、扣余额、生成订单。如果用 TCC,每个参与方都要实现三个接口。

4.1.1 Try 阶段

Try 是“预留资源”。不是真的扣减,而是先占住位置。

// 库存服务 - Try 接口
public boolean tryDeductStock(Long orderId, Long skuId, int quantity) {
    // 1. 检查库存是否充足
    Stock stock = stockMapper.selectBySkuId(skuId);
    if (stock.getAvailable() < quantity) {
        return false; // 资源不足,Try 失败
    }
    // 2. 冻结库存(不是扣减)
    stock.setFrozen(stock.getFrozen() + quantity);
    stock.setAvailable(stock.getAvailable() - quantity);
    stockMapper.updateById(stock);
    // 3. 记录 Try 日志(用于幂等和回滚)
    tryLogMapper.insert(new TryLog(orderId, skuId, quantity, "FROZEN"));
    return true;
}

注意,这里只动了“冻结”字段。真正的库存还没扣。我见过不少新手直接把库存扣了,结果 Cancel 时还得加回去——万一 Cancel 失败,数据就乱了。

4.1.2 Confirm 阶段

Confirm 是把预留的资源真正扣掉。这个阶段理论上不会失败,因为 Try 已经保证了资源足够。

// 库存服务 - Confirm 接口
public boolean confirmDeductStock(Long orderId, Long skuId, int quantity) {
    // 1. 检查 Try 日志是否存在
    TryLog tryLog = tryLogMapper.selectByOrderId(orderId);
    if (tryLog == null || !"FROZEN".equals(tryLog.getStatus())) {
        return false; // 没有 Try 记录,拒绝 Confirm
    }
    // 2. 真正扣减:把冻结库存转为已扣减
    Stock stock = stockMapper.selectBySkuId(skuId);
    stock.setFrozen(stock.getFrozen() - quantity);
    // 注意:available 已经在 Try 阶段扣过了,这里不再动
    stockMapper.updateById(stock);
    // 3. 更新 Try 日志状态
    tryLog.setStatus("CONFIRMED");
    tryLogMapper.updateById(tryLog);
    return true;
}

Confirm 要做的事很简单:把冻结的库存清掉。但这里有个坑——Confirm 必须幂等。为什么?因为网络超时后,协调器会重试 Confirm。如果重复执行,库存就多扣了。

4.1.3 Cancel 阶段

Cancel 是释放 Try 阶段预留的资源。说白了,就是把冻结的库存还回去。

// 库存服务 - Cancel 接口
public boolean cancelDeductStock(Long orderId, Long skuId, int quantity) {
    // 1. 检查 Try 日志
    TryLog tryLog = tryLogMapper.selectByOrderId(orderId);
    if (tryLog == null) {
        // 这里要处理空回滚,后面会讲
        return handleEmptyRollback(orderId);
    }
    // 2. 释放冻结库存
    Stock stock = stockMapper.selectBySkuId(skuId);
    stock.setFrozen(stock.getFrozen() - quantity);
    stock.setAvailable(stock.getAvailable() + quantity);
    stockMapper.updateById(stock);
    // 3. 更新日志
    tryLog.setStatus("CANCELED");
    tryLogMapper.updateById(tryLog);
    return true;
}

Cancel 同样要幂等。我曾经遇到过一个 Case:Cancel 接口执行到一半时宕机,恢复后协调器重试 Cancel,结果把已经释放的库存又释放了一次——库存直接翻倍了。

4.2 空回滚问题

空回滚,说白了就是:Try 还没执行,Cancel 先来了。

为什么会这样?我举个例子:协调器调用了服务 A 的 Try,但网络超时了。协调器判断 Try 失败,于是决定全局回滚,开始调所有参与方的 Cancel。但服务 A 其实已经收到了 Try 请求,只是响应慢了。这时候 Cancel 到了,但 Try 还没执行完——甚至可能还没开始执行。

空回滚的危害:如果 Cancel 里直接操作了 Try 阶段才有的数据(比如 Try 日志),就会报空指针或者更新不到数据。更严重的是,Cancel 可能把别人预留的资源给释放了。

怎么解决?我的做法是:Cancel 接口必须能处理“没有 Try 记录”的情况。

// 空回滚处理
private boolean handleEmptyRollback(Long orderId) {
    // 1. 记录一条“空回滚”日志
    cancelLogMapper.insert(new CancelLog(orderId, "EMPTY_ROLLBACK"));
    // 2. 直接返回成功
    // 注意:这里不能做任何资源操作,因为 Try 还没执行
    return true;
}

但光记录日志还不够。如果后续 Try 请求到了,发现已经有 Cancel 日志了,怎么办?这就引出了下一个问题——悬挂。

4.3 悬挂问题

悬挂是空回滚的“镜像问题”:Cancel 已经执行了,Try 才姗姗来迟。

还是刚才那个场景:协调器因为网络超时认为 Try 失败,发了 Cancel。Cancel 执行完了,但 Try 请求其实还在路上。等 Try 到达时,Cancel 已经释放了资源。这时候如果 Try 再冻结库存,那这笔冻结就永远没人去解了——这就是“悬挂”。

我的经验:悬挂问题在异步网络环境下特别常见。尤其是用了消息队列做 TCC 协调时,消息乱序会导致 Try 和 Cancel 的到达顺序完全不可控。

解决方案也不复杂:Try 接口在执行业务逻辑前,先检查是否已经有 Cancel 日志。

// Try 接口增加悬挂检查
public boolean tryDeductStock(Long orderId, Long skuId, int quantity) {
    // 1. 检查是否有 Cancel 日志
    CancelLog cancelLog = cancelLogMapper.selectByOrderId(orderId);
    if (cancelLog != null) {
        // 已经有 Cancel 了,Try 不能再执行
        // 记录一条“悬挂拒绝”日志
        hangLogMapper.insert(new HangLog(orderId, "TRY_REJECTED"));
        return false;
    }
    // 2. 继续正常的 Try 逻辑
    // ...
}

这样,即使 Try 晚到了,也不会造成资源悬挂。Cancel 已经释放了,Try 再冻结就是给自己找麻烦。

4.4 幂等性设计

幂等性,说白了就是:同一个操作执行多次,结果跟执行一次一样。

在 TCC 里,Confirm 和 Cancel 都可能被重试。为什么?因为协调器为了保证事务最终完成,会不断重试失败的接口。如果接口不幂等,重试就是灾难。

我常用的幂等方案有两种:

方案 原理 适用场景 缺点
状态机法 用事务日志记录状态,每次执行前检查状态 所有 TCC 接口 需要额外维护日志表
唯一键法 用业务 ID 做唯一约束,重复插入会报错 Confirm 阶段 Cancel 阶段不太适用

我个人更推荐状态机法。你看上面的代码示例,每个接口都先查 TryLog 的状态:

  • 如果状态是 FROZEN,Confirm 可以执行
  • 如果状态已经是 CONFIRMED,Confirm 直接返回成功(幂等)
  • 如果状态是 CANCELED,Confirm 直接返回失败

这样,不管重试多少次,结果都一样。

幂等设计的核心原则:先查后做,查不到不做,做了不重做。

4.5 TCC 整体流程示意图

下面这张图是我自己画的 TCC 三阶段流程,包含了空回滚和悬挂的处理路径。你看一眼就能明白整个协调过程。

TCC 三阶段流程(含空回滚与悬挂处理) 协调器 Try:预留资源 悬挂检查 记录 Try 日志 1. 调用 Try Confirm:确认执行 幂等检查 更新日志状态 2. 调用 Confirm(可重试) Cancel:回滚释放 空回滚检查 记录 Cancel 日志 3. 调用 Cancel(可重试) 空回滚:无 Try 记录时直接成功 悬挂拒绝:有 Cancel 时拒绝 Try 无 Try 记录 有 Cancel 记录 图例 正常流程 异常处理 条件分支

4.6 避坑指南

最后,我把自己踩过的坑总结一下:

  • Try 里不要真的扣资源——只做冻结。真扣了,Cancel 失败就完了。
  • Confirm 和 Cancel 必须幂等——用状态机法,先查日志再操作。
  • 空回滚要记录日志——但不要操作任何业务资源。
  • 悬挂要靠 Try 自己防——执行前检查是否有 Cancel 日志。
  • 日志表要独立——别跟业务表混在一起,否则事务冲突会让你头疼。

一个小建议:如果你用的是 Spring 生态,可以看看 seata 的 TCC 模式。它内置了空回滚和悬挂的处理,但底层原理还是我们今天讲的这套。理解了原理,用任何框架都不会慌。