前面两篇分别把 Basic Paxos 的单值协商过程和活锁问题讲透了。这篇继续往实用性走:Multi-Paxos 到底怎么把单实例协议变成一个可以扛住生产流量的日志复制系统,以及那些真正影响吞吐、延迟和故障恢复的优化点到底怎么落地。
如果你已经理解“Prepare 阶段负责选出一个唯一提案号,Accept 阶段负责把值定下来”,那 Multi-Paxos 的优化核心其实一句话就能说清:把 Prepare 的成本从“每提交一个值做一次”降成“每个 Leader 任期内做一次”。剩下的工作基本都是在跟网路往返、磁盘刷写、乱序到达和故障恢复作斗争。
这篇内容适合两类读者:一类是刚看完 Paxos 理论,卡在“协议懂了但不知道怎么设计”的开发者;另一类是已经做过 Raft 或类 Paxos 系统,想回头看看 Multi-Paxos 在工程上还有哪些优化空间的人。我会把优化目标、核心机制、工程细节和踩坑经验放在一起讲,尽量做到看完能直接用。
1. 从经典Paxos到Multi-Paxos:先想清楚优化目标
1.1 Basic Paxos的“成本”到底花在哪
很多人误以为 Paxos 慢是因为消息多,其实更准确地说,是“每次只协商一个值”的模型导致每一轮都要走完整的两阶段流程。
Basic Paxos 里,一个提案要达成共识,最少需要两轮往返:
- 阶段一:Proposer 向 Acceptors 发送 Prepare 请求,带上提案号 n;Acceptors 返回 Promise,承诺不再接受比 n 小的提案。
- 阶段二:Proposer 拿到多数派的 Promise 后,根据返回结果决定提案值 v,然后向 Acceptors 发送 Accept(n, v);Acceptors 接受后返回确认。
假设集群有 5 个 Acceptor,多数派是 3。一次成功提交至少要等一轮 Prepare 请求和一轮 Accept 请求都得到多数派响应。如果系统里提交 100 个不同的值,朴素的实现就是 200 轮 RTT。这还是理想情况,没有算上提案号冲突、重试、网络乱序。
更关键的问题是:Basic Paxos 只解决“一个值”的共识。可一个分布式状态机需要的是“一串值”,也就是日志里每个槽位都要达成共识。如果把 Basic Paxos 当作黑盒,对日志的每一条分别调用一次,那就等于把单值的低效率放大了 N 倍。
1.2 Multi-Paxos不是“多个Paxos”简单叠加
Multi-Paxos 的正确打开方式,不是“多跑几个 Basic Paxos 实例”,而是把日志中的每个槽位共享同一个提案号,共享同一轮 Prepare。
我们可以把每个日志槽位看成一个独立的小 Paxos 实例,但它们之间有一个共性:如果系统里存在一个稳定 Leader,这个 Leader 的提案号在所有活跃提案中始终最大,那么它根本不需要在每个槽位都重新 Prepare 一次。
原因来自 Paxos 自身的承诺机制。Acceptor 在收到提案号 n 的 Prepare 并返回 Promise 之后,就会拒绝所有提案号小于 n 的 Prepare 和 Accept。也就是说,只要 Leader 用一个足够大的提案号 E 完成了一次 Prepare,那么它在接下来整个任期内,都可以继续用 E 提交任意槽位的值,而不需要重新跑 Prepare。
这是 Multi-Paxos 所有优化里收益最大的一环。从数学上讲,它将总 RTT 从“2 × 槽位数”降到了“槽位数 + 1”。
1.3 优化的总原则:减少RTT、减少消息、减少等待
工程实现里的优化动作虽然很多,但目标不会跳出这三条:
- 减少 RTT:跳过不必要的 Prepare,批量提交多个槽位,让一次网络往返覆盖更多日志条目。
- 减少消息:一个节点的回复尽量带上更多状态信息,减少额外的状态同步请求。
- 减少等待:允许乱序确认,不因为后面的 Accept 还没回来就阻塞前面的日志应用,用水位线管理提交边界。
这三条原则会反复出现在后面的小节里。即使你换一个协议实现,比如 Raft,优化思路也基本是同一套。
2. 最核心的优化:跳过Prepare阶段
2.1 在一次Prepare之后,所有实例共享同一个Epoch
跳过 Prepare 说起来容易,但有一个前提:必须明确“一次 Prepare 到底覆盖哪些范围”。
我的做法是引入 Epoch 概念。Epoch 本质上就是提案号,但语义上代表“Leader 的任职代数”。它需要全局唯一且单调递增。常见的实现是用一个单调递增的序号加节点标识,比如(10, node-3)。这样即使某一任 Leader 因为网络分区失联,其他节点重新选主后产生的 Epoch 也不可能和旧 Leader 相同。
当新 Leader 上任时,它先执行一轮 Prepare,告诉所有 Acceptor:“我现在的 Epoch 是 E,你们承诺忽略所有小于 E 的提案。” 在这轮 Prepare 成功之后,这个 Leader 就可以拿着 E 连续提交所有日志槽位。
这里要特别强调:跳过 Prepare 并不是省略提案号检查,而是把检查从“每个槽位启动前”变成“每个 Leader 任期启动前”。Acceptor 仍然会按 E 去校验每个 Accept 请求,但校验逻辑是单纯的编号比较,不需要再额外处理一次协商过程。
2.2 Leader与Epoch的生成规则
Epoch 的生成看起来简单,实际有很多细节。如果两个节点各自生成了相同 Epoch,那么整个算法直接崩坏。因此我强烈建议不要把 Epoch 单纯设计成一个自增整数,而是用“(整数部分, 节点ID)”的组合。
举个例子。节点 A 和 B 同时开始选举,分别向 Acceptors 发送 Prepare(E=(term+1, A)) 和 Prepare(E=(term+1, B))。Acceptors 收到的两个 Epoch 的整数部分一样,但节点标识不同,仍然可以区分大小。E 的字典序可以定义为先比较整数部分,再比较节点 ID。这样从协议层面就杜绝了 Epoch 碰撞。
还有一个容易被忽略的问题:节点从网络分区恢复后,如果不知道当前集群已经产生更高的 Epoch,它可能带着一个旧 Epoch 继续提交。这其实不会破坏一致性,因为 Acceptor 会拒绝旧 Epoch 的 Prepare 和 Accept。真正危险的是节点“误以为自己的旧 Epoch 仍然有效”,于是继续处理客户端请求。所以实现时,凡是被 Acceptor 拒绝的 Leader,必须立刻退出 Leader 角色,并且把拒绝消息中携带的更高 Epoch 记录下来,用于下次选举。
2.3 跳过Prepare的边界条件与风险
跳过 Prepare 并不是无条件安全。它的安全性依赖于“Acceptor 没有忘记自己 Promise 过什么”。所以只要 Acceptor 在返回 Promise 或者 Accept 确认之前崩溃,重启后丢失了这些状态,就会破坏 Paxos 的承诺基础。
因此工程实现里,Acceptor 必须在落盘成功后才能返回响应。如果先回包再 fsync,节点崩溃后可能丢失了“我承诺过 Epoch E”的记录,导致重启后接受更低 Epoch 的提案,产生两个不同值都被多数派接受的局面。
另一个边界条件是 Leader 本身是否仍然存活。Paxos 允许旧 Leader 继续提交提案,但一旦新 Leader 已经完成 Prepare,老 Leader 的请求都会被 Acceptor 拒绝。如果旧 Leader 不能及时得知自己被取代,它会在客户端面前表现出一段时间的“假活”状态。这不是 Paxos 需要解决的问题,而是选主和故障检测需要配合处理的问题。
实际项目中,我习惯给 Leader 增加一个租约(Lease)概念。Lease 会在后面的读优化部分详细讲,这里先记住:跳过 Prepare 省下的性能,必须用严格的 Epoch 失效机制来兜底。
3. 工程化的第二阶段:批量、流水线与乱序提交
3.1 每个Slot独立跟踪,不再全局锁
Multi-Paxos 采用日志模型后,每个槽位都是独立状态。即使 Leader 用同一个 Epoch 发送 Accept,Acceptors 也可以按槽位分别接受,不受顺序影响。
这意味着 Accept 确认消息可能乱序返回。比如 Leader 给槽位 5、6、7 同时发了 Accept,可能先收到槽位 7 的确认,槽位 5 的确认还在路上。在这种情况下,不能用一个全局的“日志尾部指针”来决定提交位置,否则要么提前提交了未确认的槽位,要么一直阻塞在最早的空槽上。
正确的做法是为每个槽位维护独立状态:
acceptedBallot[slot]:该槽位当前接受的 Epoch。acceptedValue[slot]:该槽位接受的值。isApproved[slot]:Leader 是否已经收到多数派确认。
同时维护两个水位线:
commitIndex:已经确定可以提交的最高连续槽位。appliedIndex:已经应用到状态机的最高连续槽位。
只有commitIndex之前的所有日志条目都被应用到状态机,客户端请求才能开始处理下一个条目。如果出现后面的槽位先被确认、前面的槽位还没确认的情况,虽然 can't commit 新槽位,但绝不能用一把全局锁把整个日志复制的进度卡死。
3.2 批量提交如何摊薄一轮RTT
跳过 Prepare 之后,每个槽位的正常提交流程变成一轮 Accept。如果一次只提交一个槽位,那么客户端的每个写请求仍然对应一轮 RTT。高并发下,这会让 Leader 成为网络热点,而且大量小包会浪费带宽。
批量的思路很直接:把短时间内到达的多个写请求合并成一个批量,分配一组连续槽位,在同一个 Accept 请求里发给 Acceptors。
我常用的参数是两类阈值:
- 最大批量大小:比如一次最多提交 128 个日志条目。
- 最大等待时间:比如最多等 5ms,超时后即使不够批量大小也直接提交。
把这两个参数调好的效果非常明显。假设平均每毫秒到达 100 个请求,最大等待 5ms,那么每个 Accept 包能带上约 500 个条目。RTT 从“每请求一轮”变成“每 500 请求一轮”,吞吐量可以提升几个量级。
但批量提交会牺牲单请求延迟。如果某个写请求刚好错过了一个批次,它可能要多等一个等待窗口。所以不建议把等待时间设得太大。实际经验是 2ms 到 5ms 之间比较合适,低延迟场景甚至可以设到 1ms。
3.3 流水线设计中的水位线管理
批量和流水线是两回事。批量是一次 Accept 覆盖多个槽位;流水线则是允许“上一批 Accept 还没确认完毕,下一批 Accept 就已经发出”。
因为每个槽位独立,流水线天然可行。只要 Leader 知道下一个可分配的槽位编号是 S,它就可以不断分配新槽位,不必等待前面的确认。但这里有一个隐含代价:如果某一批请求最终没有被多数派接受,那么这些槽位留下来的“空位”会阻塞 commitIndex 往前推进。
我的解决方式是:在新 Leader 上任时,先把一段空的日志槽位填上 no-op 条目,保证日志前缀连续。在日常运行中,出现疑似丢槽位的情况时,立即发起一轮针对空槽位回填的 accept,让这些槽位先被“定住”,不拖慢后续日志的应用。
水位线管理还要考虑客户端可见性。日志条目只有落到commitIndex之后才会被真正“提交”,状态机只有在commitIndex处才能把结果返回给客户端。如果前面有空槽,即使后面槽位的值已经确认,也不能推进返回。这个逻辑要写清楚,避免为了吞吐牺牲线性一致性。
4. 读路径的优化:从二次RTT到本地读
4.1 为什么读操作不能直接读本地
很多人写完复制系统后会发现一个尴尬问题:写路径经过优化后延迟很低,但读路径反而很慢。
原因是状态机里每个节点的状态可能不一致。一个 Follower 节点可能因为没有收到最近一批日志,本地状态落后于 Leader。如果客户端直接读 Follower,会读到旧数据,破坏线性一致性。
在 Basic Paxos 中,读一个值也不能直接读本地,因为没有一个节点能确定自己是“最新的”。严格的做法是把读请求也当作一次协商,通过一轮 Prepare/Accept 确认最终值。但这显然代价太高。
Multi-Paxos 提供了更好的机会:如果存在一个稳定 Leader,并且所有写请求都经过它提交,那么 Leader 的日志状态一定不落后于任何已确认的写操作。因此,读操作只需要确认 Leader 身份有效,并且本地日志已经应用到最新提交位置即可。
4.2 Leader Lease与只读Leader
只读 Leader 是 Multi-Paxos 里最常用的读优化,但有一个隐患:如果旧 Leader 不知道新 Leader 已经产生,它可能继续给客户端提供读服务,返回一份“过时”但看起来正常的数据。
解决思路是引入 Leader Lease。Lease 的逻辑是:Leader 当选成功后,向所有节点广播一个租约,租约内只有它能对外提供只读服务。其他节点在租约到期前不能接受新 Leader 的选举请求,也不能赋予任何其他节点读权限。
租约需要时间同步作为前提。我的经验是:
- 心跳周期:1s。
- 租约时长:10s。
- 时钟偏差容忍:不超过 500ms。
也就是说,Leader 必须在租约到期前不断续租。如果租约到期,节点自动认为当前 Leader 失效,并允许发起新的选举。旧 Leader 发现自己的租约已经过期,就立刻停止只读服务。
这里要特别小心:Lease 只是一种性能优化,不能替代 Paxos 的安全性。如果时钟漂移太严重,两个节点可能同时认为自己拥有读权限。解决方法是把时钟同步作为一个硬性部署要求,并且在租约时间上留足余量。不要在廉价的云主机上把租约设成跟心跳一样长。
4.3 让Follower也安全地提供读服务
只读 Leader 解决了 Leader 节点的读效率,但生产环境通常还需要 Follower 承担部分读流量,否则 Leader 容易成为热点。
要让 Follower 安全读,常见做法是从 Leader 获取一个“读索引”,然后本地等待应用到该索引后再返回。读索引本质上就是 Leader 当前的 commitIndex。因为 Leader 的 commitIndex 一定不落后于所有已确认的写操作,Follower 只要把本地 appliedIndex 追上这个值,就可以从本地状态机读取。
这个方案在 Paxos 体系中并不复杂,配合 Lease 和心跳就能实现。但要注意,如果 Leader 发生切换,Follower 可能拿到了旧 Leader 的读索引。所以 Follower 在返回读结果前,最好再确认一次当前 Epoch 没有发生变化。如果 Leader 因为租约失效而更换,读请求应该被透明重试。
5. 故障场景下的优化处理
5.1 新Leader上任时的状态恢复
Leader 切换是 Multi-Paxos 最容易被低估的环节。很多人把“跳过 Prepare”想得太理想,却忘了:新 Leader 必须知道老 Leader 到底接受了哪些日志条目,否则它可能覆盖掉已经达成共识的值。
为了满足 Paxos 的约束,新 Leader 发起 Prepare 时,Acceptors 不仅要承诺忽略更低 Epoch,还需要把每个槽位已接受的值返回给新 Leader。如果返回的某个槽位已经有值,新 Leader 必须选择 Epoch 最大(最新)的那个值继续提交,不能覆盖。
这个逻辑在槽位数量少的时候没问题,但日志一旦有几百万条,全量返回显然不现实。常见的优化做法有三种:
- 定期做快照(Snapshot),Accepted 状态只保留快照之后的增量。
- 新 Leader 优先向某个已知的“最新节点”拉取日志状态,而不是等所有 Acceptor 返回全量。
- 利用“状态传输”机制,把最新节点的状态机直接同步给落后节点,代替逐槽位协商。
这些优化本质上是把“共识层”和“状态同步层”解耦。理解这一点,比死记某个参数重要得多。
5.2 配置变更:减少停机时间
配置变更(增加节点、替换节点)在 Multi-Paxos 里也是一个常见的工程问题。如果每次改配置都停服,那可用性就无从谈起。
经典做法是使用 Joint Consensus 或单成员变更。工程上我更推荐单成员变更:每次只改变一个节点,先通过日志提交变更,再在下一个槽位使用新配置。这样做的好处是可以把旧配置和新配置的重叠区间缩到最小,避免因为多个节点同时变化而产生“少数派之间互相看不到”的情况。
配置变更期间,读操作和写操作都不能停,只要求新旧配置的交集是多数派。优化点在于:不要把配置变更做成一个单独的“管理接口”,而是直接塞进日志复制流程里。这样变更本身也走 Multi-Paxos 的批量和流水线机制,对吞吐影响很小。
5.3 持久化与批量Fsync的取舍
Multi-Paxos 里,Acceptor 返回确认前必须落盘。磁盘刷写往往比网络 RTT 更贵。很多系统明明网络延迟很低,吞吐却上不去,问题就出在每次 Accept 都做一次 fsync。
批量提交之所以能大幅提升吞吐,另一个隐藏原因就是它天然合并了 fsync。一批 128 个日志条目可以一次性刷盘,刷盘成本被均摊到 128 个条目上。
但这里存在一个工程陷阱:不能为了追求刷盘合并而无限增大批量。批量越大,单次故障恢复时需要重新发送的数据越多,恢复时间越长。需要根据磁盘性能和故障恢复目标来定。通常我会观察 P99 fsync 耗时,把最大批量控制在“一次刷盘时间 × 目标吞吐量”的合理范围内。
在实际项目中,我见过因为 fsync 耗时 10ms,而批量等待时间只有 2ms,导致几乎每次 Accept 都要额外等一次独立 fsync 的情况。正确的做法是让批量等待窗口略大于平均 fsync 窗口,或者干脆采用“后台异步刷盘 + 强制刷盘点”混合策略。具体取舍没有银弹,只能靠压测数据决定。
6. 常见故障与排查经验(实测总结)
6.1 两个Leader交替commit
现象:日志里出现相互冲突的条目,客户端读到明显不一致的数据。
排查思路:先看 Epoch 的生成规则是否真的唯一。常见的错误是把 Epoch 设计成“本机当前时间戳”或“进程启动次数”。时间戳在没有严格同步的环境下可能重复,进程启动次数在节点故障重启后也可能重置。
解决办法是在 Epoch 中带上节点 ID,并且节点 ID 必须全局唯一。同时,收到拒绝消息的节点必须立刻把自己的 Leader 状态清空。不要抱着“我还能再试一次”的心态重发旧 Epoch,那样只会在日志里留下大量无效请求。
我的实际经验是,这个问题十次里有八次不是 Paxos 数学问题,而是状态管理问题。所以排查时先看日志里有没有“Epoch 被拒绝”的告警,再去看选举逻辑。
6.2 空Slot被撑大之后应用阻塞
现象:日志后面的槽位已经确认,但状态机迟迟不应用,客户端读延迟飙升。
原因通常是日志中出现了空槽位。空槽位可能来自旧 Leader 已经分配了槽位但还没提交,也可能来自新 Leader 恢复时发现某些槽位没有值。
解决办法是主动填充 no-op 条目。这里有一个细节:填充 no-op 不能让状态机产生实际业务副作用,所以它必须是一个空操作。填充完空洞之后,commitIndex 才能继续向前推进。
排查时,我会先看 acceptors 返回的槽位分布,确定空洞范围,然后检查新 Leader 的日志恢复代码是否真的处理了“空洞回填”。很多简化实现直接忽略了这一步,结果就是灾难性的:系统吞吐正常,但所有读请求都卡在某个槽位后面。
6.3 优化后反而变慢的陷阱
现象:加了批量和流水线之后,P99 延迟不降反升,吞吐也没明显变化。
我遇到过的原因有三种:
- 批量等待时间设置太长,单请求感知延迟变高,但并发量不够,批量根本没攒满。
- Leader 成为热点,所有 Accept 都要经过它转发,网络队列开始堆积。
- Follower 的 apply 速度跟不上,日志应用的 backpressure 最终传回给 Leader。
针对第三种情况,可以在 Follower 上做一个简单的背压机制:如果 apply queue 长度超过阈值,Leader 就放慢下一批 Accept 的速度。不要只在 Leader 侧无限发流水线,否则只会把积压转移到系统最慢的环节。
整体来说,Multi-Paxos 的优化并不是把性能参数调大就好,而是要在延迟、吞吐、恢复时间和一致性之间做取舍。我在正式上线前,一定会做三组压测:正常流量、Leader 切换、断网恢复。只有这三组场景都符合预期,才算真的把优化落地。