如果你已经啃完了Basic Paxos,一定会有一种感觉:道理都懂,但真要用它搭一个能扛住生产流量的系统,总觉得哪里不对。这个问题我当年也卡了很久。Paxos本身解决的是"一个值怎么被多个节点一致定下来",但真实系统每天要定下来的值是成千上万条——客户端每提交一条命令,就得在日志里占一个位置,最终还得照同一个顺序应用到状态机上。如果一个值走一轮完整的Prepare+Accept,节点一多、请求一密集,光消息数量就能把系统压垮。Multi-Paxos要做的,就是把"一次共识"变成"一条共识流水线"。这篇我们就把Multi-Paxos的完整流程掰开揉碎,说清楚它到底比Basic Paxos多做了哪些事,以及工程实现里真正考验人的地方在哪。
1. 为什么Basic Paxos够用却不好用:单值共识的天然短板
1.1 一轮Basic Paxos只能定一个值
先把Basic Paxos的流程在脑子里过一遍:Proposer先发Prepare(n),Acceptor承诺不再响应编号小于n的Prepare,同时返回自己已经接受的最高编号议案;Proposer拿到多数派响应后,如果里面已经有已接受的值,就取编号最高的那个,否则自己选一个新值,然后进入Accept阶段发送Accept(n, value);Acceptor只要还没承诺接受更高编号的提案,就接受这个值;当多数派接受后,这个值就"被选定"了。
整个过程看着很完整,但你注意一个细节:它从头到尾只针对一个值在做共识。一条客户端写请求定了下来,下一条请求来了,对不起,你得重新来一轮Prepare+Accept。如果你把每条日志条目都当成一个独立的值,那么N条日志就要跑N轮完整的Basic Paxos,每轮都是两个往返、多轮交互。这在集群规模小、请求量低的时候还能忍,一旦请求密集起来,协议开销会直接淹没业务收益。
我用一个不太严谨但好记的类比:Basic Paxos像一次议会表决,一次只讨论一个议题,表决完再讨论下一个。Multi-Paxos则相当于议会把一堆议题编号之后放进流水线,正常状态下不再为每个议题重新走一遍"提名—拉票—表决"的完整流程。
1.2 Prepare+Accept的往返开销与活锁风险
除了"一轮只定一个值"的天然限制,Basic Paxos在并发场景下还有一个经典问题:活锁。两个Proposer同时抢着发起提案,A发Prepare(1),B发Prepare(2),A的Accept(1)会被B的承诺挡住,B的Accept(2)又可能被A更新的Prepare挡住。然后A升级到Prepare(3),B再升级到Prepare(4),两边互相打断,谁都没法凑齐多数派。
活锁不是死锁,系统没有卡死,但它就是不进步。你让多个Proposer并发跑Basic Paxos,可能长时间无法达成任何共识。这在理论层面不是致命伤,因为Paxos保证的是安全性(不会选出两个不同值),而不是活性(只要有请求就一定能快速得出结果)。可工程系统要的是可用性,一个随时可能陷入"提案编号互相碾压"的协议,没法直接拿去承接线上流量。
更要命的是性能账。每个值的决议至少需要两个RTT:Prepare一个往返,Accept一个往返。如果算上Acceptor向Learner通报结果,消息数量还会翻倍。在5节点集群里,每确定一个日志条目都要牵动一大批消息,吞吐量很难看。Multi-Paxos的核心动机,其实就是把这两个问题一起解决。
1.3 Multi-Paxos的总体思路:把共识变成流水线
Multi-Paxos并不是一个和Basic Paxos平级的全新协议,它是把Basic Paxos用在"连续多个值"场景下的工程化组合用法。你可以把它理解为:既然所有值都要按顺序写入日志,那就把日志切成一长串槽位(Slot),每个槽位独立走一轮Paxos决议;同时选出一个稳定Leader作为唯一的Proposer,由它统一分配槽位、发起提案。
这样一来有两个巨大收益:第一,Leader是唯一的,并发冲突和活锁从根上被消灭;第二,这个Leader一旦通过一轮Prepare拿到某个提案编号n的"排他权",后续所有新槽位都可以直接复用这个编号发Accept,不需要再重新Prepare。每个新条目只要一个RTT就能完成共识,整个系统从"一次共识"变成了"一条共识流水线"。
后面所有讨论都围绕这个核心展开。你只要记住一句话:Multi-Paxos = 日志槽位序列 + 稳定Leader + 一轮Prepare长期复用,其余细节都是在回答"这么做为什么安全、崩溃了怎么办"。
2. Multi-Paxos的两个支柱:Leader角色与日志槽位
2.1 稳定Leader解决了什么
首先得说清楚,为什么Multi-Paxos一定要选出一个单独的Leader。除了消灭活锁,它还要解决两个问题:顺序和效率。
顺序很好理解。日志复制到每个节点上的时候,所有节点必须按照同一个顺序应用命令,否则状态机就会分叉。Basic Paxos里每个值独立决议,谁先谁后没有全局协调者,自然谈不上顺序保证。Multi-Paxos通过Leader统一分配槽位编号,客户端请求到了Leader手里就被排成一个序列,每个请求落在哪个槽位,由Leader说了算。这样即使Accept阶段的消息在网络里乱序到达,各节点最终对齐的是统一的槽位顺序。
效率就更直接了。一个稳定Leader意味着多数时候只有一个Proposer在跑,不会出现多Proposer互相覆盖编号的情况;同时等到第3节我们会看到,Leader的Prepare只需做一次,之后所有槽位的Accept都能"挂"在同一个提案编号下走,省掉了大量重复逻辑。
工程上选Leader这件事本身也可以用Paxos来完成。每个节点各自用更高编号发起"我要当Leader"的提案,得到多数派支持的那个节点胜出。当然,Paxos原文并没有把选举细节完整固定下来,所以不同实现里Leader的选法五花八门,有的还带租约、带心跳,这属于Multi-Paxos留给工程师的"留白"。
2.2 每个槽位都是一次独立的Basic Paxos
Multi-Paxos把日志建模成一个数组:log[1]、log[2]、log[3]……每个下标就是一个槽位,每个槽位里存一条待共识的值。注意,槽位是日志索引,提案编号(proposal number)是Paxos轮次的标识,这两件事必须分开,很多初学者把它们混在一起理解,后面会越看越乱。
每个槽位在协议层面仍然是一次独立的Basic Paxos决议。Acceptor需要记住自己在每个槽位上接受过什么,比如"我在槽位5接受过编号3的提案,值为cmd7"。当Prepare询问时,Acceptor要把这些已接受信息如实汇报,而不仅仅是报告一个笼统的"我接受过的最小编号"。
为什么要按槽位记忆?因为不同槽位的决议完全独立。一个节点可能在槽位5接受了A值,在槽位7接受了B值,又在槽位6什么都没收到。如果Acceptor只保存一个全局"最高接受编号",新Leader就无法知道某个具体槽位是否可能已经被选定,补日志的时候只能靠猜。这是Multi-Paxos与Basic Paxos在实现上非常关键的差异点。
2.3 "一轮Prepare+N轮Accept"的真正含义
这是Multi-Paxos最核心的性能来源,值得单独拿出来讲。正常情况下,一个Leader当选时会发起一次Prepare(highest_n),得到多数派响应后,这个Leader在任期内就有了"用编号n发起提案"的特权。之后它处理任何新槽位时,直接向所有Acceptor发送Accept(n, command),不再做Prepare。
为什么可以这样?因为Prepare阶段的承诺还在生效。所有响应过Prepare(highest_n)的Acceptor都已经承诺:不再接受编号小于highest_n的提案。而这个Leader后续所有Accept都带着同一个大于等于highest_n的编号,Acceptor没有理由拒绝。于是从每值两个RTT,降到了每值一个RTT,这就是流水线的核心秘密。
但你心里要清楚,这里说的是"每个Leader任期只做一次完整Prepare",不是说整个系统一辈子只做一次Prepare。一旦Leader挂了、换了新Leader、新编号n'大于n,那么新的Prepare必须再来一次。旧的Leader如果还没意识到自己失权,继续拿旧编号n发Accept,会被Acceptor拒绝。协议靠这一点保证,任何时候最多只有一个"合法"的Leader在推进。
3. Multi-Paxos完整流程走读:一条命令是怎么被定下来的
3.1 新Leader上任:用Prepare拿下"排他权"
假设集群里3个节点A、B、C,其中B刚刚通过一轮Paxos选举成为新Leader,它的提案编号是8。B接下来要做的事,是对所有Acceptor(包括它自己)发送Prepare(8)。
每个Acceptor收到Prepare(8)后,先判断这个编号是否大于自己之前承诺过的最大编号。如果是,就更新自己的承诺编号到8,并把自己在每个槽位上接受过的最高编号提案返回给B;如果不是,就拒绝。
B拿到多数派响应后,从这一堆返回信息里能推导出两件事:第一,自己在这轮任期内不会再被旧编号的提案打扰,取得了"排他权";第二,它知道了哪些槽位已经可能被选定,哪些是空白,这为后面补洞和继续服务新请求打好了基础。这里有一个关键点:Prepare本身没有确定任何新值,但它决定了"谁敢在未来发提案"以及"新Leader如何继承历史"。它不是可选项,而是安全性的基石。
3.2 给请求分配槽位:被多数人忽略的先后问题
B成为Leader并完成Prepare后,开始接收客户端请求。客户端发来命令cmd1,B把当前可分配的槽位号拿过来,假定是槽位10,于是构造日志条目log[10] = cmd1,然后向所有Acceptor发送Accept(8, slot=10, value=cmd1)。
接下来cmd2来了,B给它分配槽位11,再发Accept(8, slot=11, value=cmd2)。你可以看到,槽位分配完全由Leader单方面决定,不需要与任何节点协商。这也意味着,如果客户端同时发来多个请求,Leader必须自己决定一个顺序,比如按接收先后顺序排,或者按客户端携带的序号排。这个顺序最终会通过日志传播到所有节点。
如果Leader在分配了槽位10之后,还没来得及让cmd1在多数派上确认就崩溃了,会发生什么?槽位10这个值可能只存在于一部分节点上,没有被选定;新Leader上任后有权覆盖它。客户端那边表现为"请求超时或结果未知",这是可以接受的,Paxos本身就允许未提交的命令丢失,客户端要通过幂等重试来兜底。
3.3 多个槽位的Accept可以并行,但提交必须克制
很多人在设计时会问:Leader必须等槽位10提交了才能给槽位11发Accept吗?答案是:不需要。Accept阶段可以完全并行。B可以同时对Acceptor发Accept(8,10,cmd1)和Accept(8,11,cmd2),两个槽位互不干扰,Acceptor各自判断编号、各自应答。
真正要克制的是提交(commit)和应用(apply)。状态机应用永远必须按槽位顺序来:如果槽位11已经被多数派接受,但槽位10还没确定,B不能先把cmd2应用进去,否则在槽位10有值的节点和没有值的节点之间就会出现状态机不一致。
这就意味着Leader在推进的时候要留意"空洞"。出现空洞的原因通常是消息乱序、节点宕机或者网络分区,某些槽位迟迟凑不齐多数派。Leader可以选择持续重发Accept,也可以把某些尚未确定的槽位用空操作(no-op)填掉,保证日志从低到高连续,这样高槽位的值才有机会被安全应用。关于no-op,第4节会展开。
3.4 从多数派接受到状态机应用之间还差一步
当一个槽位的值被多数派Accept后,它在Paxos意义上已经被"选定"(chosen)。但日志里的其他节点不一定立刻知道这个事实,Learner需要一条路径来获知"槽位10选定的值是cmd1"。
经典Paxos里,每个Acceptor可以直接向Learner发送自己的接受信息,Learner自己统计多数派;工程上更常见的是由Leader统一广播一条commit消息:"槽位10已经选定,值是cmd1"。这种方式消息量小、控制集中,但代价是Learner的进度依赖Leader。如果commit消息丢失,某些节点可能暂时不知道已选定的事实。为了应对这种情况,工程实现通常会让follower定期向Leader查询自己落后的范围,或者通过心跳消息带上最新的已提交槽位号。
这里还要区分两个概念:chosen是"值已被多数派接受",applied是"状态机已经执行了这条命令"。Multi-Paxos保证所有节点最终对chosen集合收敛,但对applied的要求更严格——所有节点必须按相同的槽位顺序应用同样的值。因此,哪怕某个节点已经收到槽位12的commit,如果槽位10、11还没补齐,它也得先卡着,绝不能跳着执行。
4. 崩溃恢复与Leader切换:Multi-Paxos最考验人的地方
4.1 新Leader的第一道关卡:Prepare能问到什么
Multi-Paxos最复杂、也最容易让人理解出偏差的场景,就是Leader切换。假设旧Leader A带着编号7工作到一半崩溃了,节点B发起新一轮选举,获得多数派支持,编号升到9。B要做的第一件事依然是Prepare(9),但这次Prepare的"问询"范围比第一次选Leader时要大得多。
B需要知道:在这3个节点上,哪些槽位已经被接受过提案、每个槽位被接受过的最小编号是多少、值是什么。于是每个Acceptor把自己记忆中的已接受列表都返回给B。因为多数派之间必然有交集,B通过比对这些列表,就能判断每个槽位是否已经被选定:如果一个值出现在至少一个多数派集合里,它就是chosen;如果没有出现在任何多数派中,那它就没有被选定。
这一步是整个Paxos安全性最集中的体现。旧Leader A可能已经把槽位5的cmd5发送给了B和C,B和C都接受了,那么哪怕A立刻崩溃,B作为新Leader也必然能从Prepare响应里看到"槽位5被多数派接受过",从而继承这个值,不会也不敢用新值覆盖它。正是靠多数派交集这个数学事实,Paxos才能承诺"已经选定的值永远不会丢"。
4.2 日志空洞怎么处理:no-op填充与请求回滚
新Leader完成Prepare后,面对的通常是一张千疮百孔的日志表:某些槽位已经确定,某些槽位只在个别节点上有影子,某些槽位完全空白。Multi-Paxos的处理思路分两步。
第一步,把所有已经被选定的槽位原样继承下来,这些是历史事实,新Leader无权更改。第二步,对未被选定的槽位做填充。填充时优先查看Prepare响应里有没有这个槽位的高编号已接受值——如果有,就继续沿用那个值重新发起Accept,因为那可能是一个旧Leader已经"半提交"的请求,保留它更稳妥;如果没有任何节点报告过这个槽位,那就可以用no-op空操作占位,保证日志连续,之后新请求从更高槽位继续分配。
需要理解no-op背后的含义:它不代表任何客户端命令,执行后状态机不发生任何变化。它纯粹是为了填坑,防止日志出现空洞导致高槽位无法推进。所以某些客户端请求可能在Leader切换时丢失,表现为"发出去了但没收到确认"。这是Multi-Paxos允许的正常现象——只有真正被多数派持久化的命令才被视为提交成功,其余都可以被新任期中的no-op或后继请求覆盖。
4.3 持久化清单:哪些东西不落盘会出事
如果要我用一句话概括Multi-Paxos工程实现的难点,那就是:磁盘上的状态决定了崩溃恢复后的行为。Acceptor至少要把两样东西落盘:承诺过的最大提案编号,以及每个槽位上接受过的最高编号提案和值。
为什么承诺编号必须持久化?因为如果你不记得自己已经承诺"不再接受小于7的提案",崩溃恢复后你可能会稀里糊涂接受一个编号6的旧提案,破坏整个协议的安全性。为什么已接受记录必须持久化?因为如果你忘记了自己在槽位5接受过cmd5,在新Leader问询时你就汇报不出这条历史,可能导致新Leader以为槽位5没有被选定,从而用另一个值覆盖它。
落盘的节奏也有讲究。Acceptor必须在回复Accept响应之前完成持久化,也就是先fsync、再回包。如果你先回包再落盘,节点可能在回包后、落盘前断电,Leader以为已经凑齐多数派了,实际数据却没了。这种问题在单元测试里很难暴露,但真实断电演练会毫不犹豫地给你好看。
4.4 一次完整的Leader切换推演
把上面这些串起来,看一个完整场景。三个节点A、B、C,A是旧Leader,编号7;A曾向B、C发出槽位1到6的Accept,三个节点都已经落盘确认,槽位1到6全部安全提交。随后A给C发送了槽位7的cmd7,C接受了并落盘,但B没收到,A在这时崩溃。
B开始竞选,编号升到9,发Prepare(9)给B和C。B自己知道槽位1到6已经在多数派上,C返回的信息里有槽位7的最高接受编号为7、值为cmd7。B通过比对发现:槽位7只出现在C一个节点上,没有凑成多数派,所以没有被选定。
接下来B怎么处理槽位7?按照Basic Paxos的规则,Prepare响应里出现了这个槽位上的最高编号已接受值cmd7,B应该沿用cmd7重新发Accept(9, slot=7, value=cmd7)。与此同时,B对槽位1到6不做任何操作,因为它们是已提交的历史。处理完槽位7后,B才能开始分配槽位8及以后的请求。
这个推演里最值得品味的是槽位7的命运:旧Leader A可能以为自己"发出去了就算处理了",但对整个系统来说,只要没有多数派持久化,它就不算提交成功。B出于安全协议保留了旧值cmd7,而不是随便用no-op覆盖,这正体现了Paxos对"可能已选定值"的尊重。
5. Multi-Paxos与Raft的同与异:背靠背对照理解
5.1 两者本质上都在做同一件事
很多人纠结Multi-Paxos和Raft到底是什么关系。站在更高的层面看,它们解决的是同一个问题:如何为一长串日志条目提供有序、一致、可复制的共识。两者都选出一个唯一的Leader,都由Leader分配日志槽位,都依赖多数派确认提交,都要求状态机按日志顺序应用。
Raft在设计上确实更细致、更工程化:它把领导选举、日志同步、安全性约束、集群成员变更都写得非常具体,可以直接照着实现。而Multi-Paxos更像一个范式,它告诉你要用槽位+Paxos轮次来解决多值共识,但把选举触发、日志对齐这些细节留给了实现者。这也是为什么很多人学Paxos会觉得"理论懂了但不知道怎么写代码",而学Raft会觉得"每一步都能落到底"。
5.2 把差异摊开来看
我整理了一张常用对照表,方便你快速定位两者的关键差别。
| 对比维度 | Multi-Paxos(经典形式) | Raft |
|---|---|---|
| 领导者定位 | 稳定的Distinguished Proposer,正常情况唯一提案者 | 唯一Leader,选举依赖超时和随机化 |
| 轮次/任期标识 | 单调递增提案编号,全局唯一即可 | 任期号term,每个任期绑定一次选举 |
| 新Leader的历史继承 | 通过Prepare响应查询各槽位已接受信息 | 通过选举限制,候选人必须包含最新已提交日志才有资格当选 |
| 日志一致性机制 | 需要自行补洞、决策保留或no-op | 日志匹配机制,强制follower对齐Leader日志 |
| 提交确认 | 自定义,常见为Leader收到多数派Accept后广播commit | Leader更新commitIndex,随下一次AppendEntries广播 |
| 实现复杂度 | 留白较多,工程上需要自行补齐多处细节 | 细节固定,可直接照论文实现 |
你可以把Raft看成Multi-Paxos的"确定化实现",它在设计上砍掉了不少模糊空间,代价是约束更严格,而Multi-Paxos更灵活,但灵活性往往意味着实现者要承担责任。
5.3 Raft的约束,恰好补掉了Multi-Paxos的"留白"
先看选举限制。Multi-Paxos的新Leader通过Prepare了解历史,理论上只要多数派响应里能看到所有已提交槽位,它就不会覆盖旧值;但问题在于,Prepare响应只反映"Acceptor已接受的最高编号提案",如果某个Acceptor虽然落后但没参与多数派交集,新Leader可能拿不到完整信息。Raft选择用更直接的办法:投票时follower会检查候选人的日志是否至少和自己一样新,如果不是就不投给它。这样能赢得选举的候选人,日志天然包含多数节点上的最新条目,已提交日志必在其中。
再看日志匹配。Raft规定,如果两个节点在同一个槽位上的任期相同,那么这个槽位之前的所有日志都必须相同。这条属性让新Leader可以强制所有follower以自己为基准补日志,直接允许follower删除与Leader不一致的尾部。Multi-Paxos没有这样强制的对齐手段,它更多依赖Prepare结果和工程上的拉平策略。理解这个区别之后你会发现,Multi-Paxos的安全性承诺更"抽象",但也正因为抽象,它没有为工程实现提供足够的护栏。
对我来说,学习路径很清晰:先用Multi-Paxos理解Paxos家族的核心机制,再用Raft理解一套完整、可落地的工程化约束。两边对照着看,很多原本模糊的概念都会变得具体。
6. 学习与实现Multi-Paxos的常见误区(含个人经验)
6.1 误区一:Leader稳定后Prepare就永远不需要了
前面说过,同一个Leader任期内确实不用重复Prepare,但这不意味着Prepare从这个系统里消失了。最危险的场景是网络分区:分区一侧的旧Leader还活着,继续给少数派节点发Accept;分区另一侧选出了新Leader,编号更高。新Leader的Prepare会让Acceptor拒绝所有旧编号提案。可如果旧Leader没有感知到自己已经被"架空",它还会一直向客户端承诺请求成功,最后客户端会发现一堆请求实际上没有提交。
工程上一般通过租约解决:Leader必须周期性续租,续租失败就主动下台,不再对外提供写服务。这里有一个很实用的经验:实现Multi-Paxos时,宁可让写服务短暂不可用,也不要让两个"自认为是Leader"的节点同时处理客户端请求,否则就会出现两个客户端拿到不同结果的风险。
6.2 误区二:所有节点的日志必须完全一致
很多人看完Paxos后下意识以为它像同步复制一样"每个节点都有一模一样的日志"。实际上Multi-Paxos只保证所有节点对已提交日志最终收敛一致,未提交日志和空洞完全可以各不相同。一个follower可以滞后很多条、可以临时有空洞,只要它在提交推进之前补齐并确认即可。
把"日志对齐"和"状态机一致"分开看,是理解所有复制状态机的关键。日志是中间数据,状态机的应用序列才是对外一致性的最终体现。所以实现时,不要追求所有节点实时完全一致,而是要让追赶机制(日志同步、快照、比对)足够高效,让落后的节点能在合理时间内追上Leader。
6.3 误区三:多数派接受就等于安全提交
多数派接受只是"这一瞬间多数节点收到了这个值"。如果多数派里有一部分节点没有把日志落盘就返回了接受响应,一旦这些节点同时断电,重启后整个多数派可能就不复存在,这个值实际上从未被持久化过。安全提交的准确定义应当是:多数派节点已将日志持久化,并且系统能通过后续协议保证该值不会被覆盖。
我见过不止一个项目在测试环境跑得很顺,一上真实集群就出现"提交成功但数据丢失"的怪事,最后定位到就是Accept响应先于持久化返回。如果你正在实现Multi-Paxos,请务必把fsync作为发送响应之前的最后一步,而且尽量用一条fsync批量覆盖多个槽位的写入,别把性能牺牲得太多。
6.4 我踩过的三个实现细节坑
第一个坑是提案编号生成。提案编号必须全局唯一且严格递增,我用过"时间戳+节点ID"的简单拼接,结果高并发下同一毫秒两个节点生成了相同编号,Prepare互相干扰,系统吞吐直接崩了。后来改成"任期号占高位、节点ID占低位"的组合方式,再配合Node ID比较,才彻底解决冲突问题。风格大致是:
# 提案编号生成:保证全局唯一且有序 proposal_id = (term << 16) | node_id new_term = max(term, last_seen_peer_term) + 1第二个坑是Prepare响应只返回了单个最高编号值。当时我图省事,让Acceptor只汇报一个"全局最高编号提案",结果新Leader根本没法判断具体哪一个槽位已经选定,补洞的时候只能乱来。正确的做法是按槽位返回已接受的高编号提案列表,必要时做快照压缩,避免日志太长导致Prepare响应包过大。
第三个坑是commit消息丢失后follower永久卡死。Leader广播commit,如果某个follower没收到,它就一直等着,而后续commit又接着来,它的日志永远缺一块。后来我在follower侧加了主动查询机制:收到高槽位commit时,如果发现中间有空洞,就向Leader请求缺失区间的commit信息,而不是傻等。这个改动看着小,却实实在在地救了生产环境的稳定性。如果你也正在实现Multi-Paxos,建议把"节点落后补日志"和"Leader切换推演"这两类场景直接写成测试用例,先跑通再写业务代码,能少走很多弯路。我自己从Basic Paxos走到Multi-Paxos,花时间最多的不是看证明,而是把"新Leader如何确定哪些槽位已经chosen"这件事反复画图。协议本身的规则不多,复杂的是你有没有真的想清楚崩溃恢复那一刻发生了什么。希望这篇能把Multi-Paxos的流程讲得比教科书更贴近工程实践,对正在读协议、准备动手实现的你有帮助。