4、最终一致性方案:TCC(Try-Confirm-Cancel)模式详解、空回滚与悬挂问题

4.1 从两阶段提交到TCC:为什么我们需要它?

聊分布式事务,绕不开两阶段提交(2PC)。但说实话,我在生产环境里很少直接用2PC。为什么?因为它太「重」了。2PC要求所有参与者在第一阶段都锁定资源,第二阶段才提交或回滚。一旦协调者挂了,或者某个参与者响应超时,整个事务就卡住了。资源锁着不放,系统吞吐量直线下降。

我记得有一次,一个支付系统用了2PC,结果数据库连接池被占满,业务直接瘫痪。从那以后,我对这种强一致性的方案就特别谨慎。

TCC模式的出现,说白了就是为了解决2PC的痛点。它把事务拆成三个阶段:Try、Confirm、Cancel。每个阶段都是独立的业务操作,不依赖数据库的锁机制。你想想看,这就像我们平时买东西——先下单(Try),确认收货(Confirm),或者取消订单(Cancel)。每个步骤都是独立的,互不干扰。

4.2 TCC的三个阶段:Try、Confirm、Cancel

我个人习惯把TCC理解成「业务层面的两阶段提交」。它不依赖底层数据库的XA协议,而是由业务代码自己控制事务逻辑。这样做的好处是灵活,坏处是——你得自己写很多代码。

4.2.1 Try阶段:预留资源

Try阶段的核心是「预留」。不是真的扣减资源,而是先占个坑。比如在电商下单场景中:

  • 库存服务:冻结库存,不是直接扣减
  • 账户服务:冻结金额,不是直接扣款
  • 优惠券服务:标记优惠券为「使用中」,不是直接核销

我建议你在Try阶段尽量做轻量操作。别搞复杂的计算,也别锁表。我曾经见过一个项目,Try阶段里做了大量数据校验,结果接口响应时间飙到3秒以上。嗯,这其实违背了TCC的初衷。

4.2.2 Confirm阶段:真正执行

Confirm阶段才是真正的业务提交。它把Try阶段预留的资源正式扣减掉。这个阶段要求是「幂等」的——也就是说,执行一次和执行多次,结果一样。

为什么强调幂等?因为网络可能抖动,Confirm请求可能重复发送。如果你的Confirm逻辑不是幂等的,用户可能被扣两次钱。我在项目中遇到过这种问题,后来加了个事务状态表,每次Confirm前先查一下状态,才彻底解决。

4.2.3 Cancel阶段:回滚释放

Cancel阶段负责回滚。它把Try阶段预留的资源释放掉。同样,Cancel也必须是幂等的。

这里有个坑:Cancel阶段可能被调用多次。比如第一次Cancel成功了,但协调者没收到响应,又发了一次Cancel。如果你的Cancel逻辑是「释放资源」,那第二次调用时资源已经释放了,就会出问题。所以,我习惯在Cancel里先判断资源状态,再决定是否执行释放操作。

4.3 TCC的代码示例:一个简单的账户转账

光说不练假把式。我们来看一个账户转账的例子。假设A账户转100元给B账户:

// Try阶段:冻结A账户的100元
public boolean tryTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 检查A账户余额是否充足
    BigDecimal balance = getBalance(fromAccount);
    if (balance.compareTo(amount) < 0) {
        return false; // 余额不足,Try失败
    }
    // 冻结金额:在冻结表中插入一条记录
    freezeAmount(fromAccount, amount);
    return true;
}

// Confirm阶段:真正扣减A账户,增加B账户
public boolean confirmTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 幂等检查:如果已经Confirm过,直接返回成功
    if (isConfirmed(transactionId)) {
        return true;
    }
    // 扣减A账户
    deductBalance(fromAccount, amount);
    // 增加B账户
    addBalance(toAccount, amount);
    // 标记事务为已确认
    markConfirmed(transactionId);
    return true;
}

// Cancel阶段:释放A账户的冻结金额
public boolean cancelTransfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 幂等检查:如果已经Cancel过,直接返回成功
    if (isCancelled(transactionId)) {
        return true;
    }
    // 释放冻结金额
    unfreezeAmount(fromAccount, amount);
    // 标记事务为已取消
    markCancelled(transactionId);
    return true;
}

你看,每个阶段都很清晰。Try只做冻结,Confirm做实际扣减,Cancel做释放。每个方法都考虑了幂等性。

4.4 空回滚问题:一个让人头疼的Bug

空回滚,说白了就是「还没Try,就收到了Cancel」。为什么会这样?

假设网络超时了。协调者发了Try请求,但没收到响应。协调者认为Try失败了,于是发Cancel给所有参与者。但实际上,Try请求可能只是响应慢了,服务端已经执行了Try。这时候Cancel来了,服务端就得回滚一个「还没执行」的操作。

我刚开始做TCC时,就踩过这个坑。当时一个订单服务,Try阶段还没执行完,Cancel就来了。结果Cancel里直接去释放资源,但资源根本没被冻结,导致数据不一致。

避坑指南:我曾经在Cancel方法里直接调用「释放资源」的逻辑,没做任何判断。结果线上出了好几次数据对不上的问题。后来我加了一个事务状态表,每次Cancel前先查一下Try是否真的执行过。如果没执行过,Cancel就直接返回成功,什么都不做。

解决空回滚的方案其实很简单:在Cancel方法里,先判断Try阶段是否真的执行了。如果没执行,Cancel就直接返回成功。这需要你在Try阶段记录一个事务状态。

4.5 悬挂问题:比空回滚更隐蔽

悬挂问题,是空回滚的「镜像」。它发生在:Cancel先执行了,然后Try才到达。

场景是这样的:协调者发了Try,超时了,于是发Cancel。Cancel执行成功,资源释放了。但这时候,那个迟到的Try请求才到达服务端。服务端一看,Try请求来了,于是冻结了资源。但此时Cancel已经执行过了,这个冻结的资源就永远没人来释放了——这就是「悬挂」。

你想想看,这多可怕。资源被白白占着,既不会被Confirm,也不会被Cancel。时间长了,系统里全是这种悬挂的资源,业务就慢慢卡死了。

我的经验:解决悬挂问题,我一般用「事务状态机」。在Try方法里,先检查当前事务的状态。如果状态已经是「已取消」,那Try就直接返回成功,但不做任何资源操作。说白了,就是「假装Try成功了,但什么都不做」。这样后续的Confirm或Cancel就不会出问题。

4.6 TCC的优缺点总结

说了这么多,我们来总结一下TCC的优缺点:

优点 缺点
不依赖数据库锁,性能高 业务侵入性强,需要写大量代码
支持最终一致性,适合高并发场景 需要处理空回滚和悬挂问题
每个阶段独立,灵活性高 幂等性设计复杂,容易出错
可以跨数据库、跨消息队列 调试困难,问题定位成本高

我个人觉得,TCC适合那些对一致性要求不是特别高,但对性能要求很高的场景。比如支付、积分、优惠券等。如果你的业务要求强一致性,那还是考虑其他方案吧。

4.7 小结

TCC模式,说白了就是用业务代码来模拟分布式事务。它把一个大事务拆成三个小阶段,每个阶段都独立执行。Try预留资源,Confirm真正执行,Cancel回滚释放。

空回滚和悬挂是TCC的两个经典问题。空回滚是「没Try就Cancel」,悬挂是「先Cancel后Try」。解决它们的关键是:引入事务状态表,在每个阶段都做状态判断。

嗯,TCC的内容就讲到这里。下一章我们聊聊另一种最终一致性方案——本地消息表。那个更简单,但也更实用。