Skip to content

Seata 分布式事务 ​

S4-023选修高级约 60 分钟JDK 21企业开发进阶 · 微服务

1. 本节定位 ​

《Sentinel 限流与熔断》讲熔断;本节讲**"跨服务操作怎么保证数据一致"**。

★ 本节的实测用同一个场景、同一次失败跑五种方案 ✓✓:

text
场景:下单 = 扣款(账户)+ 扣库存(库存)+ 加积分(积分)
故障注入:**第 2 步(扣库存)失败**

① 无事务    → **不一致** ✗✗(钱扣了,货没减)
② 2PC       → 一致 ✓✓(但准备阶段资源被冻结)
③ TCC       → 一致 ✓✓(Try 阶段资源被预占)
④ Saga      → 一致 ✓(补偿后;**中间状态是真的扣了** ✗)
⑤ 本地消息表 → 一致(最终)✓✓(重试 2 次后)

★ 必须说清的边界 ✓:本机没有 Seata,这里实现的是方案的骨架(状态流转与补偿逻辑)—— 真实 Seata 会把协调、日志、补偿都封装起来,但取舍本身不会变 ✓

2. 学习目标 ​

  • 能说出"跨服务操作不是原子的"会造成什么 ★★★;
  • 能对比 2PC / TCC / Saga / 本地消息表的四项代价 ★★★;
  • 知道 2PC 的阻塞与协调者单点是它的致命伤 ★★★;
  • 能说清 TCC 的三个方法与三条硬要求(幂等、空回滚、防悬挂) ★★★;
  • 知道 Saga 最大的坑是"没有隔离性" ★★★;
  • 知道"最好的分布式事务方案是不需要分布式事务" ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看"无事务"的实测结果 ✗✗:

text
  执行结果:余额=900(冻结 0,可用 900)| 库存=10(预占 0,可用 10)| 积分=0
  是否一致:**不一致** ✗✗

★★ 问题不在"失败",而在"没人负责回滚" ✓✓:

用户的钱扣了、货没减 → 必须人工对账或退款 ✗

★★★ 而更糟的情况是"不确定" ✓✓:

如果第 2 步是"超时"而不是"明确失败" ✗ ——

上游根本不知道库存到底扣没扣 → 重试可能超卖,不回滚又可能漏扣 ✗✗

这种"不确定"才是分布式事务真正要解决的问题 ✓✓

5. 核心概念 ​

5.1 五种方案的实测对照 ★★★ ​

★ 同一场景、同一次失败的结果 ✓✓:

方案结果一致吗中间状态
无事务 ✗✗余额 900 / 库存 10 / 积分 0不一致 ✗✗—
2PC ✓✓余额 1000(冻结 100)→ 回滚后 1000一致 ✓✓资源被冻结 ✗
TCC ✓✓余额 1000(预占 1)→ Cancel 后 1000一致 ✓✓资源被预占 ✗
Saga ✓余额 900(真的扣了)→ 补偿后退回一致 ✓真的扣了 ✗✗
本地消息表 ✓✓重试 2 次后:余额 900 / 库存 9 / 积分 100一致(最终)✓✓有延迟 ⚠️

★★ 注意 2PC/TCC 与 Saga 的中间状态 ✗✓ ——这是最本质的区别 ✓✓:

2PC / TCC:资源没真的动(冻结 / 预占),只是"可用量"减少了 ✓ → 别的请求看到的是 1000(实扣未发生) ✓✓

Saga / 本地消息表:资源真的动了(余额已经变成 900)✗ → 别的请求会看到"钱扣了但货没减" ✗✗

5.2 2PC:强一致,但有阻塞与单点 ★★★ ​

实测 ✓:

text
  准备阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
  提交阶段:库存服务失败 → 协调者决定全局回滚 → 冻结解除
  是否一致:一致 ✓✓

★ 2PC 的两条致命伤 ✗✗:

#问题说明
①准备到提交之间,资源被锁定 ✗这段时间里用户的钱"看不到但用不了" ✓
②协调者是单点 ✗✗它在准备后崩溃,所有参与者会一直等(阻塞) ✗✗

★ 所以 2PC 的适用场景很窄 ✓:

内部、短事务、低并发(如 XA 事务)✓

跨公司、跨机房的场景基本不用它 ✓

5.3 TCC:把"锁"交给业务定义 ★★★ ​

实测 ✓:

text
  Try 阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
  Cancel:库存确认失败 → 账户解冻、库存释放、积分撤销
  是否一致:一致 ✓✓

★ TCC 与 2PC 的关键区别 ✓✓:

维度2PCTCC
锁的粒度数据库资源锁(粗)业务自定义预留(细)✓✓
谁控制数据库 / 事务管理器业务代码 ✓
阻塞时间全程锁定 ✗✗只锁 Try 到 Confirm 之间 ✓
开发成本低(框架做)高(每个服务写三个方法) ✗✗

★★★ TCC 的三条硬要求(面试常问) ✓✓:

#要求为什么
①三个方法都要幂等 ★★★Try / Confirm / Cancel 都可能被重复调用(网络重试)✗
②Cancel 必须能处理"Try 没执行过"的情况 ★★★即"空回滚"——Cancel 先到、Try 还没跑 ✗✓
③要防"悬挂" ★★★Cancel 先到、Try 后到 → Try 要能识别并拒绝 ✗✗

★ 为什么这三条这么重要 ✓:

它们不是"理论上的边界情况",而是分布式环境下必然出现的时序问题 ✓

TCC 的复杂度主要就来自这里 ✗

5.4 Saga:无阻塞,但没有隔离性 ★★★ ​

实测 ✓:

text
  第 2 步失败时的**中间状态**:余额=900(冻结 0)| 库存=10 | 积分=0
  补偿:退款 100
  是否一致:一致 ✓

★★★ Saga 最大的坑 ✓✓:

没有隔离性 ✗✗ ——

在"扣款成功、还没补偿"的那段时间里,别的请求能看到这个中间状态 ✗

例如:用户看到钱扣了、订单却没生成 → 以为被骗了 ✗✗

★ 三种缓解手段 ✓:

手段说明
业务上容忍中间态 ★★★如状态显示为"处理中" ✓✓
补偿操作尽快执行 ★★缩短中间态的窗口 ✓
对账兜底 ★★★每天跑一次对账,修复不一致 ✓✓

★ 补偿的三条硬要求(与 TCC 的 Cancel 一样) ✓✓:

#要求
①补偿必须幂等(可能被重试)✓
②补偿必须能"空执行"(前面没成功时也要能跑)✓
③补偿不能失败(要能无限重试,否则会永久不一致)✗✗

5.5 本地消息表:最实用的最终一致方案 ★★★ ​

实测 ✓✓:

text
  本地事务:扣款 100 + 写消息表(原子)
  第 1 次投递库存服务:失败,稍后重试 ✗
  第 2 次投递库存服务:成功 ✓
  重试 2 次后:余额=900 | 库存=9 | 积分=100
  是否一致:**一致(最终)** ✓✓

★ 为什么说它"最实用" ✓✓:

优点说明
不依赖特殊中间件 ★★★一个数据库表 + 一个 MQ 就够 ✓✓
利用本地事务的原子性 ★★★扣款和写消息在同一个库里 ✓✓
天然支持重试 ★★★消息表就是"待办清单" ✓✓
可观测 ★★表里有状态,能查、能告警 ✓

★ 三条必须做的事 ✓:

#事项
①消费端必须幂等(消息会重投,《消息可靠性、幂等与顺序》)✓✓
②消息表要清理(否则无限增长)✓
③要有对账兜底(长期 PENDING 的记录要告警)✓✓

5.6 五种方案的横向对照与选型 ★★★ ​

方案最终一致中间状态有无阻塞业务侵入性
无事务否 ✗✗—无无(但会脏数据)✗
2PC是 ✓✓资源被冻结 ✗是 ✗✗低 ✓
TCC是 ✓✓资源被预占部分 ✓高 ✗✗
Saga是(最终)✓真的扣了 ✗✗无 ✓中 ⚠️
本地消息表是(最终)✓✓有延迟 ⚠️无 ✓✓中 ⚠️

★★★ 选型三条判断(按顺序问) ✓✓:

text
第 1 问:能不能接受"最终一致"?
   能  → **用本地消息表**(最省事、最稳)✓✓
   不能(如余额必须实时准确)→ 继续第 2 问

第 2 问:能不能接受"中间状态被看到"?
   不能 → **2PC 或 TCC** ✓
   能(业务上有"处理中"状态)→ **Saga** ✓

第 3 问:能不能承受开发成本?
   TCC 的侵入性最高(每个服务三个方法)✗

★★★ 一条最重要的工程忠告 ✓✓:

最好的分布式事务方案是"不需要分布式事务" ✓✓

· 把「必须原子」的操作放进同一个服务/同一个库(《微服务架构与拆分原则》 的拆分层析)✓✓

· 用业务设计规避:如"扣款"和"流水记录"本就在一个库,不要拆 ✗✓

· 引入分布式事务前,先问:这个跨服务操作真的必须原子吗? ✓

★★ 还有一条兜底 ✓✓:无论用哪种方案,都要有"每日对账" —— 对账是最后一道防线,也是发现"补偿没跑完"的唯一手段 ✓✓

6. 递进示例 ​

6.1 最小示例:本地消息表 ​

java
@Transactional
public void createOrder(OrderRequest request) {
    orderRepository.save(request);                          // 业务操作
    messageRepository.save(new MessageRecord(              // ★ 同一个本地事务里
            "order-" + request.id(), "扣库存", "PENDING"));
}

// 定时任务:扫消息表,投递成功就标记 DONE
@Scheduled(fixedDelay = 5000)
public void deliver() {
    for (MessageRecord msg : messageRepository.findByStatus("PENDING")) {
        try {
            mqProducer.send(msg.topic(), msg.payload());
            msg.markDone();                                 // ✓ 标记完成
        } catch (Exception e) {
            log.warn("投递失败,下次重试", e);                 // ✓ 靠定时任务重试
        }
    }
}

6.2 业务示例:Seata 的 AT 模式(自动补偿) ​

java
@GlobalTransactional                                  // ★ 一个注解包住跨服务调用
public void createOrder(OrderRequest request) {
    accountService.deduct(request);                   // 每个服务仍是本地事务
    inventoryService.deduct(request);
    pointsService.add(request);
}

★ Seata AT 模式的原理 ✓:每个本地事务提交前记录"前镜像/后镜像", 全局失败时用这些镜像自动生成反向 SQL 回滚 ✓✓

★ 它的代价 ✓:需要 undo_log 表、全局锁(并发下有争用)⚠️

6.3 反例:为了"微服务"而拆掉强一致的业务 ​

text
✗ 把"扣余额"和"记流水"拆成两个服务
→ 本来一个本地事务就能保证的事,现在要上分布式事务 ✗✗

★ 这是最常见的"自找麻烦" ✓(呼应 《微服务架构与拆分原则》 的"共享同一张表的先别拆")✓✓

7. 完整可运行代码与实测 ​

代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/micro/DistributedTxLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.micro.DistributedTxLesson

实测输出(关键部分) ✓:

text
===== 1. 无事务 =====
  执行结果:余额=900(冻结 0)| 库存=10(预占 0)| 积分=0
  是否一致:**不一致** ✗✗

===== 2. 2PC =====
  准备阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
  是否一致:一致 ✓✓(回滚后冻结解除)

===== 3. TCC =====
  Try 阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
  是否一致:一致 ✓✓

===== 4. Saga =====
  第 2 步失败时的中间状态:余额=900(冻结 0)| 库存=10 | 积分=0    ← ★ 真的扣了 ✗✗
  是否一致:一致 ✓(补偿后)

===== 5. 本地消息表 =====
  重试 2 次后:余额=900 | 库存=9 | 积分=100
  是否一致:**一致(最终)** ✓✓

★ 三条可落地的结论 ✓:

#结论依据
①无事务一定留脏数据余额 900 / 库存 10 ✗✗
②2PC/TCC 的中间状态是"冻结",Saga 是"真的扣了"1000 vs 900 ✓✓
③本地消息表最实用(不依赖特殊中间件、天然支持重试)重试 2 次成功 ✓✓

8. Debug 观察点 ​

  1. 把"第 2 步失败"改成"第 2 步超时":观察"不确定"带来的复杂度(要不要重试?)✗✗;
  2. 让补偿操作也失败:观察它会永久不一致 ✗✗(所以补偿必须能无限重试);
  3. 让消息表里长期留下 PENDING:观察对账告警 ✓;
  4. 在 Saga 的中间状态里插入一次查询:观察"脏读" ✗✓;
  5. 把必须原子的两个操作放进同一个库:验证"不需要分布式事务" ✓✓。

9. 常见错误 ​

#错误说明
1跨服务操作不做任何事务处理 ✗✗必留脏数据 ✓
2以为"重试"能解决一致性问题 ✗✗重试要幂等才安全 ✓
3TCC 的 Cancel 不考虑"空回滚" ✗✗Cancel 先到就会出错 ✓✓
4Saga 的补偿会失败 ✗✗会永久不一致 ✓
5忘了对账 ✗✗补偿没跑完没人知道 ✓✓
6为了微服务拆掉强一致的业务 ✗✗自找麻烦 ✓✓
7本地消息表的消费端不幂等 ✗✗重复扣库存 ✓

10. 动手实验 ​

目标:给自己系统里"跨服务的写操作"选一个方案。

步骤:

  1. 跑 DistributedTxLesson,记录五种方案的结果 ✓;
  2. 列出你系统里所有"跨服务的写操作",逐个问"真的必须原子吗" ★★★;
  3. 把必须原子的操作合并到一个服务/库,看能不能消掉分布式事务 ✓✓;
  4. 给一个场景实现本地消息表,用故障注入验证重试 ✓;
  5. 加一个每日对账任务,人为制造一次不一致,验证对账能发现 ★★★。

验收方式:能说出"你的系统里有几处需要分布式事务、各自选了什么方案、为什么"。

扩展任务:实现一次"TCC 的三个边界情况":

  • 重复调用 Try(幂等)✓;
  • Cancel 空回滚(Try 没执行就 Cancel)✓✓;
  • 悬挂(Cancel 先到,Try 后到,要拒绝)✓✓;
  • 说明:为什么这三条是"必然出现"而不是"特殊情况"(提示:网络重试与乱序是常态)★★

11. 练习题 ​

  1. 判断:跨服务操作失败时,"重试"总能保证最终一致。(对 / 错)
  2. 填空:2PC 的两个致命伤是 ______ 和 ______。
  3. 简答:TCC 的 Cancel 为什么必须能"空回滚"?
  4. (场景判断)
text
需求:用户下单要扣余额、扣库存。
     余额和库存现在分属两个服务。

你会怎么设计?为什么? 5. (设计)给"退款"设计分布式事务方案:钱要退到账户,库存要回滚,积分要扣回。

参考答案(要点)
  1. 错。重试必须幂等才安全 ✗ ——否则会重复扣款/重复扣库存 ✓★
  2. 准备到提交之间资源被锁定;协调者是单点(它崩溃会导致参与者一直等)✓
  3. 因为 Cancel 可能先于 Try 到达(网络乱序/重试)✗ —— 如果 Cancel 不判断"Try 有没有执行过"就直接执行"释放资源",会释放不该释放的资源 ✗✗★
  4. 先问"能不能把这两个操作放进同一个服务" ✓✓ —— 如果余额与库存都在订单域内,用一个本地事务最省事 ✓; 如果确实必须分属不同服务,优先用本地消息表(最终一致) ✓; 只有在业务要求强一致时才上 TCC/Saga ✓★
  5. 要点:退款是核心操作,必须成功(可重试 + 幂等)✓✓; 库存回滚与积分扣回可以异步(本地消息表 + 对账)✓; 失败要有告警(长期未完成的退款是严重问题)✓✓; 全流程要有对账兜底 ✓

12. 掌握标准 ​

  • [ ] 能说出"跨服务操作不是原子"的两种后果(脏数据 / 不确定) ★★★;
  • [ ] 能对比五种方案的四项代价 ★★★;
  • [ ] 知道 2PC 的阻塞与单点,以及它的适用边界 ★★★;
  • [ ] 能说出 TCC 的三条硬要求 ★★★;
  • [ ] 知道 Saga 最大的坑是没有隔离性 ★★★;
  • [ ] 能说清本地消息表为什么"最实用" ★★★;
  • [ ] 能在选型时先问"真的需要分布式事务吗" ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《接口管理平台》:怎么保证"接口改坏了没人被影响"。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引