写在前面:分布式事务这东西,理论上谁都能讲两句,但真到了生产环境,能落地的人不多。我见过太多项目用最终一致性当借口,该扣的库存没扣,该加的积分没加,最后对账发现一堆烂账。TCC模式虽然侵入性强,但它能给业务一个明确的一致性保证。今天咱们把TCC从原理到实战彻底搞清楚。

文章目录
-
- 一、为什么需要分布式事务?
-
- [1.1 一个电商下单的真实困境](#1.1 一个电商下单的真实困境)
- [1.2 生活类比:转账](#1.2 生活类比:转账)
- [1.3 本地事务 vs 分布式事务](#1.3 本地事务 vs 分布式事务)
- 二、分布式事务理论
-
- [2.1 CAP定理](#2.1 CAP定理)
- [2.2 BASE理论](#2.2 BASE理论)
- [2.3 强一致性 vs 最终一致性的选择](#2.3 强一致性 vs 最终一致性的选择)
- 三、常见分布式事务方案对比
-
- [3.1 六种主流方案](#3.1 六种主流方案)
- [3.2 各方案的核心思想](#3.2 各方案的核心思想)
- 四、TCC模式原理
-
- [4.1 什么是TCC?](#4.1 什么是TCC?)
- [4.2 三阶段详解](#4.2 三阶段详解)
- [4.3 完整流程图](#4.3 完整流程图)
- [4.4 TCC的三大经典问题](#4.4 TCC的三大经典问题)
- [五、Seata TCC实现](#五、Seata TCC实现)
-
- [5.1 Seata架构](#5.1 Seata架构)
- [5.2 电商下单TCC完整实现](#5.2 电商下单TCC完整实现)
- [5.3 Seata配置](#5.3 Seata配置)
- 六、TCC的三大问题与解决方案
-
- [6.1 空回滚](#6.1 空回滚)
- [6.2 悬挂](#6.2 悬挂)
- [6.3 幂等性](#6.3 幂等性)
- [6.4 三大问题总结](#6.4 三大问题总结)
- 七、踩坑指南
-
- [7.1 TCC对业务代码侵入性强](#7.1 TCC对业务代码侵入性强)
- [7.2 Try阶段预留资源影响并发性能](#7.2 Try阶段预留资源影响并发性能)
- [7.3 网络超时导致的事务状态不一致](#7.3 网络超时导致的事务状态不一致)
- [7.4 Seata Server的高可用部署](#7.4 Seata Server的高可用部署)
- 八、问题与解答
-
- [Q1: TCC和Saga的区别?](#Q1: TCC和Saga的区别?)
- [Q2: TCC的Try阶段失败怎么办?](#Q2: TCC的Try阶段失败怎么办?)
- [Q3: Seata的AT模式和TCC模式怎么选?](#Q3: Seata的AT模式和TCC模式怎么选?)
- 九、面试高频考点汇总
- 十、模拟面试官提问
-
- 场景题1:设计一个电商下单的分布式事务方案
- [场景题2:TCC vs Saga vs 消息最终一致性选型](#场景题2:TCC vs Saga vs 消息最终一致性选型)
- 场景题3:资金冻结场景的TCC实现
- 场景题4:分布式事务的性能优化
- 场景题5:Seata集群部署与高可用
- 十一、互动话题
- 参考资料
一、为什么需要分布式事务?
1.1 一个电商下单的真实困境
先看一个最经典的场景:用户下单。
电商下单流程(涉及三个独立服务):
用户点击"下单"
|
├──> 订单服务:创建订单(MySQL)
|
├──> 库存服务:扣减库存(MySQL)
|
└──> 积分服务:增加积分(MySQL)
问题来了:
- 订单创建成功了,库存扣减失败了 → 用户下了单但没扣库存,超卖!
- 库存扣减成功了,积分增加失败了 → 用户付了钱但没拿到积分,投诉!
- 三个操作分布在不同的数据库,本地事务管不了!
这就是分布式事务要解决的核心问题:跨服务、跨数据库的操作,如何保证要么全部成功,要么全部失败?
1.2 生活类比:转账
A给B转100元,这个操作其实包含两步:
- A的账户扣100元
- B的账户加100元
这两步必须同时成功或同时失败。如果A扣了100但B没加上,钱就凭空消失了;如果B加了100但A没扣,钱就凭空多出来了。
本地事务(同一个数据库)用@Transactional就能搞定。但如果A在工商银行,B在建设银行,两个银行是独立的系统,这就是分布式事务了。
1.3 本地事务 vs 分布式事务
| 对比维度 | 本地事务 | 分布式事务 |
|---|---|---|
| 参与方 | 单个数据库/服务 | 多个数据库/服务 |
| 一致性保证 | ACID(强一致性) | 视方案而定 |
| 实现方式 | @Transactional |
TCC/Saga/消息等 |
| 复杂度 | 低 | 高 |
| 性能影响 | 小 | 较大 |
| 故障影响范围 | 单个服务 | 可能影响多个服务 |
二、分布式事务理论
2.1 CAP定理
CAP定理是分布式系统的基石,三个字母代表三个特性:
| 特性 | 含义 | 说明 |
|---|---|---|
| C(Consistency) | 一致性 | 所有节点看到的数据是一致的 |
| A(Availability) | 可用性 | 每个请求都能在合理时间内得到响应 |
| P(Partition Tolerance) | 分区容错性 | 网络分区时系统仍能正常工作 |
CAP定理告诉我们:三者只能同时满足两个。
在分布式系统中,P是必须保证的(网络分区不可避免),所以只能在C和A之间做选择:
- CP:保证一致性,牺牲可用性(如Zookeeper)
- AP:保证可用性,牺牲一致性(如Eureka)
2.2 BASE理论
BASE理论是对CAP中AP的延伸,是最终一致性的理论基础:
| 特性 | 含义 | 举例 |
|---|---|---|
| BA(Basically Available) | 基本可用 | 响应时间可以稍微长一点 |
| S(Soft State) | 软状态 | 允许数据在中间状态存在一段时间 |
| E(Eventually Consistent) | 最终一致性 | 数据最终会达到一致状态 |
类比一下:强一致性就像银行转账,A扣了B必须马上加上。最终一致性就像微信转账,A扣了B可能过几秒才收到,但最终一定会收到。
2.3 强一致性 vs 最终一致性的选择
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 资金交易 | 强一致性(TCC) | 涉及钱,不能有中间状态 |
| 库存扣减 | 强一致性(TCC) | 超卖问题代价太大 |
| 积分发放 | 最终一致性(消息) | 积分晚几秒到账用户可以接受 |
| 通知推送 | 最终一致性(消息) | 推送延迟可以容忍 |
| 数据同步 | 最终一致性(Canal+MQ) | 允许短暂不一致 |
三、常见分布式事务方案对比
3.1 六种主流方案
| 方案 | 一致性 | 性能 | 复杂度 | 侵入性 | 适用场景 |
|---|---|---|---|---|---|
| 2PC(两阶段提交) | 强一致 | 低 | 中 | 低(数据库层面) | 传统数据库分布式事务 |
| 3PC(三阶段提交) | 强一致 | 低 | 高 | 低 | 理论方案,实际很少用 |
| TCC(Try-Confirm-Cancel) | 强一致 | 中 | 高 | 高(业务代码侵入) | 资金、库存等核心业务 |
| Saga | 最终一致 | 高 | 中 | 中(编排/协调) | 长流程业务编排 |
| 本地消息表 | 最终一致 | 高 | 中 | 中(需建消息表) | 异步场景,如积分发放 |
| 事务消息 | 最终一致 | 高 | 低 | 低(MQ层面) | 异步解耦场景 |
3.2 各方案的核心思想
2PC(Two-Phase Commit):协调者问所有参与者"能提交吗?",都OK就提交,有一个不行就全部回滚。问题是同步阻塞,性能差。
Saga:把长事务拆成多个本地事务,每个本地事务有对应的补偿操作。如果某步失败了,逆序执行补偿。像多米诺骨牌一样,倒了一个就往回推。
本地消息表:业务操作和消息写入放在同一个本地事务里,定时任务扫描消息表发送消息,消费者消费成功后标记。简单可靠但需要额外建表。
事务消息:RocketMQ原生支持。先发半消息(消费者看不到),执行本地事务成功后再提交消息。RocketMQ会定期回查本地事务状态。
踩坑提醒:这个坑我踩过!当时用2PC做分布式事务,一个服务响应慢导致整个事务阻塞了30秒,所有连接都卡住了。从那以后,核心业务我宁愿用TCC多写点代码,也不用2PC。
四、TCC模式原理
4.1 什么是TCC?
TCC是Try-Confirm-Cancel 三个词的缩写,是一种业务层面的两阶段提交协议。
和2PC不同的是,TCC不依赖数据库的事务机制,而是在业务代码层面实现两阶段提交。
4.2 三阶段详解
以转账场景为例:A给B转100元。
Try阶段:预留资源
java
// Try:冻结资源,不实际扣减
public boolean tryTransfer(String fromAccount, String toAccount, BigDecimal amount) {
// 1. 在A账户冻结100元(A的可用余额减少,冻结金额增加)
accountDao.freeze(fromAccount, amount);
// 2. 在B账户预增加100元(冻结金额增加)
accountDao.preAdd(toAccount, amount);
return true;
}
Try阶段只是冻结资源,不实际扣减。就像转账前先把钱放到"待转出"里。
Confirm阶段:确认操作
java
// Confirm:确认转账,实际扣减冻结的资源
public boolean confirmTransfer(String fromAccount, String toAccount, BigDecimal amount) {
// 1. A账户:扣减冻结的100元
accountDao.deductFrozen(fromAccount, amount);
// 2. B账户:将预增加的100元转为可用余额
accountDao.confirmAdd(toAccount, amount);
return true;
}
Confirm阶段才是真正的操作。把冻结的资源实际扣减掉。
Cancel阶段:取消操作
java
// Cancel:取消转账,释放冻结的资源
public boolean cancelTransfer(String fromAccount, String toAccount, BigDecimal amount) {
// 1. A账户:释放冻结的100元(退回可用余额)
accountDao.unfreeze(fromAccount, amount);
// 2. B账户:取消预增加的100元
accountDao.cancelPreAdd(toAccount, amount);
return true;
}
Cancel阶段释放Try阶段冻结的资源,恢复原状。
4.3 完整流程图
正常流程(Try → Confirm):
TM发起全局事务
|
v
[Try] A账户冻结100元 → 成功
|
[Try] B账户预增加100元 → 成功
|
TM决定:所有Try都成功 → 提交
|
[Confirm] A账户扣减冻结100元 → 成功
|
[Confirm] B账户确认增加100元 → 成功
|
事务完成 ✓
异常流程(Try → Cancel):
TM发起全局事务
|
v
[Try] A账户冻结100元 → 成功
|
[Try] B账户预增加100元 → 失败!
|
TM决定:有Try失败 → 回滚
|
[Cancel] A账户释放冻结100元 → 成功
|
事务回滚完成 ✓
4.4 TCC的三大经典问题
| 问题 | 描述 | 后果 |
|---|---|---|
| 空回滚 | Try未执行,Cancel先到了 | Cancel找不到要释放的资源 |
| 悬挂 | Cancel比Try先执行 | Try执行后资源被冻结但永远没人释放 |
| 幂等性 | Confirm/Cancel被重复调用 | 资源被重复扣减或释放 |
这三个问题是TCC实现中最容易踩的坑,后面会详细讲解决方案。
五、Seata TCC实现
5.1 Seata架构
Seata是阿里巴巴开源的分布式事务框架,架构中有三个核心角色:
| 角色 | 全称 | 职责 | 类比 |
|---|---|---|---|
| TC | Transaction Coordinator | 事务协调者,维护全局事务状态 | 银行经理 |
| TM | Transaction Manager | 事务管理器,发起/提交/回滚全局事务 | 客户 |
| RM | Resource Manager | 资源管理器,管理分支事务 | 柜员 |
Seata架构图:
Client(TM) Seata Server(TC)
┌──────────┐ ┌──────────┐
│ 订单服务 │───── 开启全局事务 ────>│ │
│ (RM) │<──── 返回XID ──────│ 协调者 │
│ │ │ │
│ 库存服务 │───── 注册分支事务 ───>│ │
│ (RM) │<──── 返回BranchID ──│ │
│ │ │ │
│ 积分服务 │───── 注册分支事务 ───>│ │
│ (RM) │<──── 返回BranchID ──│ │
└──────────┘ └──────────┘
5.2 电商下单TCC完整实现
先定义数据库表结构(需要额外的冻结字段):
sql
-- 库存表(增加冻结库存字段)
CREATE TABLE `stock` (
`id` BIGINT PRIMARY KEY,
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`total_stock` INT NOT NULL COMMENT '总库存',
`frozen_stock` INT NOT NULL DEFAULT 0 COMMENT '冻结库存',
`available_stock` INT NOT NULL COMMENT '可用库存 = total_stock - frozen_stock'
) COMMENT='库存表';
-- 账户表(增加冻结金额字段)
CREATE TABLE `account` (
`id` BIGINT PRIMARY KEY,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`balance` DECIMAL(15,2) NOT NULL COMMENT '可用余额',
`frozen_amount` DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT '冻结金额'
) COMMENT='账户表';
-- 事务状态表(解决空回滚和悬挂问题)
CREATE TABLE `tcc_transaction_log` (
`xid` VARCHAR(128) PRIMARY KEY COMMENT '全局事务ID',
`action` VARCHAR(32) NOT NULL COMMENT '操作标识',
`status` VARCHAR(16) NOT NULL COMMENT '状态:TRY/CONFIRMED/CANCELLED',
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL
) COMMENT='TCC事务状态日志';
库存服务TCC实现
java
@Service
@Slf4j
public class StockTccService {
@Autowired
private StockMapper stockMapper;
@Autowired
private TccTransactionLogMapper logMapper;
/**
* Try阶段:冻结库存
*/
@TwoPhaseBusinessAction(
name = "deductStock",
commitMethod = "confirmDeductStock",
rollbackMethod = "cancelDeductStock"
)
public boolean tryDeductStock(
@BusinessActionContextParameter(paramName = "productId") Long productId,
@BusinessActionContextParameter(paramName = "count") Integer count) {
String xid = RootContext.getXID();
log.info("[Try] 冻结库存, xid={}, productId={}, count={}", xid, productId, count);
// 防悬挂:如果Cancel已经执行过,Try直接返回false
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
return false;
}
// 查询库存
Stock stock = stockMapper.selectByProductId(productId);
if (stock == null) {
throw new RuntimeException("商品不存在");
}
// 检查可用库存是否足够
int availableStock = stock.getTotalStock() - stock.getFrozenStock();
if (availableStock < count) {
throw new RuntimeException("库存不足");
}
// 冻结库存
int rows = stockMapper.freezeStock(productId, count);
if (rows <= 0) {
throw new RuntimeException("冻结库存失败");
}
// 记录事务状态
logMapper.insertOrUpdate(xid, "deductStock", "TRY");
return true;
}
/**
* Confirm阶段:扣减冻结的库存
*/
public boolean confirmDeductStock(BusinessActionContext context) {
String xid = context.getXid();
Long productId = (Long) context.getActionContext("productId");
Integer count = (Integer) context.getActionContext("count");
log.info("[Confirm] 扣减冻结库存, xid={}, productId={}, count={}", xid, productId, count);
// 幂等校验:如果已经Confirm过,直接返回成功
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
return true;
}
// 扣减冻结库存(实际扣减total_stock,减少frozen_stock)
stockMapper.deductFrozenStock(productId, count);
// 更新事务状态
logMapper.updateStatus(xid, "deductStock", "CONFIRMED");
return true;
}
/**
* Cancel阶段:释放冻结的库存
*/
public boolean cancelDeductStock(BusinessActionContext context) {
String xid = context.getXid();
Long productId = (Long) context.getActionContext("productId");
Integer count = (Integer) context.getActionContext("count");
log.info("[Cancel] 释放冻结库存, xid={}, productId={}, count={}", xid, productId, count);
// 防空回滚:如果Try没有执行过,直接返回成功
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord == null) {
log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
return true;
}
// 幂等校验:如果已经Cancel过,直接返回成功
if ("CANCELLED".equals(logRecord.getStatus())) {
log.info("[Cancel] 已回滚过,幂等返回, xid={}", xid);
return true;
}
// 释放冻结库存
stockMapper.unfreezeStock(productId, count);
// 更新事务状态
logMapper.updateStatus(xid, "deductStock", "CANCELLED");
return true;
}
}
账户服务TCC实现
java
@Service
@Slf4j
public class AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private TccTransactionLogMapper logMapper;
/**
* Try阶段:冻结金额
*/
@TwoPhaseBusinessAction(
name = "deductAmount",
commitMethod = "confirmDeductAmount",
rollbackMethod = "cancelDeductAmount"
)
public boolean tryDeductAmount(
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount) {
String xid = RootContext.getXID();
log.info("[Try] 冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);
// 防悬挂检查
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
return false;
}
// 查询账户
Account account = accountMapper.selectByUserId(userId);
if (account == null) {
throw new RuntimeException("账户不存在");
}
// 检查可用余额
BigDecimal available = account.getBalance().subtract(account.getFrozenAmount());
if (available.compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
// 冻结金额
accountMapper.freezeAmount(userId, amount);
// 记录事务状态
logMapper.insertOrUpdate(xid, "deductAmount", "TRY");
return true;
}
/**
* Confirm阶段:扣减冻结的金额
*/
public boolean confirmDeductAmount(BusinessActionContext context) {
String xid = context.getXid();
Long userId = (Long) context.getActionContext("userId");
BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
log.info("[Confirm] 扣减冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);
// 幂等校验
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
return true;
}
// 实际扣减:减少balance,减少frozen_amount
accountMapper.deductFrozenAmount(userId, amount);
// 更新事务状态
logMapper.updateStatus(xid, "deductAmount", "CONFIRMED");
return true;
}
/**
* Cancel阶段:释放冻结的金额
*/
public boolean cancelDeductAmount(BusinessActionContext context) {
String xid = context.getXid();
Long userId = (Long) context.getActionContext("userId");
BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
log.info("[Cancel] 释放冻结金额, xid={}, userId={}, amount={}", xid, userId, amount);
// 防空回滚
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductAmount");
if (logRecord == null) {
log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
return true;
}
// 幂等校验
if ("CANCELLED".equals(logRecord.getStatus())) {
log.info("[Cancel] 已回滚过,幂等返回, xid={}", xid);
return true;
}
// 释放冻结金额
accountMapper.unfreezeAmount(userId, amount);
// 更新事务状态
logMapper.updateStatus(xid, "deductAmount", "CANCELLED");
return true;
}
}
订单服务(TM端,发起全局事务)
java
@Service
@Slf4j
public class OrderService {
@Autowired
private StockTccService stockTccService;
@Autowired
private AccountTccService accountTccService;
@Autowired
private PointsService pointsService;
@Autowired
private OrderMapper orderMapper;
/**
* 电商下单(TCC分布式事务)
* 使用 @GlobalTransactional 开启Seata全局事务
*/
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public OrderResult createOrder(OrderRequest request) {
Long userId = request.getUserId();
Long productId = request.getProductId();
Integer count = request.getCount();
BigDecimal amount = request.getAmount();
log.info("开始创建订单, userId={}, productId={}, count={}", userId, productId, count);
// 1. Try阶段:冻结库存
boolean stockResult = stockTccService.tryDeductStock(productId, count);
if (!stockResult) {
throw new RuntimeException("冻结库存失败");
}
// 2. Try阶段:冻结金额
boolean accountResult = accountTccService.tryDeductAmount(userId, amount);
if (!accountResult) {
throw new RuntimeException("冻结金额失败");
}
// 3. 创建订单记录(本地事务)
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setCount(count);
order.setAmount(amount);
order.setStatus("CREATED");
orderMapper.insert(order);
// 4. 增加积分(最终一致性,发消息异步处理)
pointsService.addPointsAsync(userId, amount);
log.info("订单创建成功, orderId={}", order.getId());
return OrderResult.success(order.getId());
}
}
5.3 Seata配置
yaml
# application.yml
seata:
enabled: true
application-id: order-service
tx-service-group: my-tx-group
service:
vgroup-mapping:
my-tx-group: default
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: seata
group: SEATA_GROUP
application: seata-server
六、TCC的三大问题与解决方案
6.1 空回滚
问题:Try阶段因为网络原因没有执行(或者超时了),但TC认为Try失败了,触发了Cancel。Cancel执行时发现Try根本没执行过,找不到要释放的资源。
解决方案:在Cancel方法中检查事务状态表,如果Try没有执行过,直接返回成功(不做任何操作)。
java
public boolean cancelDeductStock(BusinessActionContext context) {
String xid = context.getXid();
// 防空回滚:Try没有执行过,直接返回成功
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord == null) {
log.warn("[Cancel] Try未执行,空回滚直接返回, xid={}", xid);
return true; // 空回滚,直接成功
}
// 正常回滚逻辑...
}
6.2 悬挂
问题:Cancel比Try先执行了(网络延迟导致Try还没到,Cancel先到了)。Cancel执行后,Try才到达并执行成功,资源被冻结了,但再也没有Confirm或Cancel来处理它。
解决方案:在Try方法中检查事务状态表,如果Cancel已经执行过,Try直接返回false,拒绝执行。
java
public boolean tryDeductStock(Long productId, Integer count) {
String xid = RootContext.getXID();
// 防悬挂:如果Cancel已经执行过,Try直接拒绝
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord != null && "CANCELLED".equals(logRecord.getStatus())) {
log.warn("[Try] 检测到已回滚记录,拒绝执行(防悬挂), xid={}", xid);
return false;
}
// 正常Try逻辑...
}
6.3 幂等性
问题:TC在Confirm或Cancel超时后会自动重试,可能导致Confirm或Cancel被多次执行。如果不做幂等,资源会被重复扣减或释放。
解决方案:在Confirm和Cancel方法中检查事务状态表,如果已经执行过,直接返回成功。
java
public boolean confirmDeductStock(BusinessActionContext context) {
String xid = context.getXid();
// 幂等校验:已经Confirm过就直接返回
TccTransactionLog logRecord = logMapper.selectByXidAndAction(xid, "deductStock");
if (logRecord != null && "CONFIRMED".equals(logRecord.getStatus())) {
log.info("[Confirm] 已确认过,幂等返回, xid={}", xid);
return true;
}
// 正常Confirm逻辑...
// 执行完后更新状态为CONFIRMED
}
6.4 三大问题总结
| 问题 | 原因 | 解决方案 | 检查时机 |
|---|---|---|---|
| 空回滚 | Try未执行,Cancel先到 | Cancel中检查Try是否执行过 | Cancel方法入口 |
| 悬挂 | Cancel比Try先执行 | Try中检查Cancel是否已执行 | Try方法入口 |
| 幂等性 | Confirm/Cancel被重复调用 | 检查事务状态是否已处理 | Confirm/Cancel方法入口 |
踩坑提醒:这三个问题我全部踩过!最坑的是悬挂问题,排查了三天才发现是网络延迟导致Cancel先到了。从那以后,TCC的三大问题我每次实现都会检查一遍,绝不偷懒。
七、踩坑指南
7.1 TCC对业务代码侵入性强
踩坑提醒:TCC最大的缺点就是侵入性太强。每个参与事务的业务方法都要拆成Try、Confirm、Cancel三个方法,数据库表要加冻结字段,还要维护事务状态表。我见过一个项目,为了TCC把所有表都加了冻结字段,代码量翻了一倍。
缓解方案:
- 只在核心业务(资金、库存)用TCC,非核心业务用最终一致性
- 抽取TCC模板,减少重复代码
- 用Seata的注解简化开发
7.2 Try阶段预留资源影响并发性能
Try阶段需要冻结资源,这意味着可用资源变少了。
比如库存100件,有10个订单在Try阶段各冻结了10件,虽然还没真正扣减,但可用库存只剩0了。新来的订单全部因为"库存不足"被拒绝。
缓解方案:
- Try阶段冻结时间尽量短(Seata默认60秒超时)
- 监控冻结资源数量,异常告警
- 考虑用Saga模式替代(不需要预留资源)
7.3 网络超时导致的事务状态不一致
分布式环境下,网络超时是常态。可能出现:
- Try执行成功了,但返回结果超时,TC认为Try失败,触发Cancel
- Confirm执行成功了,但返回结果超时,TC认为Confirm失败,再次触发Confirm(需要幂等)
缓解方案:
- 所有Confirm和Cancel必须实现幂等
- 合理设置Seata的超时时间
- 网络抖动时做好重试机制
7.4 Seata Server的高可用部署
踩坑提醒:Seata Server单点部署的话,一旦挂了所有分布式事务都不可用。生产环境必须集群部署!
Seata Server高可用架构:
Client Seata Cluster Registry
┌──────┐ ┌──────────┐ ┌──────────┐
│ 订单 │──┐ │ Seata-1 │──┐ │ │
│ 服务 │ │ │ (Leader) │ │ │ Nacos │
└──────┘ │ 注册/发现 └──────────┘ │ 注册/发现 │ Registry │
┌──────┐ │ ──────────> ┌──────────┐ │ ────────> │ │
│ 库存 │──┤ │ Seata-2 │──┤ └──────────┘
│ 服务 │ │ │(Follower) │ │
└──────┘ │ └──────────┘ │
┌──────┐ │ ┌──────────┐ │
│ 账户 │──┘ │ Seata-3 │──┘
│ 服务 │ │(Follower) │
└──────┘ └──────────┘
关键配置:
yaml
# Seata Server配置(集群模式)
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
cluster: default
store:
mode: db # 事务日志存储在数据库中(集群模式必须)
db:
datasource: druid
db-type: mysql
八、问题与解答
Q1: TCC和Saga的区别?
A : 核心区别在于资源锁定方式:
- TCC:Try阶段预留(冻结)资源,Confirm阶段才实际操作。资源在Try期间被锁定,一致性更强,但并发性能受影响
- Saga:直接执行本地事务,失败时执行补偿操作。不预留资源,并发性能好,但补偿操作可能复杂
| 对比维度 | TCC | Saga |
|---|---|---|
| 资源预留 | 需要(冻结) | 不需要 |
| 一致性 | 强一致 | 最终一致 |
| 并发性能 | 较低(资源被冻结) | 较高 |
| 补偿复杂度 | 低(释放冻结资源) | 可能很高(需要逆向操作) |
| 适用场景 | 资金、库存 | 长流程编排 |
Q2: TCC的Try阶段失败怎么办?
A: Try阶段失败分为两种情况:
- 业务异常(如库存不足):直接抛异常,TC会触发其他已成功Try的分支执行Cancel,回滚整个事务
- 系统异常(如网络超时、数据库连接失败):TC会根据超时机制判断,如果超时未收到Try结果,也会触发Cancel
java
// Try阶段抛异常,TC会自动触发Cancel
@TwoPhaseBusinessAction(name = "deductStock",
commitMethod = "confirm", rollbackMethod = "cancel")
public boolean tryDeductStock(Long productId, Integer count) {
if (stock < count) {
// 业务异常,直接抛出,TC会触发Cancel
throw new RuntimeException("库存不足");
}
// 冻结库存...
}
Q3: Seata的AT模式和TCC模式怎么选?
A: 说实话,能用AT就用AT,实在不行才用TCC。
| 对比维度 | AT模式 | TCC模式 |
|---|---|---|
| 代码侵入 | 低(自动管理) | 高(手写三个方法) |
| 一致性 | 最终一致 | 强一致 |
| 性能 | 高(有全局锁) | 中(冻结资源) |
| 适用场景 | 通用CRUD | 资金、库存等需要精确控制的场景 |
| 开发成本 | 低 | 高 |
选择建议:
- 普通业务(订单创建、状态更新):用AT模式,开发成本低
- 核心业务(资金转账、库存扣减):用TCC模式,一致性保证更强
- 长流程编排:用Saga模式
九、面试高频考点汇总
考点1:TCC的三个阶段分别做什么?
答案:
- Try阶段:预留/冻结资源。不做实际的业务操作,只是把需要的资源"锁"起来。比如冻结库存、冻结金额。
- Confirm阶段:确认操作。Try全部成功后执行,实际扣减Try阶段冻结的资源。比如扣减冻结库存、扣减冻结金额。
- Cancel阶段:取消操作。Try有失败时执行,释放Try阶段冻结的资源。比如释放冻结库存、释放冻结金额。
考点2:TCC的空回滚、悬挂、幂等性问题怎么解决?
答案:
- 空回滚:Try未执行但Cancel先到了。解决:Cancel中检查事务状态表,如果Try没执行过,直接返回成功。
- 悬挂:Cancel比Try先执行。解决:Try中检查事务状态表,如果Cancel已执行,Try拒绝执行。
- 幂等性:Confirm/Cancel被重复调用。解决:Confirm/Cancel中检查事务状态,已处理过则直接返回成功。
核心手段:事务状态表,记录每个分支事务的执行状态。
考点3:Seata的AT模式和TCC模式的区别?
答案:
- AT模式:基于支持本地ACID事务的关系型数据库。Seata自动管理一阶段(提交本地事务并记录undo_log)和二阶段(异步删除undo_log或根据undo_log回滚)。对业务代码无侵入。
- TCC模式:需要业务自定义Try、Confirm、Cancel三个方法,对业务代码侵入性强。适合不依赖数据库事务的场景,或需要精确控制资源锁定的场景。
考点4:分布式事务有哪些方案?各有什么优缺点?
答案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 2PC | 强一致性 | 同步阻塞,性能差 |
| TCC | 强一致,灵活 | 侵入性强,开发成本高 |
| Saga | 性能好,适合长流程 | 补偿复杂,最终一致 |
| 本地消息表 | 简单可靠 | 需要额外建表 |
| 事务消息 | 性能好,低侵入 | 依赖MQ支持 |
考点5:CAP定理和BASE理论的关系?
答案:
CAP定理指出分布式系统不可能同时满足一致性(C)、可用性(A)和分区容错性(P),最多只能满足两个。由于P是必须的,实际选择只有CP和AP。
BASE理论是对CAP中AP方案的延伸指导:
- BA(基本可用):允许损失部分可用性
- S(软状态):允许数据存在中间状态
- E(最终一致性):保证数据最终一致
BASE理论告诉我们:在AP系统中,通过接受"软状态"和"最终一致性",可以实现高可用。
十、模拟面试官提问
场景题1:设计一个电商下单的分布式事务方案
面试官:电商下单涉及订单、库存、支付、积分四个服务,你会怎么设计分布式事务方案?
参考答案:
方案设计:
1. 订单服务(TM):使用 @GlobalTransactional 发起全局事务
2. 库存服务(RM):TCC模式,Try冻结库存,Confirm扣减
3. 支付服务(RM):TCC模式,Try冻结金额,Confirm扣减
4. 积分服务(RM):最终一致性,异步发MQ消息增加积分
为什么积分用最终一致性?
- 积分晚几秒到账用户可以接受
- 不需要冻结资源,不影响并发性能
- 即使积分增加失败,可以后台补偿
关键原则:核心链路用TCC保证强一致,非核心链路用最终一致性保证性能。
场景题2:TCC vs Saga vs 消息最终一致性选型
面试官:你们项目怎么选分布式事务方案?
参考答案:
我的选型策略:
| 业务场景 | 方案 | 理由 |
|---|---|---|
| 支付、转账 | TCC | 涉及资金,必须强一致 |
| 库存扣减 | TCC | 超卖代价太大 |
| 订单创建 | AT | 普通CRUD,用AT降低开发成本 |
| 积分发放 | 消息最终一致性 | 允许延迟,不需要强一致 |
| 跨系统数据同步 | Saga | 长流程,用Saga编排 |
核心原则:不是所有业务都需要强一致性。根据业务的重要性和可容忍的不一致时间来选择方案。能用简单的方案就不要用复杂的。
场景题3:资金冻结场景的TCC实现
面试官:如果让你实现一个转账的TCC,你会怎么设计数据库表和代码?
参考答案:
数据库设计:
sql
-- 账户表需要冻结金额字段
ALTER TABLE account ADD COLUMN frozen_amount DECIMAL(15,2) DEFAULT 0;
-- 可用余额 = balance - frozen_amount
核心逻辑:
java
// Try:冻结转出方的金额
UPDATE account SET frozen_amount = frozen_amount + #{amount}
WHERE user_id = #{userId} AND (balance - frozen_amount) >= #{amount};
// Confirm:实际扣减
UPDATE account SET balance = balance - #{amount}, frozen_amount = frozen_amount - #{amount}
WHERE user_id = #{userId} AND frozen_amount >= #{amount};
// Cancel:释放冻结
UPDATE account SET frozen_amount = frozen_amount - #{amount}
WHERE user_id = #{userId} AND frozen_amount >= #{amount};
关键点:所有SQL都要加条件判断 (WHERE frozen_amount >= #{amount}),防止并发场景下数据错乱。
场景题4:分布式事务的性能优化
面试官:TCC性能不太好,你们是怎么优化的?
参考答案:
- 减少TCC的使用范围:只在核心业务用TCC,非核心业务用最终一致性
- 缩短Try阶段耗时:Try阶段的冻结操作尽量快,减少资源锁定时间
- 异步Confirm:Seata支持异步提交,减少全局事务的阻塞时间
- 批量操作合并:多个小事务合并为一个大事务,减少网络交互
- 读写分离:查询操作走从库,减少主库压力
- 合理设置超时:Seata默认超时60秒,根据业务调整,避免长时间锁定资源
yaml
# Seata超时配置优化
seata:
client:
tm:
commit-retry-count: 3 # 提交重试次数
rollback-retry-count: 3 # 回滚重试次数
default-global-transaction-timeout: 30000 # 全局事务超时30秒
场景题5:Seata集群部署与高可用
面试官:Seata Server怎么部署才能保证高可用?
参考答案:
高可用部署方案:
1. Seata Server集群(至少3个节点)
- 注册中心:Nacos集群
- 事务日志存储:MySQL主从(或Redis)
- 部署方式:K8s Deployment(3副本)
2. 数据库高可用
- Seata Server的事务日志表(lock_table、global_table等)放在MySQL
- MySQL主从复制 + MHA/Orchestrator自动切换
3. 客户端配置
- 所有服务配置相同的tx-service-group
- 通过Nacos自动发现Seata Server集群
- 失败自动重试其他节点
关键点:Seata Server的事务日志必须存数据库(db模式),不能存文件(file模式)。集群模式下,每个Seata Server节点都能从数据库读取事务状态,任何节点挂了都不影响。
十一、互动话题
你在项目中用过分布式事务吗?用的哪种方案?有没有遇到过空回滚或悬挂的问题?欢迎在评论区分享你的实战经验和踩坑故事!
参考资料
- Seata官方文档 - TCC模式 - Seata TCC模式官方说明
- Seata源码分析 - TCC模式实现原理 - Seata TCC源码级解析