4. 最终一致性方案之TCC:Try/Confirm/Cancel模式详解
聊到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在生产环境中很少直接用2PC——性能开销太大,而且那个"协调者"一旦挂了,整个事务就卡死了。后来我接触到TCC,才觉得这才是真正适合互联网场景的方案。
TCC全称是Try-Confirm-Cancel,说白了就是把一个分布式事务拆成三个阶段。我习惯把它理解成"预定-确认-取消"三部曲。你想想看,这跟我们在生活中订酒店很像:先预定(Try),到了前台确认入住(Confirm),如果计划有变就取消预定(Cancel)。
4.1 Try阶段:资源预留
Try阶段要做的事情很简单——检查并预留资源。注意我说的是"预留",不是"占用"。我在项目中遇到过不少新手,直接在Try阶段就把数据改了,结果Confirm失败时回滚特别麻烦。
举个例子,假设我们要做一个转账服务:
// Try接口:冻结账户金额
public boolean tryTransfer(Account from, Account to, BigDecimal amount) {
// 1. 检查余额是否充足
if (from.getBalance().compareTo(amount) < 0) {
return false;
}
// 2. 冻结金额(不是直接扣减)
from.setFrozenAmount(from.getFrozenAmount().add(amount));
// 3. 记录转账流水(状态为"待确认")
saveTransferLog(from, to, amount, TransferStatus.TRY);
return true;
}
嗯,这里要注意:Try阶段一定要保证幂等性。为什么?因为网络抖动可能导致Try请求被重复发送。我曾经就被这个问题坑过——同一个订单被冻结了两次金额,用户投诉说钱少了。
4.2 Confirm阶段:执行业务
如果所有参与者的Try都成功了,协调者就会调用Confirm接口。这个阶段才是真正执行业务逻辑的地方。
// Confirm接口:完成转账
public boolean confirmTransfer(String transferId) {
TransferLog log = getTransferLog(transferId);
if (log == null || log.getStatus() != TransferStatus.TRY) {
// 幂等性检查:已经确认过的直接返回成功
return true;
}
// 1. 从冻结金额中扣减
Account from = log.getFromAccount();
from.setBalance(from.getBalance().subtract(log.getAmount()));
from.setFrozenAmount(from.getFrozenAmount().subtract(log.getAmount()));
// 2. 增加目标账户余额
Account to = log.getToAccount();
to.setBalance(to.getBalance().add(log.getAmount()));
// 3. 更新流水状态
log.setStatus(TransferStatus.CONFIRMED);
return true;
}
我个人习惯在Confirm阶段也做幂等性检查。你可能会问:"Try都成功了,Confirm还会重复调用吗?"会的,协调者超时重试时就会重复调用。所以幂等性不是可选项,是必选项。
4.3 Cancel阶段:回滚补偿
Cancel阶段是TCC的精髓。当任何一个参与者Try失败,或者Confirm超时,协调者就会对所有已Try成功的参与者调用Cancel。
// Cancel接口:取消转账
public boolean cancelTransfer(String transferId) {
TransferLog log = getTransferLog(transferId);
if (log == null) {
// 空回滚:Try从未执行过,直接返回成功
return true;
}
if (log.getStatus() == TransferStatus.CANCELLED) {
// 幂等性检查:已经取消过的直接返回
return true;
}
// 解冻金额
Account from = log.getFromAccount();
from.setFrozenAmount(from.getFrozenAmount().subtract(log.getAmount()));
log.setStatus(TransferStatus.CANCELLED);
return true;
}
4.4 TCC的空回滚与幂等性
这两个概念我放在一起讲,因为它们经常一起出现。
| 概念 | 说明 | 我的建议 |
|---|---|---|
| 空回滚 | Try未执行,但Cancel被调用了 | Cancel接口必须能处理"无记录"的情况 |
| 幂等性 | 同一个请求被多次执行,结果一致 | Try/Confirm/Cancel三个接口都要实现幂等 |
| 悬挂问题 | Cancel先于Try到达 | Try接口要检查是否已被Cancel |
说白了,TCC的难点不在技术实现,而在边界情况的处理。我记得有一次线上事故,就是因为没有处理好悬挂问题——Cancel先到了,把资源释放了,然后Try才到,又把资源冻结了。结果那个资源永远没人去解冻。
4.5 TCC实战案例:跨行转账
光说不练假把式。我们来看一个完整的跨行转账案例。假设用户A(工行)要给用户B(建行)转1000元。
// 转账服务协调者
public class TransferCoordinator {
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
// 1. Try阶段:冻结双方账户
boolean tryResult = tryAll(fromAccount, toAccount, amount);
if (!tryResult) {
// 2. 如果Try失败,取消所有已冻结的资源
cancelAll(fromAccount, toAccount, amount);
throw new RuntimeException("转账失败,余额不足");
}
try {
// 3. Confirm阶段:执行转账
boolean confirmResult = confirmAll(fromAccount, toAccount, amount);
if (!confirmResult) {
// 4. Confirm失败,回滚
cancelAll(fromAccount, toAccount, amount);
throw new RuntimeException("转账失败,确认阶段异常");
}
} catch (Exception e) {
// 5. 异常情况:记录日志,人工介入
log.error("转账异常,需要人工处理", e);
cancelAll(fromAccount, toAccount, amount);
throw e;
}
}
}
这个案例看起来简单,但实际生产环境要考虑的东西很多:
- 超时处理:每个阶段都要设置合理的超时时间。我一般设5秒,超过就触发重试或回滚。
- 重试机制:Confirm失败后要自动重试,通常重试3次,间隔递增。
- 日志记录:每个阶段的状态变更都要记录日志,方便排查问题。
- 人工补偿:对于重试多次仍然失败的场景,要提供人工介入的接口。
最后说一句,TCC不是银弹。它解决了2PC的性能问题,但引入了业务侵入性。每个参与者都要实现三个接口,这对业务代码的改造量不小。不过话说回来,在互联网高并发场景下,TCC确实是最实用的最终一致性方案之一。