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方法里,先判断Try阶段是否真的执行了。如果没执行,Cancel就直接返回成功。这需要你在Try阶段记录一个事务状态。
4.5 悬挂问题:比空回滚更隐蔽
悬挂问题,是空回滚的「镜像」。它发生在:Cancel先执行了,然后Try才到达。
场景是这样的:协调者发了Try,超时了,于是发Cancel。Cancel执行成功,资源释放了。但这时候,那个迟到的Try请求才到达服务端。服务端一看,Try请求来了,于是冻结了资源。但此时Cancel已经执行过了,这个冻结的资源就永远没人来释放了——这就是「悬挂」。
你想想看,这多可怕。资源被白白占着,既不会被Confirm,也不会被Cancel。时间长了,系统里全是这种悬挂的资源,业务就慢慢卡死了。
4.6 TCC的优缺点总结
说了这么多,我们来总结一下TCC的优缺点:
| 优点 | 缺点 |
|---|---|
| 不依赖数据库锁,性能高 | 业务侵入性强,需要写大量代码 |
| 支持最终一致性,适合高并发场景 | 需要处理空回滚和悬挂问题 |
| 每个阶段独立,灵活性高 | 幂等性设计复杂,容易出错 |
| 可以跨数据库、跨消息队列 | 调试困难,问题定位成本高 |
我个人觉得,TCC适合那些对一致性要求不是特别高,但对性能要求很高的场景。比如支付、积分、优惠券等。如果你的业务要求强一致性,那还是考虑其他方案吧。
4.7 小结
TCC模式,说白了就是用业务代码来模拟分布式事务。它把一个大事务拆成三个小阶段,每个阶段都独立执行。Try预留资源,Confirm真正执行,Cancel回滚释放。
空回滚和悬挂是TCC的两个经典问题。空回滚是「没Try就Cancel」,悬挂是「先Cancel后Try」。解决它们的关键是:引入事务状态表,在每个阶段都做状态判断。
嗯,TCC的内容就讲到这里。下一章我们聊聊另一种最终一致性方案——本地消息表。那个更简单,但也更实用。