2026/10/13 21:58:03

Paxos算法实战指南:从分布式一致性到Multi-Paxos工程落地

Paxos算法实战指南:从分布式一致性到Multi-Paxos工程落地 先说个题外话看到“Paoxs”这个写法我就知道多半是输入法把“Paxos”打出了重音问题不大。Paxos算法作为分布式一致性领域最经典的基石之一名字出现各种变体太正常了。如果你刚接触分布式存储、微服务注册中心或者分布式协调服务总觉得“数据一致性”是个玄而又玄的东西那这篇想着重聊的就是你迟早会撞上的那道坎。我会从“问题长什么样”开始讲再一步步拆到Paxos的协议细节最后落到工程实现和踩坑经验上尽量让你看完之后能自己动手撸一个能跑通的小原型。1. 分布式系统为什么总是跟“一致性”过不去1.1 从一次普通的写操作说起假设你维护了一个用户余额服务为了扛住高并发和机房容灾把数据复制到三台机器上。用户发起一笔转账余额扣减100元。正常情况下三台机器都写成功数据一致没有任何问题。但现实世界从来不按正常剧本走可能其中一台机器网络抖动写入请求超时可能另一台机器磁盘很慢响应迟迟没回来可能恰好有两条并发的转账请求被分发到了不同的副本同一时刻处理最终三台机器各自记了一笔互不相同的账。这时候问题就来了用户到底是不是成功扣款了后续查询读到的余额哪个才是“正确”的如果四台机器里有三台说扣了、一台说没扣到底信谁的这就是分布式一致性问题的原始形态。只要存在多副本只要网络不是100%可靠副本之间的状态就可能分叉而分叉正是所有分布式系统故障的根源。1.2 什么是“一致”什么是“共识”在分布式系统语境下一致性Consistency和共识Consensus经常被混着用但它们严格说不是同一个东西。共识指的是多个节点对某个值达成一致意见比如“这个提案的编号是5值是100”。一致性则更宽泛指的是多个副本对外表现出相同的数据视图。共识往往是实现一致性的核心手段。你可以这样理解一堆人打牌首先得对“这局谁赢了”达成共识共识然后大家对桌上的牌面记录才会一样一致性。如果连谁赢了都各执一词那牌谱肯定对不上。Paxos解决的就是这个“谁赢了”的问题——它是分布式领域里最经典的共识算法之一目标是在节点可能宕机、网络可能分区的情况下让所有存活节点对某个提议值最终达成一致。1.3 一致性不解决上层全是豆腐渣工程很多人觉得一致性是理论问题离业务远。实际上几乎所有分布式存储、分布式事务、服务协调组件都在解决一致性问题。比如分布式数据库的主从复制主库和从库之间怎么写才能不丢数据注册中心里服务实例列表如果每个节点看到的不一样调用方就可能把请求发给一个已经下线的实例分布式锁两个节点同时持有同一把锁业务侧就会发生重复执行配置中心配置项在多个副本之间不一致服务就可能出现行为分叉简单说只要数据有多副本、状态有冗余你就躲不开一致性问题。Paxos解决的是其中最核心、最底层的一环在不可靠环境下仍然能对某个值形成唯一、可被多数派承认的结论。2. Paxos算法核心拆解三个角色与两阶段握手2.1 提案、角色与编号先认识“演员表”Paxos把参与节点划分成三类角色Proposer提议者、Acceptor表决者、Learner学习者。一个节点可以同时担任多个角色实际工程里也经常如此。Proposer负责提出议案议案的内容就是“我建议这个值等于V”。Acceptor负责对议案投票同时必须记住自己最近承诺过什么、接受过什么。Learner负责学习最终被选定的值本身不参与投票。提案Proposal由两部分组成提案编号Round Number单调递增和提案值Value。为什么一定要有单调递增的编号因为提议者可能有多个并发场景下大家各自编号编号就是用来区分“谁更新的”。没有编号Acceptor拿到一个提案时根本无从判断该不该接受。编号不仅要全局唯一还得在多次提议中严格递增。这里有个关键点很容易被初学者忽略编号本身是不带业务含义的它只是协议需要的排序元数据。谁编号大谁在表决中说话更有分量。2.2 多数派定理一切正确性的根Paxos成立的核心依据是一个数学上几乎无懈可击的约束任何两个包含超过一半节点的集合必然有至少一个公共节点。这个说法太抽象拿具体的例子来算一遍。假设集群有5个Acceptor任意两个多数派集合的大小都要求超过2也就是至少3个节点。A集合选前3台机器B集合选后3台机器两个集合都包含第3台机器。不管你怎么选任何两个多数派一定相交于至少一个节点。这个节点的存在使得较早那轮提案的“投票记录”可以被后来者看到。于是后来的提议者可以基于前一轮已经达成的一致继续推动整个状态前进而不是推翻重来。很多人第一次看Paxos会问为什么非要多数派而不是全部节点因为分布式系统里节点会宕机如果必须等所有节点响应任何一台宕机都会让整个系统卡死。多数派保证的是只要集群里多数节点存活并通信正常就能继续推进。代价是容错能力上限为 f (n-1)/2即5节点集群最多容忍2台故障。再多挂一台多数派就无法形成系统进入只读或不可用状态。2.3 Prepare阶段把“陈年旧事”翻出来看Paxos被很多人视为天书主要卡在“两阶段”以及两阶段的意图上。第一阶段叫Prepare预备第二阶段叫Accept接受。Prepare阶段里Proposer生成一个编号N向所有Acceptor广播“编号为N的准备请求”。Acceptor收到这个请求后遵守两条承诺规则如果N大于它之前承诺过的任何编号那么它承诺以后不再接受编号小于N的任何Prepare或Accept请求并把结果告诉该Proposer。应答中包含Acceptor当前接受过的编号最大的提案如果它之前接受过提案的话。如果它从未接受过任何提案返回一个空值。第一阶段的核心目的就是让新提议者在正式提出议案前先修复自己对已有状态的认知。它就像你去投票站填表之前工作人员先翻一遍登记册看看之前有没有人已经投过票了如果投过你的选票内容必须是那个值而不是你自己的新主张。这个“补课”机制是Paxos安全性的关键枢纽。2.4 Accept阶段正式投票与最终确定Pre phase 跑完后Proposer手里攥着两个关键信息一是收到的若干应答二是大多数Acceptor承诺不再接受更小编号的提案。此时Proposer需要从这些应答里挑一个值如果准备阶段收到过任何已接受提案选择其中编号最大的那个值作为本轮提案值。不管这个值是不是自己想要的都只能认。如果收到的应答都是空值也就是没有任何历史包袱那Proposer就可以自由选择自己的值。定好提案后Proposer向Acceptor发送“接受编号为N、值为V的提案”请求。Acceptor在收到该请求后只要自己之前没有对编号大于N的Prepare请求作过承诺就可以正式接受这个提案并把( N , V )持久化下来。一旦某个提案被超过半数的Acceptor接受这个值就被“选定”了Learner负责把这个结果扩散给所有想知道的人。这里有一个非常反直觉的点一个提案被多数派“接受”并不等于协议结束因为后面还有可能有更大编号的提案把值改掉。Paxos的共识是一个“最终会收敛的连续过程”而非一次性的最终裁定。工程上我们通常通过Multi-Paxos或选主机制来避免多个提案者确保“选定”更接近一次终结。2.5 用一场“村委会表决”串起来把上面两阶段套到现实场景里村委会要决定一块地怎么用A和B两个人都想用自己的方案。A先发号施令各位编号1的方案我提议盖公园Prepare。过半数的村民回复我们承诺编号大于1的提议才会考虑目前没有已经同意的方案。A继续好那就编号1、盖公园大家表决Accept。多数人同意了A觉得自己赢了。这时B进场他提出编号2的准备请求。村民收到后回复编号2我承诺考虑但别忘了我们之前已经同意过编号1盖公园。B一看历史记录只能放弃自己的商业中心计划转而把编号2的值定为“盖公园”。即使A和B并发或者B的编号后来居上最终的结果仍然会收敛在“先达成多数决的那个值”上。这个例子越琢磨越有用。“后来者必须沿用已有值”这个约束就是Paxos能在各种混乱场景下收敛的唯一原因。3. 从Basic Paxos到Multi-Paxos工程实践的转折点3.1 Basic Paxos的致命短板Basic Paxos在理论上很漂亮但实际上没人直接在生产环境用裸版的它。原因很简单没有主次之分所有Proposer随时可以提新编号导致两阶段流程反复被打破。多个Proposer同时发起提案时每次编号咬得很紧A准备编号5B准备编号6A再准备编号7B再准备编号8……大家无休止地打断对方这就是经典的“活锁”Livelock系统一直处于准备和打断中就是选不出值。另一个问题是效率。每个提案都需要两轮RTTRound-Trip Time网络交互且每轮必须等待多数派响应。在请求量大的场景里这个开销完全不可接受。于是工程界对它做了两个重要改进产品形态就是Multi-Paxos。3.2 Multi-Paxos选出一个Leader一劳永逸Multi-Paxos的思路非常朴素与其让所有Proposer争来争去不如先通过一轮Basic Paxos选出一个LeaderLeader成为唯一的有效Proposer。既然唯一了后续所有提案就不再需要Prepare阶段直接进入Accept阶段。也就是说只有第一轮需要两阶段握手之后每个提案都只要一轮RTT。这里还要解决一个更实际的问题——多个并发值怎么办。Basic Paxos只解决一个值的共识但真实系统里我们需要提交的是一系列值比如日志条目。Multi-Paxos把日志序列分成多个“槽位”Slot每个槽位独立跑一个Paxos实例。比如槽位1提交第一条日志槽位2提交第二条日志各槽位之间并行但有序。这个设计把Paxos从“一次一致性协议”升级成了“一个日志复制协议”也是现代共识系统的基础架构。靠着一个固定LeaderMulti-Paxos把原本复杂的多值共识简化成了多数派节点顺序接收并确认日志的工程问题性能和正确性才同时有了保证。3.3 与Raft、ZAB等算法的关系很多人第一次接触Paxos时会顺带看到Raft和ZAB容易混淆。这里用一张表理清楚它们的关系算法核心思想易实现性典型场景Basic Paxos单值共识编号约束保障安全难懂难实现理论基石多被改造使用Multi-Paxos选Leader 日志槽位较复杂需要自行处理Leader变更与日志补齐分布式存储、协调服务内核Raft强Leader 日志连续性 随机超时相对清晰有明确算法步骤etcd、各种云原生组件ZAB主备模式 事务日志恢复中等复杂度绑定ZooKeeper使用分布式协调服务Raft经常被说是“为了让人看懂而降级的Paxos”它把选主、日志复制、安全性拆成了三个相对独立的问题。Paxos则更抽象把共识的所有复杂性压缩在一套精悍的规则里。二者本质上在同一道理上跑确保多数派一致后续通过日志复制来达成全序状态。4. 核心实现要点与伪代码搞清楚Acceptor该记住什么4.1 Acceptor的状态机三个关键变量Acceptor在实现时本质上是一个状态机。你可以用三个持久化字段来概括它的全部核心状态字段含义作用promised_id已承诺的最高编号拒绝编号更小的Prepare和Accept请求accepted_id已接受的最高编号应答给新Proposer用于历史回溯accepted_value已接受的提案值与accepted_id一并保存值是从历史中继承下来的Acceptor不需要记住所有历史提案它只需要记住“承诺过的最大编号”和“接受过的最大编号及其值”。这是一个非常精简的记忆模型只要这两个字段持久化下来即使重启也能恢复到正确状态。4.2 Proposer的流程伪代码Proposer的逻辑比Acceptor多一点调度策略核心实现也不复杂def propose(value): n next_round_number() # Phase 1: Prepare replies broadcast_prepare(n) if not is_majority(replies): return retry_with_higher_number() # decide value from prepares max_accepted find_max_accepted(replies) final_value max_accepted.value if max_accepted else value # Phase 2: Accept accept_replies broadcast_accept(n, final_value) if is_majority(accept_replies): return (committed, final_value) else: return retry_with_higher_number()这里有个很容易踩的坑retry时绝对不能沿用旧编号。分布式环境下多个请求并发重试如果编号复用Acceptor的承诺机制就会失效整个Paxos的安全性瞬间崩溃。因此每个提议者必须持久化自己的最大编号计数器确保编号严格递增。4.3 持久化Paxos的“隐藏底线”协议的逻辑再完美只要Acceptor宕机丢失状态一切归零。Acceptor在自己的节点上必须保证三个字段在返回任何承诺或接受回复之前先写入磁盘。这和数据库里的WAL预写日志是同一个道理先落盘再应答。如果先应答再写盘宕机后节点可能把曾经的正式接受记录丢失回滚到旧值进而破坏多数派交集上的安全保证。工程上常见的做法是采用fsync级别的刷盘策略但这就需要我们在性能和安全之间找平衡。很多生产系统会把响应延迟的核心瓶颈定位在磁盘刷盘上因此会用批量合并、组提交等技术来优化但原则绝不能动摇承诺和接受的结果必须先持久化。4.4 网络超时与幂等设计分布式环境下请求可能超时而超时并不代表对方没收到。Proposer必须对“请求重发”做幂等处理。也就是同一条Prepare或Accept消息重发多次Acceptor不能重复写入而产生副作用。最好的做法是在每条消息里携带全局唯一的提案编号和消息类型Acceptor根据状态机知道哪些编号已经处理过直接忽略重复。我之前遇到过一种坑消息重发时Proposer重新生成新编号导致编号跳跃过快间接诱发活锁。正确思路是网络超时后先重试同编号的请求若干次确认不行再提升编号重新跑两阶段。盲目升号只会让多个Proposer互相踩踏让系统更难收敛。5. 生产环境中的常见问题与排查实录5.1 活锁Paxos最著名的“死局”活锁在文档里经常被一笔带过实操里却非常常见。多个Proposer同时提案、不断用更高编号打断对方导致系统在Prepare阶段反复横跳迟迟无法完成Accept。有一次我在测试环境模拟并发写入就曾看到一条提案卡了十几秒都没能被选定日志里全是Prepare和拒绝。排查方法很直接看Acceptor日志里是不是频繁出现higher number promised / rejected记录。解决手段有几种最简单的是引入Leader机制保证只有一个Proposer在干活其次是采用随机退避策略Proposer被打断后随机等待一段时间再开始新一轮减少撞车概率。5.2 网络分区多数派不成立的场景假设5节点集群裂成两个分区一边3个节点一边2个节点。3个节点的分区里拥有多数派可以继续接受提案、推动日志复制。2个节点的分区里无论怎么折腾都不可能凑齐3个响应所有写入都会超时。这意味着Paxos在分区下的行为是“一边继续服务一边停止写入”而不是两边各写各的、最后数据撕裂。如果业务侧发现有节点在分区期间依然响应“写入成功”那问题往往出在Acceptor承诺逻辑上要么没有持久化承诺编号要么接受请求时没有再次校验编号。解决思路是对一切“成功响应”做双重检查一次在收到请求时判断编号一次在真正持久化前再判断一次。5.3 日志空洞与槽位补全Multi-Paxos引入日志槽位后会出现一个新问题某个槽位可能因为Leader临时宕机始终没有值被提交形成空洞。后续日志已经提交到槽位3、槽位4但是槽位2还是空的。这时新Leader必须补齐所有空洞才能把日志状态完整地同步给多数派。补洞逻辑听起来简单实现起来需要在槽位空洞上做探测和填充确认每个槽位要么自己提交要么继承前任已提交的值。最稳妥的方案是让新Leader先发起一轮no-op提案空提案把空洞槽位全部占住并同步值等日志序列连续了再继续对外提供写服务。这个细节如果漏掉就会出现日志断层、状态机回放错位的严重故障。5.4 一个速查表日常排查指南症状可能原因排查重点提交总是超时重试无效活锁或分区导致多数派不成立检查Acceptor日志中拒绝记录、分区状态服务返回的数据不一致Learner同步不及时检查Learner是否接收所有已选定提案重启后提案历史丢失持久化不完整检查Acceptor刷盘顺序和fsync配置出现两个“Leader”安全约束被绕过核对承诺逻辑确认没有跳过Prepare阶段数据恢复后出现旧值覆盖新值编号计数器未持久化检查Proposer编号生成和恢复逻辑6. 写在最后的一点个人心得翻来覆去讲这么多最后只想说一个体会Paxos真正的难点从来不在“看懂”而在“接受它的设计直觉”。它跟我们平时写业务代码的直觉完全不一样——我们习惯先想清零再写值Paxos则是先摸清历史再决定怎么补一笔我们习惯用锁和崩溃恢复保证安全Paxos则靠编号和多数派硬生生推导出安全。第一次看可能会觉得绕但当你亲手在模拟器里造出两个分区看着一边能提交、一边只能干等你会对“为什么必须多数派”有肌肉记忆一样的理解。如果你打算实践我的建议是从一个单值Paxos的Python模拟器开始先跑通两阶段再慢慢加上网络延迟、消息丢失和节点宕机等故障注入。过程中你会把所有理论问题踩一遍这些踩坑经验比看十篇论文都管用。分布式没有银弹Paxos也不是银弹它是所有工程方案里复杂度与正确性折中得最漂亮的那一个。