🎓 分布式每日一学 — Day 4:分布式事务(2PC / 3PC / Saga / TCC)
前 3 天我们打的是「副本一致性」这条线:CAP 告诉我们 P 没得选,Raft/Paxos 告诉我们"怎么让一组机器对同一份数据达成一致"。
今天我们换一个战场——「跨多个服务/数据库做完一整件事」怎么保证不出错。一个下单链路要改订单库、库存库、账户库、积分库,任何一个环节挂了都不能让用户的钱"消失"或"凭空多出来"。这就是分布式事务的领域。
📝 昨日思考题解答(Day 3 — Paxos)
题目回顾:5 节点 Paxos 集群 A/B/C/D/E。P1(在 A 上)发起
Prepare(1),A/B/C 回 Promise 且未接受过提案 → P1 准备Accept(1, V1=100)时 A 宕机重启 → P2(在 D 上)发起Prepare(2),B/C/D/E 回 Promise → B 报告"收到过 Accept(1, V1=100) 但还没回复就被打断"。
Q1:P2 应该选什么 V 提交?
答案:P2 必须选 V = 100。
推导过程:
B 是否真正「接受」了 Accept(1, V1=100)?
Paxos 中"接受"(accept)指的是 Acceptor 收到 Accept 请求后、检查编号 ≥ 自己承诺的最小编号 → 记录该提案。题目说 B “收到过 Accept 但还没回复”——只要 B 已经记录了(1, V1=100),就视为已接受,无论 ACK 是否发出去。B 在 Promise(2) 时报告了什么?
Acceptor 的 Promise 必须附带"自己已接受的最高编号提案"。所以 B 在回复 P2 的Prepare(2)时,会报告:已接受 (1, V1=100)。Paxos Phase 2 的值选择规则:
Proposer 收到多数 Promise 后:- 如果有 Acceptor 报告已接受提案 → 选编号最大的那个已接受值
- 如果没人报告 → 用自己的值
这里 B 报告了
(1, V1=100),所以P2 即使本来想提别的值,也必须改成 V=100。
🧠这是 Paxos 安全性(Safety)的核心:一旦某个值被多数 Acceptor 接受(或即将被接受),后续 Proposer 就无法再改变它。哪怕 P2 是"后来者",也必须"服从"已有的决议。
Q2:后续如果 P1 醒来再发起 Accept(1, V1) 会发生什么?
答案:Accept(1) 会被拒绝,P1 必须用更高编号重新发起。
推导过程:
时间线回顾: P1 的 Accept(1) 发出时 A 宕机了 之后 B/C 已经 Promise(2) → 承诺不再接受编号 < 2 的提案 P1 醒来后发 Accept(1, V1=100): ┌─────────────────────────────────────────────┐ │ B: 拒绝 ❌(已 Promise(2),1 < 2) │ │ C: 拒绝 ❌(已 Promise(2),1 < 2) │ │ D: 拒绝 ❌(已 Promise(2),1 < 2) │ │ E: 拒绝 ❌(已 Promise(2),1 < 2) │ │ A: 可能接受 ✅(重启后磁盘保留 Promise(1)) │ └─────────────────────────────────────────────┘ P1 最多只能拿到 A 的 1 票 → 不够多数 → Accept 失败P1 的出路:必须用更高编号(如Prepare(3))重新发起提案。但即使 P1 用编号 3 重新跑,Phase 1 时 B/C/D/E 中只要有人报告"已接受 (2, V=100)",P1 就必须继续选 V=100。
📌结论:值 100 已经"锁定"在系统中,任何后续提案都无法改变它。这就是 Paxos 保证的“一旦值被选定,就永远不变”。
关键启示
| Paxos 规则 | 本题体现 |
|---|---|
| Acceptor Promise 后不再接受更低编号提案 | B/C 拒绝 P1 的 Accept(1) |
| Proposer 必须采用已接受的最高编号值 | P2 被迫选 V=100 |
| 安全性优先于活性(Safety > Liveness) | 值锁定后不可逆,但 P1 可能需要重试(活锁风险) |
一、为什么需要分布式事务?单机 ACID 不够用了吗?
我们先复习一下单库事务的"四大法宝"(ACID):
| 属性 | 含义 |
|---|---|
| A — Atomicity(原子性) | 要么全做、要么全不做 |
| C — Consistency(一致性) | 事务前后数据满足约束 |
| I — Isolation(隔离性) | 并发事务互不干扰 |
| D — Durability(持久性) | 提交后数据不丢 |
这套机制在一个数据库内部很完美。但只要业务开始"拆开"——分库分表、微服务化、跨库 Join、跨服务调用——单库 ACID 就罩不住了。
🔥 一个真实例子:电商下单
用户下单,链路里要碰 5 个东西: 1. 订单服务 → 写订单表 (order_db) 2. 库存服务 → 扣库存 (stock_db) 3. 账户服务 → 扣余额 (pay_db) 4. 积分服务 → 加积分 (point_db) 5. 消息服务 → 发短信 (msg_service)如果做到第 3 步账户服务挂了、库存已经扣了,订单也已经写了——用户的钱付了,但货没下成,平台要背锅。
💡 这就是分布式事务要解决的核心问题:让一组跨资源/跨服务的写操作,要么全部成功,要么全部"看起来没发生过"。
二、方案 1:两阶段提交(2PC,Two-Phase Commit)
这是最古老、最直接的思路,来自于 1970 年代的数据库理论。
🎬 角色
| 角色 | 职责 |
|---|---|
| 协调者(Coordinator) | 老大哥,负责问所有人"准备好了没"和最后拍板"提交/回滚" |
| 参与者(Participants) | 各个数据库/服务,执行具体写操作 |
🎬 两个阶段
协调者 参与者们 │ │ ┌────────────┼────────────┐ │ │ ▼ │ │ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ │ │ 库存DB │ │ 订单DB │ │ 账户DB │ │ └───┬───┘ └───┬───┘ └───┬───┘ │ │ │ │ │ └────────────┼────────────┘ │ │ │ ═══════════════════════════════════════════════════ 阶段 1 ── Prepare(准备) ═══════════════════════════════════════════════════ │ ▼ 协调者问所有人: "可以提交吗?" ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ 库存 订单 账户 上锁+写undo 上锁+写undo 上锁+写undo 回 Yes 回 Yes 回 No❌ │ ═══════════════════════════════════════════════════ 阶段 2 ── Commit / Rollback(根据投票结果拍板) ═══════════════════════════════════════════════════ │ 全 Yes → 协调者发 "Commit" → 各方真正写入 有 No → 协调者发 "Rollback" → 各方回滚到 undo🔴 2PC 的四大致命问题
| 问题 | 解释 |
|---|---|
| 同步阻塞 | 整段时间内所有参与者都持有锁和资源,其他请求被卡死 |
| 协调者单点 | 协调者宕机 = 整批参与者永远等"第二阶段"指令,被锁死 |
| 数据不一致 | 协调者发完 Commit 后自己宕机,部分参与者收到、部分没收到 → 状态分裂 |
| 不幂等 | 网络重发同一个 Commit,可能触发重复写 |
🧠想想为什么 2PC 看起来很像 Raft/Paxos,却没那么强?
因为 Raft/Paxos 解决的是"同一份数据谁说了算",而 2PC 是"多个独立资源要不要一起动"。它是投票没错,但承诺是"重"的——一旦 Prepare 成功就锁住资源,没有 Paxos 那样的"编号单调性"兜底。
三、方案 2:三阶段提交(3PC)
理论界觉得 2PC 太脆,于是加了阶段:
Phase 1 ── CanCommit 协调者问:"大家能提交吗?"(不锁资源) Phase 2 ── PreCommit 参与者写 undo log,回复 ACK,正式待命 Phase 3 ── DoCommit 协调者发 commit,参与者提交🆚 3PC 相对 2PC 的改进
- 多了一次"预问"减少盲目锁资源
- 参与者超时机制:超时未收到 DoCommit 就默认提交(避免被锁死)
- 引入超时心跳之后,协调者宕机参与者也能自己推下去
🪦 为什么工程上几乎不用 3PC?
| 原因 | 说明 |
|---|---|
| 协调者宕机还是无解 | 3PC 的"超时自提交"在网络分区下反而会导致脑裂——一部分人 commit、一部分 rollback |
| 多一轮 RTT 太贵 | 性能严重下降 |
| 实现复杂度↑ | 边界条件更多 |
📌 业界真实状态:3PC 是个"教学价值高、工程价值低"的方案。MySQL、Oracle、PG 的 XA 实现都是 2PC 系。
四、方案 3:Saga 模式(微服务时代的宠儿)
1987 年由 Hector Garcia-Molina 提出,被 Saga Service、Apache ServiceComb Seata 等广泛使用。
🎬 核心思想:长事务拆成 T + 补偿 C
把一笔大事务拆成若干子事务,每个子事务有自己的"反向补偿操作":
主流程: T1 → T2 → T3 → T4 → ✅ 补偿流程: ← C1 ← C2 ← C3 ← C4 ↑ T1 失败 → 走 C2.5 补偿 T2,再走 C1 补偿 T1例如订餐链路的 Saga 拆解:
| 子事务 | 正向 T | 补偿 C |
|---|---|---|
| 订单创建 | T1: 写入订单 | C1: 撤销订单 |
| 库存扣减 | T2: 减库存数 | C2: 加回库存 |
| 扣款 | T3: 账户减余额 | C3: 加回余额 |
| 发短信 | T4: 通知用户 | C4: 发撤回短信 |
🤹 两种 Saga 协调方式
方式 A:编曲式 / Choreography(事件驱动)
- 每个 T 完成就发 MQ 事件,下游订阅
- 没有中心协调器
- 优点:解耦、简单
- 缺点:链路难追踪、容易"循环依赖"
方式 B:编排式 / Orchestration(中心化)
- 有一个 Saga Coordinator,按顺序调用 T1→T2→…
- 类似工作流引擎
- 优点:链路可视化、易调试
- 缺点:协调器成了关键路径
⚠️ Saga 的几个"硬骨头"
- 隔离性弱:T2 已扣库存但 T3 失败时,库存被别人看到"已经少"了,但最终要补回去——中间窗口期是脏读。
- 必须幂等:补偿可能被重试,每个 Ti / Ci 都要能经得起重复执行。
- 补偿不一定能"完全反向":比如"发短信"发出去没法撤回,只能"再发一条说明"。这是补偿 ≠ 反向事务的关键区别。
五、方案 4:TCC(Try-Confirm-Cancel)
支付宝的蚂蚁金服、阿里 Seata 主推的模式,比 Saga 早但工程侵入性更强。
🎬 三阶段
| 阶段 | 含义 | 关键 |
|---|---|---|
| Try | 资源预留:扣库存时只把"可用 → 冻结",不真正减 | 必须可逆 |
| Confirm | 真正提交:冻结 → 扣减,全部 Try 成功后调用 | 必须幂等 |
| Cancel | 释放预留:把"冻结 → 可用"还回去 | 必须幂等 |
所有 Try 全部成功 → 批量 Confirm(这一步通常异步化) 任一 Try 失败 → 调用已成功的 Try 对应的 Cancel🆚 Saga vs TCC
| 维度 | Saga | TCC |
|---|---|---|
| 一致性强度 | 最终一致 | 接近强一致 |
| 业务侵入性 | 低,只加补偿方法 | 高,每个业务要写 Try/Confirm/Cancel 三套 |
| 资源占用 | 不锁资源 | Try 阶段长期占着"冻结"额 |
| 性能 | 好 | 一般(多一次 Reserve) |
| 适用场景 | 长链路 + 跨多服务 | 短链路 + 对一致性要求高(支付类) |
📌 选型口诀:链长、轻业务 → Saga;链短、重资金 → TCC。
六、其他常见"野路子"方案(实战高频)
除了上面四个"学院派",工程上更常用的是消息中间件派的最终一致方案:
🟢 本地消息表(经典老炮)
主流程: 异步: ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ 业务表写入 │ │ 消息表写入 │ ──轮询──>│ 投递到 MQ │ └──────────┘ └──────────┘ └──────────────┘ └─ 同一事务,一致 ✔ ↓ 下游消费 MQ,处理业务 失败 → 重试 N 次 → 人工介入关键点:业务表和消息表写在同一个本地事务,靠后台 polling 把消息从 MySQL 推到 MQ。简单、好理解、几乎人人用过。
🟢 事务消息(RocketMQ 5.0+ 一类)
- 生产者发送 Half 消息到 MQ,MQ 不投递
- 本地事务完成 → 二阶段回调告诉 MQ 是 commit / rollback
- 回查机制:MQ 定期问生产者"刚才那个消息你还活着吗?"避免悬挂
🟢 最大努力通知(跨公司用)
- 不要求强一致,只要求"尽量通知到"
- 典型场景:第三方支付回调、退款通知、运营商充值回调
- 配重试 + 幂等 + 对账补偿
七、大对照表(建议截图保存)
| 方案 | 一致性 | 性能 | 可用性 | 业务侵入 | 复杂度 | 典型场景 |
|---|---|---|---|---|---|---|
| 2PC(XA) | 强一致 | ❌ 差 | ❌ 协调者单点 | 低 | 中 | 单库多库、强一致短事务 |
| 3PC | 强一致 | ❌ 很差 | ⚠️ | 中 | 高 | 几乎不用 |
| Saga | 最终一致 | ✅ 好 | ✅ 高 | 中(写补偿) | 中 | 长链路、跨多服务、订单 |
| TCC | 接近强一致 | ⚠️ 一般 | ✅ 高 | 高(三套方法) | 高 | 支付、资金类短链 |
| 本地消息表 | 最终一致 | ✅ 好 | ✅ 高 | 低 | 低 | 99% 的常规业务异步解耦 |
| 事务消息 | 最终一致 | ✅ 好 | ✅ 高 | 中 | 中 | 不希望有 polling 的升级版 |
| 最大努力通知 | 弱一致 | ✅ 好 | ✅ 高 | 极低 | 低 | 跨公司回调、第三方通知 |
八、实战选型决策树
Q1: 是不是要求强一致 + 数据量不大? ├─ 是 → 2PC (XA),例如 Seata-XA └─ 否 ↓ Q2: 链路上每个动作都能很方便定义补偿吗? ├─ 不能 → TCC(强制业务做预留) └─ 能 ↓ Q3: 链路长不长(>3 个子事务)? ├─ 长 → Saga(编排式用 Camunda/Cadence/Seata Saga) └─ 短 → Saga 或 本地消息表 都行 Q4: 跨公司吗? └─ 是 → 最大努力通知 + 对账 + 幂等💡实战真相:80% 的分布式事务场景用「本地消息表 + MQ」就能搞定,剩下 18% 用 Saga,最后 2% 才是 TCC/2PC 战场。别一上来就上 TCC,复杂度的代价远超想象。
九、和前 3 天的连接
| 之前的知识点 | 和分布式事务的关系 |
|---|---|
| CAP 理论(Day 1) | 分布式事务本质是在 P 必然存在下选 C 或 A:2PC 偏 C,Saga 偏 A |
| Raft(Day 2) | 共识算法是 2PC / Saga 协调器的"心跳保活"基础 |
| Paxos(Day 3) | Seata-Server 集群内部就用了类 RAFT/Paxos 来保持协调器高可用 |
也就是说:分布式事务 = 业务一致性协议 + 共识协议 + MQ。三块拼起来,才是一套完整方案。
十、课堂思考题
🧠 想象一条真实的"转账链路":
用户 A 给用户 B 转账 100 元,要走:
① 风控服务(检查是不是洗钱)→
② 账户服务(A -100,B +100)→
③ 账本服务(写流水)→
④ 积分服务(A +10 积分)→
⑤ 通知服务(短信/站内信)。
问题 1:你会用 Saga 还是 TCC?为什么?
问题 2:如果用 Saga,② “A -100、B +100” 这个子事务,补偿动作具体怎么设计?要注意什么边界条件?
问题 3:如果 MQ 通知用户短信发送失败,重试到第 3 次还是失败,你怎么办?这条业务流算"成功"还是"失败"?
(提示:风控/账本可重;账户主操作要幂等;积分是"福利";通知是"尽最大努力"。)
一句话带走
CAP 告诉你分布式是有代价的,Raft/Paxos 告诉你副本怎么共识,而分布式事务告诉你"怎么把跨服务的多个写操作缝合成一件要么全成要么全不成的事"。80% 的场景用本地消息表 + MQ 就够了;剩下 20% 才是 Saga 和 TCC 的战场。
明天预告:Day 5 — 分布式锁(Redlock、Redisson、ZooKeeper 锁、Chubby 锁),以及"为什么我说你不能完全相信 Redis 的 SETNX?"