前言
在第一篇中,我们讨论了分布式一致性的理论困境:FLP定理告诉我们异步网络中无法达成完美共识,CAP定理说网络分区时一致性和可用性不可兼得。
但在工程实践中,我们面临的一致性问题其实分为两类:
- 怎么让不同的业务节点一起成功?(比如:订单服务和库存服务,要么都成,要么都败)。这是**原子提交(Atomic Commit)**问题。
- 怎么让同一个数据的多个备份保持一致?(比如:三台机器存同一份订单数据)。这是**共识(Consensus)**问题。
本篇我们将聚焦于第一类问题——原子提交。
想象一个场景:你在电商平台下单,系统需要协调三个独立的服务:
- 订单服务:创建订单记录
- 库存服务:扣减商品库存
- 积分服务:扣除用户积分
这三个操作必须"要么都成功,要么都失败"。工程界给出的第一个答案,就是二阶段提交协议(2PC)。
在理想网络环境下,这个协议看起来天衣无缝。但在现实的分布式工程中,一旦遭遇“拔网线”级别的网络分区,这个“完美”的协议不仅不能保护数据,反而可能导致资产冻结甚至资金损失。
这篇文章,我们就来看看 2PC 是如何在现实中一步步崩塌的,以及为什么这是一条最终被证明走不通的死胡同。
一、2PC:全员投票的逻辑与代价
角色定义
在2PC中,系统中的节点分为两种角色:
协调者(Coordinator):事务的发起者和指挥者,负责统一决策全局Commit还是Abort,通常是应用服务器或事务管理器,单点存在,具有独裁权。
参与者(Participant):实际执行事务操作的节点,根据自身情况投Yes或No,必须服从协调者的最终决议,可能是数据库节点、服务实例等。
在我们的下单例子中:
- 协调者:订单应用的事务管理器
- 参与者:订单服务、库存服务、积分服务
协议本质
2PC的核心思想非常朴素:既然要保证原子性,那就让一个协调者(Coordinator)来统一指挥,所有参与者(Participant)投票表决。
整个流程分两个阶段:
阶段一:准备(Prepare)
协调者向所有参与者发送"准备提交"请求,询问它们能否执行事务。参与者收到请求后:
- 执行事务的本地操作(但不提交)
- 将操作记录写入Redo日志
- 锁定相关资源
- 回复Yes(可以提交)或No(无法提交)
如果协调者发出Prepare请求,超时未收到某个参与者响应:
- 对于协调者:认为失败,第二阶段发起Abort指令。
- 对于参与者:
- 收到Prepare请求:锁住资源,一直阻塞,下面要讲到的同步阻塞。
- 未收到Prepare请求:无事发生。
阶段二:提交(Commit)
协调者收集所有投票:
- 如果全部投Yes → 向所有参与者发送Commit指令
- 如果任何一个投No → 向所有参与者发送Abort指令
参与者收到指令后执行相应操作并释放锁。
完整了流程如下:
---
config:
theme: forest
look: neo
---
sequenceDiagram
participant C as 协调者
participant P1 as 订单服务
participant P2 as 库存服务
participant P3 as 积分服务
Note over C,P3: 阶段一:准备
C->>P1: Prepare?
C->>P2: Prepare?
C->>P3: Prepare?
Note over P1: 写Redo日志<br/>锁定资源
Note over P2: 写Redo日志<br/>锁定资源
Note over P3: 写Redo日志<br/>锁定资源
P1-->>C: Yes
P2-->>C: Yes
P3-->>C: Yes
Note over C,P3: 阶段二:提交
C->>P1: Commit
C->>P2: Commit
C->>P3: Commit
Note over P1: 持久化<br/>释放锁
Note over P2: 持久化<br/>释放锁
Note over P3: 持久化<br/>释放锁
P1-->>C: ACK
P2-->>C: ACK
P3-->>C: ACK
看起来很完美对吗?全员投票,一票否决,确保了原子性。但问题就藏在这个"完美"之下。
致命三缺陷
缺陷一:同步阻塞
参与者在Prepare阶段投票后,资源就被锁定了,必须等待协调者的最终决议。如果协调者迟迟不发指令,这些资源就会一直被占用,无法服务其他请求。
在我们的下单场景中,假设库存服务锁定了"iPhone 15"的库存数量,在等待协调者决议期间,其他用户想买这个商品就会被阻塞。高并发场景下,这种阻塞会严重影响系统吞吐量。
缺陷二:单点故障
协调者是整个协议的核心。如果协调者在发送Commit指令的过程中宕机,会发生什么?
---
config:
theme: forest
look: neo
---
sequenceDiagram
participant C as 协调者
participant P1 as 订单服务
participant P2 as 库存服务
participant P3 as 积分服务
Note over C,P3: 阶段一完成,都投了Yes
C->>P1: Commit
Note over C: 💥 宕机
Note over P1: 已提交
Note over P2: 锁定中...
Note over P3: 锁定中...
Note over P2,P3: 不知道该提交还是回滚<br/>资源永久锁定
订单服务收到了Commit并执行了,但库存和积分服务还在傻等。它们不知道协调者的决定是什么,也不敢自己做主(万一协调者准备发Abort呢?)。
缺陷三:数据不一致风险
这是最要命的问题。当网络分区发生时,协调者可能只把 Commit 消息发送给了部分参与者。
假设网络故障导致协调者和积分服务失联:
- 订单服务:收到了 Commit,成功提交事务。
- 积分服务:投了 Yes 票,但迟迟等不到协调者的下一条指令。
此时积分服务处于**“不确定状态(Uncertainty)”**:它不敢提交(万一协调者决定 Abort 呢?),也不敢回滚(万一协调者决定 Commit 呢?)。
如果积分服务采用了“超时保守回滚”策略,而订单服务已经提交,就会导致:订单创建成功,但积分没扣。全剧终,资损产生。
工程妥协
那为什么银行、支付系统还在用2PC?
因为在某些场景下,宁可阻塞,也不能错。金融系统可以接受在故障期间暂停服务(牺牲可用性),但绝对不能接受账户余额不一致(保证一致性)。
而且金融系统通常会配套使用:
- 协调者主备机制(减少单点故障概率)
- 超时后统一Abort(保守策略)
- 人工对账系统(兜底方案)
但这些只是妥协,不是解决。2PC的根本问题依然存在。
二、3PC:一次聪明但徒劳的改进
既然2PC有同步阻塞和单点故障问题,工程师们很自然地想到:能不能让参与者在协调者宕机时自己做决定?
这就是**三阶段提交协议(3PC)**的出发点。
改进思路
3PC做了两个关键改进:
- 拆分Prepare阶段:CanCommit(询问能力) + PreCommit(预提交)
- 引入超时机制:参与者超时后可以自主决策
流程变成了三个阶段:
阶段一:CanCommit
协调者询问参与者"你能不能执行这个事务?",这次不需要锁资源,只是轻量级询问。虽然不锁定数据库行锁,但参与者在回复 Yes 后,应在逻辑上保证预留出处理该事务的潜在余力(如不关闭服务、不进入维护模式),以确保后续 PreCommit 能顺利执行。
阶段二:PreCommit
如果所有参与者都回答"能",协调者发送PreCommit指令。参与者:
- 写Redo日志
- 锁定资源
- 如果成功 → 回复ACK
- 如果失败 → 回复Abort(比如资源被占用、磁盘故障等)
阶段三:DoCommit
协调者收到所有ACK后,发送DoCommit。参与者持久化数据并释放锁。
如果协调者在PreCommit阶段收到任何Abort或超时,则发送DoAbort,参与者回滚并释放资源。
---
config:
theme: forest
look: neo
---
sequenceDiagram
autonumber
participant C as 协调者 (Coordinator)
participant P as 参与者 (Participants)
%% === 阶段一:CanCommit ===
rect rgb(240, 248, 255)
Note over C, P: 阶段一:CanCommit (询问阶段)
C->>P: 包含事务内容的 CanCommit 请求
Note right of P: 检查自身状态<br/>(磁盘/网络/负载)<br/>**不锁定资源**
P-->>C: Yes (我能做)
end
%% === 阶段二:PreCommit ===
rect rgb(255, 248, 220)
Note over C, P: 阶段二:PreCommit (准备阶段)
C->>P: 发送 PreCommit 请求
Note right of P: 1. **锁定资源**<br/>2. 写入 Undo/Redo Log<br/>3. 进入预提交状态
P-->>C: ACK (准备好了)
end
%% === 阶段三:DoCommit ===
rect rgb(240, 255, 240)
Note over C, P: 阶段三:DoCommit (提交阶段)
C->>P: 发送 DoCommit 请求
Note right of P: 1. 正式提交事务<br/>2. **释放资源锁**
P-->>C: ACK (完成)
end
Note over C, P: 核心改进:若 P 在阶段三等待超时,会自动提交 (Auto-Commit)
超时处理机制
3PC的核心创新是超时自决机制:
- 如果在CanCommit后超时:参与者认为协调者可能出问题了,保守起见选择Abort
- 如果在PreCommit后超时:参与者认为既然已经走到预提交了,大家应该都同意了,乐观地选择Commit
这个设计看起来很聪明:解决了2PC的同步阻塞问题,参与者不会无限期等待。
但是,这恰恰埋下了致命的隐患。
致命一击:网络分区场景
假设一个5节点的系统,在PreCommit阶段后发生了网络分区:
graph TB
subgraph NetA [子网络A]
C[协调者]
P1[参与者P1]
P2[参与者P2]
end
subgraph NetB [子网络B]
P3[参与者P3]
P4[参与者P4]
P5[参与者P5]
end
C -.网络分区.-> P3
C -.网络分区.-> P4
C -.网络分区.-> P5
style C fill:#ff6b6b
style P1 fill:#4ecdc4
style P2 fill:#4ecdc4
style P3 fill:#ffe66d
style P4 fill:#ffe66d
style P5 fill:#ffe66d
%% 背景颜色
style NetA fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
style NetB fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
时间线:
- T1:CanCommit阶段完成,所有参与者回复"能执行"
- T2:协调者发送PreCommit,所有参与者都收到
- T3:网络分区发生,系统分裂成子网络A和子网络B
- T4:
- P1、P2的ACK送达协调者(同在子网络A)
- P3、P4、P5的ACK无法送达(被网络分区隔离)
- T5:协调者只收到部分ACK,决定Abort,发送DoAbort
- DoAbort送达P1、P2 → 执行Abort ❌
- DoAbort无法送达P3、P4、P5(被网络分区隔离)
- T6:P3、P4、P5等待超时
- 已收到PreCommit,等待DoCommit超时 → 按规则自动Commit ✅
---
config:
theme: forest
look: neo
---
sequenceDiagram
participant C as 协调者
participant G1 as P1、P2
participant G2 as P3、P4、P5
Note over C,G2: CanCommit完成
C->>G1: PreCommit
C->>G2: PreCommit
Note over C,G2: ⚡ 网络分区发生
G1-->>C: ACK ✓
G2-xC: ACK ✗(隔离)
Note over C: 只收到部分ACK<br/>决定Abort
C->>G1: DoAbort ✓
C-xG2: DoAbort ✗(隔离)
Note over G1: 执行Abort ❌
Note over G2: 等待超时<br/>自动Commit ✅
Note over G1,G2: 💥 数据不一致
结果:P1、P2 Abort,P3、P4、P5 Commit,脑裂!数据不一致
问题根源在哪?
核心矛盾:协议层面的“判定分歧”
这里最讽刺的地方在于,双方都严格遵守了协议,但结果却是灾难性的:
- 协调者视角:我发出了 PreCommit,但在规定时间内没收到所有人的 ACK(因为分区)。根据协议,为了安全,我必须决定 Abort。我做得对吗?对。
- 分区内的参与者视角:我收到了 PreCommit,这说明大家第一阶段都同意了。现在协调者失联了,根据“超时自动提交”的协议规则,为了防止资源锁死,我必须决定 Commit。我做得对吗?也对。
结果: 协调者 Abort,分区参与者 Commit。
这暴露了 3PC 的根本缺陷:它试图用“超时机制”来掩盖“状态未知”的本质。 在网络分区面前,超时既可能意味着“对方挂了”,也可能意味着“对方活着但没法说话”。光靠猜,一定会错。
三、核心论证:为什么全员模型注定失败?
回顾2PC和3PC的失败,它们有一个共同点:都要求所有参与者最终达成一致(全部Commit或全部Abort)。
这就是全员模型的本质。
全员模型的困境
2PC的做法:
- 协调者必须收到所有参与者的Prepare响应
- 任何一个参与者失联或投No → 全局Abort
- 结果:同步阻塞,单点故障,数据不一致
3PC的尝试:
- 引入超时自决,试图解决阻塞
- 但网络分区时,不同参与者按各自的超时规则做决定
- 结果:数据不一致
问题的本质:网络分区发生时,系统被分割成多个子网络,子网络之间无法通信。
graph TB
subgraph NetA [子网络A]
A1[节点1]
A2[节点2]
end
subgraph NetB [子网络B]
B1[节点3]
B2[节点4]
B3[节点5]
end
A1 -.网络分区.-> B1
style A1 fill:#4ecdc4
style A2 fill:#4ecdc4
style B1 fill:#ffe66d
style B2 fill:#ffe66d
style B3 fill:#ffe66d
%% 背景颜色
style NetA fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
style NetB fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
如果要求"全员一致":
- 子网络A:只有2个节点,不知道另外3个节点的状态
- 子网络B:只有3个节点,也不知道另外2个节点的状态
- 双方都无法确认"所有节点是否都同意"
此时只有两个选择:
- 阻塞等待(2PC的选择):等网络恢复 → 牺牲可用性
- 自行决策(3PC的选择):按超时规则各自行动 → 可能数据不一致
CAP定理的映射
graph LR
A[全员一致模型] --> B{网络分区P发生}
B -->|选择一致性C| C[必须阻塞等待<br/>2PC]
B -->|选择可用性A| D[可能数据不一致<br/>3PC]
C --> E[牺牲A]
D --> F[牺牲C]
style A fill:#ff6b6b
style C fill:#4ecdc4
style D fill:#ffe66d
这就是CAP定理在原子提交协议上的体现:
- C(一致性):所有节点看到相同的数据
- A(可用性):系统能持续提供服务
- P(分区容忍):网络分区时系统仍能工作
网络分区(P)是客观存在的,无法避免。此时:
- 2PC选择C → 必须牺牲A(阻塞)
- 3PC选择A → 必须牺牲C(可能不一致)
不可能三角:全员模型 + 分区容忍 → C和A不可兼得
数学上的不可能性
从理论角度看,全员模型的问题在于:
FLP不可能性定理指出:在异步网络中,即使只有一个节点可能故障,也不存在能保证终止的确定性共识算法。
2PC和3PC的困境正是这个定理的体现:
- 2PC:无法保证终止(参与者阻塞)
- 3PC:无法保证正确性(数据不一致)
全员模型要求的是什么?
- N个节点中,需要N个节点都响应
- 容错能力:0个节点故障
在网络分区下会怎样?
- 分区发生后,协调者可能只能联系到部分节点
- 如果坚持等所有节点响应 → 永久阻塞
- 如果允许部分节点自行决策 → 可能冲突
这就是为什么2PC和3PC都走不通:它们试图在一个数学上无解的前提下构建协议。
四、思想转折:出路在哪里?
既然 2PC 和 3PC 这种“全员模型”在理论上就走不通,那分布式事务是不是没救了?
这里我们需要做一个深刻的思维转换。
业务层的“顽固”
我们必须承认一个事实:在业务层面,原子性是无法妥协的。 只要业务要求“订单和库存必须同时成功”,那么这就依然是一个要求“全员同意”的 2PC 问题。
既然业务逻辑改不了,那 2PC 为什么会挂? 核心原因是:参与 2PC 的组件(协调者、参与者)太脆弱了。
- 协调者是单点,挂了就完蛋。
- 参与者是单点,挂了数据就丢。
引入多数派:给组件穿上“防弹衣”
如果我们能让“协调者”和“参与者”即使挂了也能复活,即使部分节点失联也能工作,2PC 的阻塞问题不就缓解了吗?
这就是**共识算法(Consensus)**进场的地方。
我们不再让一台物理机器充当协调者,而是用一组机器(比如 3 台或 5 台)组成一个“高可用协调者集群”。
这组机器内部通过多数派(Quorum)机制来同步状态:
- 写入数据时,只要 5 台中的 3 台确认,就认为写入成功。
- 读取数据时,只要能联系上 3 台,就一定能读到最新数据。
多数派的威力
这个简单的数学性质解决了全员模型的核心困境:
对比全员模型:
- 全员模型:5个节点必须全部响应,任何1个故障都会导致阻塞
- 多数派模型:5个节点只需要3个响应,可以容忍2个节点故障
容错能力:
- N个节点的系统可以容忍 **(N-1)/2 **个节点故障
- 5节点系统可以容忍2个节点故障
- 故障节点比例可达40%
网络分区处理:
- 分区后的子网络如果拥有多数派(≥3个节点),可以继续服务
- 没有多数派的子网络自动停止服务
- 不会出现脑裂:两个子网络不可能同时都拥有多数派,避免了双主问题
graph LR
subgraph Net [分区场景]
subgraph NetA [子网络A-拥有多数派]
A1[节点1]
A2[节点2]
A3[节点3]
end
subgraph NetB [子网络B-少数派]
B1[节点4]
B2[节点5]
end
end
A1 -.可以继续服务.-> A2
A2 -.可以继续服务.-> A3
B1 -.停止服务.-> B2
style A1 fill:#4ecdc4
style A2 fill:#4ecdc4
style A3 fill:#4ecdc4
style B1 fill:#ff6b6b
style B2 fill:#ff6b6b
%% 背景颜色
style NetA fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
style NetB fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
style Net fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
思维升华: 2PC 没有死,它只是需要一个更强的底座。 我们用 Paxos/Raft 这种共识算法来解决组件的高可用问题(Data Consistency),然后在这些高可用的组件之上,再去跑业务层的 2PC(Atomic Commit)。
总结
- 2PC 的死穴:在于它假设所有节点都不会永久故障,一旦遇到网络分区或协调者宕机,系统就会陷入同步阻塞。
- 3PC 的补丁:试图用超时解决阻塞,结果在分区时导致了更严重的数据不一致(脑裂)。
- 真正的出路:承认单机不可靠,引入多数派(Quorum)机制。只要半数以上节点存活,系统就能继续工作。
下篇预告:
既然“多数派”这么好,那实现起来容易吗? 并没有。 当两个客户端同时向多数派发起写入请求,一个要把值改成 A,一个改成 B,且都获得了部分节点的支持,系统该听谁的?
下一篇,我们将深入 Paxos 算法,看它如何用“提案编号”,在乱序的分布式网络中达成完美的共识。
思考题
- 为什么金融系统仍在使用2PC?
参考答案
- 业务特性:金融转账绝不能出现"一边扣款成功、一边到账失败"的情况,宁可交易失败,不能数据不一致
- 环境可控:银行间转账通常通过专线连接,网络质量有保障,不像互联网那样频繁分区
- 低并发可接受:跨行转账量相对互联网系统少,同步阻塞带来的性能损失可以接受
- 兜底机制:配套有人工对账、清算系统,出问题时人工介入处理
- MySQL XA事务如何规避2PC的同步阻塞?
参考答案
- 超时机制:设置innodb_lock_wait_timeout,Prepare后等待超时自动回滚释放锁
- 事务时长限制:业务层强制限制事务执行时间,避免长时间占用资源
- 连接池复用:使用连接池减少连接建立开销,但锁定的资源仍然被占用
- 异步化改造:实际生产中很少直接用XA,而是改用消息队列 + 最终一致性方案
- 跨机房电商系统应该选择什么方案?
参考答案
不适合的方案:
- 数据库XA(MySQL XA):跨机房网络延迟高、分区风险大,数据库层2PC的同步阻塞不可接受
- TCC模式(核心交易链路)
- 本质还是2PC,但在业务层实现
- Try:预留资源(如冻结库存),不锁数据库
- Confirm:确认提交(扣减冻结的库存)
- Cancel:取消回滚(释放冻结的库存)
- 优势:资源锁定时间短,业务可控
- 框架:Seata TCC、ByteTCC
- Seata AT模式(简单场景)
- 自动生成反向SQL的2PC,业务代码改动小
- 适合跨服务但业务逻辑简单的场景
- 本地消息表(非核心异步场景)
- 订单和消息在同一个本地事务中提交
- 定时任务扫描消息表投递消息到下游
- 保证消息最终一定发出
- 适用:积分、优惠券等可异步处理的业务
- 消息队列 + 补偿(非核心异步场景)
- 核心操作成功后发送消息
- 下游消费消息异步处理
- 失败时通过补偿事务回滚
- 适用:通知、日志、数据同步等场景
- Saga模式(长事务场景)
- 将长事务拆分成多个本地短事务
- 失败时执行补偿操作(如取消订单、恢复库存)
- 适用:业务流程长、涉及多个服务的场景