Skip to content

分布式事务 ​

S6-014建议学高级约 90 分钟JDK 21Java 高级与架构 · 分布式

1. 本节定位 ​

上上阶段已经把五种方案的取舍讲透了;本节回答的是「方案选好之后,代码怎么写才不出事」。

★ 四件套全部一手实测(同一套订单场景) ✓✓:

text
① 幂等键  :超时重试 → 无幂等双扣(库存 8 ✗✗)/ 有幂等只扣一次(库存 9 ✓✓)
② 空回滚  :Cancel 先到、Try 未执行 → 记 CANCELLED,不报错 ✓
③ 防悬挂  :Cancel 之后 Try 才到 → 状态=CANCELLED → 拒绝执行 ✓✓✓
④ 对账   :发现「已扣款但订单失败」1 笔 → 自动退款,复查一致 ✓✓

★★ 与 《Seata 分布式事务》 的分工 ✓✓:《Seata 分布式事务》 决定「怎么走」(2PC/TCC/Saga/消息表),本节决定「走得稳不稳」。

2. 学习目标 ​

  • 能说清「不确定态」为什么是分布式事务的真正难题 ★★★;
  • 会设计幂等键并解释「为什么不放在业务事务外」★★★;
  • 能用状态机实现 TCC 的空回滚与防悬挂 ★★★;
  • 会设计补偿重试(退避 + 上限 + 人工)与对账兜底 ★★★。

3. 前置知识检查 ​

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

★ 分布式事务的难点不是「失败」,而是「不知道成功还是失败」 ✗✓✓:

text
调用方发出「扣库存」→ 响应超时
到底扣了没有?**调用方无法判断** ✗
  · 重试 → 可能扣两次(双扣)✗✗
  · 不重试 → 可能一件没扣(漏扣)✗✗

★★ 幂等、空回滚、防悬挂、对账 —— 这四个机制全部是在为「不确定」兜底 ✓✓✓。

5. 核心概念 ​

5.1 不确定态与幂等键(实测)★★★ ​

★ 反例:无幂等设计,上游重试 ✗✗:

text
第 1 次请求:扣减成功(库存 9),但上游超时没收到 ✓
第 2 次请求(重试):又扣了一次 → 库存 8 ✗✗ 双扣(卖了 1 件、扣了 2 件)

★ 正解:业务唯一号当幂等键 ✓✓:

text
第 2 次请求(同 order-9527):幂等表命中 → 直接返回上次结果 → 库存仍 9 ✓✓

★★ 三条设计要点 ✓✓✓:

  1. 幂等键 = 业务唯一号(订单号 / 请求号),不能用自增 ID、时间戳、随机数 ✗;
  2. 幂等记录与业务变更在同一个本地事务里写入 —— 否则会出现「业务成功、记录没写」的漏判窗口 ✗✗;
  3. 返回值也要一致:第二次调用要返回与第一次相同的结果(客户端重试常靠它判断)✓。

5.2 TCC 状态机:空回滚、防悬挂、幂等(实测)★★★ ​

★ 核心实现:每个 txId 一张状态表,Try / Confirm / Cancel 都「先查表、再动作」 ✓✓:

text
-- 场景 A:空回滚(Cancel 先到,Try 还没来)--
Cancel(tx-1):状态表无记录 → 记 CANCELLED,**不报错、不做业务动作**(空回滚成功)✓
Try(tx-1) 随后到达:状态=CANCELLED → **拒绝执行** ✓✓✓(这就是「防悬挂」)

-- 场景 B:正常执行 + 重复请求 --
Try(tx-2):冻结 100 → TRY_DONE ✓
Try(tx-2) 重复到达:状态=TRY_DONE → 直接返回(不重复冻结)✓✓ 幂等
Confirm(tx-2):真正扣款 → CONFIRM_DONE ✓
Confirm(tx-2) 重复到达:状态=CONFIRM_DONE → 直接返回(不重复扣款)✓✓ 幂等

★★★ 三张「防身符」的含义 ✓✓✓:

机制要防什么为什么会出现
幂等重复调用被重复执行网络重试、补偿重试 ✓
空回滚没有 Try 就来 Cancel,Cancel 报错/把好事搅坏Try 请求丢失/延迟 ✗
防悬挂Cancel 之后 Try 才到 → 资源被「复活」占用网络乱序 ✗✗

★ 一句话 ✓✓:状态机是 TCC 的心脏,数据库行级唯一约束 + 条件 UPDATE 就是最简单可靠的实现 ✓。

5.3 补偿重试:失败不能静默丢弃(实测)★★★ ​

text
第 1 次补偿:失败 ✗ → 退避 100ms 后重试
第 2 次补偿:失败 ✗ → 退避 200ms 后重试
第 3 次补偿:成功 ✓(退避后自动恢复)
-- 若重试到上限仍失败 --
**绝不能静默放弃** ✗✗:进「人工介入」队列 + 告警,由对账任务继续兜底 ✓✓

★ 两条规则 ✓✓:指数退避 + 上限(无退避会打死下游;无上限会持续占资源); 补偿不能失败到没人管(到上限就升级为人工)✓✓。

5.4 对账:最后一道防线(实测)★★★ ​

text
账户流水:[order-9527|-100, order-9528|-200, order-9529|-50]
订单状态:{order-9527=FAILED, order-9528=PAID, order-9529=PAID}
对账规则:**有扣款流水、但订单不是 PAID** → 挂账 ✗✗
发现不一致:order-9527 已扣款但状态=FAILED → 自动退款 + 标记 ✓✓
对账结果:修复 1 笔 → {order-9527=REFUNDED, ...}

★ 三条设计要点 ✓✓:幂等(重复跑不能重复退款)、只兜底不掩盖(能修则修、不能修则告警)、 定时 + 触发(每天全量 + 关键事件后增量)✓✓。

★★★ 没有对账的最终一致 = 裸奔 —— 这是所有「最终一致」方案敢上线的底气 ✓✓。

5.5 工程清单(与《Seata 分布式事务》方案搭配使用)★★★ ​

机制解决什么本节实测
幂等键重试导致的双扣 / 双发 ✗✗幂等表命中,库存不再变 ✓✓
空回滚Cancel 先到、Try 未执行 ✗记 CANCELLED 不报错 ✓
防悬挂Cancel 后 Try 才到,资源被二次占用 ✗✗状态=CANCELLED → 拒绝 ✓✓
退避重试补偿偶发失败 ✗第 3 次成功 ✓
对账所有「漏网之鱼」的最后兜底 ✗✗✗发现 1 笔并退款 ✓✓

6. 递进示例 ​

6.1 幂等扣减(最小实现) ​

java
private static int deduct(int stock, Map<String, Boolean> processed, String requestId) {
    if (processed.putIfAbsent(requestId, true) != null) {
        return stock;              // 重复请求:直接返回,不重复执行
    }
    return stock - 1;
}

★ 生产版 ✓:幂等表放数据库(request_id 唯一索引),与业务 UPDATE 同事务提交 ✓✓。

6.2 TCC 状态机的骨架 ​

text
状态:TRY_DONE → CONFIRM_DONE / CANCELLED
Try     :状态为空 → 冻结 → 记 TRY_DONE;状态已 TRY_DONE → 直接返回
Confirm :状态 TRY_DONE → 扣款 → 记 CONFIRM_DONE
Cancel  :状态为空 → 记 CANCELLED(空回滚);状态 CANCELLED → 直接返回
Try 到达时若状态=CANCELLED → **拒绝**(防悬挂)

6.3 反例:把 Confirm 写成「先扣款、后记状态」 ​

text
扣款成功 → 进程崩溃(状态没记)→ 重试又来扣一次 ✗✗
正解:**状态与业务同事务**(或状态机先行 + 业务可重入)✓✓

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

代码:labs/java-deep/src/main/java/cn/kb/learn/deep/distributed/DistributedTxRecoveryLesson.java

实测输出(节选):

text
===== 1. 不确定态:超时的那次请求,其实成功了 =====
  -- 反例:无幂等设计,上游重试 --
  第 1 次请求:扣减成功(库存 9),但上游超时没收到 ✓
  第 2 次请求(重试):又扣了一次 → 库存 8 ✗✗ 双扣(卖了 1 件、扣了 2 件)
  -- 正解:业务唯一号当幂等键 --
  第 2 次请求(同 order-9527):幂等表命中 → 直接返回上次结果 → 库存仍 9 ✓✓

===== 2. TCC 状态机:空回滚与防悬挂 =====
  Cancel(tx-1):状态表无记录 → 记 CANCELLED,**不报错、不做业务动作**(空回滚成功)✓
  Try(tx-1) 随后到达:状态=CANCELLED → **拒绝执行** ✓✓✓(这就是「防悬挂」)

===== 4. 对账:最后一道防线 =====
  发现不一致:order-9527 已扣款但状态=FAILED → 自动退款 + 标记 ✓✓
  对账结果:修复 1 笔 → {order-9527=REFUNDED, order-9528=PAID, order-9529=PAID}

8. Debug 观察点 ​

  1. 把幂等表放到「业务变更之后」写:复现「业务成功、记录没写」的漏判 ✗✗✓;
  2. 给状态机加「超时未完成」的扫描任务:把长期 TRY_DONE 的记录回滚掉 ✓✓;
  3. 让补偿一直失败(把成功条件改成 false):观察是否进入「人工介入」分支 ✓✓;
  4. 制造 2 笔不一致(再加一个失败订单):观察对账都能发现并修复 ✓✓。

9. 常见错误 ​

#错误说明
1幂等键用自增 ID / 时间戳 ✗✗重试时值会变,等于没有幂等 ✓✓
2幂等记录与业务分开提交 ✗✗✗「业务成功、记录没写」→ 重试会双扣 ✓
3Cancel 不查状态直接报错 ✗✗空回滚会把整个事务卡死 ✓✓
4Try 不检查 CANCELLED ✗✗✗悬挂:资源被已取消的事务占用 ✓✓
5补偿失败静默丢弃 ✗✗✗最终不一致且无人知晓 ✓✓
6没有对账就上线最终一致 ✗✗✗漏网之鱼永远没机会被发现 ✓✓

10. 动手实验 ​

  1. 跑 DistributedTxRecoveryLesson 四段 ✓✓✓;
  2. 用真实数据库(项目 A 的 PostgreSQL)实现「幂等表 + 唯一索引」,跑并发重试压测 ✓✓✓;
  3. 给「补偿重试」加真实退避(Thread.sleep)并观察日志节奏 ✓;
  4. 写一个对账任务:扫描订单表 vs 账户流水,输出不一致清单(含修复动作)✓✓✓。

验收方式:给定一个跨服务写操作,能说出:幂等键是什么、状态机怎么建、补偿失败怎么办、谁对账。

扩展任务:为项目 A 的「借书 + 扣信用分」跨模块操作补齐四件套,并写一份故障注入测试用例 ✓✓

11. 练习题 ​

  1. 为什么「不确定态」比「明确失败」更难处理?
  2. 幂等键为什么必须用业务唯一号?幂等表该在什么时候写?
  3. 空回滚与防悬挂分别防什么?怎么用一张状态表同时实现?
  4. 补偿重试的两条规则是什么?到上限还失败怎么办?
  5. 为什么说「没有对账的最终一致是裸奔」?
参考答案(要点)
  1. 失败可以重试/回滚;不确定则「重试可能重复、不重试可能丢失」 ✓✓★
  2. 重试时业务号不变,自增/时间戳会变;与业务变更同事务写入 ✓✓★
  3. 空回滚防「无 Try 的 Cancel 报错」;防悬挂防「Cancel 后 Try 复活资源」;同一张状态表 + 条件更新 ✓✓★
  4. 指数退避 + 上限;到上限进人工队列 + 告警,并由对账继续兜底 ✓✓★
  5. 因为「最终一致」允许中间失败,只有对账能发现并修复漏网的不一致 ✓✓★

12. 掌握标准 ​

  • [ ] 说清不确定态与幂等键设计 ★★★;
  • [ ] 能实现 TCC 三张防身符 ★★★;
  • [ ] 会设计补偿重试与升级路径 ★★★;
  • [ ] 会写对账规则并解释其必要性 ★★★;
  • [ ] 能把四件套与 《Seata 分布式事务》 的方案组合使用 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《分布式 ID 与一致性哈希》 分布式 ID 与一致性哈希 —— 换一个「分布式基础件」的角度继续深入。

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

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