前言
在上一篇中,我们见证了原子提交协议的挫败:2PC 败在同步阻塞与单点故障,3PC 试图用超时自决来打补丁,却在网络分区面前制造了更严重的脑裂。
它们失败的根因是全员模型------要求所有节点都参与且都存活。只要有一个节点失联,系统要么阻塞,要么分裂。
在第二篇末尾,我们提出了破局思路:放弃全员,拥抱多数派。业务层的原子提交改不掉,但可以用一组机器组成高可用集群来替代脆弱的单点组件,集群内部通过多数派机制同步状态,只要半数以上节点存活,该组件就不会挂。
但"多数派"并不是一句口号,它是一个需要严格证明的数学性质,也是一套需要精密设计的工程协议。
本篇,我们将从多数派的数学基础出发,一步步推演出分布式共识领域最经典的算法------Basic Paxos。
一、思想飞跃:从"全员"到"多数派"
为什么多数派不会脑裂?
上一篇提到,3PC 的脑裂根源在于:网络分区后,两个子网络各自独立做决策,产生了冲突的结果。
多数派机制的核心保证是:在一个 N 节点的系统中,任何两个多数派集合必然存在交集。
这听起来很直觉,但让我们用数学语言严格表述。
集合交集定律(Intersection Property)
假设系统有 N 个节点,我们定义多数派为任意一个包含超过 N/2 个节点的子集。
定理:任意两个多数派集合 Q₁ 和 Q₂,必然满足 Q₁ ∩ Q₂ ≠ ∅。
证明:反证法。假设 Q₁ ∩ Q₂ = ∅,即两个集合没有公共元素,那么 |Q₁| + |Q₂| ≤ N。但根据多数派定义,|Q₁| > N/2 且 |Q₂| > N/2,因此 |Q₁| + |Q₂| > N。矛盾。证毕。
以 5 节点系统为例,多数派至少需要 3 个节点:
无论你怎么挑,任意两组 3 节点的集合至少共享 1 个节点(上图中的节点 C)。这个共享节点就是信息的桥梁------它同时参与两次决策,就能把第一次的结果传递给第二次。
Quorum NWR 模型
集合交集定律在工程中的经典应用就是 Quorum NWR 模型。假设一个数据有 N 个副本:
- W(Write Quorum):写入时需要确认的副本数
- R(Read Quorum):读取时需要查询的副本数
只要满足 W + R > N,读写集合就必然存在交集,读操作一定能看到最新写入的数据。
几种典型的 NWR 配置:
| 配置 | W | R | 特点 | 典型场景 |
|---|---|---|---|---|
| 强一致读写 | 3 | 3 | 读写都走多数派,强一致 | 配置中心、分布式锁 |
| 读优化 | 3 | 1 | 写慢读快,但读只访问一个节点无法保证一致 | 读多写少(需配合版本号) |
| 写优化 | 1 | 5 | 写快读慢,读时必须查全量才能保证看到最新值 | 日志写入、消息队列 |
从 Quorum 到共识的距离
Quorum NWR 解决了"读到最新数据"的问题,但还不够。在分布式系统中,一个更棘手的问题是:
如果两个客户端同时向多数派发起写入,一个要写 X=1,另一个要写 X=2,最终系统该取谁的值?
Quorum 模型本身没有回答这个问题。它只保证"读写有交集",但不保证"写写之间的顺序"。
要解决写冲突问题,我们需要一个严格的共识协议------让所有节点就"选择哪个值"达成一致。
这就是 Paxos 登场的原因。
二、Basic Paxos:在混乱中达成共识
问题定义
Paxos 要解决的问题可以精炼为一句话:
多个节点各自提出一个值(Propose),所有节点最终就同一个值达成一致(Consensus)。
这个"一致"需要满足三个条件:
- 只有被提出的值才能被选定(不会凭空冒出一个值)
- 只有一个值会被选定(不会出现两个结果)
- 值一旦被选定,就不会改变(决定不可撤回)
角色定义
Paxos 中有三种角色:
- 提议者(Proposer):发起提案,试图让自己的值被选定。可以类比为"提案的发起人"。
- 接受者(Acceptor):对提案进行投票。多数派 Acceptor 接受同一个提案,该提案就被选定。
- 学习者(Learner):不参与投票,只是被动获知最终结果。
💡实际系统中,一个物理节点通常同时扮演多个角色。比如在一个 5 节点集群中,每个节点既是 Proposer 也是 Acceptor。
从朴素方案到 Paxos
我们不直接给出 Paxos 的协议,而是从一个最朴素的想法出发,看看会遇到什么问题,然后一步步修正,最终"推导"出 Paxos。
尝试一:只有一个 Acceptor
最简单的方案:只用一个 Acceptor,谁先到就选谁。
问题显而易见:单点故障。这个唯一的 Acceptor 一旦宕机,整个系统停摆。和 2PC 的协调者一样的老问题。
尝试二:多个 Acceptor,值直接写入
用多个 Acceptor,Proposer 将提案发给所有 Acceptor,获得多数派接受就算选定。
但考虑这个场景:
还是先收到 X=2? Note over A3: 接受 X=2
Acceptor 2 同时收到两个提案,它该接受哪个?如果它接受了 X=1,那 A(多数派 {1,2})胜出;如果接受了 X=2,那 B(多数派 {2,3})胜出。
更糟糕的是,如果网络延迟导致 A1 接受了 X=1,A3 接受了 X=2,A2 两个都收到了------我们没有任何机制来仲裁谁先谁后。
问题出在哪?没有全局排序。我们需要一种方式来区分提案的"先后"或"优先级"。
尝试三:给提案编号
这是 Paxos 最关键的洞察:给每个提案分配一个全局唯一且递增的编号(Proposal Number)。
编号大的提案优先级更高。如果 Acceptor 已经见过编号更大的提案,就拒绝编号更小的。
但光有编号还不够。考虑以下场景:
- Proposer A 用编号 1 发起提案 X=1,获得多数派 {A1, A2} 接受,值已经被选定。
- Proposer B 用编号 2 发起提案 X=2,因为编号更大,如果 Acceptor 直接接受,就会覆盖已选定的值。
这违反了我们的第三条要求:"值一旦被选定,就不会改变"。
核心矛盾:新提案的编号更大,但它不能覆盖已经被选定的值。
Paxos 的天才之处在于:用两阶段协议解决这个矛盾。
Paxos 两阶段协议
阶段一:Prepare(抢占提案权)
text
Proposer Acceptors
│ │
│──── Prepare(n) ─────────────>│ "我要用编号 n 发起提案,行不行?"
│ │
│<─── Promise(n) ──────────────│ "行,我承诺不再接受编号比 n 小的提案"
│ + (acceptedN, acceptedV) │ "另外,我有一份之前接受的提案,一起给你"
具体规则:
- Proposer 生成一个全局递增的提案编号 n,向所有 Acceptor 广播
Prepare(n)。 - Acceptor 检查编号 n:比自己见过的都大 → 做出承诺,并附带自己已接受的提案编号和值
(acceptedN, acceptedV)(没接受过就不带);否则直接拒绝。 - Proposer 只需等到多数派回复 Promise,即可进入下一阶段。
阶段二:Accept(值的落地)
arduino
Proposer Acceptors
│ │
│ 确定值 v │
│──── Accept(n, v) ───────────>│ "请接受提案(n, v)"
│ │
│<─── Accepted / Rejected ─────│ "收到!" 或 "不行,我已经承诺了更大的编号"
│ │
└────> Learners ───────────────> 通知学习者结果
值 v 怎么定? 这是 Paxos 最关键的约束,如果多数派的 Promise 响应中:
- 没有任何带回已接受的提案 → Proposer 可以自由使用自己想提的值。
- 有带回了已接受的提案 → 不同 Acceptor 可能在不同时间接受过不同 Proposer 的提案,返回的
(acceptedN, acceptedV)各不相同。Proposer 必须选 acceptedN 最大的那个对应的值。理由:编号越大说明提案越晚发起,它的值越可能已经被多数派接受,继承它才不会覆盖已有的共识。
确定 v 后:
-
发出
Accept(n, v)------n 是自己的编号,v 可能是别人的值。 -
Acceptor 收到后:自己没承诺过更大的编号 → 接受;否则 → 拒绝。
-
多数派接受 → 共识达成,值 v 被选定。
完整流程:
无已接受提案
自由选值 v="X" rect rgb(240, 255, 240) Note over P,A3: 阶段二:Accept(值的落地) P->>A1: Accept(n=1, v="X") P->>A2: Accept(n=1, v="X") P->>A3: Accept(n=1, v="X") A1-->>P: Accepted ✅ A2-->>P: Accepted ✅ A3-->>P: Accepted ✅ end Note over P,A3: 多数派接受 → 值 "X" 被选定 🎉
核心问题:为什么新 Proposer 能"看见"旧值?
这是理解 Paxos 的关键。让我们用一个具体场景来演示:
场景:Proposer A 的提案已经被部分接受,然后 Proposer B 发起了新的提案。
未通知 A3 Note over PB,A3: Proposer B 的回合 rect rgb(255, 248, 220) PB->>A2: Prepare(n=2) PB->>A3: Prepare(n=2) A2-->>PB: Promise + 已接受(1, "X") 📢 A3-->>PB: Promise(无已接受提案) end Note over PB: 发现已有接受值 "X"
必须沿用此值! rect rgb(240, 255, 240) PB->>A2: Accept(2, "X") PB->>A3: Accept(2, "X") Note over A2: 已接受 (2, "X") ✅ Note over A3: 已接受 (2, "X") ✅ end Note over PB,A3: 多数派 {A2, A3} 接受 → "X" 被选定 🎉
关键步骤解读:
- Proposer A 让 {A1, A2} 接受了值 "X"(已构成多数派,值其实已经被选定),但 A 自己宕机了,没来得及通知 A3。
- Proposer B 发起
Prepare(n=2),询问 {A2, A3}(构成多数派)。 - A2 回复:"我承诺不再接受编号 <2 的提案,但我之前已经接受了 (1, "X")"。
- B 看到 A2 返回了已接受值 "X",根据 Paxos 规则,必须使用 "X" 而不是自己的值。
- B 用编号 2 发起
Accept(2, "X"),"X" 继续被传播。
这就是集合交集发挥作用的地方:
- A 的写入多数派是 {A1, A2}
- B 的 Prepare 多数派是 {A2, A3}
- 交集是 {A2},A2 将旧值 "X" 传递给了 B
没有 A2 这个"信息桥梁",B 就不知道 "X" 已经被选定,可能会写入一个不同的值。 多数派的交集保证了任何新的 Proposer 都能发现旧值。
安全性论证
我们来严格论证为什么 Paxos 不会出现"两个不同的值都被选定"的情况。
反证法:假设值 v₁ 被选定(多数派 Q₁ 接受了提案 (n₁, v₁)),之后值 v₂ ≠ v₁ 也被选定(多数派 Q₂ 接受了提案 (n₂, v₂),n₂ > n₁)。
- v₂ 被选定,意味着 Proposer 在发起提案 n₂ 之前,先执行了
Prepare(n₂),并获得了多数派 Q₃ 的承诺。 - Q₁ ∩ Q₃ ≠ ∅(集合交集定律),所以 Q₃ 中至少有一个 Acceptor 已经接受了 (n₁, v₁)。
- 这个 Acceptor 在回复
Prepare(n₂)时,必然返回了它已接受的值 v₁(或编号更大的其他值)。 - 根据 Paxos 规则,Proposer 必须使用返回值中编号最大的值。如果这个最大编号值可以追溯回 v₁,那么 v₂ = v₁,矛盾。
严格的数学归纳证明需要对 n₁ 到 n₂ 之间所有提案编号做归纳,这里给出核心直觉。
三、活锁:Paxos 的致命软肋
Basic Paxos 保证了安全性(Safety) ------永远不会选定两个冲突的值。但它不保证活性(Liveness)------系统有可能永远选不出值。
活锁场景
当两个 Proposer 交替抢占时,可能出现"你踩我,我踩你"的死循环:
过程分析:
- A 发起
Prepare(1),获得承诺。 - B 发起
Prepare(2),编号更大,Acceptor 转而承诺 B。 - A 的
Accept(1, ...)被拒绝------Acceptor 已经承诺了更大的编号 2。 - A 不甘心,发起
Prepare(3),又抢回承诺。 - B 的
Accept(2, ...)被拒绝。 - B 发起
Prepare(4)......如此反复。
两个 Proposer 互相踩脚,谁都无法完成 Accept 阶段。理论上这个过程可以无限持续。
解药:选一个 Leader
活锁的根源是多个 Proposer 并发竞争 。解决方案很直观:选出一个 Leader,让它独占提案权。
这就是 Multi-Paxos 的核心思想:
- 先通过一轮 Basic Paxos 选出一个 Leader。
- 只有 Leader 能发起提案。其他节点将请求转发给 Leader。
- Leader 存活期间,Prepare 阶段只需执行一次,后续提案直接进入 Accept 阶段,将两阶段协议优化为一阶段。
X 当选 Leader ✅"] end subgraph Phase2 ["后续:客户端转发给 Leader,直接 Accept"] direction LR C1["Client 请求 v1"] -->|转发| L["Leader X"] C2["Client 请求 v2"] -->|转发| L C3["Client 请求 v3"] -->|转发| L L --> A1["Accept v1 ✅"] L --> A2["Accept v2 ✅"] L --> A3["Accept v3 ✅"] end Phase1 -->|"Leader 存活期间
无需再 Prepare"| Phase2 style P fill:#ffe66d style Promise fill:#ffe66d style A fill:#ffe66d style Chosen fill:#ff6b6b style C1 fill:#f0f0f0 style C2 fill:#f0f0f0 style C3 fill:#f0f0f0 style L fill:#ff6b6b style A1 fill:#4ecdc4 style A2 fill:#4ecdc4 style A3 fill:#4ecdc4 style Phase1 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5 style Phase2 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
性能对比:
| Basic Paxos | Multi-Paxos | |
|---|---|---|
| 每次提案的网络轮次 | 2 轮(Prepare + Accept) | 1 轮(仅 Accept) |
| 并发冲突风险 | 高(活锁) | 低(Leader 独占) |
| Leader 故障影响 | 无 Leader 概念 | 需要重新选举 |
Multi-Paxos 大幅提升了效率,但 Paxos 的原论文只给出了核心思想,并未定义完整的工程实现细节(比如 Leader 怎么选、日志怎么管理)。这也是为什么后来 Raft 算法诞生时,将"可理解性"作为首要设计目标------它本质上就是一个工程化的 Multi-Paxos。
总结
本篇我们完成了从"全员模型"到"多数派模型"的关键跨越:
- 集合交集定律是多数派机制的数学基石。W + R > N 保证了任意两个多数派必然存在交集,信息不会丢失。
- Basic Paxos 用两阶段协议(Prepare + Accept)解决了多数派模型下的写冲突问题。Prepare 阶段通过编号抢占实现全局排序,Accept 阶段通过"继承旧值"保证已选定的值不会被覆盖。
- 活锁是 Basic Paxos 的固有缺陷,多个 Proposer 并发竞争可能导致系统无法推进。解药是引入 Leader,即 Multi-Paxos。
2PC/3PC"] -->|"容错为0
分区即崩溃"| B["多数派模型
Quorum"] B -->|"解决写冲突"| C["Basic Paxos
两阶段共识"] C -->|"解决活锁"| D["Multi-Paxos
Leader 机制"] D -->|"工程化规范"| E["Raft"] style A fill:#ff6b6b style B fill:#ffe66d style C fill:#4ecdc4 style D fill:#4ecdc4 style E fill:#4ecdc4
下篇预告:
Multi-Paxos 指明了方向------选一个 Leader,由它来统一调度。但具体怎么选?Leader 怎么把日志安全地复制到所有节点?Leader 挂了怎么保证数据不丢?
下一篇,我们将深入 Raft 算法,看它如何用"任期(Term)"和"日志复制(Log Replication)"两把利剑,把 Multi-Paxos 的思想落地为一套清晰可实现的工程方案。
思考题
- 在一个 5 节点系统中,如果有 3 个节点同时宕机,Paxos 还能工作吗?为什么?
参考答案
不能工作。
- 原因:5 节点系统的多数派需要至少 3 个节点。如果 3 个节点宕机,只剩 2 个节点,无法构成多数派。
- Prepare 阶段:Proposer 无法获得 3 个 Acceptor 的承诺,因此无法进入 Accept 阶段。
- 安全性保证:系统不会做出错误的决定(不会选定两个冲突的值),但会停止服务------牺牲可用性来保证一致性。
- N 节点系统的容错上限:最多容忍 ⌊(N-1)/2⌋ 个节点故障,5 节点最多容忍 2 个。
总结:这正是 CAP 定理的体现------在网络分区(或节点故障)时,Paxos 选择了一致性(C),牺牲了可用性(A)。
- Paxos 的 Prepare 阶段返回"已接受的最大编号提案",如果不返回这个信息会怎样?
参考答案
会导致已选定的值被覆盖,破坏一致性。
具体场景:
- Proposer A 用编号 1 让多数派接受了值 "X"(值已被选定)
- Proposer B 用编号 2 发起 Prepare,如果 Acceptor 不返回已接受的值------
- B 不知道 "X" 已被选定,自由选择了 "Y"
- B 发起 Accept(2, "Y"),因为编号更大,Acceptor 接受了
- 结果:系统先选定了 "X",又选定了 "Y",一致性被破坏
本质:Prepare 阶段返回已接受值,是 Paxos 的"信息传递机制"。正是通过多数派的交集,新 Proposer 才能发现旧值并继承它,这是 Paxos 安全性的核心保障。
- 提案编号(Proposal Number)在实际系统中如何生成?怎样保证全局唯一且递增?
参考答案
常用方案:轮次 + 节点ID
- 编号格式 :
proposal_number = round * N + node_id,其中 N 是节点总数,node_id 是节点的唯一编号(0 到 N-1) - 举例(3 节点系统) :
- 节点0 第一轮:0×3+0=0,第二轮:1×3+0=3,第三轮:2×3+0=6
- 节点1 第一轮:0×3+1=1,第二轮:1×3+1=4,第三轮:2×3+1=7
- 节点2 第一轮:0×3+2=2,第二轮:1×3+2=5,第三轮:2×3+2=8
- 特点:不同节点生成的编号天然不会重复;每个节点只需维护本地的 round 计数器;递增通过增加 round 值实现
- 优化 :节点在收到其他提案的编号后,可以将自己的 round 更新为
max(本地round, 收到的编号/N) + 1,避免使用过时的小编号
总结:这个方案简洁高效,无需中心化的编号分配器,是 Paxos 工程实现中的经典设计。