Paxos 这四个字母,在分布式系统工程师圈子里几乎是“劝退级”的存在。我最早去啃 Lamport 那篇《The Part-Time Parliament》的时候,满脑子都是希腊小岛上的议员、牧师、法案编号,硬看了两星期,感觉懂了,合上书又不知道它到底凭什么让一群节点达成一致。后来因为要自己搭配置中心、做跨机房金额对账,被迫把 Paxos 重新拆了一遍,才发现它一点都不复杂——复杂的是它被包装在一个议会隐喻里。把“议员”换成服务器,“法案编号”换成提案编号,整个算法就一句话:大家商量着来,但是由多数派拍板。这篇文章我打算用最具体的场景、最直白的口语,把 Paxos 的三个角色、两阶段流程、安全性原理、工程坑位一次讲透。适合刚接触分布式一致性的同学,也适合面试前想快速把 Paxos 理清楚的开发。我不保证你看完能背出 Lamport 原文,但我保证你看完能跟同事把一个共识过程从头到尾讲明白,并且能动手写出一个最小实现。
1. 先从一件糟心事说起:为什么系统需要 Paxos
1.1 一个能复现的分布式事故:双主脑裂
假设你维护了一个配置中心,两个机房各自有一台服务器,正常情况下一台是主(Master),一台是备(Backup),主挂了备顶上。这个模型最大的隐患不是服务器宕机,而是网络抖动。比如说机房间的交换机充血了 30 秒,主节点照样活着,备机却以为自己联系不上主了,于是它立刻把自己提升为新的主节点。问题来了:老主还在服务流量,新主也在接受写请求。同一个业务配置,两个机房写成了两个版本,而且两边都认为自己才是合法的主节点。
这是我在生产环境里真实遇到过的问题,现象非常隐蔽:业务方读配置时从本地机房走,所以两边看到的配置一时半会儿不会不一致;等网络恢复,两个主开始同步数据,冲突记录大量弹出,业务才炸锅。传统的高可用方案,比如心跳、租约,本质上是在“预分配权力”:你先约定好谁是主,大家都听他的。但网络不可靠的时候,你没法保证所有节点对“现任主是谁”这件事看法一致。Paxos 换了一个思路:任何一次领导权的更迭、任何一条数据的写入,都必须让整个集群中的多数派节点亲口确认。只要多数派点头,这件事就算定案,谁也不能反悔。这样一来,两个机房各自拿到半数以下的确认,就无法同时当选,双主脑裂从根上被堵住了。
1.2 Paxos 到底保证了一个什么性质
用一句话抽象:一群节点里,每个节点都可以提出一个值,Paxos 保证最终所有活着且能通信的节点,会就“选哪一个值”达成一致,而且选出来的值必须是某一次真正被某个节点提出过的值,不能凭空冒出来一个新值。
这里有个容易忽略的点。Paxos 解决的是一次性的“单值共识”:大家商量好一个值是 v,共识就算完成。真实系统里我们要写一条条日志、一个个配置,所以后来才有了 Multi-Paxos 的做法,把无数个单值共识串成一个日志序列。但理解 Paxos 的最佳路径,永远是先把“一次共识”搞清楚。一次共识搞明白了,后面无非是套模板。
2. 角色、编号、法定人数:三个概念盘清楚,算法就懂了一半
2.1 Proposer、Acceptor、Learner 分别是谁
Paxos 把参与者分成三种角色,虽然名字听着洋气,但本质特别朴素。
Proposer 是“发起提案的人”,它负责提出一个编号,再提出一个值,推动讨论往前走。Acceptor 是“投票箱”,它收到提案后表态:答应、拒绝、或者告诉对方自己之前收过什么票。Learner 是“吃瓜群众”,它不参与决策,只需要把已经定案的结果记录走。工程实现里,同一个节点往往同时担任三种角色,因为服务器身兼数职很正常。
我用一个生活化类比帮你把角色焊死在脑子里。一栋老楼要决定加装电梯,热心业主(Proposer)拿着方案挨家挨户征求意见;每户代表(Acceptor)在意见表上签字表态;物业和施工队(Learner)最后只管照着定案开工。如果两拨热心业主同时拿着不同方案敲门,整栋楼要怎么避免先签了 A 方案的人又被 B 方案撬走?这就是 Paxos 要解决的“盖章”难题。
Acceptor 是算法的核心角色,它的一切行为非常简单,只能记住两个东西:一是“我现在最大的承诺编号是多少”,二是“我最终接受过的提案编号和值”。只有这两个本地变量,没有全局列表,也不需要互相通信。这正是 Paxos 厉害的地方:全局一致性,是靠每个节点只做局部决策达成的。
2.2 提案编号:全局唯一且单调递增
Paxos 每一步都围绕着提案编号(Proposal Number)展开,它必须满足两个条件:任何两个节点提出的编号都不会重复;编号之间可以比较大小。
在工程上,最常见的做法是用“逻辑任期号 + 节点 ID”组成一个复合编号。比如节点 ID 是 3,当前逻辑时钟是 41,那么编号就可以是41.3,比较时先比整数部分,再比小数部分。这样做既保证了全局唯一,又让“后来者编号一定更大”这个条件天然成立。
为什么编号要“更大”?因为 Paxos 的规矩是:编号大的提案有权打断编号小的提案。如果两个 Proposer 同时跑,编号小的那个先发动的流程,很可能被编号大的半路截胡。这不是 bug,而是设计如此:用编号来决定优先级,才能让整个系统在任何乱序场景下都能收敛到同一个值。
2.3 多数派为什么就够用了:quorum 交集的魔力
法定人数(quorum)的标准是 (N/2 + 1),也就是多数派。比如 3 节点集群,2 个节点就算多数;5 节点集群,3 个节点就算多数。
为什么不是所有节点都点头才算数?因为活着的节点数量是未知的,你没法保证每个节点都不宕机、不分区。如果要求全员确认,任何一个节点掉线,整个系统就卡死了,可用性为零。多数派的好处是:只要超过半数的节点还活着,系统就能继续推进。
多数派还有一个更深层的数学性质:任意两个多数派必然有交集。5 个节点的集群,两个多数派分别是 3 个节点,无论怎么选,这两个三人小组里至少有 1 个节点是重合的。这个性质直接构成了 Paxos 的安全底座。后面你会看到,当一个新的提案者拿到“多数派承诺”时,它必然会收到某个节点报告之前已经接受过的旧值,于是它只能在旧值的基础上继续推进,而不是另起炉灶。
3. 两阶段提交:把一次提案的完整流程走给你看
3.1 阶段一 Prepare:先查旧账,再做承诺
现在假设我们有 A、B、C 三个节点,B 作为 Proposer 打算提交一个值v = hello。B 先生成一个编号1,然后把Prepare(1)广播给 A、B、C 三个节点。
Acceptor 收到 Prepare 请求后,逻辑很简单:如果这个编号大于自己见过的最大编号,它就答应下来,记录“我已经承诺不再接受编号小于 1 的提案”,然后把自己的承诺反馈回去,顺带把以前接受过的最高编号提案的值也告诉 Proposer;如果编号不够大,就直接拒绝,告诉对方“我已经承诺过更高的编号了,你往后稍稍”。
在我们的例子中,A、B、C 都是第一次接触编号 1,所以都会回复 B:“行,我记住了,以后小于 1 的我不理,但我没接受过任何值。”B 收到两个以上的肯定回复,即可进入下一阶段。这里要特别注意,B 自己也算一票,本地节点可以直接模拟一个 Acceptor 回复自己,不需要真的发一条网络消息给自己。
3.2 阶段二 Accept:正式盖章定案
B 拿到多数派承诺后,检查这些回复里有没有携带“旧值”。如果有多个旧值,就选编号最大的那个;如果没有旧值,就用自己最开始想写的值。在此例中没有任何旧值,所以 B 决定用hello,于是广播Accept(1, hello)。
Acceptor 收到 Accept 请求后,再做一个简单判断:如果这个提案编号大于等于自己承诺过的最大编号,就接受它,记录(1, hello),并且向 Proposer 回复“我接受了”。如果这个编号比自己承诺过的最大编号小,就必须拒绝,哪怕之前没接受过,也不能破例。最终 B 只要收到多数派接受回复,就可以断定hello已经被集群“选定”了。就算此时有节点掉线,只要多数人记住了这个值,它就永久有效。
第一次看这个流程的人,很容易问:“既然 Prepare 时已经拿到了承诺,为什么 Accept 阶段还要再次检查编号?”因为从 B 发 Prepare 到发 Accept 之间有时间差,这期间可能有另一个节点 C 也发起了编号更大的 Prepare,并且已经抢走了多数派的支持。Accept 时的二次检查,保证了“最后盖章”的那一刻,选票仍然有效。
3.3 核心安全点:为什么后来者必须继承旧值
这个点值得单独拉出来细讲,因为它是 Paxos 里最容易听糊涂的一环。我换一个反面场景说明。
假设 B 带着编号 1 提案hello,C 同时带着编号 2 提案world。如果 Paxos 允许 C 在拿到更高编号后直接提world,就可能出现这样的乱局:B 已经把hello在 A、B 两个节点上盖了章,C 又在 A、C 两个节点上把world盖了章,而 A 这个节点连着盖了两次。两个值在各得一票的情况下,最后谁也没法说服谁,系统就分裂了。
所以 Paxos 定了铁律:Proposer 在收到多数派的 Prepare 回复时,只要听到任何一个人说“我之前已经接受过(1, hello)”,那么无论自己多想写world,都必须在 Accept 阶段改写为hello。你看到没有,这不是道德约束,而是算法强制。这个强制规则保证了同一轮共识里不可能出现两个没有交集的值:只要某个值已经被多数派看重,后面所有试图写新值的提案者,都会在 Prepare 阶段收到来自交集节点的“旧案通报”,然后被迫继承旧值。
我建议你把这一小段场景在自己的草稿纸上画一遍:画三个圆圈代表 A、B、C,用两条箭头分别代表 B 和 C 的提案路径,再标出哪个节点同时出现在两个多数派里。画完你会发现,Paxos 的所有防错逻辑都只是为了让一句话成立:第二个提案者永远不可能带着一个与旧值无关的新值横空出世。
4. 从单个值到日志流:Multi-Paxos 到底优化了什么
4.1 单次 Paxos 的问题:每写一条数据要跑两轮广播
如果每写一个日志条目都完整跑一遍 Prepare + Accept,性能会很糟心。每轮共识是两次全集群广播,写一条日志要两轮,这些成本乘上高吞吐场景,网络会先扛不住。况且 Prepare 阶段本身不写入任何新值,它只是在反复确认“我有没有资格写”,这个确认在系统平稳运行时是多余的。
你可以把这种状态类比成:每次开会都要重新选一次主持人,而实际上会议室里大部分人明明还在讨论同一个议题。重复选主持人,就是一种浪费。
4.2 Multi-Paxos 的套路:先选出一个稳定的 Leader
Multi-Paxos 的核心思路,是让系统长期维护一个 Leader(也叫主 Proposer),由它连续不断地使用递增的提案编号发起请求。因为 Leader 在短时间内是唯一会发起 Prepare 的节点,集群里的 Acceptor 已经把承诺编号提到很高的位置,再也没有别的提案者来抢票,所以跳过 Prepare,直接发送 Accept 就是安全的。也就是说,稳定场景下 Multi-Paxos 把每次写入从两轮广播压到一轮广播。
但这并不意味着可以完全不做 Prepare。Leader 刚上任、或者旧 Leader 失联、新 Leader 接替的那一刻,仍然需要跑一轮 Prepare,把自己的编号推到全场最高,同时确认一下之前有没有被“已定案但还没来得及通知”的值。这个动作等于重新建立权威,后续就又可以一路绿灯了。
4.3 日志即共识:把状态机映射到 Paxos
真实系统里,我们不是只共识一个hello,而是共识一连串操作。常见做法是:给每个日志条目分配一个“槽位编号”,槽位编号从 0、1、2 一直递增,每个槽位用一次 Multi-Paxos 共识来决定该写什么。所有节点把同一条日志序列从头到尾应用一遍,就能得到相同的状态机结果。这样,数据库的每次写入、配置项的每次变更,本质都是“在槽位 N 上共识出一个值”。
这里有一个体验上的细节:一旦某节点落后,它不需要等所有槽位都共识完才补,它可以按顺序把漏掉的槽位逐个补上。因为共识结果已经是多数派敲定的,补日志只是复制已定案的数据,不会再产生新的分歧。也可以多提一句:很多知名共识系统的工作原理,都是在这个模型的骨架上做工程优化。
5. 实操中的坑:活锁、乱序、持久化
5.1 活锁:Paxos 的不一致之锁
Paxos 的安全性已经由理论保证,意思是它永远不可能出现两个不同的值都被选定。但 Paxos 的一个历史痛点叫“活锁”(livelock):系统一直在忙,却迟迟无法产生共识。
假设两个 Proposer 互为死对头。Proposer P1 发起了编号 5 的 Prepare,P2 发起了编号 6 的 Prepare,P2 抢走多数派承诺。P1 的 Accept 被拒,于是 P1 提高编号到 7 再战;P2 看到 7 更高,也把编号提高到 8;两者反复互相打断,谁也收集不到稳定的多数派承诺,系统陷入永无止境的“抬杠”。
解决活锁的标准套路是引入 Leader 选举:正常情况下只允许一个 Proposer 干活,其他 Proposer 只作为备份。再用随机退避时间进一步降低两个备份同时抢跑的概率。这也是 Raft 等算法更注重“强 Leader”风格的原因:它们牺牲了部分灵活性,换来了更可预测的活性。
5.2 乱序与丢包:工程实现对算法正确性的威胁
我在第一次实现 Paxos demo 时,犯过一个很典型的错误:Acceptor 收到编号更高的 Prepare,本地改了自己的承诺编号,结果进程崩溃了,重启后承诺编号丢失,又接受了旧编号的提案。从 Paxos 纯理论的角度看,算法假设节点不会“失忆”,但真实的计算机随时可能断电重启。
所以工程化时必须把 Acceptor 的max_promised和last_accepted持久化到磁盘。每次更新必须先落盘再返回确认,否则一旦宕机,就可能违反承诺,破坏安全性。另一个容易忽视的是网络乱序:Accept(1, hello) 可能比 Prepare(5) 后到。Acceptor 不能因为刚到的是 Accept 就盲目覆盖,必须看编号是否符合承诺。
5.3 一张表记住常见故障与排查思路
| 现象 | 可能原因 | 推荐处理办法 |
|---|---|---|
| 提案一直被 reject | 有其他 Proposer 在用更高编号竞争 | 等待随机退避后重试,或让 Leader 定期发布心跳压制竞争者 |
| Prepare 回复丢失 | 网络瞬时抖动或节点繁忙 | Proposer 增加超时重试机制,重新发起同编号 Prepare 或提升编号 |
| 某节点宕机重启后参与投票 | 持久化未保证,承诺状态没了 | 检查磁盘写入顺序,必须落盘后再回 ACK |
| 多数派不可达 | 网络分区、节点大面积故障 | 系统进入只读或等待恢复状态,因为 Paxos 无法在少数派中完成共识 |
| 学习到的值有延迟 | Learner 只能从多数派收集 accepted 消息 | 不需要特殊处理,最终一致即可;若需快速感知,可引入专线通知 Learner |
6. 一行一行看代码:最少可运行的 Paxos 长什么样
6.1 Acceptor 的本体:就两个状态变量加两个判断
我在最开始学习时,总以为 Paxos 的实现会很复杂。真正动手做最小实现之后才明白,Acceptor 的代码比想象中短得多。核心逻辑如下:
# 单个 Acceptor 的本地状态 class Acceptor: def __init__(self, node_id): self.max_promised = None # 承诺过的最大提案编号 self.last_accepted = None # 最近接受的 (编号, 值) def handle_prepare(self, n): # 如果 n 比之前承诺的都大,则承诺不再接受小于 n 的提案 if self.max_promised is None or n > self.max_promised: self.max_promised = n return ("promise", n, self.last_accepted) # 否则拒绝,并告诉对方当前的最大承诺编号 return ("reject", n, self.max_promised) def handle_accept(self, n, value): # 编号必须不小于承诺编号,才允许接受 if self.max_promised is None or n >= self.max_promised: self.max_promised = n self.last_accepted = (n, value) return ("accepted", n, value) return ("reject", n, self.max_promised)注意handle_accept里的判断条件是n >= max_promised,因为只要编号没小于自己的承诺,接受它就是安全的。而handle_prepare里用的是严格大于,因为如果编号等于当前承诺编号,说明可能是重复消息,不必重复承诺。
6.2 Proposer 的最小流程:收集承诺再决定写什么
class Proposer: def __init__(self, node_id, logical_round): self.node_id = node_id self.round = logical_round # 构造全局唯一的提案编号:用 round + node_id 的小数部分复合 self.n = round + node_id def propose(self, value): promises = broadcast_prepare(self.n) # 广播 Prepare if not has_quorum(promises): return False # 从所有 promise 中找到编号最大的旧值 highest = None for msg in promises: if msg.last_accepted and (highest is None or msg.last_accepted[0] > highest[0]): highest = msg.last_accepted # 核心规则:如果已经有旧值,必须继承旧值 final_value = highest[1] if highest else value acks = broadcast_accept(self.n, final_value) # 广播 Accept return has_quorum(acks)broadcast_prepare和broadcast_accept是网络层封装,真实实现中要处理重试、超时、节点列表变更等。但当你看懂了这段骨架,再去看任何成熟的共识库源码,你会发现它们的主体逻辑并没有跳出这个框架。
6.3 几个从实操中悟出来的经验
第一个经验:不要把编号做得太随意。用“纳秒时间戳”或“随机数”做提案编号,表面看唯一性没问题,但无法保证单调递增。我在团队里推荐的做法是:每个 Proposer 自己维护一个本地轮次计数器,生成编号时把计数器叠加到上一次见过的全球最大编号之上,再用节点 ID 保证唯一。这样即使旧 Leader 失联后又回来,它的新编号也一定比之前见过的都大。
第二个经验:测试 Paxos 时不要只测“完好网络”。我当时写了个小脚本,在每个消息上按比例随机丢弃、延迟、乱序,再同时并发发起多个不同值的提案,最终结果依然只可能是某一个值被选定。这类混沌测试是验证安全性的神器。如果你的实现有条日志打出来发现两个节点各自拿到了不同的“共识结果”,赶紧回去检查 Acceptor 的持久化逻辑,而不是怀疑算法本身错了。
第三个经验:Paxos 的“共识”只保证一致性,不保证即时全局感知。某个 Learner 宕机了,恢复后它可能需要追赶之前已经定案的日志,这很正常,不要把它误判成系统故障。很多运维事故,都是因为把“某个节点暂时不知道结果”当成了集群分裂。
个人在实际操作中的体会是:Paxos 的难点从来不是记住那几个阶段的名字,而是搞懂为什么阶段顺序和值继承规则设计成这个样子。我见过不少人能熟练说出 Prepare、Accept、Promise、Quorum,但问他“两个多数派一定有交集,这个交集为什么能防止提案者提出新值”,就卡壳了——而对 Paxos 的理解深度,恰恰就体现在这种“追问为什么”的时刻。我自己的学习路径是:先把三节点场景手动画十遍,再去看 Lamport 原文,最后亲手实现一个 100 行不到的 demo。这样一遍走下来,Almost 你能应付绝大多数分布式系统的面试和设计讨论。