大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
分布式事务有一个诡异的定律:测试环境跑一百遍都没事,上线第一分钟就翻车。
TCC的代码在测试环境跑了几百遍,Try、Confirm、Cancel三个接口都写得漂漂亮亮。上线之后,账对不上了。查了半天日志才发现——Cancel执行了,但Try根本没执行过。
这就是空回滚。今天把TCC和Saga的三个核心陷阱彻底拆开讲清楚。
先搞懂几个词:
TCC(Try-Confirm-Cancel) :分布式事务的一种实现模式。Try做资源预留,Confirm确认提交,Cancel取消回滚。相比2PC的同步阻塞,TCC不长时间持有锁,性能更好。
空回滚:Cancel执行了,但Try根本没执行过。
悬挂:Try在Cancel之后才到达,资源被冻结但没有对应的Confirm或Cancel来处理。
事务记录表:TCC解决空回滚和悬挂的核心工具。Try执行前先插入一条记录,Cancel执行前先查这条记录是否存在。
补偿风暴:Saga模式下,补偿操作级联触发,导致系统被补偿请求淹没。
一、TCC空回滚:Cancel执行了,但Try从没来过
时序成因
空回滚的触发链路是这样的:
调用方发起Try请求
Try请求在网络中拥塞,超时了
调用方认为Try失败,触发Cancel
Cancel执行,但Try根本没有执行过
Try请求最终到达了——但Cancel已经执行完了
为什么会出问题?
Cancel的逻辑是“释放Try阶段预留的资源”。但如果Try根本没有执行,就没有资源被预留。Cancel执行时,会尝试“解冻”一笔不存在的冻结金额——如果代码写得不严谨,可能直接把账户余额给改了,造成数据不一致。
解决方案:事务记录表
核心思路是:Try执行前先记录,Cancel执行前先检查。
CREATE TABLE tcc_transaction (
tx_id VARCHAR(64) PRIMARY KEY COMMENT '事务ID',
status VARCHAR(16) NOT NULL COMMENT 'INIT/TRIED/CONFIRMED/CANCELLED',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
// Try:先标记,再冻结
public void try(String txId) {
insert(txId, "INIT");
freezeAmount(100);
updateStatus(txId, "TRIED");
}
// Cancel:先检查Try是否执行过
public void cancel(String txId) {
String status = queryStatus(txId);
if (status == null) {
// 空回滚:Try从未执行过,记录并返回成功
insert(txId, "CANCELLED");
return;
}
if ("CANCELLED".equals(status)) {
// 幂等:已经回滚过了
return;
}
unfreezeAmount(100);
updateStatus(txId, "CANCELLED");
}
关键点:Cancel识别到空回滚后,不能直接返回成功——必须先记录一条CANCELLED记录。否则悬挂问题会紧随其后。
二、TCC悬挂:Cancel已经执行完了,Try才姗姗来迟
时序成因
悬挂的触发链路:
Try请求在网络中拥塞,超时了
调用方触发Cancel,Cancel执行成功
Try请求最终到达,开始执行业务操作
资源被冻结了,但事务管理器认为这个事务已经结束,不会再发Confirm或Cancel
冻结的资源永远无法释放
空回滚和悬挂是一对孪生问题。 空回滚是“Cancel先到了”,悬挂是“Try后到了”。两者都源于网络延迟导致的乱序。
解决方案
在Cancel空回滚返回成功之前,先记录该事务已回滚。Try接口在执行前检查这条记录:
// Try:检查是否已经被Cancel过
public void try(String txId) {
String status = queryStatus(txId);
if ("CANCELLED".equals(status)) {
// 悬挂:Cancel已经执行过了,拒绝执行Try
throw new RuntimeException("事务已被取消,拒绝执行Try");
}
insert(txId, "INIT");
freezeAmount(100);
updateStatus(txId, "TRIED");
}
空回滚和悬挂的完整防御需要三个检查:
| 场景 | 检查时机 | 检查内容 | 处理方式 |
|---|---|---|---|
| 空回滚 | Cancel执行前 | Try是否执行过 | 未执行过 → 记录CANCELLED,返回成功 |
| 悬挂 | Try执行前 | 是否已被Cancel | 已被Cancel → 拒绝执行Try |
| 幂等 | Confirm/Cancel执行前 | 是否已执行过 | 已执行过 → 直接返回成功 |
三、幂等:Confirm和Cancel可能被重复调用
TCC的三个接口(Try/Confirm/Cancel)都可能被重复调用。原因有两个:网络重试机制、事务管理器故障恢复后的重试。
解决方案:Confirm和Cancel执行前先查询事务状态。如果状态已经是CONFIRMED或CANCELLED,直接返回成功。
public void confirm(String txId) {
String status = queryStatus(txId);
if ("CONFIRMED".equals(status)) {
return; // 幂等
}
commitAmount(100);
updateStatus(txId, "CONFIRMED");
}
四、Saga补偿风暴:补偿链越长,风险越大
Saga模式和TCC不同。TCC是“预留+确认”,Saga是“直接执行+反向补偿”。每个本地事务执行后立即提交,失败时按反向顺序执行补偿操作。
补偿风暴的触发链路
Saga流程是:LT1 → LT2 → LT3 → ... → LTn。如果LTn失败,需要按反向顺序执行:CTn → CTn-1 → ... → CT2 → CT1。
补偿风暴发生在补偿操作本身也失败时。如果CTn-1补偿失败,事务管理器会重试。如果重试多次仍然失败,可能需要人工介入。更严重的是,如果补偿操作触发了新的Saga流程(比如补偿操作本身是一个跨服务调用),补偿链会级联扩散。
一个真实的场景:电商订单流程涉及下单、扣库存、扣积分、发优惠券、通知物流。如果第5步失败,需要依次补偿前4步。但如果“退优惠券”这个补偿操作本身也失败了(比如优惠券服务不可用),补偿链就卡住了。此时如果第5步的补偿触发了一个新的Saga流程,整个系统可能被补偿请求淹没。
解决方案
方案一:补偿操作加限流。 给补偿操作设置独立的线程池和限流阈值,避免补偿请求挤占正常业务资源。
方案二:补偿操作加退避。 补偿失败后不立即重试,而是等待一段时间后再试。重试间隔逐次递增。
方案三:补偿操作独立幂等。 补偿操作必须幂等——重复执行结果一致。这样即使重试多次,也不会造成数据错误。
方案四:设置补偿超时和人工介入阈值。 补偿操作超过一定次数后,转为人工处理,避免系统被无限重试拖垮。
五、小结
TCC的空回滚、悬挂、幂等三个问题有一个共同的根因:网络延迟导致的时序乱序。空回滚是Cancel先到了,悬挂是Try后到了,幂等是重复请求。解决方案的核心是事务记录表——Try执行前记录,Cancel执行前检查,Confirm/Cancel执行前查询状态。Saga的补偿风暴则源于补偿链的级联效应,需要通过限流、退避、独立幂等和人工介入阈值来控制。
小耶在手,SQL 不愁
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~