4. TCC 模式详解:Try-Confirm-Cancel 三阶段详解、空回滚与悬挂问题、幂等性设计
说到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在生产环境里很少直接用 2PC——它太“重”了,而且对资源锁定时间太长。后来接触了 TCC,才觉得这才是真正适合业务系统的柔性事务方案。
TCC 全称是 Try-Confirm-Cancel,说白了就是把一个分布式事务拆成三个阶段。我最早在电商支付场景里用 TCC 时,觉得它跟 2PC 有点像,但用久了才发现,它俩完全是两码事。
核心区别一句话:2PC 是数据库层面的锁,TCC 是业务层面的预留。
4.1 TCC 三阶段详解
咱们先看一个典型的场景:用户下单,扣库存、扣余额、生成订单。如果用 TCC,每个参与方都要实现三个接口。
4.1.1 Try 阶段
Try 是“预留资源”。不是真的扣减,而是先占住位置。
// 库存服务 - Try 接口
public boolean tryDeductStock(Long orderId, Long skuId, int quantity) {
// 1. 检查库存是否充足
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getAvailable() < quantity) {
return false; // 资源不足,Try 失败
}
// 2. 冻结库存(不是扣减)
stock.setFrozen(stock.getFrozen() + quantity);
stock.setAvailable(stock.getAvailable() - quantity);
stockMapper.updateById(stock);
// 3. 记录 Try 日志(用于幂等和回滚)
tryLogMapper.insert(new TryLog(orderId, skuId, quantity, "FROZEN"));
return true;
}
注意,这里只动了“冻结”字段。真正的库存还没扣。我见过不少新手直接把库存扣了,结果 Cancel 时还得加回去——万一 Cancel 失败,数据就乱了。
4.1.2 Confirm 阶段
Confirm 是把预留的资源真正扣掉。这个阶段理论上不会失败,因为 Try 已经保证了资源足够。
// 库存服务 - Confirm 接口
public boolean confirmDeductStock(Long orderId, Long skuId, int quantity) {
// 1. 检查 Try 日志是否存在
TryLog tryLog = tryLogMapper.selectByOrderId(orderId);
if (tryLog == null || !"FROZEN".equals(tryLog.getStatus())) {
return false; // 没有 Try 记录,拒绝 Confirm
}
// 2. 真正扣减:把冻结库存转为已扣减
Stock stock = stockMapper.selectBySkuId(skuId);
stock.setFrozen(stock.getFrozen() - quantity);
// 注意:available 已经在 Try 阶段扣过了,这里不再动
stockMapper.updateById(stock);
// 3. 更新 Try 日志状态
tryLog.setStatus("CONFIRMED");
tryLogMapper.updateById(tryLog);
return true;
}
Confirm 要做的事很简单:把冻结的库存清掉。但这里有个坑——Confirm 必须幂等。为什么?因为网络超时后,协调器会重试 Confirm。如果重复执行,库存就多扣了。
4.1.3 Cancel 阶段
Cancel 是释放 Try 阶段预留的资源。说白了,就是把冻结的库存还回去。
// 库存服务 - Cancel 接口
public boolean cancelDeductStock(Long orderId, Long skuId, int quantity) {
// 1. 检查 Try 日志
TryLog tryLog = tryLogMapper.selectByOrderId(orderId);
if (tryLog == null) {
// 这里要处理空回滚,后面会讲
return handleEmptyRollback(orderId);
}
// 2. 释放冻结库存
Stock stock = stockMapper.selectBySkuId(skuId);
stock.setFrozen(stock.getFrozen() - quantity);
stock.setAvailable(stock.getAvailable() + quantity);
stockMapper.updateById(stock);
// 3. 更新日志
tryLog.setStatus("CANCELED");
tryLogMapper.updateById(tryLog);
return true;
}
Cancel 同样要幂等。我曾经遇到过一个 Case:Cancel 接口执行到一半时宕机,恢复后协调器重试 Cancel,结果把已经释放的库存又释放了一次——库存直接翻倍了。
4.2 空回滚问题
空回滚,说白了就是:Try 还没执行,Cancel 先来了。
为什么会这样?我举个例子:协调器调用了服务 A 的 Try,但网络超时了。协调器判断 Try 失败,于是决定全局回滚,开始调所有参与方的 Cancel。但服务 A 其实已经收到了 Try 请求,只是响应慢了。这时候 Cancel 到了,但 Try 还没执行完——甚至可能还没开始执行。
空回滚的危害:如果 Cancel 里直接操作了 Try 阶段才有的数据(比如 Try 日志),就会报空指针或者更新不到数据。更严重的是,Cancel 可能把别人预留的资源给释放了。
怎么解决?我的做法是:Cancel 接口必须能处理“没有 Try 记录”的情况。
// 空回滚处理
private boolean handleEmptyRollback(Long orderId) {
// 1. 记录一条“空回滚”日志
cancelLogMapper.insert(new CancelLog(orderId, "EMPTY_ROLLBACK"));
// 2. 直接返回成功
// 注意:这里不能做任何资源操作,因为 Try 还没执行
return true;
}
但光记录日志还不够。如果后续 Try 请求到了,发现已经有 Cancel 日志了,怎么办?这就引出了下一个问题——悬挂。
4.3 悬挂问题
悬挂是空回滚的“镜像问题”:Cancel 已经执行了,Try 才姗姗来迟。
还是刚才那个场景:协调器因为网络超时认为 Try 失败,发了 Cancel。Cancel 执行完了,但 Try 请求其实还在路上。等 Try 到达时,Cancel 已经释放了资源。这时候如果 Try 再冻结库存,那这笔冻结就永远没人去解了——这就是“悬挂”。
我的经验:悬挂问题在异步网络环境下特别常见。尤其是用了消息队列做 TCC 协调时,消息乱序会导致 Try 和 Cancel 的到达顺序完全不可控。
解决方案也不复杂:Try 接口在执行业务逻辑前,先检查是否已经有 Cancel 日志。
// Try 接口增加悬挂检查
public boolean tryDeductStock(Long orderId, Long skuId, int quantity) {
// 1. 检查是否有 Cancel 日志
CancelLog cancelLog = cancelLogMapper.selectByOrderId(orderId);
if (cancelLog != null) {
// 已经有 Cancel 了,Try 不能再执行
// 记录一条“悬挂拒绝”日志
hangLogMapper.insert(new HangLog(orderId, "TRY_REJECTED"));
return false;
}
// 2. 继续正常的 Try 逻辑
// ...
}
这样,即使 Try 晚到了,也不会造成资源悬挂。Cancel 已经释放了,Try 再冻结就是给自己找麻烦。
4.4 幂等性设计
幂等性,说白了就是:同一个操作执行多次,结果跟执行一次一样。
在 TCC 里,Confirm 和 Cancel 都可能被重试。为什么?因为协调器为了保证事务最终完成,会不断重试失败的接口。如果接口不幂等,重试就是灾难。
我常用的幂等方案有两种:
| 方案 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 状态机法 | 用事务日志记录状态,每次执行前检查状态 | 所有 TCC 接口 | 需要额外维护日志表 |
| 唯一键法 | 用业务 ID 做唯一约束,重复插入会报错 | Confirm 阶段 | Cancel 阶段不太适用 |
我个人更推荐状态机法。你看上面的代码示例,每个接口都先查 TryLog 的状态:
- 如果状态是
FROZEN,Confirm 可以执行 - 如果状态已经是
CONFIRMED,Confirm 直接返回成功(幂等) - 如果状态是
CANCELED,Confirm 直接返回失败
这样,不管重试多少次,结果都一样。
幂等设计的核心原则:先查后做,查不到不做,做了不重做。
4.5 TCC 整体流程示意图
下面这张图是我自己画的 TCC 三阶段流程,包含了空回滚和悬挂的处理路径。你看一眼就能明白整个协调过程。
4.6 避坑指南
最后,我把自己踩过的坑总结一下:
- Try 里不要真的扣资源——只做冻结。真扣了,Cancel 失败就完了。
- Confirm 和 Cancel 必须幂等——用状态机法,先查日志再操作。
- 空回滚要记录日志——但不要操作任何业务资源。
- 悬挂要靠 Try 自己防——执行前检查是否有 Cancel 日志。
- 日志表要独立——别跟业务表混在一起,否则事务冲突会让你头疼。
一个小建议:如果你用的是 Spring 生态,可以看看 seata 的 TCC 模式。它内置了空回滚和悬挂的处理,但底层原理还是我们今天讲的这套。理解了原理,用任何框架都不会慌。