主题
界面
主题
《Sentinel 限流与熔断》讲熔断;本节讲**"跨服务操作怎么保证数据一致"**。
★ 本节的实测用同一个场景、同一次失败跑五种方案 ✓✓:
场景:下单 = 扣款(账户)+ 扣库存(库存)+ 加积分(积分)
故障注入:**第 2 步(扣库存)失败**
① 无事务 → **不一致** ✗✗(钱扣了,货没减)
② 2PC → 一致 ✓✓(但准备阶段资源被冻结)
③ TCC → 一致 ✓✓(Try 阶段资源被预占)
④ Saga → 一致 ✓(补偿后;**中间状态是真的扣了** ✗)
⑤ 本地消息表 → 一致(最终)✓✓(重试 2 次后)★ 必须说清的边界 ✓:本机没有 Seata,这里实现的是方案的骨架(状态流转与补偿逻辑)—— 真实 Seata 会把协调、日志、补偿都封装起来,但取舍本身不会变 ✓
先看"无事务"的实测结果 ✗✗:
执行结果:余额=900(冻结 0,可用 900)| 库存=10(预占 0,可用 10)| 积分=0
是否一致:**不一致** ✗✗★★ 问题不在"失败",而在"没人负责回滚" ✓✓:
用户的钱扣了、货没减 → 必须人工对账或退款 ✗
★★★ 而更糟的情况是"不确定" ✓✓:
如果第 2 步是"超时"而不是"明确失败" ✗ ——
上游根本不知道库存到底扣没扣 → 重试可能超卖,不回滚又可能漏扣 ✗✗
这种"不确定"才是分布式事务真正要解决的问题 ✓✓
★ 同一场景、同一次失败的结果 ✓✓:
| 方案 | 结果 | 一致吗 | 中间状态 |
|---|---|---|---|
| 无事务 ✗✗ | 余额 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)✗ → 别的请求会看到"钱扣了但货没减" ✗✗
实测 ✓:
准备阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
提交阶段:库存服务失败 → 协调者决定全局回滚 → 冻结解除
是否一致:一致 ✓✓★ 2PC 的两条致命伤 ✗✗:
| # | 问题 | 说明 |
|---|---|---|
| ① | 准备到提交之间,资源被锁定 ✗ | 这段时间里用户的钱"看不到但用不了" ✓ |
| ② | 协调者是单点 ✗✗ | 它在准备后崩溃,所有参与者会一直等(阻塞) ✗✗ |
★ 所以 2PC 的适用场景很窄 ✓:
内部、短事务、低并发(如 XA 事务)✓
跨公司、跨机房的场景基本不用它 ✓
实测 ✓:
Try 阶段完成:余额=1000(冻结 100,可用 900)| 库存=10(预占 1,可用 9)| 积分=0
Cancel:库存确认失败 → 账户解冻、库存释放、积分撤销
是否一致:一致 ✓✓★ TCC 与 2PC 的关键区别 ✓✓:
| 维度 | 2PC | TCC |
|---|---|---|
| 锁的粒度 | 数据库资源锁(粗) | 业务自定义预留(细)✓✓ |
| 谁控制 | 数据库 / 事务管理器 | 业务代码 ✓ |
| 阻塞时间 | 全程锁定 ✗✗ | 只锁 Try 到 Confirm 之间 ✓ |
| 开发成本 | 低(框架做) | 高(每个服务写三个方法) ✗✗ |
★★★ TCC 的三条硬要求(面试常问) ✓✓:
| # | 要求 | 为什么 |
|---|---|---|
| ① | 三个方法都要幂等 ★★★ | Try / Confirm / Cancel 都可能被重复调用(网络重试)✗ |
| ② | Cancel 必须能处理"Try 没执行过"的情况 ★★★ | 即"空回滚"——Cancel 先到、Try 还没跑 ✗✓ |
| ③ | 要防"悬挂" ★★★ | Cancel 先到、Try 后到 → Try 要能识别并拒绝 ✗✗ |
★ 为什么这三条这么重要 ✓:
它们不是"理论上的边界情况",而是分布式环境下必然出现的时序问题 ✓
TCC 的复杂度主要就来自这里 ✗
实测 ✓:
第 2 步失败时的**中间状态**:余额=900(冻结 0)| 库存=10 | 积分=0
补偿:退款 100
是否一致:一致 ✓★★★ Saga 最大的坑 ✓✓:
没有隔离性 ✗✗ ——
在"扣款成功、还没补偿"的那段时间里,别的请求能看到这个中间状态 ✗
例如:用户看到钱扣了、订单却没生成 → 以为被骗了 ✗✗
★ 三种缓解手段 ✓:
| 手段 | 说明 |
|---|---|
| 业务上容忍中间态 ★★★ | 如状态显示为"处理中" ✓✓ |
| 补偿操作尽快执行 ★★ | 缩短中间态的窗口 ✓ |
| 对账兜底 ★★★ | 每天跑一次对账,修复不一致 ✓✓ |
★ 补偿的三条硬要求(与 TCC 的 Cancel 一样) ✓✓:
| # | 要求 |
|---|---|
| ① | 补偿必须幂等(可能被重试)✓ |
| ② | 补偿必须能"空执行"(前面没成功时也要能跑)✓ |
| ③ | 补偿不能失败(要能无限重试,否则会永久不一致)✗✗ |
实测 ✓✓:
本地事务:扣款 100 + 写消息表(原子)
第 1 次投递库存服务:失败,稍后重试 ✗
第 2 次投递库存服务:成功 ✓
重试 2 次后:余额=900 | 库存=9 | 积分=100
是否一致:**一致(最终)** ✓✓★ 为什么说它"最实用" ✓✓:
| 优点 | 说明 |
|---|---|
| 不依赖特殊中间件 ★★★ | 一个数据库表 + 一个 MQ 就够 ✓✓ |
| 利用本地事务的原子性 ★★★ | 扣款和写消息在同一个库里 ✓✓ |
| 天然支持重试 ★★★ | 消息表就是"待办清单" ✓✓ |
| 可观测 ★★ | 表里有状态,能查、能告警 ✓ |
★ 三条必须做的事 ✓:
| # | 事项 |
|---|---|
| ① | 消费端必须幂等(消息会重投,《消息可靠性、幂等与顺序》)✓✓ |
| ② | 消息表要清理(否则无限增长)✓ |
| ③ | 要有对账兜底(长期 PENDING 的记录要告警)✓✓ |
| 方案 | 最终一致 | 中间状态 | 有无阻塞 | 业务侵入性 |
|---|---|---|---|---|
| 无事务 | 否 ✗✗ | — | 无 | 无(但会脏数据)✗ |
| 2PC | 是 ✓✓ | 资源被冻结 ✗ | 是 ✗✗ | 低 ✓ |
| TCC | 是 ✓✓ | 资源被预占 | 部分 ✓ | 高 ✗✗ |
| Saga | 是(最终)✓ | 真的扣了 ✗✗ | 无 ✓ | 中 ⚠️ |
| 本地消息表 | 是(最终)✓✓ | 有延迟 ⚠️ | 无 ✓✓ | 中 ⚠️ |
★★★ 选型三条判断(按顺序问) ✓✓:
第 1 问:能不能接受"最终一致"?
能 → **用本地消息表**(最省事、最稳)✓✓
不能(如余额必须实时准确)→ 继续第 2 问
第 2 问:能不能接受"中间状态被看到"?
不能 → **2PC 或 TCC** ✓
能(业务上有"处理中"状态)→ **Saga** ✓
第 3 问:能不能承受开发成本?
TCC 的侵入性最高(每个服务三个方法)✗★★★ 一条最重要的工程忠告 ✓✓:
最好的分布式事务方案是"不需要分布式事务" ✓✓
· 把「必须原子」的操作放进同一个服务/同一个库(《微服务架构与拆分原则》 的拆分层析)✓✓
· 用业务设计规避:如"扣款"和"流水记录"本就在一个库,不要拆 ✗✓
· 引入分布式事务前,先问:这个跨服务操作真的必须原子吗? ✓
★★ 还有一条兜底 ✓✓:无论用哪种方案,都要有"每日对账" —— 对账是最后一道防线,也是发现"补偿没跑完"的唯一手段 ✓✓
@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); // ✓ 靠定时任务重试
}
}
}@GlobalTransactional // ★ 一个注解包住跨服务调用
public void createOrder(OrderRequest request) {
accountService.deduct(request); // 每个服务仍是本地事务
inventoryService.deduct(request);
pointsService.add(request);
}★ Seata AT 模式的原理 ✓:每个本地事务提交前记录"前镜像/后镜像", 全局失败时用这些镜像自动生成反向 SQL 回滚 ✓✓
★ 它的代价 ✓:需要 undo_log 表、全局锁(并发下有争用)⚠️
✗ 把"扣余额"和"记流水"拆成两个服务
→ 本来一个本地事务就能保证的事,现在要上分布式事务 ✗✗★ 这是最常见的"自找麻烦" ✓(呼应 《微服务架构与拆分原则》 的"共享同一张表的先别拆")✓✓
代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/micro/DistributedTxLesson.java
运行:
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.micro.DistributedTxLesson实测输出(关键部分) ✓:
===== 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 次成功 ✓✓ |
| # | 错误 | 说明 |
|---|---|---|
| 1 | 跨服务操作不做任何事务处理 ✗✗ | 必留脏数据 ✓ |
| 2 | 以为"重试"能解决一致性问题 ✗✗ | 重试要幂等才安全 ✓ |
| 3 | TCC 的 Cancel 不考虑"空回滚" ✗✗ | Cancel 先到就会出错 ✓✓ |
| 4 | Saga 的补偿会失败 ✗✗ | 会永久不一致 ✓ |
| 5 | 忘了对账 ✗✗ | 补偿没跑完没人知道 ✓✓ |
| 6 | 为了微服务拆掉强一致的业务 ✗✗ | 自找麻烦 ✓✓ |
| 7 | 本地消息表的消费端不幂等 ✗✗ | 重复扣库存 ✓ |
目标:给自己系统里"跨服务的写操作"选一个方案。
步骤:
DistributedTxLesson,记录五种方案的结果 ✓;验收方式:能说出"你的系统里有几处需要分布式事务、各自选了什么方案、为什么"。
扩展任务:实现一次"TCC 的三个边界情况":
需求:用户下单要扣余额、扣库存。
余额和库存现在分属两个服务。你会怎么设计?为什么? 5. (设计)给"退款"设计分布式事务方案:钱要退到账户,库存要回滚,积分要扣回。
《接口管理平台》:怎么保证"接口改坏了没人被影响"。