4. 最终一致性方案之TCC:TCC模式详解
聊到分布式事务,很多人第一反应就是两阶段提交(2PC)。但说实话,我在金融系统里待了这么多年,2PC 用的真不多。为啥?因为它太「重」了,而且对资源的锁定时间太长,高并发场景下根本扛不住。
那有没有一种方案,既能保证最终一致性,又不会长时间锁资源?有,就是 TCC。
TCC 全称是 Try-Confirm-Cancel,说白了就是一种「补偿型」事务方案。它不像 2PC 那样死等,而是先把资源预留好,最后再统一确认或回滚。我参与过的几个核心交易系统,最终都选了 TCC 作为主力方案。
4.1 TCC 的核心思想
TCC 把一次分布式事务拆成了三个阶段:
- Try(预留):检查资源是否充足,并锁定资源。比如转账时检查余额,冻结要转出的金额。
- Confirm(确认):如果所有 Try 都成功了,就执行真正的业务操作。比如扣减冻结金额,增加对方余额。
- Cancel(取消):如果任何一个 Try 失败了,就释放预留的资源。比如解冻之前冻结的金额。
你想想看,这其实很像我们平时买东西的「预授权」流程。先冻结信用卡额度,等确认收货了再扣款,如果退货了就解冻。嗯,就是这个道理。
核心要点:TCC 的本质是「业务层面的两阶段提交」。它把锁资源的粒度从数据库层面提升到了业务层面,所以性能更好,也更灵活。
4.2 TCC 的三个阶段详解
4.2.1 Try 阶段
Try 阶段要做两件事:
- 业务检查:确认当前状态是否允许执行操作。比如账户是否正常、余额是否足够。
- 资源预留:把需要的资源「冻结」起来,不让其他事务动它。
举个例子,转账 100 元:
// Try 阶段:冻结转出金额
public boolean tryTransfer(Account from, Account to, BigDecimal amount) {
// 1. 检查账户状态
if (from.getStatus() != AccountStatus.ACTIVE) {
return false;
}
// 2. 检查余额是否足够
if (from.getBalance().compareTo(amount) < 0) {
return false;
}
// 3. 冻结金额(在冻结字段中记录)
from.setFrozenAmount(from.getFrozenAmount().add(amount));
from.setBalance(from.getBalance().subtract(amount));
return true;
}
这里有个坑,我在项目中遇到过:Try 阶段一定要保证幂等。因为网络超时可能导致 Try 被重复调用,如果没做幂等处理,就会重复冻结资金,最后用户的钱就「冻」没了。
4.2.2 Confirm 阶段
当所有参与者的 Try 都成功后,协调器会调用 Confirm。这个阶段才是真正的业务操作。
// Confirm 阶段:完成转账
public boolean confirmTransfer(Account from, Account to, BigDecimal amount) {
// 1. 解冻金额
from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
// 2. 增加对方余额
to.setBalance(to.getBalance().add(amount));
return true;
}
Confirm 操作也必须是幂等的。为什么?因为协调器可能会重试 Confirm。如果 Confirm 执行到一半挂了,恢复后再次执行,不能出现重复扣款的情况。
我的习惯:在 Confirm 和 Cancel 方法里,我都会先检查当前状态。如果已经 Confirm 过了,就直接返回成功。这样即使重试也不会出问题。
4.2.3 Cancel 阶段
如果 Try 阶段有任何一个参与者失败了,或者协调器超时了,就会触发 Cancel。Cancel 的作用就是「撤销」Try 阶段做的预留操作。
// Cancel 阶段:取消转账
public boolean cancelTransfer(Account from, Account to, BigDecimal amount) {
// 1. 解冻金额
from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
// 2. 恢复余额
from.setBalance(from.getBalance().add(amount));
return true;
}
Cancel 同样要保证幂等。而且 Cancel 的执行顺序和 Try 相反——后 Try 的先 Cancel。这是为了防止出现资源依赖问题。
4.3 TCC 的空回滚与防悬挂
这两个问题,是 TCC 方案里最容易踩的坑。我当年第一次落地 TCC 时,就被空回滚坑过一次。
4.3.1 空回滚
什么叫空回滚?就是 Cancel 执行了,但对应的 Try 根本没执行过。
场景是这样的:协调器调用 A 服务的 Try,A 服务处理成功了,但返回结果时网络超时。协调器认为 A 失败了,于是触发 Cancel。但 A 服务收到 Cancel 时,它已经执行了 Try。这时候 Cancel 是正常的。
但如果反过来呢?协调器调用 A 服务的 Try,请求在网络上丢了,A 根本没收到。协调器超时后触发 Cancel,A 收到了 Cancel。这时候 A 的 Try 没执行过,Cancel 却要执行——这就是空回滚。
解决方案:在 Cancel 方法里,先判断 Try 是否执行过。如果没执行过,就直接返回成功。判断的依据可以是事务 ID 或业务主键。
public boolean cancelTransfer(String txId, Account from, Account to, BigDecimal amount) {
// 先检查 Try 是否执行过
TransactionRecord record = transactionDao.getByTxId(txId);
if (record == null) {
// Try 没执行过,直接返回成功(空回滚)
return true;
}
// Try 执行过,正常回滚
from.setFrozenAmount(from.getFrozenAmount().subtract(amount));
from.setBalance(from.getBalance().add(amount));
return true;
}
4.3.2 防悬挂
防悬挂和空回滚是「双胞胎」问题。悬挂的意思是:Cancel 先执行了,Try 后执行。
场景是这样的:协调器调用 A 服务的 Try,请求超时了。协调器触发 Cancel,A 收到了 Cancel 并执行了空回滚。但这时候,之前超时的 Try 请求又到达了 A 服务。A 执行了 Try,冻结了资金。但协调器已经认为事务结束了,不会再调用 Confirm 或 Cancel。这笔资金就被「挂」在那里了。
我曾经在一个支付系统里遇到过这个问题,排查了好久才发现是网络延迟导致的。
防悬挂的核心思路:在 Try 方法里,先检查 Cancel 是否已经执行过。如果 Cancel 已经执行了,Try 就直接返回成功(不做任何操作),或者直接拒绝。
public boolean tryTransfer(String txId, Account from, Account to, BigDecimal amount) {
// 检查 Cancel 是否已经执行过
TransactionRecord record = transactionDao.getByTxId(txId);
if (record != null && "CANCELLED".equals(record.getStatus())) {
// Cancel 已经执行了,直接返回成功,防止悬挂
return true;
}
// 正常 Try 逻辑
// ...
}
4.4 TCC 的代码实现(以转账为例)
好了,理论说完了,咱们直接上代码。下面是一个完整的 TCC 转账实现。
4.4.1 事务记录表
首先,我们需要一张表来记录事务状态:
CREATE TABLE tcc_transaction (
tx_id VARCHAR(64) PRIMARY KEY, -- 事务ID
from_account_id VARCHAR(32), -- 转出账户
to_account_id VARCHAR(32), -- 转入账户
amount DECIMAL(18,2), -- 金额
status VARCHAR(16), -- TRYING, CONFIRMED, CANCELLED
create_time DATETIME,
update_time DATETIME
);
4.4.2 TCC 服务接口
public interface TccTransferService {
// Try:预留资源
boolean tryTransfer(String txId, String fromAccountId,
String toAccountId, BigDecimal amount);
// Confirm:确认转账
boolean confirmTransfer(String txId);
// Cancel:取消转账
boolean cancelTransfer(String txId);
}
4.4.3 实现类
@Service
public class TccTransferServiceImpl implements TccTransferService {
@Autowired
private AccountDao accountDao;
@Autowired
private TransactionDao transactionDao;
@Override
@Transactional
public boolean tryTransfer(String txId, String fromAccountId,
String toAccountId, BigDecimal amount) {
// 防悬挂:检查 Cancel 是否已执行
TransactionRecord record = transactionDao.getByTxId(txId);
if (record != null && "CANCELLED".equals(record.getStatus())) {
return true; // 直接返回,防止悬挂
}
// 创建事务记录
if (record == null) {
record = new TransactionRecord();
record.setTxId(txId);
record.setFromAccountId(fromAccountId);
record.setToAccountId(toAccountId);
record.setAmount(amount);
record.setStatus("TRYING");
transactionDao.insert(record);
}
// 冻结金额
Account fromAccount = accountDao.getById(fromAccountId);
if (fromAccount.getBalance().compareTo(amount) < 0) {
return false; // 余额不足
}
accountDao.freezeAmount(fromAccountId, amount);
return true;
}
@Override
@Transactional
public boolean confirmTransfer(String txId) {
// 幂等检查
TransactionRecord record = transactionDao.getByTxId(txId);
if (record == null || "CONFIRMED".equals(record.getStatus())) {
return true;
}
// 执行转账:解冻 + 增加对方余额
accountDao.unfreezeAmount(record.getFromAccountId(), record.getAmount());
accountDao.increaseBalance(record.getToAccountId(), record.getAmount());
// 更新状态
transactionDao.updateStatus(txId, "CONFIRMED");
return true;
}
@Override
@Transactional
public boolean cancelTransfer(String txId) {
// 空回滚检查
TransactionRecord record = transactionDao.getByTxId(txId);
if (record == null) {
return true; // Try 没执行过,空回滚
}
if ("CANCELLED".equals(record.getStatus())) {
return true; // 已经取消过了
}
// 解冻金额
accountDao.unfreezeAmount(record.getFromAccountId(), record.getAmount());
// 更新状态
transactionDao.updateStatus(txId, "CANCELLED");
return true;
}
}
4.4.4 协调器调用逻辑
public class TccCoordinator {
public boolean executeTransfer(String fromAccountId,
String toAccountId, BigDecimal amount) {
String txId = UUID.randomUUID().toString();
// 1. Try 阶段:调用所有参与者
boolean tryResult = tccService.tryTransfer(txId, fromAccountId,
toAccountId, amount);
if (tryResult) {
// 2. Confirm 阶段:所有 Try 成功
return tccService.confirmTransfer(txId);
} else {
// 3. Cancel 阶段:有 Try 失败
tccService.cancelTransfer(txId);
return false;
}
}
}
4.5 TCC 的优缺点
| 优点 | 缺点 |
|---|---|
| 性能好,不长时间锁资源 | 业务侵入性强,需要实现三个接口 |
| 最终一致性,适合高并发 | 开发复杂度高,需要处理空回滚和防悬挂 |
| 灵活性高,可以自定义业务逻辑 | 对网络稳定性要求高 |
| 支持跨数据库、跨服务 | 调试和排查问题比较困难 |
我的建议:TCC 适合那些对一致性要求高、但又能接受短暂不一致的场景。比如转账、支付、订单处理。如果业务逻辑简单,用消息队列+本地事务表可能更省事。别为了用 TCC 而用 TCC。
4.6 本章小结
TCC 的核心就三句话:Try 预留资源,Confirm 确认操作,Cancel 回滚补偿。但真正落地时,空回滚和防悬挂这两个坑一定要踩平。我见过太多项目上线后出问题,最后发现都是这两个细节没处理好。
嗯,TCC 就先聊到这儿。记住一点:分布式事务没有银弹,TCC 也不是万能的。选方案的时候,一定要结合自己的业务场景来权衡。