主题
界面
主题
上上阶段已经把五种方案的取舍讲透了;本节回答的是「方案选好之后,代码怎么写才不出事」。
★ 四件套全部一手实测(同一套订单场景) ✓✓:
① 幂等键 :超时重试 → 无幂等双扣(库存 8 ✗✗)/ 有幂等只扣一次(库存 9 ✓✓)
② 空回滚 :Cancel 先到、Try 未执行 → 记 CANCELLED,不报错 ✓
③ 防悬挂 :Cancel 之后 Try 才到 → 状态=CANCELLED → 拒绝执行 ✓✓✓
④ 对账 :发现「已扣款但订单失败」1 笔 → 自动退款,复查一致 ✓✓★★ 与 《Seata 分布式事务》 的分工 ✓✓:《Seata 分布式事务》 决定「怎么走」(2PC/TCC/Saga/消息表),本节决定「走得稳不稳」。
★ 分布式事务的难点不是「失败」,而是「不知道成功还是失败」 ✗✓✓:
调用方发出「扣库存」→ 响应超时
到底扣了没有?**调用方无法判断** ✗
· 重试 → 可能扣两次(双扣)✗✗
· 不重试 → 可能一件没扣(漏扣)✗✗★★ 幂等、空回滚、防悬挂、对账 —— 这四个机制全部是在为「不确定」兜底 ✓✓✓。
★ 反例:无幂等设计,上游重试 ✗✗:
第 1 次请求:扣减成功(库存 9),但上游超时没收到 ✓
第 2 次请求(重试):又扣了一次 → 库存 8 ✗✗ 双扣(卖了 1 件、扣了 2 件)★ 正解:业务唯一号当幂等键 ✓✓:
第 2 次请求(同 order-9527):幂等表命中 → 直接返回上次结果 → 库存仍 9 ✓✓★★ 三条设计要点 ✓✓✓:
★ 核心实现:每个 txId 一张状态表,Try / Confirm / Cancel 都「先查表、再动作」 ✓✓:
-- 场景 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 就是最简单可靠的实现 ✓。
第 1 次补偿:失败 ✗ → 退避 100ms 后重试
第 2 次补偿:失败 ✗ → 退避 200ms 后重试
第 3 次补偿:成功 ✓(退避后自动恢复)
-- 若重试到上限仍失败 --
**绝不能静默放弃** ✗✗:进「人工介入」队列 + 告警,由对账任务继续兜底 ✓✓★ 两条规则 ✓✓:指数退避 + 上限(无退避会打死下游;无上限会持续占资源); 补偿不能失败到没人管(到上限就升级为人工)✓✓。
账户流水:[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, ...}★ 三条设计要点 ✓✓:幂等(重复跑不能重复退款)、只兜底不掩盖(能修则修、不能修则告警)、 定时 + 触发(每天全量 + 关键事件后增量)✓✓。
★★★ 没有对账的最终一致 = 裸奔 —— 这是所有「最终一致」方案敢上线的底气 ✓✓。
| 机制 | 解决什么 | 本节实测 |
|---|---|---|
| 幂等键 | 重试导致的双扣 / 双发 ✗✗ | 幂等表命中,库存不再变 ✓✓ |
| 空回滚 | Cancel 先到、Try 未执行 ✗ | 记 CANCELLED 不报错 ✓ |
| 防悬挂 | Cancel 后 Try 才到,资源被二次占用 ✗✗ | 状态=CANCELLED → 拒绝 ✓✓ |
| 退避重试 | 补偿偶发失败 ✗ | 第 3 次成功 ✓ |
| 对账 | 所有「漏网之鱼」的最后兜底 ✗✗✗ | 发现 1 笔并退款 ✓✓ |
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 同事务提交 ✓✓。
状态:TRY_DONE → CONFIRM_DONE / CANCELLED
Try :状态为空 → 冻结 → 记 TRY_DONE;状态已 TRY_DONE → 直接返回
Confirm :状态 TRY_DONE → 扣款 → 记 CONFIRM_DONE
Cancel :状态为空 → 记 CANCELLED(空回滚);状态 CANCELLED → 直接返回
Try 到达时若状态=CANCELLED → **拒绝**(防悬挂)扣款成功 → 进程崩溃(状态没记)→ 重试又来扣一次 ✗✗
正解:**状态与业务同事务**(或状态机先行 + 业务可重入)✓✓代码:labs/java-deep/src/main/java/cn/kb/learn/deep/distributed/DistributedTxRecoveryLesson.java
实测输出(节选):
===== 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}| # | 错误 | 说明 |
|---|---|---|
| 1 | 幂等键用自增 ID / 时间戳 ✗✗ | 重试时值会变,等于没有幂等 ✓✓ |
| 2 | 幂等记录与业务分开提交 ✗✗✗ | 「业务成功、记录没写」→ 重试会双扣 ✓ |
| 3 | Cancel 不查状态直接报错 ✗✗ | 空回滚会把整个事务卡死 ✓✓ |
| 4 | Try 不检查 CANCELLED ✗✗✗ | 悬挂:资源被已取消的事务占用 ✓✓ |
| 5 | 补偿失败静默丢弃 ✗✗✗ | 最终不一致且无人知晓 ✓✓ |
| 6 | 没有对账就上线最终一致 ✗✗✗ | 漏网之鱼永远没机会被发现 ✓✓ |
DistributedTxRecoveryLesson 四段 ✓✓✓;Thread.sleep)并观察日志节奏 ✓;验收方式:给定一个跨服务写操作,能说出:幂等键是什么、状态机怎么建、补偿失败怎么办、谁对账。
扩展任务:为项目 A 的「借书 + 扣信用分」跨模块操作补齐四件套,并写一份故障注入测试用例 ✓✓
《分布式 ID 与一致性哈希》 分布式 ID 与一致性哈希 —— 换一个「分布式基础件」的角度继续深入。
暂无