4. 最终一致性方案之TCC:Try/Confirm/Cancel模式详解

聊到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在生产环境中很少直接用2PC——性能开销太大,而且那个"协调者"一旦挂了,整个事务就卡死了。后来我接触到TCC,才觉得这才是真正适合互联网场景的方案。

TCC全称是Try-Confirm-Cancel,说白了就是把一个分布式事务拆成三个阶段。我习惯把它理解成"预定-确认-取消"三部曲。你想想看,这跟我们在生活中订酒店很像:先预定(Try),到了前台确认入住(Confirm),如果计划有变就取消预定(Cancel)。

核心思想:TCC通过业务逻辑的补偿机制,将分布式事务的原子性保证从数据库层面提升到了业务层面。每个参与者都需要实现三个接口。

4.1 Try阶段:资源预留

Try阶段要做的事情很简单——检查并预留资源。注意我说的是"预留",不是"占用"。我在项目中遇到过不少新手,直接在Try阶段就把数据改了,结果Confirm失败时回滚特别麻烦。

举个例子,假设我们要做一个转账服务:

// Try接口:冻结账户金额
public boolean tryTransfer(Account from, Account to, BigDecimal amount) {
    // 1. 检查余额是否充足
    if (from.getBalance().compareTo(amount) < 0) {
        return false;
    }
    // 2. 冻结金额(不是直接扣减)
    from.setFrozenAmount(from.getFrozenAmount().add(amount));
    // 3. 记录转账流水(状态为"待确认")
    saveTransferLog(from, to, amount, TransferStatus.TRY);
    return true;
}

嗯,这里要注意:Try阶段一定要保证幂等性。为什么?因为网络抖动可能导致Try请求被重复发送。我曾经就被这个问题坑过——同一个订单被冻结了两次金额,用户投诉说钱少了。

4.2 Confirm阶段:执行业务

如果所有参与者的Try都成功了,协调者就会调用Confirm接口。这个阶段才是真正执行业务逻辑的地方。

// Confirm接口:完成转账
public boolean confirmTransfer(String transferId) {
    TransferLog log = getTransferLog(transferId);
    if (log == null || log.getStatus() != TransferStatus.TRY) {
        // 幂等性检查:已经确认过的直接返回成功
        return true;
    }
    // 1. 从冻结金额中扣减
    Account from = log.getFromAccount();
    from.setBalance(from.getBalance().subtract(log.getAmount()));
    from.setFrozenAmount(from.getFrozenAmount().subtract(log.getAmount()));
    // 2. 增加目标账户余额
    Account to = log.getToAccount();
    to.setBalance(to.getBalance().add(log.getAmount()));
    // 3. 更新流水状态
    log.setStatus(TransferStatus.CONFIRMED);
    return true;
}

我个人习惯在Confirm阶段也做幂等性检查。你可能会问:"Try都成功了,Confirm还会重复调用吗?"会的,协调者超时重试时就会重复调用。所以幂等性不是可选项,是必选项。

4.3 Cancel阶段:回滚补偿

Cancel阶段是TCC的精髓。当任何一个参与者Try失败,或者Confirm超时,协调者就会对所有已Try成功的参与者调用Cancel。

// Cancel接口:取消转账
public boolean cancelTransfer(String transferId) {
    TransferLog log = getTransferLog(transferId);
    if (log == null) {
        // 空回滚:Try从未执行过,直接返回成功
        return true;
    }
    if (log.getStatus() == TransferStatus.CANCELLED) {
        // 幂等性检查:已经取消过的直接返回
        return true;
    }
    // 解冻金额
    Account from = log.getFromAccount();
    from.setFrozenAmount(from.getFrozenAmount().subtract(log.getAmount()));
    log.setStatus(TransferStatus.CANCELLED);
    return true;
}
空回滚陷阱:我曾经遇到过一个场景——Try请求超时了,协调者直接调用了Cancel。但后来Try请求又到达了服务端。这时候Cancel接口必须能正确处理"Try从未执行过"的情况,这就是空回滚。解决方案很简单:在Cancel中判断业务流水是否存在,不存在就直接返回成功。

4.4 TCC的空回滚与幂等性

这两个概念我放在一起讲,因为它们经常一起出现。

概念 说明 我的建议
空回滚 Try未执行,但Cancel被调用了 Cancel接口必须能处理"无记录"的情况
幂等性 同一个请求被多次执行,结果一致 Try/Confirm/Cancel三个接口都要实现幂等
悬挂问题 Cancel先于Try到达 Try接口要检查是否已被Cancel

说白了,TCC的难点不在技术实现,而在边界情况的处理。我记得有一次线上事故,就是因为没有处理好悬挂问题——Cancel先到了,把资源释放了,然后Try才到,又把资源冻结了。结果那个资源永远没人去解冻。

避坑指南:我曾经在Try接口中加了一个"防悬挂"检查:如果发现该笔交易已经有Cancel记录,就直接拒绝Try请求。这个方案虽然简单,但很有效。

4.5 TCC实战案例:跨行转账

光说不练假把式。我们来看一个完整的跨行转账案例。假设用户A(工行)要给用户B(建行)转1000元。

// 转账服务协调者
public class TransferCoordinator {
    
    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        // 1. Try阶段:冻结双方账户
        boolean tryResult = tryAll(fromAccount, toAccount, amount);
        if (!tryResult) {
            // 2. 如果Try失败,取消所有已冻结的资源
            cancelAll(fromAccount, toAccount, amount);
            throw new RuntimeException("转账失败,余额不足");
        }
        
        try {
            // 3. Confirm阶段:执行转账
            boolean confirmResult = confirmAll(fromAccount, toAccount, amount);
            if (!confirmResult) {
                // 4. Confirm失败,回滚
                cancelAll(fromAccount, toAccount, amount);
                throw new RuntimeException("转账失败,确认阶段异常");
            }
        } catch (Exception e) {
            // 5. 异常情况:记录日志,人工介入
            log.error("转账异常,需要人工处理", e);
            cancelAll(fromAccount, toAccount, amount);
            throw e;
        }
    }
}

这个案例看起来简单,但实际生产环境要考虑的东西很多:

  • 超时处理:每个阶段都要设置合理的超时时间。我一般设5秒,超过就触发重试或回滚。
  • 重试机制:Confirm失败后要自动重试,通常重试3次,间隔递增。
  • 日志记录:每个阶段的状态变更都要记录日志,方便排查问题。
  • 人工补偿:对于重试多次仍然失败的场景,要提供人工介入的接口。
我的经验:TCC最适合那些"业务逻辑清晰、资源可预留"的场景。比如库存扣减、账户冻结、优惠券锁定等。如果业务逻辑特别复杂,或者资源无法预留(比如发送短信),那TCC就不太合适了。

最后说一句,TCC不是银弹。它解决了2PC的性能问题,但引入了业务侵入性。每个参与者都要实现三个接口,这对业务代码的改造量不小。不过话说回来,在互联网高并发场景下,TCC确实是最实用的最终一致性方案之一。

TCC事务执行流程 事务协调者 参与者A(工行) 参与者B(建行) ① Try(冻结1000元) ① Try(冻结1000元) ② Try成功 ② Try成功 ③ 判断是否全部Try成功 ④ Confirm(扣减余额) ④ Confirm(增加余额) ⑤ Confirm成功 ⑤ Confirm成功 ⑥ 事务完成 Try/Confirm成功 结果返回 Confirm指令

专注资料整理