21.4 Multi-Paxos

一句话

Multi-Paxos 把 Basic Paxos 的"每个值两轮"压成"选一次 leader,之后每个值只跑第二轮"——省下的是阶段 1 的 1 个 RTT,代价是引入 leader 选举(论文没规定怎么做),并接受日志可以有洞、可以乱序,恢复时必须先补齐才知道哪些值已经选定。

从"一个值"到"一串值"

slot:       1      2      3      4      5
          ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
日志      │ a  │ │ b  │ │ c  │ │ ?  │ │ e  │   ← 每个 slot 是一次独立的 Basic Paxos
          └────┘ └────┘ └────┘ └────┘ └────┘
                                ↑ 洞:还没选定

每个 slot 独立跑一次 Paxos,就得到一条"可复制的日志"。但朴素做法要为每个 slot 付 2 RTT / 4N 条消息——这就是 Multi-Paxos 要解决的性能问题(Basic Paxos 的协议与证明见 21.3 Basic Paxos)。

三个优化

优化 做法 省下什么 前提(不满足就退化)
稳定 leader 选一个 proposer 长期负责,之后的 slot 跳过阶段 1,直接 accept 每值 1 RTT、2N 消息 leader 稳定:没有别的 proposer 用更高编号打断
流水线(pipelining) 不等 slot i 的 accepted 就发 slot i+1 吞吐 ≈ 窗口 / RTT 乱序响应要按 slot 归位
批量(batching) 一条 accept 带多个 slot 摊薄 RTT 与 fsync 单条延迟上升(等攒批)

为什么"跳过阶段 1"是安全的:leader 连续用递增的编号提案,在它稳定期间没有别人发更大的 prepare,所以"承诺"这件事已经被第一次阶段 1 覆盖了;而它自己知道每个 slot 的最新状态(它就是那个发提案的人)。一旦有别的 proposer 用更大编号出现,旧 leader 的 accept 会被拒绝——此时它要么退位,要么重新跑阶段 1。

⚠️ "跳过阶段 1"只对"确定没有更早选定值"的 slot 安全。leader 换人后,新 leader 对任何不确定的 slot 都必须先跑阶段 1,否则可能覆盖已选定的值——这就是「恢复」那一节要展开的规则。

日志可以有洞、可以乱序

现象 为什么允许 读的时候要注意什么
洞(slot 3 未选定,slot 4 已选定) 每个 slot 独立决定,后面的 slot 不必等前面的 读不能"跳过洞":读到洞就停下等它被填上,否则状态机顺序错乱
乱序完成 不同 slot 的 Paxos 实例独立推进 apply 必须按 slot 顺序,不能按完成顺序
重复提案 客户端超时重试 需要幂等(见 25.4 幂等设计与去重)

对比 Raft:Raft 的日志必须连续、且提交位置由"多数派 matchIndex 中位数"推进;Multi-Paxos 允许洞,代价是"已选定到哪"不像 Raft 那样有单一指针,恢复时要逐 slot 确认(见 21.6 Raft:日志复制)。

恢复:新 leader 必须先补齐

旧 leader L1:slot1..3 已选定(多数派接受),但只来得及通知一部分节点就崩了
新 leader L2:知道 slot1、slot2,但 slot3 是"已选定却不知道"
                │
                ├─ 错误做法:直接 accept(slot3, 新值) → 覆盖已选定的值
                └─ 正确做法:对 slot3 先 prepare → 学到旧值 → 沿用
package main

import "fmt"

type slot struct {
	promised int // 该 slot 上承诺过的最大提案编号
	an       int // 已接受的编号
	av       string
}

type acceptor struct {
	name  string
	slots []slot // 下标 0 对应 slot 1
}

func newCluster(n, m int) []*acceptor {
	as := make([]*acceptor, n)
	for i := range as {
		as[i] = &acceptor{name: fmt.Sprintf("A%d", i+1), slots: make([]slot, m)}
	}
	return as
}

func (a *acceptor) prepare(s, n int) (bool, int, string) {
	if n > a.slots[s].promised {
		a.slots[s].promised = n
		return true, a.slots[s].an, a.slots[s].av
	}
	return false, 0, ""
}

func (a *acceptor) accept(s, n int, v string) bool {
	if n >= a.slots[s].promised {
		a.slots[s].promised = n
		a.slots[s].an, a.slots[s].av = n, v
		return true
	}
	return false
}

// 某个 slot 上被多数派接受的值("已选定"),没有则返回空
func chosen(as []*acceptor, s int) string {
	cnt := map[string]int{}
	for _, a := range as {
		if a.slots[s].an > 0 {
			cnt[a.slots[s].av]++
		}
	}
	for v, c := range cnt {
		if c >= len(as)/2+1 {
			return v
		}
	}
	return ""
}

func phase1(as []*acceptor, s, n int) (string, bool) {
	bestN, inherit, promises := -1, "", 0
	for _, a := range as {
		if ok, an, av := a.prepare(s, n); ok {
			promises++
			if an > bestN {
				bestN, inherit = an, av
			}
		}
	}
	return inherit, promises >= len(as)/2+1
}

func phase2(as []*acceptor, s, n int, v string) bool {
	acc := 0
	for _, a := range as {
		if a.accept(s, n, v) {
			acc++
		}
	}
	return acc >= len(as)/2+1
}

func copyCluster(as []*acceptor) []*acceptor {
	out := make([]*acceptor, len(as))
	for i, a := range as {
		out[i] = &acceptor{name: a.name, slots: append([]slot(nil), a.slots...)}
	}
	return out
}

func main() {
	const M = 3
	as := newCluster(3, M)

	// L1:用编号 1..3 写 slot1..3,但 A3 掉线,只有 A1、A2 接受
	for i := 0; i < M; i++ {
		v := string(rune('a' + i))
		if phase2(as[:2], i, i+1, v) {
			fmt.Printf("L1 accept slot%d n=%d v=%q → A1、A2 接受(A3 掉线)\n", i+1, i+1, v)
		}
	}
	fmt.Printf("此时 chosen = [%q %q %q]:slot3 已被多数派选定为 %q,但 L1 还没来得及学习就崩了\n\n",
		chosen(as, 0), chosen(as, 1), chosen(as, 2), chosen(as, 2))

	// 错误路径:新 leader 跳过阶段 1,直接用自己的值覆盖
	bad := copyCluster(as)
	fmt.Println("错误做法:L2 跳过阶段 1,直接 accept(slot3, n=10, \"X\")")
	phase2(bad, 2, 10, "X")
	fmt.Printf("  → chosen[slot3] = %q(已选定的 %q 被覆盖)\n\n", chosen(bad, 2), "c")

	// 正确路径:先跑阶段 1,继承已接受的值
	good := copyCluster(as)
	fmt.Println("正确做法:L2 先 prepare(slot3, n=10)")
	inherit, ok := phase1(good, 2, 10)
	fmt.Printf("  → 多数派承诺=%v,返回已接受的最高编号值 %q\n", ok, inherit)
	v := "X"
	if inherit != "" {
		v = inherit // 必须沿用,不能替换
	}
	phase2(good, 2, 10, v)
	fmt.Printf("  → 提案值被强制改为 %q,chosen[slot3] = %q\n", v, chosen(good, 2))
}

输出(本机实测):

L1 accept slot1 n=1 v="a" → A1、A2 接受(A3 掉线)
L1 accept slot2 n=2 v="b" → A1、A2 接受(A3 掉线)
L1 accept slot3 n=3 v="c" → A1、A2 接受(A3 掉线)
此时 chosen = ["a" "b" "c"]:slot3 已被多数派选定为 "c",但 L1 还没来得及学习就崩了

错误做法:L2 跳过阶段 1,直接 accept(slot3, n=10, "X")
  → chosen[slot3] = "X"(已选定的 "c" 被覆盖)

正确做法:L2 先 prepare(slot3, n=10)
  → 多数派承诺=true,返回已接受的最高编号值 "c"
  → 提案值被强制改为 "c",chosen[slot3] = "c"

这条规则说起来简单,工程上要落到三个地方:

场景 必须做什么 漏了会怎样
新 leader 上任 对每个"不确定"的 slot 跑阶段 1(或一次覆盖全部 slot 的批量阶段 1) 覆盖已选定值(上图错误路径)
从快照恢复 快照里必须记录"覆盖到哪个 slot",之后的 slot 重新确认 重放错位或跳过未决 slot
客户端读 读到洞就停,不能跳过 状态机顺序错乱,业务看到"未来的状态"

🔑 Paxos 的恢复比 Raft 更"贵":Raft 的新 leader 只需要提交一条当前任期的 no-op,就能通过"日志匹配 + 多数派"反推提交位置(见 21.7 Raft:安全性证明);Multi-Paxos 没有"连续日志"这个结构,只能逐 slot 询问"你接受过什么"。这是"允许洞"换来的灵活性所付的账。

与 Raft 的对比:安全性等价,工程完备度不同

维度 Multi-Paxos Raft
安全性 等价(都满足共识三性质) 等价
leader 选举 论文未规定,各实现自造(Chubby 用租约、Spanner 用 TrueTime + term) 任期 + 随机超时,规则明确
日志 允许洞、允许并行提案 必须连续,先补齐再提交
恢复 逐 slot 阶段 1 补齐 提交当前任期 no-op + 日志匹配
成员变更 未定义 单节点变更 / 联合共识(21.8 Raft:成员变更与快照)
快照 未定义 InstallSnapshot
实现难度 高("Paxos 很难"主要难在这些没写的地方) 中(论文给全了状态机与 RPC)

这张表的结论不是"谁更强",而是:Paxos 是一个可证明的核心,Multi-Paxos 是一份留给工程填空的骨架。Chubby / Spanner 之所以能基于它,是因为 Google 自己把那部分填空做了二十年(细节对比见 21.12 共识算法对比)。

代价表:两轮变一轮省了多少

方案 每值消息数 每值 RTT 吞吐上限(批大小 B)
Basic Paxos 4N 2 B / (2 RTT)
Multi-Paxos(稳态) 2N 1 B / RTT
Multi-Paxos + 流水线 2N(每批) 摊销到 ≈ RTT / 窗口 ≈ 窗口 × B / RTT

所以"Multi-Paxos 比 Basic Paxos 快一倍"只在同步场景成立;真正把吞吐拉起来的是批量 + 流水线,RTT 只是下限(同 21.6 Raft:日志复制 的结论一致)。

反模式

反模式 后果
leader 换人后直接用自己的值写"未确认的 slot" 覆盖已选定值(上图错误路径)
把洞当成"空",读的时候跳过去 状态机顺序错乱
无稳定 leader,每个值都跑两轮 吞吐减半,且 dueling 风险回归(21.3 Basic Paxos)
流水线无上限 内存暴涨;乱序缓存开销超过收益
以为 Multi-Paxos 自带选主 论文没有;不实现选主就退化成"多个 proposer 抢编号"
批量无限攒 单条延迟不可控;小流量下"永远等不到批"

自检

Q1 Multi-Paxos 为什么能跳过阶段 1?什么情况下这个"跳过"不再安全?

▸ 答案:因为同一个 leader 连续用递增编号提案,阶段 1 的"承诺"已经覆盖了它后续的提案,且它自己掌握每个 slot 的状态;在没有更高编号 prepare 出现时,不会有人覆盖它。一旦发生 leader 变更(或另一个 proposer 用更高编号出现),"每个 slot 上是否有更早的选定值"就不再确定,新 leader 必须对不确定的 slot 重新跑阶段 1,否则可能覆盖已选定值。

Q2 上图里 L1 崩在"slot3 已被选定但没来得及学习"的时刻。为什么这是可能的?

▸ 答案:"选定"的判据是多数派接受,而"学习"是 proposer 收到多数派 accepted 之后的动作,两者之间有一个窗口。L1 在 A1、A2 接受后就已经让 slot3 选定,但它在收到/处理这些响应之前崩溃,于是它自己(以及任何依赖它通知的节点)都不知道 slot3 已定。这正是新 leader 必须"先问再说"的原因。

Q3 Multi-Paxos 允许日志有洞,这给读带来什么额外约束?

▸ 答案:状态机必须按 slot 顺序 apply,所以读不能跳过洞去读后面的 slot——否则会读到"建立在缺失状态之上"的结果,业务看到未来的值(例如"扣款了但订单还没创建")。实现上通常维护一个 applied 指针,只有洞被填上、指针连续推进后才对外提供读。

Q4 为什么 Multi-Paxos 的恢复比 Raft 贵?

▸ 答案:Raft 的日志连续 + 日志匹配性质,让新 leader 只要提交一条当前任期条目,就能用"多数派 matchIndex 中位数"反推所有旧的提交位置;Multi-Paxos 的每个 slot 是独立的 Paxos 实例,没有"连续前缀"可依赖,只能对每个不确定的 slot 跑一次阶段 1(或用批量阶段 1 一次覆盖所有 slot),成本与"不确定 slot 的数量"成正比。

Q5 Basic Paxos 2 RTT、Multi-Paxos 1 RTT,是不是把 RTT 砍半就能让吞吐翻倍?

▸ 答案:不一定。同步场景(一次只发一个值、等结果)下确实约 2 倍;但真实吞吐由"每个 RTT 能确认多少字节"决定,所以批大小与流水线才是主杠杆。批 100 条、RTT 1ms 时,Basic 也能到 50k/s,Multi-Paxos 的收益主要体现在"省掉一半的协调往返与消息量",而不是无条件的吞吐翻倍。

关联