← 返回并发与锁

🌐 分布式事务实战

5种方案 + Seata + 选型决策 · 业务场景对照 · 完整代码注释 · 陷阱细节

2PC TCC Saga 本地消息表 事务消息 Seata 对账兜底

1. 先搞清楚:为什么单库 @Transactional 不够用

单库场景下,`@Transactional` 一把锁搞定 — 数据库内部的 redo/undo log 保证 ACID。但分布式场景下,一个业务跨多个独立数据库,每个库有自己的 @Transactional,谁也管不了别人。

典型场景:电商下单

用户下单要操作 4 个独立服务:订单服务(订单库A)+ 库存服务(库存库B)+ 积分服务(积分库C)+ 支付服务(支付库D)。
如果积分加失败,但订单已成功、库存已扣 → 数据不一致!
每个服务有自己的 @Transactional,但跨服务就管不到了 — 这就需要"协调者"统一管这 4 个本地事务,要么全成,要么全败。这就是分布式事务

核心难点:分布式事务要解决两个问题 — ① 多个独立资源怎么协同(网络不可靠、节点会宕);② 一致性强度 vs 性能 的权衡(强一致代价大,最终一致有延迟)。

2. 理论基础 — CAP 和 BASE

选型前必须懂这两个理论,它们决定了分布式事务方案的"天花板"。

理论核心含义
CAP三选二C 一致性(所有节点数据一致)· A 可用性(每次请求有响应)· P 分区容错(网络分区时还能跑)。分布式系统 P 必选,所以只能在 CA 之间二选一。
BASE最终一致BAsically Available(基本可用)· Soft state(软状态,中间态可见)· Eventually consistent(最终一致)。是 AP 的实践指南 — 放弃强一致,换可用性。
工程结论:互联网业务99% 选 AP(最终一致),只有金融核心场景才考虑 CP(强一致)。所以 5 种方案里,只有 2PC 和 TCC 是强一致,其他都是最终一致。

3. 方案 1:2PC(两阶段提交)— 传统强一致

🔐 2PC (Two-Phase Commit)

"协调者问大家能不能成,都能成就提交,有一个不成就回滚"

原理:引入一个"协调者"(Coordinator),分两阶段:
Prepare 阶段:协调者问所有参与者"能不能提交?",参与者执行本地事务但不 commit,把结果(可以/不行)回给协调者。
Commit/Rollback 阶段:所有参与者都说"可以" → 协调者发 commit;有任何一个说"不行"或超时 → 协调者发 rollback。

📋 2PC 流程图解 + Java 伪代码(XA 协议) 点击展开
2PC 时序
阶段 1: Prepare
协调者 → 各参与者
"能否提交?"
参与者: 执行不提交
→ 回 YES/NO
阶段 2: Commit
全 YES → 全 commit
有 NO → 全 rollback
TwoPCDemo.java — MySQL XA
// MySQL 原生支持 XA 协议(2PC 的标准实现) // 场景:订单库 + 库存库 跨库事务 // ===== 资源 1:订单库的 XA 连接 ===== XAConnection xaConn1 = dataSource1.getXAConnection(); XAResource xaRes1 = xaConn1.getXAResource(); Connection conn1 = xaConn1.getConnection(); // ===== 资源 2:库存库的 XA 连接 ===== XAConnection xaConn2 = dataSource2.getXAConnection(); XAResource xaRes2 = xaConn2.getXAResource(); Connection conn2 = xaConn2.getConnection(); // ⚠️ 生成全局事务 ID byte[] gtrid = "order-tx-001".getBytes(); Xid xid1 = new MyXid(gtrid, "b1".getBytes()); Xid xid2 = new MyXid(gtrid, "b2".getBytes()); try { // ===== 阶段 1:Prepare ===== xaRes1.start(xid1, XAResource.TMNOFLAGS); conn1.executeUpdate("INSERT INTO orders ..."); xaRes1.end(xid1, XAResource.TMSUCCESS); xaRes2.start(xid2, XAResource.TMNOFLAGS); conn2.executeUpdate("UPDATE stock SET qty=qty-1 ..."); xaRes2.end(xid2, XAResource.TMSUCCESS); // ⚠️ 问两个资源:能否提交? int r1 = xaRes1.prepare(xid1); int r2 = xaRes2.prepare(xid2); // ===== 阶段 2:Commit 或 Rollback ===== if (r1 == XAResource.XA_OK && r2 == XAResource.XA_OK) { xaRes1.commit(xid1, true); // 两阶段提交 true xaRes2.commit(xid2, true); } else { xaRes1.rollback(xid1); // 任何一个失败,全部回滚 xaRes2.rollback(xid2); } } catch (Exception e) { // ⚠️ 协调者挂了,参与者会一直阻塞在 prepare 状态(致命缺点) }
优点
  • 强一致(所有参与者要么全成,要么全败)
  • 业务无侵入(数据库层实现)
  • MySQL 原生支持 XA 协议
缺点
  • 同步阻塞 — prepare 后资源一直锁定,性能差
  • 协调者单点 — 挂了参与者卡死
  • 数据不一致 — commit 阶段部分成功部分失败,无法保证
  • 性能太差,互联网基本不用
工程结论:2PC 性能差 + 协调者单点问题,互联网基本不用。仅传统金融/银行核心系统在用,且通常配合专有中间件。面试知道原理即可。

4. 方案 2:TCC — 业务侵入式强一致

💰 TCC (Try-Confirm-Cancel)

"业务层自己写 3 套逻辑:预留 → 确认 → 取消"

原理:把每个操作拆成三个方法:
Try:预留资源(不真扣,先冻结)— 比如冻结库存、冻结余额
Confirm:确认执行(真扣,把冻结的减掉)
Cancel:取消执行(解冻,把冻结的还回去)
所有 Try 成功 → 调 Confirm;任何 Try 失败 → 调 Cancel。

📋 TCC 完整代码:扣余额场景(Seata-TCC) 点击展开
TCC 结构
📦 account
📁 service
AccountService.java
📁 action
AccountTccAction.java
AccountTccActionImpl.java
AccountTccAction.java — 接口定义
// ===== 1. 定义 TCC 接口(用 @LocalTCC 注解)===== @LocalTCC @TwoPhaseBusinessAction(name = "deductAccount", commitMethod = "confirm", rollbackMethod = "cancel") public interface AccountTccAction { // ⚠️ Try 阶段:冻结余额(不真扣) @BusinessActionContextParameter(paramName = "userId") @BusinessActionContextParameter(paramName = "amount") boolean prepare(BusinessActionContext ctx); // ⚠️ Confirm 阶段:真扣(把冻结的减掉) boolean confirm(BusinessActionContext ctx); // ⚠️ Cancel 阶段:解冻(把冻结的还回去) boolean cancel(BusinessActionContext ctx); } // ===== 2. 实现 ===== @Service public class AccountTccActionImpl implements AccountTccAction { @Autowired private AccountMapper accountMapper; @Autowired private FreezeMapper freezeMapper; @Override public boolean prepare(BusinessActionContext ctx) { Long userId = ctx.getActionContext("userId", Long.class); BigDecimal amount = ctx.getActionContext("amount", BigDecimal.class); // ⚠️ Try:冻结余额。balance 减,freeze 加 int rows = accountMapper.freeze(userId, amount); if (rows == 0) throw new RuntimeException("余额不足"); // Try 失败 return true; } @Override public boolean confirm(BusinessActionContext ctx) { // ⚠️ Confirm:真扣。把 freeze 里的清零 Long userId = ctx.getActionContext("userId", Long.class); BigDecimal amount = ctx.getActionContext("amount", BigDecimal.class); accountMapper.deductFreeze(userId, amount); return true; } @Override public boolean cancel(BusinessActionContext ctx) { // ⚠️ Cancel:解冻。balance 加回,freeze 清零 Long userId = ctx.getActionContext("userId", Long.class); BigDecimal amount = ctx.getActionContext("amount", BigDecimal.class); accountMapper.unfreeze(userId, amount); return true; } } // ===== 3. 业务调用 ===== @Service public class TransferService { @Autowired private AccountTccAction accountTccAction; @GlobalTransactional // ⚠️ Seata 全局事务注解 public void transfer(Long from, Long to, BigDecimal amount) { accountTccAction.prepare(null); // 扣钱方 Try accountTccAction.prepare(null); // 收钱方 Try // 两个 Try 都成功 → Seata 自动调 confirm // 任何一个失败 → Seata 自动调 cancel } }
优点
  • 强一致(业务层保证)
  • 性能好(不锁资源,用冻结字段)
  • 无阻塞,适合高并发
  • 金融场景首选
缺点
  • 业务侵入大 — 每个操作要写 3 套
  • 要改表结构 — 加冻结字段
  • 要处理空回滚/悬挂/幂等 3 个经典问题
  • 开发成本高
TCC 三大经典坑(面试必问):
  1. 空回滚 — Try 没执行,Cancel 却被调用(网络抖动导致)。Cancel 要能识别"没 Try 过"的情况,直接返回成功。
  2. 悬挂 — Cancel 先到,Try 后到(超时重试)。Try 要能识别"已经 Cancel 过",直接返回失败不执行。
  3. 幂等 — Confirm/Cancel 可能被重试。每次执行前先查"是否处理过",处理过直接返回成功。

5. 方案 3:Saga — 长流程最终一致

📋 Saga

"长流程串行执行,每步失败就反向补偿前面的"

原理:把长事务拆成 N 个本地事务 T1→T2→T3→...→Tn,每个 Ti 配一个补偿 Ci。
正向:T1 成功 → T2 成功 → T3 成功 → ...
补偿:T3 失败 → 反向执行 C2 → C1(补偿已执行的)。
不预留资源,执行完就提交,失败靠补偿回滚。

📋 Saga 示例:旅行预订(机票+酒店+租车) 点击展开
Saga 流程
正向
T1: 订机票
T2: 订酒店
T3: 订租车
补偿(若 T3 失败)
C2: 取消酒店
C1: 取消机票
TravelSaga.java — Seata-Saga
// Saga 适合长流程:订机票 → 订酒店 → 订租车 // 任何一步失败,反向补偿前面已成功的步骤 @Component public class TravelSaga { public void bookTravel(Long userId, TravelRequest req) { // ⚠️ Saga 用状态机驱动,Seata-Saga 引擎负责编排 StateMachineEngine engine = stateMachineEngine; Map<String, Object> params = new HashMap<>(); params.put("userId", userId); params.put("flightReq", req.getFlight()); params.put("hotelReq", req.getHotel()); params.put("carReq", req.getCar()); // 启动状态机:会按定义的流程执行 // T1(bookFlight) → T2(bookHotel) → T3(bookCar) // 任何一步失败 → 反向执行补偿 // T3 失败 → C2(cancelHotel) → C1(cancelFlight) StateMachineInstance inst = engine.start( "travel-booking", // 状态机定义(JSON) BusinessType.BOOKING, params ); if (inst.getStatus() == ExecutionStatus.FA) { // 全流程失败(补偿已自动执行) throw new RuntimeException("预订失败,已自动取消"); } } } /* 状态机定义(JSON 片段): { "Name": "travel-booking", "States": [ {"Name": "BookFlight", "Type": "ServiceTask", "ServiceName": "flightService", "ServiceMethod": "book", "CompensateState": "CancelFlight"}, {"Name": "BookHotel", "Type": "ServiceTask", "ServiceName": "hotelService", "ServiceMethod": "book", "CompensateState": "CancelHotel"}, {"Name": "BookCar", "Type": "ServiceTask", "ServiceName": "carService", "ServiceMethod": "book", "CompensateState": "CancelCar"} ] } */
优点
  • 适合长流程(几秒到几分钟)
  • 每步提交不锁资源,性能好
  • 业务侵入比 TCC 小
  • Seata-Saga 引擎成熟
缺点
  • 最终一致 — 中间态可见(可能短暂不一致)
  • 补偿难写 — 不是所有操作都能补偿(比如发了短信)
  • 缺乏隔离性 — 可能脏读
  • 流程长时调试困难
关键细节:Saga 没有"隔离性" — T1 提交后到 T2 之间,别人能看到 T1 的结果。如果 T2 失败要补偿 T1,中间窗口的数据已被读取/使用。解法:业务层加锁乐观锁 + 状态机控制。

6. 方案 4:本地消息表 — 工程首选(无框架)

📋 本地消息表

"业务表 + 消息表放同一个库,本地事务保证一致,定时任务把消息发出去"

原理:① 业务操作 + 写消息表放同一个本地事务(用 @Transactional 保证原子性);② 后台定时任务扫描消息表,把消息发到 MQ;③ 消费者消费 MQ 执行下游业务,消费成功后回 ACK。
核心:把"业务+消息"绑成一个本地事务,解决了"业务成功但消息丢了"的问题。

📋 本地消息表完整代码:下单 + 加积分 点击展开
本地消息表
📦 order
📁 service
OrderService.java
MessageScheduler.java
📁 consumer
PointConsumer.java
OrderService.java — 业务 + 消息同事务
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private MessageTableMapper msgMapper; // 消息表 Mapper @Transactional // ⚠️ 本地事务,保证原子性 public void createOrder(Order order) { // ① 写订单表 orderMapper.insert(order); // ② 写本地消息表(同一个事务) // ⚠️ 关键:订单和消息在同一个事务,要么都成功要么都失败 Message msg = new Message(); msg.setBizId(order.getId()); msg.setType("ADD_POINT"); msg.setPayload(JSON.toJSONString(order)); msg.setStatus(0); // 0=待发送 msg.setRetryCount(0); msg.setCreateTime(new Date()); msgMapper.insert(msg); // 事务提交:订单 + 消息 一起落库 } } // ===== 定时任务:扫描消息表,发到 MQ ===== @Component public class MessageScheduler { @Scheduled(fixedDelay = 1000) // 每秒扫一次 public void sendMessages() { List<Message> pending = msgMapper.selectByStatus(0, 100); // 取 100 条 for (Message msg : pending) { try { mqProducer.send("point-topic", msg); msgMapper.updateStatus(msg.getId(), 1); // 1=已发送 } catch (Exception e) { msgMapper.incrRetry(msg.getId()); // 重试次数 +1 if (msg.getRetryCount() > 5) { msgMapper.updateStatus(msg.getId(), 2); // 2=失败,人工介入 } } } } } // ===== 积分消费者 ===== @Component @RocketMQMessageListener(topic = "point-topic", consumerGroup = "point-group") public class PointConsumer implements RocketMQListener<Message> { @Override public void onMessage(Message msg) { // ⚠️ 幂等:防止重复消费加两次积分 boolean isNew = redis.setIfAbsent( "msg:done:" + msg.getBizId(), "1", 24, HOURS ); if (!isNew) return; Order order = JSON.parseObject(msg.getPayload(), Order.class); pointService.add(order.getUserId(), order.getAmount()); } }
优点
  • 无需引入框架,实现简单
  • 业务侵入小(只加个消息表)
  • 可靠性高(消息落库不丢)
  • 最终一致,性能好
  • 工程首选
缺点
  • 最终一致(秒级延迟)
  • 要建消息表 + 定时任务
  • 消息表会增长,要定期清理
  • 和业务耦合(消息表在业务库)

7. 方案 5:事务消息 — 电商首选(RocketMQ)

📨 事务消息(RocketMQ)

"半消息 + 本地事务 + 回查机制,把消息表的逻辑挪到 MQ 里"

原理:RocketMQ 独有,把"本地消息表"的思路内置到 MQ。
① 生产者发半消息(消费者看不到)→ MQ 收到后回调生产者执行本地事务;
② 本地事务成功 → 提交消息(消费者可见);失败 → 回滚消息(删除);
③ 如果生产者超时/挂了 → MQ 主动回查生产者事务状态,根据结果补提交/回滚。

📋 RocketMQ 事务消息完整代码(下单 + 加积分) 点击展开
事务消息
📦 order
📁 service
OrderService.java
📁 listener
OrderTxListener.java
OrderService.java — 发送半消息
@Service public class OrderService { @Autowired private TransactionMQProducer producer; public void createOrder(Order order) throws Exception { Message msg = new Message( "order-topic", "create", order.getId(), JSON.toJSONString(order).getBytes() ); // ⚠️ 发送半消息 + 注册事务监听器 producer.sendMessageInTransaction(msg, null); } } // ===== 事务监听器:执行本地事务 + 回查 ===== public class OrderTxListener implements TransactionListener { @Autowired private OrderMapper orderMapper; // ① 执行本地事务(半消息发送成功后回调) @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { Order order = JSON.parseObject(new String(msg.getBody()), Order.class); orderMapper.insert(order); // 写订单表 return LocalTransactionState.COMMIT_MESSAGE; // 提交,消费者可见 } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; // 回滚,消息删除 } } // ② 回查(MQ 主动问:本地事务到底成没成?) @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // ⚠️ 必须可靠实现,否则半消息会一直挂起 String orderId = msg.getKeys(); Order order = orderMapper.selectById(orderId); if (order != null) { return LocalTransactionState.COMMIT_MESSAGE; // 订单在,事务成 } return LocalTransactionState.ROLLBACK_MESSAGE; // 订单不在,回滚 } }
优点
  • 无需消息表(逻辑在 MQ)
  • 业务侵入小
  • 性能好,最终一致
  • 电商场景标配
  • 阿里大规模验证
缺点
  • 仅 RocketMQ 支持(Kafka/RabbitMQ 没有)
  • 回查逻辑必须可靠实现
  • 最终一致(秒级延迟)
  • 对 MQ 强依赖
本地消息表 vs 事务消息:两者思路一致(业务+消息原子),区别在"消息存哪"。本地消息表存业务库(要建表+定时任务),事务消息存 MQ(框架封装,代码少)。用 RocketMQ 就选事务消息,不用 RocketMQ 就用本地消息表。

8. Seata — 一站式分布式事务框架

阿里开源,把上述几种模式封装成开箱即用的框架,实际工程用得最多。

Seata 模式对应方案特点适用场景
AT 模式(默认)2PC 变种业务无侵入(还写 @Transactional),框架自动解析 SQL 生成回滚 SQL大部分场景(默认选这个)
TCC 模式TCC业务自己写 Try/Confirm/Cancel钱场景,高性能要求
Saga 模式Saga状态机驱动,长流程补偿跨多服务长流程
XA 模式2PC数据库原生 XA 协议跨多个不同数据库,强一致
📋 Seata-AT 模式完整代码(业务无侵入) 点击展开
Seata-AT
📦 order
📁 service
OrderService.java
📁 config
application.yml
📦 stock
StockService.java
OrderService.java — AT 模式
// ===== Seata-AT 模式:业务代码几乎不用改 ===== @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockFeignClient stockFeignClient; // 调库存服务 @GlobalTransactional // ⚠️ 唯一改动:换注解 public void createOrder(Order order) { // ① 写订单(本地事务) orderMapper.insert(order); // ② 调库存服务扣库存(远程) Result r = stockFeignClient.deduct(order.getProductId(), order.getQty()); if (!r.isSuccess()) { throw new RuntimeException("扣库存失败"); // 抛异常,Seata 自动回滚 } // ③ 失败时:Seata 自动用 undo_log 表回滚订单 } } /* ===== application.yml 配置 ===== seata: enabled: true application-id: order-service tx-service-group: my_tx_group service: vgroup-mapping: my_tx_group: default grouplist: default: 127.0.0.1:8091 # Seata Server 地址 */ /* ===== 每个业务库都要建 undo_log 表(Seata 自动维护)===== CREATE TABLE undo_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, branch_id BIGINT NOT NULL, xid VARCHAR(100) NOT NULL, -- 全局事务 ID context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, -- 回滚 SQL log_status INT NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL ); */ /* AT 模式原理: 1. 业务 SQL 执行前,Seata 拦截,保存执行前的数据快照(before image) 2. 执行业务 SQL 3. 保存执行后的数据快照(after image) 4. 把 before/after image 写入 undo_log 表(同一个本地事务) 5. 全局事务失败时,Seata 用 undo_log 反向生成回滚 SQL 自动回滚 代价:每条 SQL 多了 2 次查询 + 1 次写入,性能比 TCC 差 优势:业务零侵入 */
Seata 选型建议:① 默认用 AT 模式(业务无侵入,大部分场景够用);② 钱场景用 TCC 模式(性能好但要写 3 套);③ 长流程用 Saga 模式;④ 跨多个不同数据库且要强一致用 XA 模式

9. 业务场景对照 — 你的业务该用哪个?

结合资料库里 9 大业务系统,逐一对照该用哪种方案。

🛒 订单系统

下单 + 加积分 + 发短信,最终一致够用。用 RocketMQ 的话直接事务消息;不用 RocketMQ 用本地消息表。

事务消息 本地消息表
💳 支付系统

扣款/转账/退款,钱相关必须强一致。TCC 性能好但要写 3 套,支付核心场景值得。

TCC
⚡ 秒杀系统

扣库存 + 生成订单,高并发。最终一致够用,用本地消息表异步落单。强一致扛不住并发。

本地消息表
💬 IM 系统

消息必达,最终一致够用。本地消息表 + 重试,不丢消息即可。

本地消息表
📰 内容社区

发帖 + 推 feed + 索引,最终一致。事务消息或本地消息表。

事务消息
🚚 物流系统

下单 → 取货 → 中转 → 配送,长流程多环节。Saga 状态机驱动,某步失败补偿前面。

Saga
📊 数据报表

数据同步到数仓,最终一致。本地消息表 + 定时同步。

本地消息表
🛂 风控系统

实时决策,要求低延迟。TCC 不合适(写 3 套太重),通常用本地消息表 + 实时特征。

本地消息表
🏢 SaaS 多租户

租户开通 + 资源初始化,长流程。Saga 编排,某步失败补偿。

Saga
规律:钱场景 → TCC · 长流程 → Saga · 其他普通业务 → 本地消息表/事务消息。80% 的场景用最终一致就够,不要动不动就 TCC/Seata-AT。

10. 工程分档 — 80% 场景不用分布式事务

最重要的工程建议:别上来就分布式事务。先问三个问题:能不能同库?能不能最终一致?是不是钱相关?

80%
第一档:最终一致就够
本地消息表 / RocketMQ 事务消息。例:下单后发短信、加积分、推数据给推荐系统。不需要引入 Seata
15%
第二档:核心交易链路,要强一致
TCC(Seata-TCC 模式)。例:支付扣款、转账、退款。业务愿意写 3 套 Try/Confirm/Cancel。
5%
第三档:跨多服务长流程
Saga(Seata-Saga 模式)。例:旅行预订(机票+酒店+租车)、理赔流程。每个环节有补偿逻辑。
基本不用
第四档:传统强一致
2PC / XA — 工程上几乎被淘汰。仅传统金融/银行核心系统在用,互联网基本不用。
兜底大杀器:对账系统(无论用哪种方案都要有)
任何分布式事务都有概率出问题(网络抖动、节点宕机、消息丢失),对账是最后一道防线:
  • T+1 跑对账:比对订单表、流水表、第三方账单
  • 发现不一致:自动修复 / 人工介入
  • 没有对账的分布式事务,等于裸奔

11. 使用陷阱 — 8 个高频坑

① 过度设计:简单场景上分布式事务 能用同库就同库(直接 @Transactional);能最终一致就别强一致。80% 场景不需要 Seata。
② TCC 的空回滚/悬挂/幂等三大坑 Try 没执行 Cancel 却来(空回滚)、Cancel 先到 Try 后到(悬挂)、Confirm/Cancel 被重试(幂等)。每个都要在业务里处理,否则数据错乱。
③ Saga 缺乏隔离性 T1 提交后到 T2 之间,别人能看到 T1 的结果。如果 T2 失败要补偿 T1,中间窗口的数据已被读取。解法:业务层加锁/乐观锁。
④ 本地消息表的消息表无限增长 消息表只增不删,几个月后几亿条。必须定期归档:已消费且超过 7 天的消息迁移到历史表。
⑤ 事务消息回查逻辑写错 checkLocalTransaction 必须可靠实现(查数据库,不依赖内存)。否则半消息会一直挂起,MQ 反复回查,堆积告警。
⑥ Seata-AT 的性能损耗被低估 每条 SQL 多 2 次查询 + 1 次写 undo_log,高频写场景性能下降明显。压测再决定,别盲目上 AT。
⑦ 消费者不幂等,重复消费导致数据错乱 MQ 保证"至少一次"投递,重复必然发生。消费者必须用 Redis/唯一索引做幂等,绝对不能假设"只消费一次"。
⑧ 没有对账系统 任何分布式事务都有失败概率。没有 T+1 对账兜底,出了不一致只能等用户投诉。对账是底线中的底线。

12. 总结 — 速查表 + 面试模板

速查表 1:5 种方案核心对比

方案一致性性能业务侵入推荐度
2PC强一致差(阻塞)⭐ 不推荐
TCC强一致极大(3套)⭐⭐⭐⭐ 钱场景
Saga最终一致中(补偿)⭐⭐⭐ 长流程
本地消息表最终一致⭐⭐⭐⭐⭐ 工程首选
事务消息最终一致⭐⭐⭐⭐⭐ 电商首选

速查表 2:场景 → 方案

业务特征选哪个
钱相关(支付/转账/退款)+ 高性能TCC(Seata-TCC)
长流程跨多服务(旅行预订/理赔)Saga(Seata-Saga)
普通业务(下单+积分+通知)+ 用 RocketMQ事务消息
普通业务 + 不想引框架本地消息表
大部分场景,默认选择Seata-AT(业务无侵入)
能同库就同库@Transactional(根本不是分布式问题)

面试一句话模板

答题脚本 "分布式事务方案有 5 种:2PC(传统强一致,性能差不用)、TCC(钱场景,写 3 套 Try/Confirm/Cancel)、Saga(长流程补偿)、本地消息表(工程首选,业务+消息同事务)、事务消息(RocketMQ 电商首选)。

实际选型分档:80% 场景用最终一致(本地消息表/事务消息),15% 钱场景用 TCC,5% 长流程用 Saga。框架用 Seata(AT 模式业务无侵入,TCC/Saga/XA 都支持)。

工程上要记住三点:① 能同库就同库,别上来就分布式;② 消费者必须幂等(MQ 保证至少一次);③ 一定要配对账系统兜底,没有对账等于裸奔。"

高频追问 FAQ

Q1:为什么 2PC 性能差?
同步阻塞 — prepare 后资源一直锁定(行锁/表锁),直到 commit/rollback 才释放,期间其他事务等着;② 协调者单点 — 协调者挂了,参与者卡在 prepare 状态无法继续;③ 网络往返多 — prepare + commit 两轮 RTT。互联网高并发场景扛不住。
Q2:TCC 的空回滚、悬挂、幂等怎么解决?
空回滚:Try 没执行 Cancel 却来。Cancel 前查"有没有 Try 记录",没有就直接返回成功。
悬挂:Cancel 先到 Try 后到。Try 前查"有没有 Cancel 记录",有就拒绝执行。
幂等:Confirm/Cancel 可能重试。执行前查"有没有处理过",处理过直接返回成功。
三者都要靠事务活动表(记录每个 xid 的 Try/Confirm/Cancel 状态)来识别。
Q3:本地消息表和事务消息的区别?
思路一致(业务+消息原子),区别在"消息存哪":
本地消息表:消息存业务库(建 message 表),定时任务扫表发 MQ。优点是不依赖特定 MQ;缺点是要建表+定时任务。
事务消息:消息存 MQ(RocketMQ 内置),半消息+回查机制。优点是代码少框架封装;缺点是仅 RocketMQ 支持。
用 RocketMQ 选事务消息,不用 RocketMQ 选本地消息表。
Q4:Seata-AT 模式原理?为什么业务无侵入?
AT 模式靠 SQL 解析 + undo_log 实现自动回滚:
① 业务 SQL 执行前,Seata 拦截,保存 before image(执行前数据);
② 执行业务 SQL;
③ 保存 after image(执行后数据);
④ 把 before/after image 写入 undo_log 表(同一个本地事务);
⑤ 全局事务失败时,Seata 用 undo_log 反向生成回滚 SQL 自动回滚。
业务代码只把 @Transactional 换成 @GlobalTransactional,其他不变,所以无侵入。代价是每条 SQL 多 2 次查询 + 1 次写 undo_log,性能比 TCC 差。
Q5:为什么说"没有对账等于裸奔"?
分布式事务无论用哪种方案,都有失败概率:网络抖动导致消息丢失、节点宕机导致状态丢失、消费者处理失败。没有任何方案能 100% 保证一致。对账系统是最后兜底:T+1 跑批比对各系统数据,发现不一致自动修复或人工介入。没有对账,出了不一致只能等用户投诉,可能已经造成资损。
Q6:什么时候该用 Seata,什么时候不用?
用 Seata:① 核心交易链路要强一致(AT/TCC);② 长流程要补偿(Saga);③ 团队不想自己造轮子。
不用 Seata:① 最终一致够用 — 本地消息表/事务消息更轻;② 能同库就同库 — 直接 @Transactional;③ 非核心业务 — 异步 + 对账兜底就行,别引入框架复杂度。
最后一句话:分布式事务的核心不是"用哪个框架",而是"一致性强度 vs 性能"的权衡。80% 场景最终一致就够(本地消息表/事务消息),15% 钱场景要强一致(TCC),5% 长流程要补偿(Saga)。无论用哪种,对账系统是底线 — 没有对账的分布式事务,等于裸奔。
分布式事务实战 · 5种方案 + Seata + 选型决策 · 并发与锁 / 08