将Paxos和Raft统一为一个协议:Abstract-paxos
提出问题 协议推导 定义 commit 定义 系统状态(State) 协议描述 工程实践 成员变更 使用 abstract-paxos 描述 paxos 使用 abstract-paxos 描述 raft
香农信息定义
读
操作,每次得到的内容应该都是唯一的,确定的。这个定义将贯穿本文,作为我们设计一致性协议的最根本的公理。分布式存储
存储系统可以看做一个可以根据外部命令(Cmd)改变系统状态(State)的东西。例如一个 key-value 存储,set x=1 或 set x=y+1 都可以看做一个 Cmd。
而分布式则表示存储系统由多个节点(node)组成(一个 node 可以简单的认为是一个进程),存储系统的操作者是多个并发的写入者(write)和读取者(reader)。
而一个可靠的分布式也意味着它必须允许宕机:它必须能容忍部分节点宕机还能正常工作。
一个 quorum 定义为一个节点(node)集合。e.g. HashSet<NodeId>。
大部分时候, 一个 quorum 都是指 majority,也就是多于半数个 node 的集合。例如【a,b】,【b,c】,【c,a】是集合【a,b,c】的 3 个 quorum。
不确定读的例子
例如下面 3 个 node N1,N2,N3 的 例子1,
N1 存储了[x,y], N3 存储了 [z],
例子2: 总能读到的结果
commit-写 quorum:以保证任何 reader 都可读到。 commit-唯一:以保证多个 reader 返回相同的结果。 commit 后不能修改:以保证多次读返回同样的结果。
commit-写 quorum
commit-唯一
这里唯一是指,在可见的基础上,增加一个唯一确定的要求:
例如在上面的例子2 中,如果 reader 一次读操作访问到 N1, N2 这 2 个 node,那么它收到的看到的 2 个 State 的副本分别是【x,y】和【z】,这 2 个 State 的副本都是可见的,但作为一个存储系统,任何一个 reader 都必须选择同样的 State 作为当前系统的 State(否则违反香农信息定义的消除不确定性的原则)。
所以我们对读操作还要求 reader 能有办法在所有可见的副本中唯一确定的选择一个 State,作为读操作的结果。
commit 后不能修改
State 不能被修改有点反直觉,,因为一般的存储系统,,先存储了 x=1,还可以修改为 x=2。看起来是允许修改的。 这里可以这样理解: 经历了 x=1,再到 x=2 的一个 State (【x=1, x=2】),跟直接到 x=2 的 State(【x=1,】)是不同的。这个不同之处体现在:可能有个时间点,可以从第一个 State 读出 x=1 的信息,而第二个 State 不行。
这是 State 的初步设计,为了实现这个一致性协议,后面我们将对 State 增加更多的信息来满足我们的要求。
commit-唯一, commit 后不能修改
读
操作的最终结果:写入了不同的 State 指:两个 State:s₁, s₂,如果 s₁ ⊆ s₂ 和 s₂ ⊆ s₁ 都不满足,那么只有一个是可能被 commit 的。否则就产生了信息的丢失。
较大的是可能 commit 的, 较小的一定不是 commit 的。
例如, 如果在 2 个节点上分别读到 2 个 log:【x, y, z】和【x, y, w]】,无法确认哪个是可能 commit 的,哪个是一定没有 commit 的:
给 State 添加用于排序的信息
例如下面 例子3 中,如果每个 node 都为其 State 增加一个序号(在例子中的角标位置),那么 reader 不论联系到哪 2 个节点,都可以确定选择序号更大的【y】作为读取结果,这时就可以认为【y】是一个信息了。
而 commit 后不能修改 的原则要求系统所有的修改,都要基于已 commit 的 State,所以当系统中再次 commit 一个数据后可能是在【y】s 之上追加【z,w】:
为了实现上述逻辑, 一个简单的实现是让最后一个 log 节点决定 2 个 State 之间的大小关系。
于是我们可以对 State 的每个 log 节点都需要加入一个偏序关系的属性 commit_index(本文为了简化描述, 使用一个整数)来确定 State 的全序关系:
在后面的例子中,我们将 commit_index 写成每条 log 的下标的形式,例如
commit_index 的值是由 writer 写入 State 时决定。即 writer 决定它写入的 State 的大小。
如果两个 State 不是包含关系,那么大小关系由 commit_index 决定。writer 通过 quorum-write 写入一个足够大的 State,就能保证一定被 reader 选择,就完成了 commit。
非包含关系的 2 个 State 的 commit_index 不能相同。否则 State 之间无法确定全序关系。即,任意 2 个 writer 不允许产生相同的 commit_index。
同一个 writer 产生的 State 一定是包含关系,不需要使用 commit_index 去决定大小:
对于 2 个包含关系的 State:sₐ ⊆ sᵦ,显然对于 reader 来说,应该选择更大的 sᵦ,无需 commit_index 来确定 State 的大小。因此一个 writer 产生的 State,允许多个 log 的 commit_index 相同。并用 log 的长度确定大小关系。
State-全序定义
上面提到,commit_index 是一个具有偏序关系的值,不同类型的 commit_index 会将 abstract-paxos 具体化为某种协议或协议族,例如:
如果 commit_index 是一个整数,那就是类似 paxos 的 rnd。 而 raft 中,与 commit_index 对应的概念是【term, Option<NodeId>】,它是一个偏序关系的值,也是它造成了 raft 中选举容易出现冲突。 关于 abstract-paxos 如何映射为 paxos 或 raft,在本文的最后讨论。 另一方面,从 writer 的角度来说:
如果一个 writer 可以生成一个 commit_index 使之大于任何一个已知的 commit_index,那么这时 abstract-paxos 就是一个活锁的系统:它永远不会阻塞,但有可能永远都不会成功提交。例如 paxos 或 raft 如果一个 writer 无法生成任意大的 commit_index,那么它就是一个死锁的系统,例如 2pc 当然也可以构造 commit_index 使 abstract-paxos 既活锁又死锁,那么可以认为它是一个结合了 paxos 和 2pc 的协议。
现在我们来设计整个协议,首先,有一个 writer w,w 最终 commit 的操作是在 phase-2 将 State 写到一个quorum。writer 的数据结构定义为一个它选择的 quorum,以及它决定使用的 commit_index:
例如下面 例子5 中描述的场景,如果最终写入 State 前不做防御,那么是无法完成 commit 的:假设有 2 个 writer w₁, w₂ 同时在写它们自己的 State 到自己的 quorum:
t1 时间 w₁ 将 【y₅】写到 N2, N3,
t2 时间 w₂ 将 【x₁,y₇】写到了 N1。
那么当一个 reader 联系到 N1, N2 进行读操作时,它会认为【x₁,y₇】是 commit 完成的, 而真正由 w₁ commit 的数据就丢失了,违背了香农信息定义。
Phase-1.1 阻止更小的 State 被 commit
假设 writer w₁
要写入的 State 是 s₁
,在 w₁
将 s₁
写到一个 quorum 前,整个系统必须阻止小于 s₁
的 State 被 commit。
因为不同的 writer 不会产生同样的 commit_index。所以整个系统只需阻止更小的 commit_index 的 State 被 commit:
为达到这个目的,在这一步,首先通知 w₁ quorum 中的每个节点:拒绝所有其他 commit_index 小于 w₁。commit_index 的 phase-2 请求。
在后面的例子中,我们将用一个数字前缀表示 node 中的 commit_index,例如:
将表示为:
Phase-1.2 读已完成 commit 的 State
Phase-1
Phase-1: Data
Phase-1: Req
Phase-1: Reply
Phase-1: Handler
Phase-2
这里有一个学习分布式系统时经常提出的问题: Q:
因为在 phase-1 中 w 已经阻止了所有小于 w.commit_index 的 State 的提交,phase-2 是否可以写入一个小于 w.commit_index 的 State? A:
不可以,phase-2 写入的 State 的commit_index() 跟 w.commit_index 相等时才能保证安全,简单分析下:
显然要写的 s₁.commit_index() 不能大于 w₁.commit_index,因为 phase-1.1 没有保护大于 w₁.commit_index 的 State 的写入。 虽然在 phase-1 阶段,系统已经阻止了所有小于 s₁.commit_index() 的其他 State 的 phase-2 写入,如果 w₁ 写的 State 的 s_1.commit_index() 小于w.commit_index,那么系统中可能存在另一个稍大一点的 State (但没有 commit,导致 reader 不认为 s₁ 是 commit 的。 例如:
一个 writer w₅ 在 t1 时间完成了 phase-1,在 t2 时间 phase-2 只写入了 N1; 然后另一个 writer w₆ 在 t3 时间完成了 phase-1,phase-2 只写入了一个较小的 commit_index=4 的 State。 那么某个 reader 如果通过访问 N1,N2 读取数据,会认为 N1 上的【x₅】是 commit 的,破坏了 香农信息定义。
Phase-2: data
和 phase-1 类似,一个 node 返回它自己的 commit_index 来表示它是否接受了 writer 的 phase-2 请求。
在 P2Req 中,如果 state 是完整的,commit_index 总是与 state.commit_index() 一样,可以去掉;这里保留是因为之后将会讨论到的分段传输:每个请求只传输 State 的一部分,这时就需要额外的 P2Req.commit_index。
Phase-2: Req
Phase-2: Reply
Phase-2: Handler
这里也是一个学习分布式容易产生误解的地方,例如很多人曾经以为的一个 paxos 的bug: paxos-bug。
可重复的 phase-2
最后将整个协议组装起来的是 writer 的逻辑,如前所讲,它需要先在一个 quorum 上完成 phase-1 来阻止更小的 State 被 commit,然后在 quorum 上完成 phase-2 完成一条日志的提交。
Phase-2: 增量复制
State 不能留空洞:有空洞的 State 跟没空洞的 State 不同,不能通过最后一条日志来确定其所在的 State 大小。 writer 在 phase-1 完成后可以保证一定包含所有已经 commit 的 State。
t1 时刻,writer W 联系到 N1,N2 完成 phase-1,读到最大的State【x₃,z₅】,添加自己的日志到最大 State 上:【x₃,z₅,w₆】,这时系统中没有任何一个 node 的 State 是 commit 完成状态的,一个 reader 可能选择【x₃,z₅】作为读取结果(访问N1,N2),可能选择【x₃,y₄】作为读取结果(访问N2,N3)。
但这时一个 State 的子集:【x₃】是commit完成的状态。
t2 时刻,W 向 N3 复制了一段 State:【x₃】,它是 N3 本地日志的子集,不做变化。
这时 reader 还是可能读到不同的结果,同样【x₃】是 commit 完成的状态。
t3 时刻,W 向 N3 复制了另一段 State z₅,它跟 N3 本地 State 冲突,于是 N3 放弃本地的一段与 writer 不一致的 Statey₄,将本地 State 更新为:【x₅,z₅】
这时【x₅,z₅】是 commit 完成状态。
t4 时刻,W继续复制 w_6 到 N3,这是【x₅,z₅,w_4】是 commit 完成状态。
Snapshot 复制
State 中某些日志(config日志)表示集群中的成员配置。 State 中最后一个成员配置(config)日志出现就开始生效。 config 日志与普通的日志写入没有区别。
例如一个普通的3成员集群的 config 【{a,b,c}】,它定义的 quorum 有
再如一个由 2 个配置组成的 joint config【{a,b,c}, {x,y,z}】。它定义的 quorum 集合是【a,b,c】的 quorum 集合跟【x,y,】的 guorum 集合的笛卡尔积:
成员变更约束-1
例如:假设 State 中某条日志定义了一个 joint config:【{a,b,c}, {x,y,z}】那么,
下一个合法的 config 可以是:
uniform config【{a,b,c}】,
或另一个 joint config 【{x,y,z}, {o,p,q}】。
但不能是【{a,x,p}】,因为它的一个 quorum【a,x}】与 上一个 config 的 quorum【{b,c}, {y,z}】没有交集。
成员变更Lemma-1
成员变更约束-2
先在旧配置上拒绝更小的 State 的提交,再 propose 新配置。根据成员变更Lemma-1,即:至少将一个与 w.commit_index 相同的 State commit 到 cᵢ 上。
再 propose cᵢ₊₁,从日志 cᵢ₊₁ 之后的日志开始,都只需 commit 到 cᵢ₊₁ 上。
成员变更的约束条件
上一个 config 在当前 commit_index 上提交后才允许 propose 下一个配置。 下一个配置必须跟最后一个已提交的配置有交集。
成员变更举例
raft 只支持以下的成员变更方式 c1 → c1c2 → c2 → c2c3 → c3 … 其中 c1c2 指 c1 跟 c2 的 joint config,例如: cᵢ :【a, b, c】; cᵢcⱼ:【{a, b, c},{x, y, z}】。 abstract-paxos 可以支持更灵活的变更: c1 → c1c2c3 → c3c4 → c4 或回退到上一个 config: c1c2c3 → c1
合法变更状态转换图示
秒变 Paxos
限制 State 中的日志只能有一条,那么它就变成 paxos。 不支持成员变更。
abstract-paxos | classic-paxos |
---|---|
writer | proposer |
node | acceptor |
Writer.commit_index | rnd/ballot |
State.commit_index() | vrnd/vbal |
秒变 Raft
term 和是否投票给了某个 Candidate:
即,VotedFor 只能从 None 变化到 Some,不能修改。或者说,Some(A)和 Some(B)没有大小关系,这限制了 raft 选主时的成功几率.。导致了更多的选主失败冲突。
raft 中,因为 VotedFor 的特殊的偏序关系的设计,日志中 Term 相同则 voted_for 一定相同,所以最终日志里并不需要记录 voted_for,也能用来唯一标识日志,State,及用于比较 State 的大小。最终记录为:
这样的确让 raft 少记录一个字段, 但使得其含义变得更加隐晦,工程上也引入了一些问题, xp 并不欣赏这样的作法。 但不否认 raft 的设计在出现时是一个非常漂亮的抽象,主要在于它对 multi-paxos 没有明确定义的问题,即多条日志之间的关系到底应该是怎样的,给出了一个确定的答案。
abstract-Paxos | raft |
---|---|
writer at phase-1 | Candidate |
writer at phase-2 | Leader |
node | node |
Writer.commit_index | (Term,VotedFor) |
State.commit_index() | Term |
Raft 的优化
一个 term 允许选出多个 leader:将 commit_index 改为字典序,允许一个 term 中先后选出多个 leader。
提前 commit:raft 中 commit 的标准是复制本 term 的一条日志到 quorum。这样在新 leader 刚刚选出后可能会延后 commit 的确认,如果有较多的较小 term 的日志需要复制的话。因此一个可以较快 commit 的做法是复制一段 State 时(raft 的 log),也带上 writer 的 commit_index 信息(即 raft leader 的 term)到每个 node,同时,对 State 的比较(即raft 的 log 的比较)改为比较【writer.commit_index, last_log_commit_index, log.len()】,在 raft 中,对应的是比较【leader_term, last_log_term, log.len()】。
成员变更允许更灵活的变化:例如 c0c1 -> c1c2.
其中 1,3 已经在 openraft 中实现(朋友说它是披着raft皮的paxos/:-))。
可靠分布式系统-paxos的直观解释 : https://blog.openacid.com/algo/paxos/ abstract-paxos : https://github.com/openacid/abstract-paxos (Not a) bug in Paxos : https://github.com/drmingdrmer/consensus-bugs#trap-the-bug-in-paxos-made-simple leveldb : https://github.com/google/leveldb openraft : https://github.com/datafuselabs/openraft Two phase commit : https://en.wikipedia.org/wiki/Two-phase_commit_protocol 偏序关系 : https://zh.wikipedia.org/wiki/偏序关系 字典序 : https://zh.wikipedia.org/wiki/字典序
作者介绍:张炎泼(xp)Databend 分布式研发负责人
END
点这里 ↓↓↓ 记得 关注✔ 标星⭐ 哦
微信扫码关注该文公众号作者