0.参考链接
1. paxos是什么
在分布式系统中保证多副本数据强一致性的算法
2. 分布式要解决的问题
paxos的工作, 就是把一堆运行的机器协同起来, 让多个机器成为一个整体系统. 在这个系统中, 每个机器都必须让系统中的状态达成一致, 例如三副本集群如果一个机器上上传了一张图片, 那么另外2台机器上也必须复制这张图片过来, 整个系统才处于一个一致的状态.
几乎所有的分布式存储都必须用某种冗余的方式在廉价硬件的基础上搭建高可靠的存储. 而冗余的基础就是多副本策略, 一份数据存多份. 多副本保证了可靠性, 而副本之间的一致, 就需要paxos这类分布式一致性算法来保证.
在早些年各种各样的复制策略都被提出来来解决各种场景下的需要. 除了复制的份数之外, 各种各样的算法实际上都是在尝试解决一致的问题. 从下一页开始简单回顾下各种复制策略, 看看他们的优缺点以及paxos如何解决副本之间一致性的问题
主从异步复制是最简单的策略之一, 它很容易实现, 但存在一个问题: 客户端收到一个数据已经安全(OK)的信息, 跟数据真正安全(数据复制到全部的机器上)在时间上有一个空隙, 这段时间负责接收客户端请求的那个机器(master)如果被闪电击中或被陨石砸到或被打扫卫生的大姐踢断了电源, 那数据就可能会丢失. 因此它不是一个可靠的复制策略(使用主从异步复制要求你必须相信宇宙中不存在闪电陨石和扫地大姐).
跟主从异步复制相比, 主从同步复制提供了完整的可靠性: 直到数据真的安全的复制到全部的机器上之后, master才告知客户端数据已经安全. 但主从同步复制有个致命的缺点就是整个系统中有任何一个机器宕机, 写入就进行不下去了. 相当于系统的可用性随着副本数量指数降低
然鹅, 在同步和异步之间, 做一个折中, 看起来是一个不错的方案. 这就是半同步复制. 它要求master在应答客户端之前必须把数据复制到足够多的机器上, 但不需要是全部. 这样副本数够多可以提供比较高的可靠性; 1台机器宕机也不会让整个系统停止写入.
但是它还是不完美, 例如数据a复制到slave-1, 但没有到达slave-2; 数据b复制达到了slave-2但没有到达slave-1, 这时如果master挂掉了需要从某个slave恢复出数据, 任何一个slave都不能提供完整的数据. 所以在整个系统中, 数据存在某种不一致
可以将这个复制策略再做一改进: 多数派读写: 每条数据必须写入到半数以上的机器上. 每次读取数据都必须检查半数以上的机器上是否有这条数据,没有主节点也就是Master节点
然鹅多数派读写的策略也有个但是, 就是对于同一条数据的更新时, 会产生不一致的状态. 例如:
- node-1, node-2都写入了a=x,
- 下一次更新时node-2, node-3写入了a=y.
这时, 一个要进行读取a的客户端如果联系到了node-1和node-2, 它将看到2条不同的数据.
为了不产生歧义, 多数派读写还必须给每笔写入增加一个全局递增的时间戳. 更大时间戳的记录如果被看见, 就应该忽略小时间戳的记录. 这样在读取过程中, 客户端就会看到a=x₁, a=y₂ 这2条数据, 通过比较时间戳1和2, 发现y是更新的数据, 所以忽略a=x₁. 这样保证多次更新同一条数据不产生歧义.
是的, 但是又来了. 这种带时间戳的多数派读写依然有问题. 就是在客户端没有完成一次完整的多数派写的时候: 例如, 上面的例子中写入, a=x₁写入了node-1和node-2, a=y₂时只有node-3 写成功了, 然后客户端进程就挂掉了, 留下系统中的状态如下:
node-1: a=x₁
node-2: a=x₁
node-3: a=y₂
这时另一个读取的客户端来了,
- 如果它联系到node-1和node-2, 那它得到的结果是a=x₁.
- 如果它联系到node-2和node-3, 那它得到的结果是a=y₂.
整个系统对外部提供的信息仍然是不一致的.
现在我们已经非常接近最终奥义了, paxos可以认为是多数派读写的进一步升级, paxos中通过2次原本并不严谨的多数派读写, 实现了严谨的强一致consensus算法.
3.从多数派读写到paxos的推导
首先为了清晰的呈现出分布式系统中的核心问题: 一致性问题, 我们先设定一个假象的存储系统, 在这个系统上, 我们来逐步实现一个强一致的存储, 就得到了paxos对一致性问题的解决方法.
在实现中, set命令直接实现为一个多数派写, 这一步非常简单. 而inc操作逻辑上也很简单, 读取一个变量的值i₁, 给它加上一个数字得到i₂, 再通过多数派把i₂写回到系统中
冰雪如你一定已经看到了这种实现方式中的问题: 如果有2个并发的客户端进程同时做这个inc的操作, 在多数派读写的实现中, 必然会产生一个Y客户端覆盖X客户端的问题. 从而产生了数据更新点的丢失.
而paxos就是为了解决这类问题提出的, 它需要让Y能检测到这种并发冲突, 进而采取措施避免更新丢失.
提取一下上面提到的问题: 让Y去更新的时候不能直接更新i₂, 而是应该能检测到i₂的存在, 进而将自己的结果保存在下一个版本i₃中, 再写回系统中.
而这个问题可以转化成: i的每个版本只能被写入一次, 不允许修改. 如果系统设计能满足这个要求, 那么X和Y的inc操作就都可以正确被执行了
于是我们的问题就转化成一个更简单, 更基础的问题: 如何确定一个值(例如iⱼ)已经被写入了.
直观来看, 解决方法也很简单, 在X或Y写之前先做一次多数派读, 以便确认是否有其他客户端进程已经在写了, 如果有, 则放弃。比如对于一个值的一个版本1,X首先写入,当Y读取到版本1已经被写入了则主动更新版本1失败
但是!!!, 这里还有个并发问题, X和Y可能同时做这个写前读取的操作, 并且同时得出一个结论: 还没有其他进程在写入, 我可以写. 这样还是会造成更新丢失的问题.
为了解决上面的问题, 存储节点还需要增加一个功能, 就是它必须记住谁最后一个做过写前读取的操作. 并且只允许最后一个完成写前读取的进程可以进行后续写入, 同时拒绝之前做过写前读取的进程写入的权限.
可以看到, 如果每个节点都记得谁读过, 那么当Y最后完成了写前读取的操作后, 整个系统就可以阻止过期的X的写入.
3.paxos算法描述
接下来的篇幅中我们将用计算机的语言准确的描述整个paxos运行的过程
我们要介绍的paxos实际上是最朴实的classic paxos
paxos算法中解决了如何在不可靠硬件基础上构建一个可靠的分布式系统的方法. 但paxos核心算法中只解决网络延迟/乱序的问题, 它不试图解决存储不可靠和消息错误的问题, 因为这两类问题本质上跟分布式关系不大, 属于数据校验层面的事情
- Proposer 可以理解为客户端.
- Acceptor 可以理解为存储节点.
- Quorum 在99%的场景里都是指多数派, 也就是半数以上的Acceptor.
- Round 用来标识一次paxos算法实例, 每个round是2次多数派读写: 算法描述里分别用phase-1和phase-2标识. 同时为了简单和明确, 算法中也规定了每个Proposer都必须生成全局单调递增的round, 这样round既能用来区分先后也能用来区分不同的Proposer(客户端).
在存储端(Acceptor)也有几个概念:
- last_rnd 是Acceptor记住的最后一次进行写前读取的Proposer(客户端)是谁, 以此来决定谁可以在后面真正把一个值写到存储中.
- v 是最后被写入的值.
- vrnd 跟v是一对, 它记录了在哪个Round中v被写入了.
v和vrnd是用于恢复一次未完成的paxos用的. 一次未完成的paxos算法运行可能留下一些没有达到多数派的值的写入(就像原生的多数派写的脏读的问题), paxos中通过vrnd来决定哪些值是最后写入的, 并决定恢复哪个未完成的paxos运行
首先是paxos的phase-1, 它相当于之前提到的写前读取过程. 它用来在存储节点(Acceptor)上记录一个标识: 我后面要写入; 并从Acceptor上读出是否有之前未完成的paxos运行. 如果有则尝试恢复它; 如果没有则继续做自己想做的事情.
我们用类似yaml的格式来描述phase-1的请求/应答的格式:
request:
rnd: int
response:
last_rnd: int
v: "xxx",
vrnd: int
phase-1成后, acceptor应该记录X的rnd为last_rnd, 并返回自己之前保存的v和vrnd.
Proposer X收到多数(quorum)个应答, 就认为是可以继续运行的.如果没有联系到多于半数的acceptor, 整个系统就hang住了, 这也是paxos声称的只能运行少于半数的节点失效.
这时Proposer面临2种情况:
-
所有应答中都没有任何非空的v, 这表示系统之前是干净的, 没有任何值已经被其他paxos客户端完成了写入(因为一个多数派读一定会看到一个多数派写的结果). 这时Proposer X继续将它要写的值在phase-2中真正写入到多于半数的Acceptor中.
-
如果收到了某个应答包含被写入的v和vrnd, 这时, Proposer X 必须假设有其他客户端(Proposer) 正在运行, 虽然X不知道对方是否已经成功结束, 但任何已经写入的值都不能被修改!, 所以X必须保持原有的值. 于是X将看到的最大vrnd对应的v作为X的phase-2将要写入的值.
这时实际上可以认为X执行了一次(不知是否已经中断的)其他客户端(Proposer)的修复.
在第2阶段phase-2, Proposer X将它选定的值写入到Acceptor中, 这个值可能是它自己要写入的值, 或者是它从某个Acceptor上读到的v(修复).
同样用类似yaml的方式描述请求应答:
request:
v: "xxx",
rnd: int
reponse:
ok: bool
当然这时(在X收到phase-1应答, 到发送phase-2请求的这段时间), 可能已经有其他Proposer又完成了一个rnd更大的phase-1, 所以这时X不一定能成功运行完phase-2.
Acceptor通过比较phase-2请求中的rnd, 和自己本地记录的rnd, 来确定X是否还有权写入. 如果请求中的rnd和Acceptor本地记录的rnd一样, 那么这次写入就是被允许的, Acceptor将v写入本地, 并将phase-2请求中的rnd记录到本地的vrnd中.
4.用例子看paxos运行
好了paxos的算法描述也介绍完了. 这些抽象的算法描述, 其中的规则覆盖了实际所有可能遇到的情况的处理方式. 一次不太容易看清楚它们的作用, 所以我们接下来通过几个例子来看看paxos如何处理各种不同状态并最终使整个系统的状态达成一致.
没冲突的例子不解释了
X和Y同时运行paxos, Y迫使X中断的例子:
- X成功完成了写前读取(phase-1), 将rnd=1写入到左边2个Acceptor.
- Y用更大的rnd=2, 覆盖了X的rnd, 将rnd=2写入到右边2个Acceptor.
- X以为自己还能运行phase-2, 但已经不行了, X只能对最左边的Acceptor成功运行phase-2, 而中间的Acceptor拒绝了X的phase-2.
- Y对右边2个Acceptor成功运行了phase-2, 完成写入v=y, vrnd=2.
继续上面的例子, 看X如何处理被抢走写入权的情况:
这时X的phase-2没成功, 它需要重新来一遍, 用更大的rnd=3.
- X成功在左边2个Acceptor上运行phase-1之后, X发现了2个被写入的值: v=x, vrnd=1 和 v=y, vrnd=2; 这时X就不能再写入自己想要写入的值了. 它这次paxos运行必须不能修改已存在的值, 这次X的paxos的运行唯一能做的就是, 修复(可能)已经中断的其他proposer的运行.
- 这里v=y, vrnd=2 是可能在phase-2达到多数派的值. v=x, vrnd=1不可能是, 因为其他proposer也必须遵守算法约定, 如果v=x, vrnd=1在某个phase-2达到多数派了, Y一定能在phase-1中看到它, 从而不会写入v=y, vrnd=2.
因此这是X选择v=y, 并使用rnd=3继续运行, 最终把v=y, vrnd=3写入到所有Acceptor中.
Paxos 还有一个不太重要的角色Learner, 是为了让系统完整加入的, 但并不是整个算法执行的关键角色, 只有在最后在被通知一下
5.凤凰架构----Paxos算法流程
Paxos 算法将分布式系统中的节点分为三类:
- 提案节点:称为 Proposer,提出对某个值进行设置操作的节点,设置值这个行为就被称之为提案(Proposal),值一旦设置成功,就是不会丢失也不可变的。请注意,Paxos 是典型的基于操作转移模型而非状态转移模型来设计的算法,这里的“设置值”不要类比成程序中变量赋值操作,应该类比成日志记录操作,在后面介绍的 Raft 算法中就直接把“提案”叫作“日志”了。
- 决策节点:称为 Acceptor,是应答提案的节点,决定该提案是否可被投票、是否可被接受。提案一旦得到过半数决策节点的接受,即称该提案被批准(Accept),提案被批准即意味着该值不能再被更改,也不会丢失,且最终所有节点都会接受该它。
- 记录节点:被称为 Learner,不参与提案,也不参与决策,只是单纯地从提案、决策节点中学习已经达成共识的提案,譬如少数派节点从网络分区中恢复时,将会进入这种状态。
在分布式环境下,如果我们说各个节点“就某个值(提案)达成一致”,指的是“不存在某个时刻有一个值为 A,另一个时刻又为 B 的情景”。解决这个问题的复杂度主要来源于以下两个方面因素的共同影响:
- 系统内部各个节点通信是不可靠的,不论对于系统中企图设置数据的提案节点抑或决定是否批准设置操作的决策节点,其发出、收到的信息可能延迟送达、也可能会丢失,但不去考虑消息有传递错误的情况 (这点可以认为通过多数派读写完成,即不强求所有节点都完全响应)。
- 系统外部各个用户访问是可并发的,如果系统只会有一个用户,或者每次只对系统进行串行访问,那单纯地应用 Quorum 机制,少数节点服从多数节点,就已经足以保证值被正确地读写。
Paxos 算法包括两个阶段,其中,第一阶段“准备”(Prepare)就相当于抢占加锁的过程。如果某个提案节点准备发起提案,必须先向所有的决策节点广播一个许可申请(称为 Prepare 请求)。提案节点的 Prepare 请求中会附带一个全局唯一的数字 n 作为提案 ID,决策节点收到后,将会给予提案节点两个承诺与一个应答。
两个承诺是指:
- 承诺不会再接受提案 ID 小于或等于 n 的 Prepare 请求。
- 承诺不会再接受提案 ID 小于 n 的 Accept 请求。
一个应答是指:
- 如果收到的提案ID是最大的,回复已经批准过的提案中 ID 最大的那个提案所设定的值和提案 ID,如果该值从来没有被任何提案设定过,则返回空值。如果收到的提案 ID 并不是决策节点收到过的最大的,那允许直接对此 Prepare 请求不予理会。
当提案节点收到了多数派决策节点的应答(称为 Promise 应答)后,可以开始第二阶段“批准”(Accept)过程,这时有如下两种可能的结果:
- 如果提案节点发现所有响应的决策节点此前都没有批准过该值(即为空),那说明它是第一个设置值的节点,可以随意地决定要设定的值,将自己选定的值与提案 ID,构成一个二元组“(id, value)”,再次广播给全部的决策节点(称为 Accept 请求)。
- 如果提案节点发现响应的决策节点中,已经有至少一个节点的应答中包含有值了,那它就不能够随意取值了,必须无条件地从应答中找出提案 ID 最大的那个值并接受,构成一个二元组“(id, maxAcceptValue)”,再次广播给全部的决策节点(称为 Accept 请求)。
当每一个决策节点收到 Accept 请求时,都会在不违背以前作出的承诺的前提下,接收并持久化对当前提案 ID 和提案附带的值。如果违反此前做出的承诺,即收到的提案 ID 并不是决策节点收到过的最大的,那允许直接对此 Accept 请求不予理会。
当提案节点收到了多数派决策节点的应答(称为 Accepted 应答)后,协商结束,共识决议形成,将形成的决议发送给所有记录节点进行学习。
6.工作实例
假设一个分布式系统有五个节点,分别命名为 S1、S2、S3、S4、S5,这个例子中只讨论正常通信的场景,不涉及网络分区。全部节点都同时扮演着提案节点和决策节点的身份。此时,有两个并发的请求分别希望将同一个值分别设定为 X(由 S1作为提案节点提出)和 Y(由 S5作为提案节点提出),以 P 代表准备阶段,以 A 代表批准阶段,这时候可能发生以下情况:
- 情况一:譬如,S1选定的提案 ID 是 3.1(全局唯一 ID 加上节点编号),先取得了多数派决策节点的 Promise 和 Accepted 应答,此时 S5选定提案 ID 是 4.5,发起 Prepare 请求,收到的多数派应答中至少会包含 1 个此前应答过 S1的决策节点,假设是 S3,那么 S3提供的 Promise 中必将包含 S1已设定好的值 X,S5就必须无条件地用 X 代替 Y 作为自己提案的值,由此整个系统对“取值为 X”这个事实达成一致,如图 6-2 所示。
情况二:事实上,对于情况一,X 被选定为最终值是必然结果,但从图 6-2 中可以看出,X 被选定为最终值并不是必定需要多数派的共同批准,只取决于 S5提案时 Promise 应答中是否已包含了批准过 X 的决策节点,譬如图 6-3 所示,S5发起提案的 Prepare 请求时,X 并未获得多数派批准,但由于 S3已经批准的关系,最终共识的结果仍然是 X。
情况三:当然,另外一种可能的结果是 S5提案时 Promise 应答中并未包含批准过 X 的决策节点,譬如应答 S5提案时,节点 S1已经批准了 X,节点 S2、S3未批准但返回了 Promise 应答,此时 S5以更大的提案 ID 获得了 S3、S4、S5的 Promise,这三个节点均未批准过任何值,那么 S3将不会再接收来自 S1的 Accept 请求,因为它的提案 ID 已经不是最大的了,这三个节点将批准 Y 的取值,整个系统最终会对“取值为 Y”达成一致
情况四:从情况三可以推导出另一种极端的情况,如果两个提案节点交替使用更大的提案 ID 使得准备阶段成功,但是批准阶段失败的话,这个过程理论上可以无限持续下去,形成活锁(Live Lock),如图 6-5 所示。在算法实现中会引入随机超时时间来避免活锁的产生。
7.multi-paxos
Basic Paxos 的活锁问题,两个提案节点互不相让地争相提出自己的提案,抢占同一个值的修改权限,导致整个系统在持续性地“反复横跳”,外部看起来就像被锁住了一样。此外,笔者还讲述过一个观点,分布式共识的复杂性,主要来源于网络的不可靠与请求的可并发两大因素,活锁问题与许多 Basic Paxos 异常场景中所遭遇的麻烦,都可以看作是源于任何一个提案节点都能够完全平等地、与其他节点并发地提出提案而带来的复杂问题。为此,Lamport 专门设计(“专门设计”的意思是在 Paxos 的论文中 Lamport 随意提了几句可以这么做)了一种 Paxos 的改进版本“Multi Paxos”算法。
Multi Paxos 对 Basic Paxos 的核心改进是增加了“选主”的过程,提案节点会通过定时轮询(心跳),确定当前网络中的所有节点里是否存在有一个主提案节点,一旦没有发现主节点存在,节点就会在心跳超时后使用 Basic Paxos 中定义的准备、批准的两轮网络交互过程,向所有其他节点广播自己希望竞选主节点的请求,希望整个分布式系统对“由我作为主节点”这件事情协商达成一致共识,如果得到了决策节点中多数派的批准,便宣告竞选成功。
当选主完成之后,除非主节点失联之后发起重新竞选,否则从此往后,就只有主节点本身才能够提出提案。此时,无论哪个提案节点接收到客户端的操作请求,都会将请求转发给主节点来完成提案,而主节点提案的时候,也就无需再次经过准备过程,也可以通俗理解为选主过后,就不会再有其他节点与它竞争,相当于是处于无并发的环境当中进行的有序操作,所以此时系统中要对某个值达成一致,只需要进行一次批准的交互即可。
可能有人注意到这时候的二元组(id, value)已经变成了三元组(id, i, value),这是因为需要给主节点增加一个“任期编号”,这个编号必须是严格单调递增的,以应付主节点陷入网络分区后重新恢复,但另外一部分节点仍然有多数派,且已经完成了重新选主的情况,此时必须以任期编号大的主节点为准。当节点有了选主机制的支持,在整体来看,就可以进一步简化节点角色,不去区分提案、决策和记录节点了,统统以“节点”来代替,节点只有主(Leader)和从(Follower)的区别
在这个理解的基础上,我们换一个角度来重新思考“分布式系统中如何对某个值达成一致”这个问题,可以把该问题划分做三个子问题来考虑,可以证明(具体证明就不列在这里了,感兴趣的读者可参考结尾给出的论文)当以下三个问题同时被解决时,即等价于达成共识:
- 如何选主(Leader Election)。
- 如何把数据复制到各个节点上(Entity Replication)。
- 如何保证过程是安全的(Safety)。
选主问题尽管还涉及许多工程上的细节,譬如心跳、随机超时、并行竞选,等等,但要只论原理的话,如果你已经理解了 Paxos 算法的操作步骤,相信对选主并不会有什么疑惑,因为这本质上仅仅是分布式系统对“谁来当主节点”这件事情的达成的共识而已,我们在前一节已经花了数千字来讲述分布式系统该如何对一件事情达成共识,这里就不重复赘述了,下面直接来解决数据(Paxos 中的提案、Raft 中的日志)在网络各节点间的复制问题。
在正常情况下,当客户端向主节点发起一个操作请求,譬如提出“将某个值设置为 X”,此时主节点将 X 写入自己的变更日志,但先不提交,接着把变更 X 的信息在下一次心跳包中广播给所有的从节点,并要求从节点回复确认收到的消息,从节点收到信息后,将操作写入自己的变更日志,然后给主节点发送确认签收的消息,主节点收到过半数的签收消息后,提交自己的变更、应答客户端并且给从节点广播可以提交的消息,从节点收到提交消息后提交自己的变更,数据在节点间的复制宣告完成。
在异常情况下,网络出现了分区,部分节点失联,但只要仍能正常工作的节点的数量能够满足多数派(过半数)的要求,分布式系统就仍然可以正常工作,这时候数据复制过程如下:
-
假设有 S1、S2、S3、S4、S5五个节点,S1是主节点,由于网络故障,导致 S1、S2和 S3、S4、S5之间彼此无法通信,形成网络分区。
-
一段时间后,S3、S4、S5三个节点中的某一个(譬如是 S3)最先达到心跳超时的阈值,获知当前分区中已经不存在主节点了,它向所有节点发出自己要竞选的广播,并收到了 S4、S5节点的批准响应,加上自己一共三票,即得到了多数派的批准,竞选成功,此时系统中同时存在 S1和 S3两个主节点,但由于网络分区,它们不会知道对方的存在。
-
这种情况下,客户端发起操作请求:
- 如果客户端连接到了 S1、S2之一,都将由 S1处理,但由于操作只能获得最多两个节点的响应,不构成多数派的批准,所以任何变更都无法成功提交。
- 如果客户端连接到了 S3、S4、S5之一,都将由 S3处理,此时操作可以获得最多三个节点的响应,构成多数派的批准,是有效的,变更可以被提交,即系统可以继续提供服务。
- 事实上,以上两种“如果”情景很少机会能够并存。网络分区是由于软、硬件或者网络故障而导致的,内部网络出现了分区,但两个分区仍然能分别与外部网络的客户端正常通信的情况甚为少见。更多的场景是算法能容忍网络里下线了一部分节点,按照这个例子来说,如果下线了两个节点,系统正常工作,下线了三个节点,那剩余的两个节点也不可能继续提供服务了。
-
假设现在故障恢复,分区解除,五个节点可以重新通信了:
- S1和 S3都向所有节点发送心跳包,从各自的心跳中可以得知两个主节点里 S3的任期编号更大,它是最新的,此时五个节点均只承认 S3是唯一的主节点。
- S1、S2回滚它们所有记录在日志中未被提交的变更。
- S1、S2从主节点发送的心跳包中获得它们失联期间发生的所有变更,将变更提交写入本地磁盘。
- 此时分布式系统各节点的状态达成最终一致。