Seata(三):TCC、Saga、XA 模式与选型实战
Seata(三):TCC、Saga、XA 模式与选型实战
导语:AT 好用但不是万能的。TCC 解决"资源预留",Saga 解决"长流程",XA 解决"强一致"。本篇把 TCC 的三大难题(空回滚、悬挂、幂等)讲透,梳理 Saga 的状态机与补偿,拆解 XA 的两阶段与 AT 的差异,最后给出四种模式的选型矩阵、TCC 实战代码与生产排查清单,共 15 题。
一、TCC 模式
1. TCC 是什么?三个阶段分别做什么?
答: TCC(Try-Confirm-Cancel)是「业务层面的两阶段提交」——把「预留 → 确认 → 取消」显式地交给业务实现。
| 阶段 | 做什么 | 关键约束 |
|---|---|---|
| Try | 检查 + 预留资源:冻结库存、预扣金额、锁定额度(不真正扣减/不真正落账) | 必须做资源检查(库存是否够、余额是否足),预留后其他事务不能再占用 |
| Confirm | 确认提交:把预留的资源真正落实(库存真扣、金额真扣) | 必须成功(Try 已预留,Confirm 理论上不会失败);必须幂等;不能做"业务校验"(此时校验已通过) |
| Cancel | 取消释放:把预留的资源释放(解冻库存、解冻金额) | 必须成功;必须幂等;必须能空回滚(Try 没执行也要能 Cancel) |
以「扣库存 + 扣余额」为例:
| 阶段 | 库存服务 | 账户服务 |
|---|---|---|
| Try | frozen_stock += 2(冻结 2 件,stock >= 2 校验) | frozen_balance += 100(冻结 100 元,balance >= 100 校验) |
| Confirm | stock -= 2; frozen_stock -= 2(真正扣减) | balance -= 100; frozen_balance -= 100 |
| Cancel | frozen_stock -= 2(解冻) | frozen_balance -= 100(解冻) |
与 AT 的最大区别:AT 的一阶段就提交了业务数据(靠镜像回滚);TCC 的一阶段只做"预留",业务数据尚未变更(靠 Cancel 释放)。因此 TCC 没有"中间态已提交"的问题(在预留层面),但其代价是业务必须显式实现三方法。
2. TCC 相比 AT 有什么优缺点?什么时候必须用 TCC?
答:
| 维度 | TCC | AT |
|---|---|---|
| 业务侵入 | 高(写 Try/Confirm/Cancel 三方法) | 几乎为零 |
| 一阶段 | 只预留,不提交业务数据 | 提交本地事务 + 写 undo_log |
| 隔离性 | 更好(Try 预留 → 资源被"锁定",不会被超卖) | 全局读未提交,可能被读中间态 |
| 锁竞争 | 更轻(预留后不长期持有行锁/全局锁) | 全局锁在热点行上会排队 |
| 性能 | 高(Confirm/Cancel 都是业务简单操作) | 中高(受全局锁与 undo_log 影响) |
| 依赖 | 业务改造 + 幂等/空回滚/悬挂处理 | 只需 undo_log 表 + 数据源代理 |
| SQL 兼容 | 无 SQL 限制(自己写业务) | 有限制(需能定位行 + 反向还原) |
| 回滚 | 业务自己写(语义清晰) | 镜像自动反向补偿 |
| 适用 | 核心资金/库存、热点资源、需要预留语义 | 通用业务、快速接入 |
必须用 TCC 的三个场景:
- 需要"资源预留"语义(库存不能被超卖、余额不能被透支)——AT 无法阻止"库存被两个事务同时看成都够"的情况(因为 AT 是提交后才补偿);
- 热点资源竞争严重——AT 的全局锁会让热点行串行;TCC 的预留可以提前分流(如库存分桶 + 预占);
- 参与方不是关系型数据库——如 Redis、外部 HTTP 服务、第三方账户系统,AT 依赖 JDBC 代理无法覆盖。
3. TCC 的三大难题是什么?(空回滚、悬挂、幂等)
答: 这是 TCC 面试必考,也是 TCC 实现中最容易出错的地方。
① 空回滚(Empty Rollback)
② 悬挂(Hanging / Suspension)
③ 幂等(Idempotency)
一张图记住三者的关系:
记忆口诀:「Try 判空回滚记录(防悬挂);Cancel 判 Try 记录(防空回滚);两者都靠唯一键(保幂等)。」
4. TCC 的 Confirm / Cancel 为什么"必须成功"?
答: 因为它们发生在"全局事务已经作出决定"之后,此时不允许再失败——失败就意味着数据不一致。
为什么理论上不会失败:
- Confirm 不会失败:Try 阶段已经完成了资源检查与预留(库存够、余额足、额度已锁),因此 Confirm 时不存在业务校验失败的可能(所以 Confirm 里不允许再做校验);
- Cancel 不会失败:只是把预留释放,释放是最宽松的操作(不涉及资源是否充足)。
但工程上仍可能失败,所以要靠重试 + 幂等:
| 失败原因 | 说明 | 应对 |
|---|---|---|
| 网络/超时 | 请求没到达,或响应丢失 | 重试(TC 会重试;Confirm/Cancel 必须幂等) |
| 服务暂时不可用 | 数据库连接池满、下游抖动 | 重试 + 退避 + 告警 |
| 空回滚/悬挂 | 见第 3 题 | 状态表判重 |
| 真正失败(不可恢复) | 如 SQL 语法错误 | 告警 + 人工介入(所以 TCC 服务要完善监控) |
实践要求(面试要点):
- Confirm/Cancel 里不要做耗时操作(不要查大表、不要调第三方),保证"只要能执行就成功";
- Confirm/Cancel 的失败必须告警,因为它们重试到上限后只会停滞,需要人工兜底;
- 有些团队会让 Confirm/Cancel 走异步队列 + 定时补偿 以保证最终一定执行。
5. Seata 的 TCC 怎么用?(注解方式)
答: Seata TCC 通过 @TwoPhaseBusinessAction 注解声明一个 TCC 服务:
@LocalTCC // ★ 标识这是一个本地 TCC 服务(1.0+ 需要)
public interface StorageTccService {
// ① Try:BusinessActionContext 用于在三个方法间传参(含 xid、branchId、业务参数)
@TwoPhaseBusinessAction(
name = "storageTccAction", // 分支事务名,需唯一
commitMethod = "confirm", // ★ 指定 Confirm 方法名
rollbackMethod = "cancel", // ★ 指定 Cancel 方法名
useTCCFence = true) // ★ 开启防悬挂/空回滚(1.5+ 推荐)
boolean prepare(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "num") Integer num);
// ② Confirm:从 ctx 中取出 Try 的参数(ctx.getActionContext("skuId"))
boolean confirm(BusinessActionContext ctx);
// ③ Cancel
boolean cancel(BusinessActionContext ctx);
}// 发起方:仍然是 @GlobalTransactional
@GlobalTransactional
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto.toOrder()); // 本地分支(可以是 AT)
storageTccService.prepare(null, dto.getSkuId(), dto.getNum()); // ★ TCC 分支(Try)
accountTccService.prepare(null, dto.getUserId(), dto.getAmount());
}三个实现细节(必知):
| 细节 | 说明 |
|---|---|
@BusinessActionContextParameter | Try 的参数需要显式标注才会被放进 BusinessActionContext,供 Confirm/Cancel 读取(否则 Cancel 里拿不到业务参数) |
BusinessActionContext 里有什么 | xid、branchId、actionName、以及 Try 的参数(getActionContext(key));这是 Confirm/Cancel 定位资源的唯一依据 |
TCC Fence(useTCCFence = true) | Seata 1.5+ 内置的防悬挂/空回滚/幂等机制:在业务库里建 tcc_fence_log 表,Seata 自动记录分支状态并做校验——强烈建议开启,可以省掉大量手写判断 |
tcc_fence_log 表的作用(Seata 官方方案):
CREATE TABLE tcc_fence_log (
xid VARCHAR(128) NOT NULL,
branch_id BIGINT NOT NULL,
action_name VARCHAR(64) NOT NULL,
status TINYINT NOT NULL, -- TRYING / COMMITTED / ROLLBACKED / SUSPENDED
gmt_create DATETIME(3) NOT NULL,
gmt_modified DATETIME(3) NOT NULL,
UNIQUE KEY ux_tcc_fence_log (xid, branch_id) -- ★ 幂等与防悬挂的核心
);- 防悬挂:Try 执行前先插入
TRYING记录;若已存在SUSPENDED(说明 Cancel 先执行过)→ 拒绝 Try; - 防空回滚:Cancel 执行时若查不到
TRYING记录 → 插入SUSPENDED并直接返回成功; - 幂等:唯一键
(xid, branch_id)保证同一分支只能落一条记录。
6. TCC 与 AT 能混用吗?
答:可以,而且很常见——这正是 Seata 的价值之一:同一个全局事务里,不同分支可以用不同模式。
@GlobalTransactional
public void placeOrder(OrderDTO dto) {
orderMapper.insert(...); // ① AT 分支(订单落库,通用业务用 AT 足够)
storageTccService.prepare(...); // ② TCC 分支(库存需要"预留",避免超卖)
pointService.addPoints(...); // ③ AT 分支(积分,最终一致即可)
}混用的注意点:
| 注意点 | 说明 |
|---|---|
| 全局一致性由 TC 统一裁决 | 无论是 AT 分支还是 TCC 分支,最终都由 TC 决定 commit/rollback,统一回滚 |
| AT 分支的全局锁 vs TCC 的预留 | 两者机制不同但互不冲突;不过要注意「AT 分支改的行」与「TCC 分支预留的资源」不要是同一行,否则会出现「全局锁 + 预留状态」叠加的复杂情况 |
| TCC 分支失败会触发全局回滚 | TCC 的 Try 失败 → 全局事务回滚 → 已成功的 AT 分支按 undo_log 回滚,TCC 分支按 Cancel 释放 |
二、Saga 模式
7. Saga 模式是什么?两种实现方式有什么区别?
答: Saga 把长事务拆成一串「本地事务 + 补偿动作」:
| 实现方式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 编排式(Choreography,事件驱动) | 各服务自己订阅事件、执行完发布事件触发下一步 | 去中心化、无单点 | 流程分散在各服务,难排查、易成环、难维护 |
| 编制式(Orchestration,中央协调) | 中央协调器(Seata 的 StateMachine) 按状态机定义依次调用各服务 | 流程集中、可视、易维护 | 协调器可能成瓶颈/单点(需高可用) |
Seata 的 Saga 实现:基于 seata-state-machine(状态机引擎),通过 JSON 定义状态机:
{
"Name": "placeOrderSaga",
"StartState": "CreateOrder",
"States": {
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "create",
"CompensateState": "CancelOrder", // ★ 补偿状态
"Next": "DeductStock"
},
"DeductStock": {
"Type": "ServiceTask",
"ServiceName": "storageService",
"ServiceMethod": "deduct",
"CompensateState": "RestoreStock",
"Next": "Succeed"
},
"Succeed": { "Type": "Succeed" }
}
}两个版本差异:Seata 的 Saga 有两种实现——状态机引擎(seata-state-machine,独立使用) 与 注解式 Saga(@SagaTransactional,集成在全局事务里),后者可以用 @GlobalTransactional 与 AT/TCC 混用。
8. Saga 与 TCC 的区别是什么?各适合什么场景?
答:
| 维度 | Saga | TCC |
|---|---|---|
| 一阶段 | 每步都是本地事务,直接提交 | 只做预留,不提交业务数据 |
| 回滚 | 调用补偿接口(业务逆向操作) | Cancel 释放预留资源 |
| 隔离性 | 无隔离(中间态对其他事务可见,会产生"脏读"式问题) | 有预留式隔离(资源被锁定,不会超卖) |
| 补偿难度 | 补偿可能"不彻底"(如已发货无法撤销,只能逆向退款) | 补偿是"释放冻结",天然可逆 |
| 业务改造 | 中(写正向 + 补偿两个动作) | 高(写三个动作 + 三大难题处理) |
| 事务时长 | 适合长事务(分钟级~小时级,跨多系统/含人工) | 适合秒级的短事务 |
| 典型场景 | 订单履约、审批流、跨多系统同步、含人工节点 | 核心交易(资金、库存) |
选型口诀:
Saga 的致命弱点(面试要主动说):
Saga 没有隔离性——比如「T1 扣款成功、T2 发货成功」,中间态对其他事务可见;如果此时用户看到"已扣款",而 T3 失败最终补偿退款,用户就会经历「扣款 → 退款」的可见抖动。因此 Saga 必须配合业务侧的"状态机/进度提示"(告诉用户"处理中"),并且补偿动作要设计成不可逆操作的"最终态错误可接受"。
9. Saga 的补偿设计有哪些注意点?
答:
| 注意点 | 说明 |
|---|---|
| 补偿必须幂等 | 网络重试可能多次调用补偿 |
| 补偿要能"空补偿" | 正向动作没执行(网络丢失)时,补偿被调到 → 应识别并直接返回成功(与 TCC 的空回滚同理) |
| 补偿要防"悬挂" | 补偿先执行、正向后到达 → 正向应被拒绝(同样靠状态记录) |
| 补偿必须"尽力而为且可重试" | 补偿失败要持续重试(Seata 状态机有重试配置),可达上限后人工介入 |
| 避免不可逆动作先执行 | 如"发短信/发货/外部支付"应放在流程后段,减少补偿需求 |
| 设计"前向恢复"作为兜底 | 有些场景不做补偿而是"重试到成功"(Forward Recovery)——例如"必须扣款成功",那就一直重试;Seata Saga 支持配置 |
| 补偿逻辑不要依赖未提交状态 | 补偿时数据可能已被后续步骤改动,要按业务主键 + 版本校验 |
Saga 的正确心态:补偿不是"回滚",是"用新的业务动作纠正前一个动作的结果"——它一定有业务语义(退款 ≠ 取消扣款)。
三、XA 模式
10. Seata 的 XA 模式原理是什么?与 AT 有什么区别?
答: Seata 在 1.2 版本引入 XA 模式:分支事务交由数据库的 XA 协议处理,Seata 只做协调。
| 维度 | XA 模式 | AT 模式 |
|---|---|---|
| 一阶段是否提交 | 否(prepare,持数据库锁) | 是(提交本地事务,释放本地锁) |
| 回滚机制 | 数据库原生 undo(XA ROLLBACK) | 应用层 undo_log 镜像补偿 |
| SQL 兼容性 | 好(几乎所有 SQL,数据库自己处理) | 有限制(需能定位行 + 反向还原) |
| 隔离性 | 数据库级隔离(强) | 全局读未提交(弱) |
| 一致性 | 强一致(CP) | 最终一致(AP 取向) |
| 性能 | 低(锁持有到二阶段) | 高 |
| 依赖 | 数据库支持 XA(MySQL 5.7+/Oracle/PG) | undo_log 表 + 全局锁 |
| 实现方式 | Seata 1.2+,数据源代理需配 XA 模式 | 默认模式 |
什么时候用 XA:
- 需要强一致,且事务很短、并发不高(如内部对账、批量任务、跨库的一次性调整);
- SQL 复杂到 AT 无法处理(如多表关联更新、复杂函数),但又不适合改业务 → XA 是"兜底";
- 注意:XA 也有"悬挂事务"问题(prepare 后 RM/TM 都挂了,需要 TC 的补偿扫描来清理),且部分数据库的 XA 实现有 bug(历史上有过 MySQL XA 崩溃恢复问题),生产需评估。
11. 为什么 Seata 同时提供 XA 和 AT?它们的关系是什么?
答: 因为它们解决的侧重点不同,是互补而非替代:
| 需求 | 选谁 |
|---|---|
| 强一致 + 短事务(一致性优先) | XA |
| 高并发 + 可容忍最终一致(性能优先) | AT |
| SQL 太复杂(AT 无法还原) | XA |
| 必须在热点行上避免长锁 | AT(本地锁短)或 TCC |
面试回答的完整逻辑:
四、选型与落地
12. 四种模式的完整选型矩阵?
答:
| 维度 | AT | TCC | Saga | XA |
|---|---|---|---|---|
| 一致性 | 最终一致 | 准强一致(预留) | 最终一致 | 强一致 |
| 隔离性 | 全局读未提交(可 FOR UPDATE 提升) | 预留式(较好) | 无隔离 | 数据库级 |
| 业务侵入 | 极低 | 高 | 中 | 低 |
| 性能 | 中高 | 高 | 高 | 低 |
| 事务时长 | 短~中 | 短 | 长 | 短 |
| 锁竞争 | 全局锁(热点行排队) | 轻 | 无 | 重(长锁) |
| SQL 限制 | 有 | 无 | 无 | 无 |
| 依赖 | undo_log 表 + 全局锁 | 三方法 + 状态表(Fence) | 正向+补偿 + 编排 | 数据库 XA |
| 适用 | 通用业务(默认) | 资金/库存等热点资源 | 长流程/跨多系统 | 强一致短事务 |
一个可背下来的决策顺序:
13. TCC 实战:库存预占的完整代码骨架?
答:
@LocalTCC
public interface StorageTccService {
@TwoPhaseBusinessAction(name = "storageTcc", commitMethod = "confirm", rollbackMethod = "cancel",
useTCCFence = true) // ★ 自动处理幂等/空回滚/悬挂
boolean tryDeduct(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "skuId") Long skuId,
@BusinessActionContextParameter(paramName = "num") Integer num);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
@Service
public class StorageTccServiceImpl implements StorageTccService {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tryDeduct(BusinessActionContext ctx, Long skuId, Integer num) {
// ① 资源检查 + 预留(一条 SQL 完成"校验 + 冻结",用乐观更新避免超卖)
int rows = storageMapper.freeze(skuId, num); // UPDATE stock SET frozen = frozen + #{num}
// WHERE sku_id = #{skuId} AND stock - frozen >= #{num}
if (rows == 0) {
throw new BizException("库存不足"); // Try 失败 → 全局回滚
}
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean confirm(BusinessActionContext ctx) {
Long skuId = Long.valueOf(ctx.getActionContext("skuId").toString());
Integer num = Integer.valueOf(ctx.getActionContext("num").toString());
// ② 落实:把"冻结"转为"真扣"(幂等由 TCC Fence 保证;业务上用状态标记也可)
storageMapper.commitFreeze(skuId, num); // UPDATE stock SET stock = stock - #{num},
// frozen = frozen - #{num} WHERE sku_id=...
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancel(BusinessActionContext ctx) {
Long skuId = Long.valueOf(ctx.getActionContext("skuId").toString());
Integer num = Integer.valueOf(ctx.getActionContext("num").toString());
// ③ 释放冻结(空回滚由 TCC Fence 自动处理:查不到 TRYING 记录则直接返回成功)
storageMapper.unfreeze(skuId, num); // UPDATE stock SET frozen = frozen - #{num} ...
return true;
}
}三个要点(面试要讲出来):
- Try 的"校验 + 冻结"必须是一条原子 SQL(
WHERE stock - frozen >= num),否则会有并发超卖; - Confirm / Cancel 依赖
BusinessActionContext中的参数(所以 Try 的参数必须用@BusinessActionContextParameter标注); useTCCFence = true+ 建tcc_fence_log表,交给框架处理空回滚/悬挂/幂等,不要手写(手写极易漏边界)。
14. Seata 生产环境的排查清单与调优参数?
答:
排查清单(按现象分类):
| 现象 | 排查方向 |
|---|---|
| 全局事务没生效(没回滚) | ①@GlobalTransactional 是否被同类内部调用绕过代理;②异常是否被 catch 吞掉;③数据源是否被 DataSourceProxy 代理 |
| 下游服务没加入全局事务 | XID 是否透传(RPC 隐式参数);线程池/异步/MQ 场景是否手动传递 XID |
Global lock wait timeout | 热点行竞争;查 TC 的 lock_table;调 lock.retryInterval/retryTimes;考虑 TCC/分桶 |
| 回滚失败 / 数据错乱 | 查业务库 undo_log 是否有残留、rollback_info 的后镜像校验是否失败(数据被外部改过) |
| TC 连不上 | tx-service-group ↔ TC 的 vgroup-mapping;注册中心里 seata-server 是否在线 |
| 事务不结束(卡住) | 查 TC 的 global_table.status;超时时间(@GlobalTransactional(timeoutMills));TC 是否 GC 卡顿 |
| 性能下降 | 全局锁 RPC 次数、undo_log 写入量、热点行串行;考虑把非核心逻辑移出全局事务 |
| undo_log 表越来越满 | 二阶段异步删除未完成/ TC 挂过;清理残留记录(确认对应全局事务已终态) |
关键调优参数(客户端):
seata:
client:
tm:
commit-retry-count: 5 # 全局提交重试
rollback-retry-count: 5 # 全局回滚重试
rm:
lock:
retry-interval: 10 # 全局锁重试间隔(ms)
retry-times: 30 # 全局锁重试次数
retry-policy-branch-rollback-on-conflict: true
report-success-enable: false
table-meta-check-enable: false # 关闭每次的表元数据检查(可提性能)
undo:
data-validation: true # 是否做后镜像校验(生产建议 true)
log-serialization: jackson
undo:
log-table: undo_log # 自定义 undo_log 表名
log:
exceptionRate: 100TC 端关键参数:
# 事务会话与锁的过期时间(防止"悬挂"的全局锁长期占用)
server.session.timeout=60000 # 全局事务超时(默认 60s)
server.max.commit.retry.timeout=300000
server.max.rollback.retry.timeout=300000
# 存储模式
store.mode=db # file / db / redis / raft15. 综合实战:一个「跨 3 个服务、耗时 5 分钟、含人工审批」的流程怎么设计?
答: 这是一道典型的「Saga 优先」题,关键是先说清为什么不用 AT/TCC:
第一步:需求分析
第二步:方案选择与对比
| 方案 | 判断 |
|---|---|
| AT | 事务时长与超时机制不匹配;且长事务会让全局锁长期占用 → 不可行 |
| TCC | 人工审批无法"预留"(Try 无法在审批前锁定结果)→ 不可行 |
| Seata Saga(状态机) | 正解:每步是本地事务,失败按补偿回退;支持长时间等待(状态机可持久化) |
| MQ 最终一致 + 流程引擎 | 也可行(如 Flowable/自研流转 + 消息驱动),Saga 状态机更贴近 |
第三步:Saga 设计(状态机)
第四步:兜底与运维
| 项 | 做法 |
|---|---|
| 状态可查 | 状态机实例状态落库(Seata Saga 默认持久化状态机实例),前端可查"流程进度" |
| 超时处理 | 人工审批超时 → 按规则自动驳回(或告警催办),避免流程永久挂起 |
| 补偿失败 | 重试 + 告警 + 人工补偿入口(运维页手动触发补偿) |
| 对账 | 日级核对「审批通过的单据 ↔ 权益已开通的记录」,差异自动补偿 |
| 用户可见性 | 中间态明确展示为"处理中/审批中",避免用户误判(Saga 无隔离性,必须靠 UI 兜住) |
第五步:必须点出的风险
Seata 系列小结:(一)架构与模式全景 →(二)AT 模式与全局锁 →(三)TCC / Saga / XA 与选型。回答 Seata 面试题的核心套路是:先说清「三种角色的协作」→ 再说清「四种模式的取舍」→ 最后落到「你的业务为什么选它、代价是什么」。真正拉开差距的不是 API,而是对「全局锁的代价、补偿的边界、最终一致的业务可接受度」的判断。
