[200 行代码实现 paxos-kv]中介绍了一款非常简洁的分布式 kv 存储实现,它是基于 classic-paxos 实现分布式一致性。在 [paxos 的直观解释]中我们提到,每次写入,也就是每个 paxos 实例需要 2 轮 RPC 完成,效率低。
一个常见的优化就是 mutli-paxos(或 raft),用一次 RPC 对多个实例运行 phase-1;再对每个实例分别运行 phase-2,这样均摊开销是一次 RPC 完成一次写入。 它通过 phase-1 在集群中确定了一个唯一可写的 leader。这种设计在跨机房(或跨云)部署的环境中的缺陷是:异地机房的写入就需要 2 个 RTT 才能完成:
client → leader → followers → leader → client
也就是说它无法做到异地多活,在 3 节点的场景里,有 2/3
的写入效率降低到 2 个 RTT。
本文从另一角度出发来解决异地多活的问题,3 机房部署的 3 副本集群中:
这就是今天要介绍的 [200 行代码实现 paxos-kv] 的改进版: mmp-3: multi-master-paxos 3 副本实现。
同样 show me the code 的原则不能变:本文实现的 3 节点多活代码在: mmp3
异地多活是目前分布式领域越来越被重视的一个问题,机房正在变成单机,单机房多机分布式在现在大规模部署的业务中已经满足不了业务的可用性需求了。
几乎所有线上环境部署的分布式存储, 都需要跨机房(或者跨云)的部署。而大家也积极在解决这些问题:
- 或者用队列等最终一致性的手段来完成跨机房的复制,这样会产生数据不一致,2 条互相冲突的数据可能同时被写入;业务层需要参与解决这类冲突。
- 或者将数据做拆分。将在 A 地写入多的分配到 A 机房为 leader 的 sharding,将 B 地写入较多的数据分配到 B 机房为 leader 的 sharding。
- 或者一个机房为主:部署 2 个副本,另一个机房部署 1 个副本来形成 3 副本的集群,这样实际上 A 机房故障会导致全局不可读写,B 机房只能提供额外的数据冗余,无法提供更多的数据可用性。
paxos 在集群较小时可以通过定制 paxos 来完成 1 个 RTT 的写入, 如果使用 [majority-quorum] 最多支持 5 个副本的多活.
在 epaxos 定义的多活设计,简单介绍了 3 节点的设计,但并没有给出实现的细节,其中各种冲突的处理以及修复的流程并没有明确的定义。
- 同时 epaxos 的 apply 算法存在不可解决的 livelock 问题:通过 SCC 来确定 instance 顺序无法保证在有限时间内结束。
- 另外 epaxos 的设计中缺少一个 rnd 记录(paxos 中的 last-seen-ballot 或 vbal),导致其一致性实现是错误的。
- 以及 instance 之间的依赖关系会在修复过程中产生不一致的问题。
- epaxos 需要另外一个 seq 来确定 instance 之间的顺序,在 mmp3 的设计中,seq 是不必要的,只需依赖关系就可以确定确定的 apply 顺序。
我们从 classic-paxos 出发来分析问题。
xp 的tips:要实现一个稳定的分布式系统,最好用 raft,因为开箱就用。要学习分布式系统,最好从 paxos 开始。raft 看似简单的设计隐藏了一些隐晦的条件,其正确性的证明要比 paxos 复杂。
我们需要达到 2 个目的:
如果 classic-paxos 不需要 2 个 RTT,我们就不需要 multi-paxos 或 raft 这些东西来优化延迟了。
在 3 节点的系统中,这是可以实现的。
首先做一些基础的设定:一个 replica 在系统中是一个 replica(或叫作 server 或 node)。它同时是 proposer 和 acceptor。一个 replica 接受到一个写入请求时,它就用本地的 proposer 来完成提交。
[200 行代码实现 paxos-kv]介绍的 classic-paxos 写入流程如下,replica-0 上的 proposer P0,顺次完成 phase-1, phase-2 和 commit:
🤔 思考以上过程…
因为 proposer 本身只是一个数据结构,在 paxos 中,它不需要跟 acceptor 有什么绑定关系,所以,我们可以让 proposer 运行在任何一个 replica 上:把 proposer 发到另一个 replica 上运行,这样消息的传输就可以转变成 proposer 的传输。
要达到 paxos 要求的 2/3 的多数派,也只需要将 proposer 发到另外一个 replica,因为这个 proposer 永远只有 1 个实例,所以不会出现不一致(proposer 或者在 R0 上工作或者在在 R1 上工作)。
如果要将 proposer 发到 2 个 replica 就会复杂一些,例如 5 节点中 quorum=3,2 个不同的 proposer 可能会尝试使用不同的值。
通过发送 proposer 的方式,paxos 可以被优化成如下的 1 RTT 实现:P0 在 R1 上顺次执行 phase-1 和 phase-2,然后再被送会 R0:
在传输 proposer 的过程中,区别于原始 paxos 的是:往返两个过程都要包括 proposer 的完整信息:
- R0 到 R1 的过程中,要带上用户要提交的值,以便在 R1 上 Prepare 成功后直接运行 Accept;
- R1 到 R0 的过程中,要带上 R1 的 Prepare 和 Accept 的执行结果。
这样一轮 RPC 后, R0 和 R1 就可以形成多数派, 然后 R0 可以直接 commit。
注意,这个模型中,除了 proposer 的位置变化了,跟 classisc-paxos 没有任何区别!也就是说,任何 paxos 能完成的事情它都可以完成。
现在我们完成了第一个任务。如果以此模型来重写 [200 行代码实现 paxos-kv],可以在 3 副本系统上实现 1 RTT 提交,但多写入点依然会有冲突,例如 R0 和 R1 同时发起同一个 paxos instance 的写入,R0 在收到发送回来的 P0 后,可能就会发现本地的 instance 已经被 P1 以更高的 ballot 覆盖了,要重新提升 P0 的 ballot 再重试。
这就是我们要解决的第二个问题:避免不同 replica 的写入冲突。
2 个 replica 同时写一个 instance 产生活锁,导致无法保证 1 个 RTT 完成写入。要避免冲突,我们就需要让每个 replica 不能产生互相冲突的 instance,所以给每个 replica 分配 instance 的空间要分开。
在 mmp3 的实现中,有 3 个 replica 就需要有 3 列 instance,每个 replica 只写其中一列。
例如:
这种结构有点类似于 3 个标准的 raft 组,每组都部署在 3 个 replica 上,第 i 组的 raft 的 leader 就是 R[i]
这样,因为没有 instance 冲突,所以不论任何一个 replica 上收到的写请求,都只需 1 个 RTT 完成 instance 的提交。
但是!
这 3 列的 instance 目前还是无关的,要想将 instance 应用到 state machine,所有 replica 上的 instance 都必须以相同的顺序 apply。(不像 raft 里的 instance 是简单的单调递增的,只要保证 instance 一致,apply 的顺序就一致)。
因此在 mmp3 中,除了 instance 内容一致外,还需要额外增加每列 instance 之间的约束,来保证 apply 顺序一致。3 个 column 中的 instance 之间是一种(较弱但一致的)拓扑顺序,因此在 mmp3 中,paxos 要确定的值(Value)包括 2 个:
这个关系我们描述为:一个 instance X 看到了哪些其他 instance:用 X.Deps 来表示,用它来确定 instance 之间的 apply 的顺序:
例如在单机系统中,并发写入 3 条数据 a, b, c,可以这样确定 a, b,c 的顺序: 如果 a 写入时没有看到 b,那么 a 就在 b 之前运行。所以可见性就表示了 instance 之间的顺序。
当然这个思路在分布式系统中要复杂一些,因为多个 replica 之间没有单机中的锁的保护,多个 replica 上同一个 instance 看到的其他 instance 也可能不一样。
最终 mmp3 中的 instance 数据结构相比 classic-paxos,多了一个 Deps 字段:
message Ins { InsId InsId Cmd Val repeated int64 Deps // <-- BallotNum VBal // <-- bool Committed }
Deps 的实现包括以下步骤的变化:
在上面 1-RTT 的 classic-paxos 基础上:
例如 instance a4,在创建它的 replica 上和被复制到的另一个 replica 上分别看到 b2, c2 和 b1, c3,对应得到的 2 个 a4.Deps 分别是: [4, 2, 2] 和 [4, 1, 3]:
那么 a4 将用来运行 accpet 的 Deps 值就是 [4, 2, 3]:
classic-paxos 中要求 prepare 阶段看到的已存在的值要使用,而 mmp3 中将所有 prepare 阶段看到的 Deps 的值做了并集, 实际上并没有破坏 paxos 的约束,只不过 classic-paxos 假设它的值是任意的,不一定可取并集,mmp3 中可以把 prepare 过程中看到的 Deps 的值认为是 VBal 为 0 的一个值。
读者可以自行验证,它不会破坏 classic-paxos 要求的任何约束。
因为 X.Deps 的值的确定也通过 paxos,所以可以保证每个 replica 上的每个 instance 最终提交的 Deps 都是一致的。
这时再通过一个确定的算法使用每个 instance Deps 的值来决定 apply 的顺序,就可以保证多个 replica 上的 state machine 最终状态一致。
以上两点满足了 apply 算法的第一个要求:Consistency。此外,apply 的顺序还需提供另外一个保证 Linearizability, 即: 如果 propose A 发生在 commit B 之后,那么 A 应该在 B 之后apply。
这是一个直觉上的要求: 如果一个命令 set x=1 发给存储系统并返回 OK(committed),那么这之后发给存储的 get x 命令,应该一定能看到 x=1 的值。
实际上 xp 认为在分布式系统全局范围内使用绝对时间的先后并不是一个理性的选择。不过它更容易被业务使用。
接下来我们设计一个算法来满足 Linearizability 的要求:
mmp3 中设定:任意 2 个 instance 都是 interfering 的,即,交换 2 个 instance 的 apply 顺序会导致结果不同(虽然可能是可以互换顺序的)。
epaxos 中认为 set x=1 和 set y=2 这 2 个 instance 可以互换顺序,因为 x 的值跟 y 的值无关,但 set x=y 和 set y=2 这 2 个 instance 不能互换顺序 appl,因为顺序的变化会产生不同的 x 的结果。也是因为 epaxos 需要通过减少 interfering 的数量来实现 1 个 RTT,所以才有了这个设计。
在 3 replica 的系统中,mmp3 有无冲突都只需要 1 个 RTT,所以我们可以无需担心 interfering 的 instance 的冲突带来的另一个 RTT 开销。只需假设任意 2 个 instance 都是 interfering 的,这样反倒能简化问题。
定义 A 依赖 B,即 A → B 为:A.Deps ∋ B
因为 mmp3 假定任意 2 个 instance 都是 interfering 的,并且 2 个 instance 提交的 quorum 必然有交集,所以任意 2 个 instance 之间至少有一个依赖关系,即, A, B 之间的关系只可能是:
依赖关系构成一个可能带环的有向图, 例如按照以下时间顺序执行:
- R0 propose a1,a1.Deps = [1, 0, 0],
- R1 propose b1,b1.Deps = [0, 1, 0],
- R0 send a1 to R1,a1.Deps = [1, 1, 0]
- R1 send b1 to R0,b1.Deps = [1, 1, 0]
- R0 commit a1
- R1 commit b1
这样 a1 ∈ b1.Deps 且 b1 ∈ a1.Deps
依赖关系很直观,这个依赖关系的图中,我们将试图寻找一个有限大小的集合来实现一个有效的 apply 算法。
首先我们有一个小结论:
如果 A 在 B commit 之后被 propose,那么一定有 A.Deps ⊃ B.Deps。
因为 B 如果 commit 了,那么 B.Deps,也就是 B 看到的所有其他 instance 的 id 集合,就已经复制到了某个 quorum。那么 A 在运行 paxos 的时候,一定会看到 B commit 的 B.Deps 的值。
又因为 A.Deps 是 2 个在 prepare 阶段看到的 Deps 的值的并集,因此 A.Deps 一定包含全部 B.Deps 的instance。
于是实现 apply 算法的思路就是:
epaxos 提供了一种简单粗暴的方法来在有环图中确定 apply 顺序:从图中一个节点出发:找到最大连通子图(Strongly-Connected-Component or SCC)(没有出向边的一个节点也是一个 SCC),然后按照节点,也就是 instance 的某个属性(例如 epaxos 中使用(seq,instanceId))来排序一个 SCC 中的节点,再按顺序 apply.
epaxos 的 SCC 算法有个问题,就是一个 SCC 可能无限增大,例如 A commit 之前有另一个 interfering 的 instance B 被 propose,然后 B commit 之前又出现 interfering 的 instance C…,
那么 epaxos 的做法就无法保证在有限时间内找出 SCC。
epaxos 建议中断一小段时间的新 instance 的 propose 来断开 SCC,这也是不容易实现的,因为必须在 n-1 个 replica 同时中断才有效。只要有 2 个 replica 在持续的写入新 instance,那么就有可能造成无限大的 SCC。
第 2 个小结论:
如果 A、B不属于同一个 SCC,即 A ∈ SCC₁ B ∉ SCC₁,那么:
因为根据 Lemma-0,任意 2 个 instance 至少有一个依赖关系,如果 X ∈ B.Deps 且 X ∉ A.Deps,那么必然有 X → A,导致 A → B → X → A 成为一个SCC。
因此,不论A、 B 是否在一个 SCC 中,保证 Linearizability 的条件都可以用 Deps 来确定,所以我们的算法不必寻找 SCC,只需遍历依赖关系。
以上 apply 算法还可以进一步优化为最多只考虑 3 个 instnace 的方式:
假设 a1,a2 是 column-A 上相邻的 2 个 instance,那么一定有 a1 ∈ a2.Deps,根据 apply 算法设计,a1.Deps ⊃ a2.Deps 一定不成立,a2 一定不会在 a1 之前 apply:
因此只需考虑每个 column 上最老的一个未 apply 的 instance 就可以找出下一个 apply 的 instance。在 mmp3 中,最多有 3 个(但算法本身不限于 3)。
定义一个依赖数量:|X.Deps| 为 X 依赖的,未 apply 的 instance 的所在 column 的数量。
例如:a3.Deps = [3, 2, 2]:
这里可以清楚的看到一个结论:A.Deps ⊃ B.Deps ⇒ |A.Deps| > |B.Deps|
最终 apply 算法为:
找到一个 column 上下一个已 commit,未 apply 的 instance X,遍历 X.Deps,得到未遍历过的 column 上的最老的未 apply 的 instance,遍历结束后,选择(|X.Deps|, X.columnIndex)最小的一个 apply 到 state machine。
下次再 apply 时,重新构造这个图,找到第二个要执行的 instance。
必须重新遍历,因为之前排序第 2 的 instance,在新加入一个 instance 之后可能还是第 2。
这样,每个 replica 上,committed 的 instance 的 Deps 值都一样,最老的 3 个 instance 构成的依赖图也都一样,于是找出第 1 个 apply 的 instance 也一样,重复这个步骤,找出的第 2 个 apply 的 instance 也一样… 最终每个 replica 上的 state machine 达到一致的状态,保证了 Consistency。
例如以下 20 个 instance 的 Deps 关系是一个有向图,最终生成的 apply 顺序是一个单向路径:
paxos 假设工作在一个网络不可靠的环境中,在标准的实现中,如果某个请求超时,理论上应该进行重试。mmp3 的运行环境假设与 classic-paxos 一样,也需要对超时重试。这里跟 classic-paxos 有一点差别,就是重试时必须提升自己的 BallotNum,重新在本地执行 prepare,再用新的 BallotNum 重发 RPC。
这是因为 prepare 过程中,在每个 replica 上得到的 Deps 值可能不同
例如 R0 propose 的 instance X,在 R1 和 R2 上的 prepare 后,可能会分别得到不同的 X.Deps 的值(2 个 replica 包含的 instance 不同)使用同一个 BallotNum 无法区分哪一个才是最新的值。重试提升 BallotNum,才能保证最后被确定的值能被识别出来。
一个修复进程(例如 R0 宕机后,R1 或 R2 都可以重新运行 paxos 进行修复)在 R1 和 R2上看到 2 个不同 BallotNum 的 X,那么说明较小 BallotNum 的 X 没有成功返回应答给 R0, R0 放弃了它,并进行了重试。这时只需考虑较大 BallotNum 的 instance,它是唯一可能被 R0 commit 的。
以下是重试过程:
上面提到的重试机制为正确的 recovery 做好了准备:当 R0 发起一轮 paxos 后并宕机了,R1 或 R2 都可以通过超时检查来发现这个问题并修复未 commit 的 instance。要修复的内容依旧是 2 个:instance 要执行的命令 Val,以及 instance 看到哪些其他的 instance: Deps。
因为这 2 个值都是通过 classic-paxos 来确立的,修复过程也很简单,提升 BallotNum 再运行一次 paxos 就可以了。相当于将 R0 的 leadership 抢走赋予给了另一个 replica。
git repo mmp3 是一份本文介绍的 multi-master 的三副本实现(mmp3 分支),其中主要的 server 端 instance 提交的逻辑实现在 mmp.go,apply 算法实现在apply_* 中。
代码中除了基本的单元测试,最主要的是:Test_set_get 对一个三副本集群进行随机读写压测,这个测试中模拟发送和接受的网络错误(各 20% 几率)在这种情况下,检查:
mmp3 设计上只支持 3 节点系统, 其次这个实现中不包含成员变更实现。
mmp3 是一个完全对等的设计实现的 multi-master consensus。之前在试图基于 epaxos 实现一个 multi-master 的存储,中间却发现几处不易修复的问题(开始还有几个容易修复的问题),于是打算自己设计一套。
期待与对这个方向感兴趣各路神仙交流蛋逼~