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

聊到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在金融系统里待了这么多年,2PC 用的真不多。为啥?因为它太「重」了,而且对资源的锁定时间太长,高并发场景下根本扛不住。

那有没有一种方案,既能保证最终一致性,又不会长时间锁资源?有,就是 TCC。

TCC 全称是 Try-Confirm-Cancel,说白了就是一种「补偿型」事务方案。它不像 2PC 那样死等,而是先把资源预留好,最后再统一确认或回滚。我参与过的几个核心交易系统,最终都选了 TCC 作为主力方案。

4.1 TCC 的核心思想

TCC 把一次分布式事务拆成了三个阶段:

  • Try(预留):检查资源是否充足,并锁定资源。比如转账时检查余额,冻结要转出的金额。
  • Confirm(确认):如果所有 Try 都成功了,就执行真正的业务操作。比如扣减冻结金额,增加对方余额。
  • Cancel(取消):如果任何一个 Try 失败了,就释放预留的资源。比如解冻之前冻结的金额。

你想想看,这其实很像我们平时买东西的「预授权」流程。先冻结信用卡额度,等确认收货了再扣款,如果退货了就解冻。嗯,就是这个道理。

核心要点:TCC 的本质是「业务层面的两阶段提交」。它把锁资源的粒度从数据库层面提升到了业务层面,所以性能更好,也更灵活。

4.2 TCC 的三个阶段详解

4.2.1 Try 阶段

Try 阶段要做两件事:

  1. 业务检查:确认当前状态是否允许执行操作。比如账户是否正常、余额是否足够。
  2. 资源预留:把需要的资源「冻结」起来,不让其他事务动它。

举个例子,转账 100 元:

// Try 阶段:冻结转出金额
public boolean tryTransfer(Account from, Account to, BigDecimal amount) {
    // 1. 检查账户状态
    if (from.getStatus() != AccountStatus.ACTIVE) {
        return false;
    }
    // 2. 检查余额是否足够
    if (from.getBalance().compareTo(amount) < 0) {
        return false;
    }
    // 3. 冻结金额(在冻结字段中记录)
    from.setFrozenAmount(from.getFrozenAmount().add(amount));
    from.setBalance(from.getBalance().subtract(amount));
    return true;
}

这里有个坑,我在项目中遇到过:Try 阶段一定要保证幂等。因为网络超时可能导致 Try 被重复调用,如果没做幂等处理,就会重复冻结资金,最后用户的钱就「冻」没了。

4.2.2 Confirm 阶段

当所有参与者的 Try 都成功后,协调器会调用 Confirm。这个阶段才是真正的业务操作。

// Confirm 阶段:完成转账
public boolean confirmTransfer(Account from, Account to, BigDecimal amount) {
    // 1. 解冻金额
    from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
    // 2. 增加对方余额
    to.setBalance(to.getBalance().add(amount));
    return true;
}

Confirm 操作也必须是幂等的。为什么?因为协调器可能会重试 Confirm。如果 Confirm 执行到一半挂了,恢复后再次执行,不能出现重复扣款的情况。

我的习惯:在 Confirm 和 Cancel 方法里,我都会先检查当前状态。如果已经 Confirm 过了,就直接返回成功。这样即使重试也不会出问题。

4.2.3 Cancel 阶段

如果 Try 阶段有任何一个参与者失败了,或者协调器超时了,就会触发 Cancel。Cancel 的作用就是「撤销」Try 阶段做的预留操作。

// Cancel 阶段:取消转账
public boolean cancelTransfer(Account from, Account to, BigDecimal amount) {
    // 1. 解冻金额
    from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
    // 2. 恢复余额
    from.setBalance(from.getBalance().add(amount));
    return true;
}

Cancel 同样要保证幂等。而且 Cancel 的执行顺序和 Try 相反——后 Try 的先 Cancel。这是为了防止出现资源依赖问题。

4.3 TCC 的空回滚与防悬挂

这两个问题,是 TCC 方案里最容易踩的坑。我当年第一次落地 TCC 时,就被空回滚坑过一次。

4.3.1 空回滚

什么叫空回滚?就是 Cancel 执行了,但对应的 Try 根本没执行过

场景是这样的:协调器调用 A 服务的 Try,A 服务处理成功了,但返回结果时网络超时。协调器认为 A 失败了,于是触发 Cancel。但 A 服务收到 Cancel 时,它已经执行了 Try。这时候 Cancel 是正常的。

但如果反过来呢?协调器调用 A 服务的 Try,请求在网络上丢了,A 根本没收到。协调器超时后触发 Cancel,A 收到了 Cancel。这时候 A 的 Try 没执行过,Cancel 却要执行——这就是空回滚。

解决方案:在 Cancel 方法里,先判断 Try 是否执行过。如果没执行过,就直接返回成功。判断的依据可以是事务 ID 或业务主键。

public boolean cancelTransfer(String txId, Account from, Account to, BigDecimal amount) {
    // 先检查 Try 是否执行过
    TransactionRecord record = transactionDao.getByTxId(txId);
    if (record == null) {
        // Try 没执行过,直接返回成功(空回滚)
        return true;
    }
    // Try 执行过,正常回滚
    from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
    from.setBalance(from.getBalance().add(amount));
    return true;
}

4.3.2 防悬挂

防悬挂和空回滚是「双胞胎」问题。悬挂的意思是:Cancel 先执行了,Try 后执行

场景是这样的:协调器调用 A 服务的 Try,请求超时了。协调器触发 Cancel,A 收到了 Cancel 并执行了空回滚。但这时候,之前超时的 Try 请求又到达了 A 服务。A 执行了 Try,冻结了资金。但协调器已经认为事务结束了,不会再调用 Confirm 或 Cancel。这笔资金就被「挂」在那里了。

我曾经在一个支付系统里遇到过这个问题,排查了好久才发现是网络延迟导致的。

防悬挂的核心思路:在 Try 方法里,先检查 Cancel 是否已经执行过。如果 Cancel 已经执行了,Try 就直接返回成功(不做任何操作),或者直接拒绝。

public boolean tryTransfer(String txId, Account from, Account to, BigDecimal amount) {
    // 检查 Cancel 是否已经执行过
    TransactionRecord record = transactionDao.getByTxId(txId);
    if (record != null && "CANCELLED".equals(record.getStatus())) {
        // Cancel 已经执行了,直接返回成功,防止悬挂
        return true;
    }
    // 正常 Try 逻辑
    // ...
}

4.4 TCC 的代码实现(以转账为例)

好了,理论说完了,咱们直接上代码。下面是一个完整的 TCC 转账实现。

4.4.1 事务记录表

首先,我们需要一张表来记录事务状态:

CREATE TABLE tcc_transaction (
    tx_id VARCHAR(64) PRIMARY KEY,        -- 事务ID
    from_account_id VARCHAR(32),           -- 转出账户
    to_account_id VARCHAR(32),             -- 转入账户
    amount DECIMAL(18,2),                  -- 金额
    status VARCHAR(16),                    -- TRYING, CONFIRMED, CANCELLED
    create_time DATETIME,
    update_time DATETIME
);

4.4.2 TCC 服务接口

public interface TccTransferService {
    // Try:预留资源
    boolean tryTransfer(String txId, String fromAccountId, 
                        String toAccountId, BigDecimal amount);
    
    // Confirm:确认转账
    boolean confirmTransfer(String txId);
    
    // Cancel:取消转账
    boolean cancelTransfer(String txId);
}

4.4.3 实现类

@Service
public class TccTransferServiceImpl implements TccTransferService {
    
    @Autowired
    private AccountDao accountDao;
    
    @Autowired
    private TransactionDao transactionDao;
    
    @Override
    @Transactional
    public boolean tryTransfer(String txId, String fromAccountId,
                                String toAccountId, BigDecimal amount) {
        // 防悬挂:检查 Cancel 是否已执行
        TransactionRecord record = transactionDao.getByTxId(txId);
        if (record != null && "CANCELLED".equals(record.getStatus())) {
            return true; // 直接返回,防止悬挂
        }
        
        // 创建事务记录
        if (record == null) {
            record = new TransactionRecord();
            record.setTxId(txId);
            record.setFromAccountId(fromAccountId);
            record.setToAccountId(toAccountId);
            record.setAmount(amount);
            record.setStatus("TRYING");
            transactionDao.insert(record);
        }
        
        // 冻结金额
        Account fromAccount = accountDao.getById(fromAccountId);
        if (fromAccount.getBalance().compareTo(amount) < 0) {
            return false; // 余额不足
        }
        accountDao.freezeAmount(fromAccountId, amount);
        
        return true;
    }
    
    @Override
    @Transactional
    public boolean confirmTransfer(String txId) {
        // 幂等检查
        TransactionRecord record = transactionDao.getByTxId(txId);
        if (record == null || "CONFIRMED".equals(record.getStatus())) {
            return true;
        }
        
        // 执行转账:解冻 + 增加对方余额
        accountDao.unfreezeAmount(record.getFromAccountId(), record.getAmount());
        accountDao.increaseBalance(record.getToAccountId(), record.getAmount());
        
        // 更新状态
        transactionDao.updateStatus(txId, "CONFIRMED");
        return true;
    }
    
    @Override
    @Transactional
    public boolean cancelTransfer(String txId) {
        // 空回滚检查
        TransactionRecord record = transactionDao.getByTxId(txId);
        if (record == null) {
            return true; // Try 没执行过,空回滚
        }
        if ("CANCELLED".equals(record.getStatus())) {
            return true; // 已经取消过了
        }
        
        // 解冻金额
        accountDao.unfreezeAmount(record.getFromAccountId(), record.getAmount());
        
        // 更新状态
        transactionDao.updateStatus(txId, "CANCELLED");
        return true;
    }
}

4.4.4 协调器调用逻辑

public class TccCoordinator {
    
    public boolean executeTransfer(String fromAccountId, 
                                    String toAccountId, BigDecimal amount) {
        String txId = UUID.randomUUID().toString();
        
        // 1. Try 阶段:调用所有参与者
        boolean tryResult = tccService.tryTransfer(txId, fromAccountId, 
                                                    toAccountId, amount);
        
        if (tryResult) {
            // 2. Confirm 阶段:所有 Try 成功
            return tccService.confirmTransfer(txId);
        } else {
            // 3. Cancel 阶段:有 Try 失败
            tccService.cancelTransfer(txId);
            return false;
        }
    }
}

4.5 TCC 的优缺点

优点 缺点
性能好,不长时间锁资源 业务侵入性强,需要实现三个接口
最终一致性,适合高并发 开发复杂度高,需要处理空回滚和防悬挂
灵活性高,可以自定义业务逻辑 对网络稳定性要求高
支持跨数据库、跨服务 调试和排查问题比较困难

我的建议:TCC 适合那些对一致性要求高、但又能接受短暂不一致的场景。比如转账、支付、订单处理。如果业务逻辑简单,用消息队列+本地事务表可能更省事。别为了用 TCC 而用 TCC。

4.6 本章小结

TCC 的核心就三句话:Try 预留资源,Confirm 确认操作,Cancel 回滚补偿。但真正落地时,空回滚和防悬挂这两个坑一定要踩平。我见过太多项目上线后出问题,最后发现都是这两个细节没处理好。

嗯,TCC 就先聊到这儿。记住一点:分布式事务没有银弹,TCC 也不是万能的。选方案的时候,一定要结合自己的业务场景来权衡。

TCC 分布式事务流程 事务协调器 1. 调用 Try Try 阶段:预留资源 检查余额 → 冻结金额 → 记录事务状态 成功? Confirm 阶段 解冻 → 增加对方余额 Cancel 阶段 解冻金额 → 恢复余额 转账成功 转账失败 ⚠ 需要处理的特殊场景 空回滚:Cancel 执行时 Try 未执行 → 直接返回成功 防悬挂:Try 执行时 Cancel 已执行 → 直接返回成功

专注资料整理