4、TCC模式:Try-Confirm-Cancel模式详解,业务补偿机制,空回滚与悬挂问题
聊到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在生产环境中很少直接用2PC——性能开销太大,而且那个协调者一旦挂了,整个事务就卡死了。后来我接触到TCC模式,才觉得这才是真正适合高并发交易系统的方案。
TCC,全称是Try-Confirm-Cancel。说白了,就是把一个完整的业务操作拆成三个阶段:预留资源、确认执行、补偿回滚。我最早是在一个支付系统中用到的,当时要处理跨行转账,既要保证扣款成功,又要保证入账成功,还不能让用户的钱"悬空"——嗯,TCC正好解决了这个问题。
4.1 TCC的核心思想
传统的分布式事务,比如XA协议,是让数据库自己去做锁和回滚。但TCC不一样,它把事务控制权交给了业务层。你想想看,业务层最清楚什么时候该锁、什么时候该释放,对吧?
TCC的三个阶段是这样的:
- Try阶段:尝试执行业务,完成资源检查和预留。比如扣款时,先冻结用户账户里的钱,而不是直接扣走。
- Confirm阶段:如果所有Try都成功了,就执行Confirm,真正完成业务操作。比如把冻结的钱真正划走。
- Cancel阶段:如果任何一个Try失败了,就执行Cancel,释放预留的资源。比如把冻结的钱解冻。
我习惯把TCC理解成"业务层面的两阶段提交"。2PC是数据库层面的事,TCC是业务代码层面的事。各有各的适用场景,但TCC更灵活,性能也更好。
4.2 一个真实的TCC例子
拿一个电商下单的场景来说。用户下单后,要扣库存、扣余额、生成订单。这三个操作分布在不同的服务里,怎么保证一致性?
我用TCC来设计:
// 库存服务的TCC接口
public interface InventoryTccService {
// Try:预扣库存,把库存数量冻结起来
boolean tryDeduct(Long productId, int quantity);
// Confirm:真正扣减库存
boolean confirmDeduct(Long productId, int quantity);
// Cancel:释放冻结的库存
boolean cancelDeduct(Long productId, int quantity);
}
// 账户服务的TCC接口
public interface AccountTccService {
// Try:冻结用户余额
boolean tryFreeze(Long userId, BigDecimal amount);
// Confirm:真正扣款
boolean confirmFreeze(Long userId, BigDecimal amount);
// Cancel:解冻余额
boolean cancelFreeze(Long userId, BigDecimal amount);
}
你看,每个服务都提供了三个方法。Try阶段只是"预留",不是真正的执行。这样做的好处是:如果某个服务Try失败了,其他服务可以安全地Cancel,不会造成数据不一致。
关键点:Try阶段一定要保证幂等性。因为网络抖动可能导致Try被调用多次,如果每次Try都冻结一次钱,那用户的钱就被多冻结了。我在项目中就踩过这个坑,后来在Try方法里加了事务ID去重,才解决。
4.3 业务补偿机制
TCC的Cancel阶段,本质上就是一种业务补偿。但补偿不是简单的"撤销",它需要考虑到业务语义。
举个例子:你Try阶段冻结了用户的钱,Cancel时解冻。这看起来很简单,对吧?但如果你在Try阶段还发送了一条短信通知用户"正在处理中",Cancel时要不要发一条"处理失败"的短信?
这就是业务补偿的复杂性。我个人的经验是:补偿操作要尽量与正向操作对称。正向做了什么,补偿就做相反的事。但有些操作是不可逆的,比如发送邮件、记录日志,这时候补偿就只能做"标记"而不是"撤销"。
| 正向操作 | 补偿操作 | 说明 |
|---|---|---|
| 冻结余额 | 解冻余额 | 完全可逆 |
| 预扣库存 | 释放库存 | 完全可逆 |
| 发送通知 | 发送失败通知 | 不可逆,只能补偿语义 |
| 生成订单 | 标记订单为失败 | 不可删除,只能改状态 |
4.4 空回滚问题
空回滚,这个词听起来有点怪,但实际中很常见。什么叫空回滚?就是Cancel方法被调用了,但对应的Try方法根本没执行过。
为什么会这样?我遇到过一种情况:Try请求因为网络超时,调用方认为失败了,于是发起了Cancel。但实际上Try请求只是慢,最终还是到达了服务端并执行成功了。这时候Cancel来了,但Try已经执行过了,怎么办?
更糟糕的是另一种情况:Try请求根本没到达服务端,Cancel却来了。这时候如果Cancel直接执行解冻操作,就会把不该解冻的钱解冻了——嗯,这会导致数据不一致。
解决方案其实不复杂:在Cancel方法里,先检查Try是否执行过。如果没执行过,Cancel就直接返回成功,什么都不做。这就是"空回滚"的含义——回滚一个没执行过的操作。
public boolean cancelFreeze(Long userId, BigDecimal amount, String txId) {
// 先检查这个事务ID是否已经执行过Try
TccRecord record = tccRecordDao.getByTxId(txId);
if (record == null) {
// Try没执行过,直接返回成功,这就是空回滚
return true;
}
// Try执行过了,才真正执行解冻
accountDao.unfreeze(userId, amount);
record.setStatus("CANCELED");
tccRecordDao.update(record);
return true;
}
注意:空回滚的处理一定要记录日志。我曾经排查过一个线上问题,就是因为空回滚没记录日志,导致后来对账时怎么都对不上。加了日志后,一眼就看出是网络超时导致的空回滚。
4.5 悬挂问题
悬挂问题比空回滚更隐蔽。什么叫悬挂?就是Try方法执行成功了,但Confirm或Cancel一直没来,导致资源被长期冻结。
我印象最深的一次:一个订单的TCC事务,Try阶段冻结了用户的1000块钱。但Confirm请求因为消息队列积压,延迟了10分钟才到达。这10分钟里,用户的钱一直处于冻结状态,既不能花,也不能提现。用户投诉说"我的钱被锁住了"——这就是典型的悬挂问题。
悬挂问题的根源在于:Try和Confirm/Cancel之间的调用不是原子的。网络延迟、服务重启、消息丢失,都可能导致Confirm/Cancel迟迟不来。
怎么解决?我常用的方案有两个:
- 设置超时机制:Try执行后,如果超过一定时间(比如30秒)没收到Confirm或Cancel,就自动执行Cancel。这相当于给TCC加了一个"兜底"策略。
- 定时任务扫描:用一个后台任务,定期扫描那些长时间处于"Try成功但未Confirm/Cancel"状态的记录,主动发起补偿。
// 定时任务:处理悬挂的事务
@Scheduled(fixedDelay = 60000) // 每分钟执行一次
public void handleSuspendedTransactions() {
List<TccRecord> suspendedRecords = tccRecordDao.getSuspendedRecords(30); // 超过30秒未确认
for (TccRecord record : suspendedRecords) {
log.warn("发现悬挂事务,txId={},自动发起Cancel", record.getTxId());
cancelFreeze(record.getUserId(), record.getAmount(), record.getTxId());
}
}
我的建议:超时时间不要设得太短。我见过有人设5秒,结果正常业务因为稍微慢了一点就被Cancel了,用户反复下单失败。一般设30秒到1分钟比较合理,具体要看你的业务响应时间。
4.6 TCC的核心流程图
下面这张图,是我画的一个TCC核心流程。它展示了Try、Confirm、Cancel三个阶段的流转关系,以及空回滚和悬挂问题的处理点。
4.7 总结一下
TCC模式的核心,就是把事务控制权交给业务层。它比2PC更灵活,性能也更好,但代价是业务代码变复杂了——每个服务都要实现三个方法。
空回滚和悬挂问题,是TCC最容易踩的两个坑。空回滚的解法是"检查再执行",悬挂问题的解法是"超时兜底+定时扫描"。这两个问题处理好了,TCC在生产环境中就能跑得很稳。
我个人觉得,TCC特别适合那些资源预留成本低、确认执行概率高的场景。比如电商下单、支付转账、预订系统。如果业务中Cancel的概率很高,那TCC的优势就不明显了——因为频繁的预留和释放反而增加了系统开销。
一句话总结:TCC用业务代码的复杂度,换来了分布式事务的性能和灵活性。空回滚和悬挂是它的"阿喀琉斯之踵",但只要做好幂等、超时和补偿,这两个问题完全可以控制住。
公众号:蓝海资料掘金营,微信deep3321