ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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准备编号5,B准备编号6,A再准备编号7,B再准备编号8……大家无休止地打断对方,这就是经典的“活锁”(Livelock),系统一直处于准备和打断中,就是选不出值。

另一个问题是效率。每个提案都需要两轮RTT(Round-Trip Time)网络交互,且每轮必须等待多数派响应。在请求量大的场景里,这个开销完全不可接受。

于是工程界对它做了两个重要改进,产品形态就是Multi-Paxos。

3.2 Multi-Paxos:选出一个Leader一劳永逸

Multi-Paxos的思路非常朴素:与其让所有Proposer争来争去,不如先通过一轮Basic Paxos选出一个Leader,Leader成为唯一的有效Proposer。既然唯一了,后续所有提案就不再需要Prepare阶段,直接进入Accept阶段。也就是说,只有第一轮需要两阶段握手,之后每个提案都只要一轮RTT。

这里还要解决一个更实际的问题——多个并发值怎么办。Basic Paxos只解决一个值的共识,但真实系统里我们需要提交的是一系列值,比如日志条目。Multi-Paxos把日志序列分成多个“槽位”(Slot),每个槽位独立跑一个Paxos实例。比如槽位1提交第一条日志,槽位2提交第二条日志,各槽位之间并行但有序。

这个设计把Paxos从“一次一致性协议”升级成了“一个日志复制协议”,也是现代共识系统的基础架构。靠着一个固定Leader,Multi-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也不是银弹,它是所有工程方案里复杂度与正确性折中得最漂亮的那一个。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进