文章目录
-
- 先说结论
- 问题场景:网络超时惹的祸
- 空回滚:Cancel 时 Try 没执行
- 悬挂:Cancel 后 Try 才到
- 解决方案:事务状态表
- Seata 1.5.1+ 的解决方案
- 回答技巧与点评
-
- 加分回答
- 面试官点评
个人网站
TCC(Try-Confirm-Cancel)看起来很完美——Try 冻结资源,Confirm 确认,Cancel 取消。但实际生产中有个隐藏的坑:悬挂。面试官问这题,说明他想考察你对 TCC 的理解不只是理论层面,而是真正落地过的。他想听的是:悬挂是怎么产生的、空回滚是什么、以及怎么解决?
先说结论
| 悬挂 | Cancel 比 Try 先执行,导致资源被冻结后永远无法释放 |
| 空回滚 | Try 没执行就收到了 Cancel 请求 |
| 根因 | 网络超时导致 Try 请求延迟,TC 提前触发了 Cancel |
| 解决方案 | 在业务表中记录事务状态,Try 检查是否已 Cancel,Cancel 检查是否已 Try |
|一句话记住:悬挂就像"先收到退课通知,后收到选课确认"——课退了但系统还记着你选了,资源被白白占着"
问题场景:网络超时惹的祸
TCC 的正常流程:
TM → Try(冻结) → Confirm(确认) ✅ 正常
TM → Try(冻结) → Cancel(取消) ✅ 正常
但网络超时可能打乱顺序:
场景1:空回滚
Try请求网络丢包 → 没到达服务B
TC等待超时 → 触发Cancel
Cancel到达服务B → 但Try根本没执行过! 👈
→ Cancel要回滚一个不存在的冻结 → 空回滚
场景2:悬挂
Try请求网络延迟(没丢,只是慢)
TC等待超时 → 触发Cancel
Cancel先到达服务B → 执行空回滚
Try延迟到达服务B → 执行冻结 👈
→ 资源被冻结了,但不会再有Confirm/Cancel → 悬挂!
悬挂的本质:Cancel 先于 Try 执行了,Cancel 做了空回滚(啥也没回滚),然后 Try 才到,冻结了资源——但事务已经结束了,没人会再来 Confirm 或 Cancel,资源永远被冻结。
空回滚:Cancel 时 Try 没执行
// 没有防悬挂的 Cancel
public boolean cancelDeduct(String accountId, BigDecimal amount) {
// Try 没执行,frozen_amount = 0
// 直接解冻 → 解冻0元 → 啥也没干
accountMapper.unfreeze(accountId, amount); // 👈 空回滚,看似无害
return true;
}
空回滚本身不是大问题——没有冻结就没有需要释放的。但如果不记录空回滚,后续的 Try 就不知道 Cancel 已经执行过了,这才是悬挂的根源。
悬挂:Cancel 后 Try 才到
// 没有防悬挂的 Try
public boolean tryDeduct(String accountId, BigDecimal amount) {
// 不知道 Cancel 已经执行过
accountMapper.freeze(accountId, amount); // 👈 冻结了,但没人来释放!
return true;
}
资源永远被冻结——这就是悬挂,也叫"资源泄漏"。
解决方案:事务状态表
核心思路:用一张事务状态表记录每个分支事务的执行状态。
— 事务状态表
CREATE TABLE tcc_transaction (
xid VARCHAR(128), — 全局事务ID
branch_id VARCHAR(128), — 分支事务ID
status VARCHAR(16), — 状态:trying/cancelled
PRIMARY KEY (xid, branch_id)
);
防悬挂的 Try:
public boolean tryDeduct(String xid, String branchId, String accountId, BigDecimal amount) {
// 1. 检查是否已经 Cancel 过
TccTransaction tx = tccMapper.findByXidAndBranch(xid, branchId);
if (tx != null && "cancelled".equals(tx.getStatus())) {
return false; // 👈 Cancel 已执行,拒绝 Try(防悬挂)
}
// 2. 记录 Trying 状态
tccMapper.insert(xid, branchId, "trying");
// 3. 执行冻结
accountMapper.freeze(accountId, amount);
return true;
}
防空回滚的 Cancel:
public boolean cancelDeduct(String xid, String branchId, String accountId, BigDecimal amount) {
// 1. 检查是否执行过 Try
TccTransaction tx = tccMapper.findByXidAndBranch(xid, branchId);
if (tx == null) {
// Try 没执行过 → 记录 Cancelled 状态(防空回滚+防悬挂) 👈
tccMapper.insert(xid, branchId, "cancelled");
return true;
}
// 2. Try 已执行 → 正常回滚
accountMapper.unfreeze(accountId, amount);
tccMapper.updateStatus(xid, branchId, "cancelled");
return true;
}
关键点:Cancel 时即使 Try 没执行,也要记录 cancelled 状态。这样后续到达的 Try 才能发现 Cancel 已经执行过,拒绝冻结资源。
Seata 1.5.1+ 的解决方案
Seata 内置了防悬挂和空回滚的处理:
# Seata 配置
seata:
tcc:
# 防悬挂:Try 时检查是否已 Cancel
# 空回滚:Cancel 时检查是否已 Try
# Seata 自动维护事务状态表 👈
但如果你自己实现 TCC,必须手动处理这两个问题。
TCC 悬挂与空回滚全景
问题
├── 空回滚 —— Cancel时Try没执行过
├── 悬挂 —— Cancel先于Try执行,Try后到冻结资源
└── 根因 —— 网络超时导致Try延迟
解决方案:事务状态表
├── Try —— 检查是否已cancelled,是则拒绝
├── Cancel —— 记录cancelled状态(即使Try没执行)
└── 状态流转 —— trying → confirmed/cancelled
Seata内置
├── 自动维护事务状态
├── 防悬挂检查
└── 空回滚处理
口诀:网络超时惹的祸,Cancel可能先于Try;
空回滚是Cancel没东西回滚,悬挂是Try后到冻结资源;
事务状态表来帮忙,Try检查Cancel来过没;
Cancel记录状态防悬挂,Seata内置自动处理
回答技巧与点评
标准回答:TCC 的悬挂问题是:网络超时导致 Cancel 先于 Try 执行,Cancel 做了空回滚后,延迟的 Try 才到达并冻结了资源,但事务已结束不会再有 Confirm/Cancel,资源永远无法释放。解决方式是用事务状态表:Try 时检查是否已 Cancel(已 Cancel 则拒绝),Cancel 时记录 Cancelled 状态(即使 Try 没执行也记录),这样后续 Try 就能感知到 Cancel 已执行。
加分回答
面试官点评
这道题考的是你对 TCC 落地难点 的理解。最忌讳的回答是只知道 Try/Confirm/Cancel 三个接口——面试官想听的是 网络异常下的边界情况。能讲清悬挂的因果链(超时→Cancel先到→Try后到→资源泄漏)和解决方案(状态表),就是高分回答。
原文阅读
内容有帮助?点赞、收藏、关注三连!评论区等你 💪