分布式一致性(三):共识的黎明——Quorum 机制与 Basic Paxos

前言

在上一篇中,我们见证了原子提交协议的挫败: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 个节点:

graph TB subgraph Q1 [多数派 Q₁] A[节点A] B[节点B] C[节点C] end subgraph Q2 [多数派 Q₂] C2[节点C] D[节点D] E[节点E] end C --- C2 style A fill:#4ecdc4 style B fill:#4ecdc4 style C fill:#ff6b6b style C2 fill:#ff6b6b style D fill:#ffe66d style E fill:#ffe66d style Q1 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5 style Q2 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5

无论你怎么挑,任意两组 3 节点的集合至少共享 1 个节点(上图中的节点 C)。这个共享节点就是信息的桥梁------它同时参与两次决策,就能把第一次的结果传递给第二次。

Quorum NWR 模型

集合交集定律在工程中的经典应用就是 Quorum NWR 模型。假设一个数据有 N 个副本:

  • W(Write Quorum):写入时需要确认的副本数
  • R(Read Quorum):读取时需要查询的副本数

只要满足 W + R > N,读写集合就必然存在交集,读操作一定能看到最新写入的数据。

graph LR subgraph 系统["5 副本系统 (N=5)"] subgraph WQ ["写入集合 W=3"] N1["副本1 ✏️"] N2["副本2 ✏️"] N3["副本3 ✏️🔍"] end subgraph RQ ["读取集合 R=3"] N3b["副本3 ✏️🔍"] N4["副本4 🔍"] N5["副本5 🔍"] end end N3 -.-|"交集:携带最新数据"| N3b style N1 fill:#4ecdc4 style N2 fill:#4ecdc4 style N3 fill:#ff6b6b style N3b fill:#ff6b6b style N4 fill:#ffe66d style N5 fill:#ffe66d style WQ fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5 style RQ fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5 style 系统 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5

几种典型的 NWR 配置:

配置 W R 特点 典型场景
强一致读写 3 3 读写都走多数派,强一致 配置中心、分布式锁
读优化 3 1 写慢读快,但读只访问一个节点无法保证一致 读多写少(需配合版本号)
写优化 1 5 写快读慢,读时必须查全量才能保证看到最新值 日志写入、消息队列

从 Quorum 到共识的距离

Quorum NWR 解决了"读到最新数据"的问题,但还不够。在分布式系统中,一个更棘手的问题是:

如果两个客户端同时向多数派发起写入,一个要写 X=1,另一个要写 X=2,最终系统该取谁的值?

Quorum 模型本身没有回答这个问题。它只保证"读写有交集",但不保证"写写之间的顺序"。

要解决写冲突问题,我们需要一个严格的共识协议------让所有节点就"选择哪个值"达成一致。

这就是 Paxos 登场的原因。

二、Basic Paxos:在混乱中达成共识

问题定义

Paxos 要解决的问题可以精炼为一句话:

多个节点各自提出一个值(Propose),所有节点最终就同一个值达成一致(Consensus)。

这个"一致"需要满足三个条件:

  1. 只有被提出的值才能被选定(不会凭空冒出一个值)
  2. 只有一个值会被选定(不会出现两个结果)
  3. 值一旦被选定,就不会改变(决定不可撤回)

角色定义

Paxos 中有三种角色:

  • 提议者(Proposer):发起提案,试图让自己的值被选定。可以类比为"提案的发起人"。
  • 接受者(Acceptor):对提案进行投票。多数派 Acceptor 接受同一个提案,该提案就被选定。
  • 学习者(Learner):不参与投票,只是被动获知最终结果。

💡实际系统中,一个物理节点通常同时扮演多个角色。比如在一个 5 节点集群中,每个节点既是 Proposer 也是 Acceptor。

从朴素方案到 Paxos

我们不直接给出 Paxos 的协议,而是从一个最朴素的想法出发,看看会遇到什么问题,然后一步步修正,最终"推导"出 Paxos。

尝试一:只有一个 Acceptor

最简单的方案:只用一个 Acceptor,谁先到就选谁。

--- config: theme: forest look: neo --- sequenceDiagram participant PA as Proposer A participant Acc as 唯一 Acceptor participant PB as Proposer B PA->>Acc: 提案: X=1 Note over Acc: 接受 X=1 ✅ PB->>Acc: 提案: X=2 Note over Acc: 已有值,拒绝 ❌

问题显而易见:单点故障。这个唯一的 Acceptor 一旦宕机,整个系统停摆。和 2PC 的协调者一样的老问题。

尝试二:多个 Acceptor,值直接写入

用多个 Acceptor,Proposer 将提案发给所有 Acceptor,获得多数派接受就算选定。

但考虑这个场景:

--- config: theme: forest look: neo --- sequenceDiagram participant PA as Proposer A participant A1 as Acceptor 1 participant A2 as Acceptor 2 participant A3 as Acceptor 3 participant PB as Proposer B PA->>A1: X=1 PA->>A2: X=1 PB->>A2: X=2 PB->>A3: X=2 Note over A1: 接受 X=1 Note over A2: 先收到 X=1?
还是先收到 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 已经见过编号更大的提案,就拒绝编号更小的。

但光有编号还不够。考虑以下场景:

  1. Proposer A 用编号 1 发起提案 X=1,获得多数派 {A1, A2} 接受,值已经被选定
  2. Proposer B 用编号 2 发起提案 X=2,因为编号更大,如果 Acceptor 直接接受,就会覆盖已选定的值。

这违反了我们的第三条要求:"值一旦被选定,就不会改变"。

核心矛盾:新提案的编号更大,但它不能覆盖已经被选定的值。

Paxos 的天才之处在于:用两阶段协议解决这个矛盾。

Paxos 两阶段协议

阶段一:Prepare(抢占提案权)

text 复制代码
Proposer                       Acceptors
   │                              │
   │──── Prepare(n) ─────────────>│  "我要用编号 n 发起提案,行不行?"
   │                              │
   │<─── Promise(n) ──────────────│  "行,我承诺不再接受编号比 n 小的提案"
   │     + (acceptedN, acceptedV) │  "另外,我有一份之前接受的提案,一起给你"

具体规则:

  1. Proposer 生成一个全局递增的提案编号 n,向所有 Acceptor 广播 Prepare(n)
  2. Acceptor 检查编号 n:比自己见过的都大 → 做出承诺,并附带自己已接受的提案编号和值 (acceptedN, acceptedV)(没接受过就不带);否则直接拒绝。
  3. Proposer 只需等到多数派回复 Promise,即可进入下一阶段。

阶段二:Accept(值的落地)

arduino 复制代码
Proposer                       Acceptors
   │                              │
   │ 确定值 v                      │
   │──── Accept(n, v) ───────────>│  "请接受提案(n, v)"
   │                              │
   │<─── Accepted / Rejected ─────│  "收到!" 或 "不行,我已经承诺了更大的编号"
   │                              │
   └────> Learners ───────────────>  通知学习者结果

值 v 怎么定? 这是 Paxos 最关键的约束,如果多数派的 Promise 响应中:

  1. 没有任何带回已接受的提案 → Proposer 可以自由使用自己想提的值。
  2. 有带回了已接受的提案 → 不同 Acceptor 可能在不同时间接受过不同 Proposer 的提案,返回的 (acceptedN, acceptedV) 各不相同。Proposer 必须选 acceptedN 最大的那个对应的值。理由:编号越大说明提案越晚发起,它的值越可能已经被多数派接受,继承它才不会覆盖已有的共识。

确定 v 后:

  1. 发出 Accept(n, v)------n 是自己的编号,v 可能是别人的值

  2. Acceptor 收到后:自己没承诺过更大的编号 → 接受;否则 → 拒绝。

  3. 多数派接受 → 共识达成,值 v 被选定。

完整流程:

--- config: theme: forest look: neo --- sequenceDiagram autonumber participant P as Proposer participant A1 as Acceptor 1 participant A2 as Acceptor 2 participant A3 as Acceptor 3 rect rgb(240, 248, 255) Note over P,A3: 阶段一:Prepare(抢占提案权) P->>A1: Prepare(n=1) P->>A2: Prepare(n=1) P->>A3: Prepare(n=1) A1-->>P: Promise(无已接受提案) A2-->>P: Promise(无已接受提案) A3-->>P: Promise(无已接受提案) end Note over P: 收到多数派响应
无已接受提案
自由选值 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 发起了新的提案。

--- config: theme: forest look: neo --- sequenceDiagram autonumber participant PA as Proposer A participant A1 as Acceptor 1 participant A2 as Acceptor 2 participant A3 as Acceptor 3 participant PB as Proposer B Note over PA,A3: Proposer A 的回合 rect rgb(240, 248, 255) PA->>A1: Prepare(n=1) PA->>A2: Prepare(n=1) A1-->>PA: Promise ✅ A2-->>PA: Promise ✅ end rect rgb(240, 255, 240) PA->>A1: Accept(1, "X") PA->>A2: Accept(1, "X") Note over A1: 已接受 (1, "X") ✅ Note over A2: 已接受 (1, "X") ✅ end Note over PA: 💥 Proposer A 宕机
未通知 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" 被选定 🎉

关键步骤解读

  1. Proposer A 让 {A1, A2} 接受了值 "X"(已构成多数派,值其实已经被选定),但 A 自己宕机了,没来得及通知 A3。
  2. Proposer B 发起 Prepare(n=2),询问 {A2, A3}(构成多数派)。
  3. A2 回复:"我承诺不再接受编号 <2 的提案,但我之前已经接受了 (1, "X")"。
  4. B 看到 A2 返回了已接受值 "X",根据 Paxos 规则,必须使用 "X" 而不是自己的值
  5. 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₁)。

  1. v₂ 被选定,意味着 Proposer 在发起提案 n₂ 之前,先执行了 Prepare(n₂),并获得了多数派 Q₃ 的承诺。
  2. Q₁ ∩ Q₃ ≠ ∅(集合交集定律),所以 Q₃ 中至少有一个 Acceptor 已经接受了 (n₁, v₁)。
  3. 这个 Acceptor 在回复 Prepare(n₂) 时,必然返回了它已接受的值 v₁(或编号更大的其他值)。
  4. 根据 Paxos 规则,Proposer 必须使用返回值中编号最大的值。如果这个最大编号值可以追溯回 v₁,那么 v₂ = v₁,矛盾。

严格的数学归纳证明需要对 n₁ 到 n₂ 之间所有提案编号做归纳,这里给出核心直觉。

三、活锁:Paxos 的致命软肋

Basic Paxos 保证了安全性(Safety) ------永远不会选定两个冲突的值。但它不保证活性(Liveness)------系统有可能永远选不出值。

活锁场景

当两个 Proposer 交替抢占时,可能出现"你踩我,我踩你"的死循环:

--- config: theme: forest look: neo --- sequenceDiagram participant PA as Proposer A participant Acc as Acceptors (多数派) participant PB as Proposer B PA->>Acc: Prepare(n=1) Acc-->>PA: Promise(1) ✅ Note over PB: B 用更大编号抢占 PB->>Acc: Prepare(n=2) Acc-->>PB: Promise(2) ✅ PA->>Acc: Accept(1, "X") Acc-->>PA: 拒绝 ❌ (已承诺n=2) Note over PA: A 用更大编号反抢 PA->>Acc: Prepare(n=3) Acc-->>PA: Promise(3) ✅ PB->>Acc: Accept(2, "Y") Acc-->>PB: 拒绝 ❌ (已承诺n=3) Note over PA,PB: 循环往复,永远无法达成共识...

过程分析

  1. A 发起 Prepare(1),获得承诺。
  2. B 发起 Prepare(2),编号更大,Acceptor 转而承诺 B。
  3. A 的 Accept(1, ...) 被拒绝------Acceptor 已经承诺了更大的编号 2。
  4. A 不甘心,发起 Prepare(3),又抢回承诺。
  5. B 的 Accept(2, ...) 被拒绝。
  6. B 发起 Prepare(4)......如此反复。

两个 Proposer 互相踩脚,谁都无法完成 Accept 阶段。理论上这个过程可以无限持续。

解药:选一个 Leader

活锁的根源是多个 Proposer 并发竞争 。解决方案很直观:选出一个 Leader,让它独占提案权

这就是 Multi-Paxos 的核心思想:

  1. 先通过一轮 Basic Paxos 选出一个 Leader。
  2. 只有 Leader 能发起提案。其他节点将请求转发给 Leader。
  3. Leader 存活期间,Prepare 阶段只需执行一次,后续提案直接进入 Accept 阶段,将两阶段协议优化为一阶段
graph TB subgraph Phase1 ["第一轮:Basic Paxos 选举 Leader"] direction LR P["Prepare(n)"] --> Promise["多数派 Promise"] Promise --> A["Accept(n, Leader=X)"] A --> Chosen["多数派 Accepted
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。
graph LR A["全员模型
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 的思想落地为一套清晰可实现的工程方案。


思考题

  1. 在一个 5 节点系统中,如果有 3 个节点同时宕机,Paxos 还能工作吗?为什么?

参考答案
不能工作。

  • 原因:5 节点系统的多数派需要至少 3 个节点。如果 3 个节点宕机,只剩 2 个节点,无法构成多数派。
  • Prepare 阶段:Proposer 无法获得 3 个 Acceptor 的承诺,因此无法进入 Accept 阶段。
  • 安全性保证:系统不会做出错误的决定(不会选定两个冲突的值),但会停止服务------牺牲可用性来保证一致性。
  • N 节点系统的容错上限:最多容忍 ⌊(N-1)/2⌋ 个节点故障,5 节点最多容忍 2 个。

总结:这正是 CAP 定理的体现------在网络分区(或节点故障)时,Paxos 选择了一致性(C),牺牲了可用性(A)。

  1. 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 安全性的核心保障。

  1. 提案编号(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 工程实现中的经典设计。

相关推荐
三千星5 小时前
从Java到AI:我的转型之路 Ⅱ —— 手撸一个DeepSeek工具库
后端
beata5 小时前
Java基础-9:深入 Java 虚拟机(JVM):从底层源码到核心原理的全面解析
java·后端
only-qi5 小时前
leetcode24两两交换链表中的节点 快慢指针实现
数据结构·算法·链表
多恩Stone5 小时前
【3D AICG 系列-9】Trellis2 推理流程图超详细介绍
人工智能·python·算法·3d·aigc·流程图
sin_hielo5 小时前
leetcode 110
数据结构·算法·leetcode
整得咔咔响5 小时前
贝尔曼最优公式(BOE)
人工智能·算法·机器学习
SimonKing5 小时前
分享一款可以管理本地端口的IDEA插件:Port Manager
java·后端·程序员
日拱一卒——功不唐捐5 小时前
字符串匹配:暴力法和KMP算法(C语言)
c语言·算法
renke33645 小时前
Flutter for OpenHarmony:数字涟漪 - 基于扩散算法的逻辑解谜游戏设计与实现
算法·flutter·游戏