大数据经典论文——Paxos 《Paxos Made Simple》
https://lamport.azurewebsites.net/pubs/paxos-simple.pdf
第一章 Paxos算法背景
Paxos算法是Lamport宗师提出的一种基于消息传递的分布式一致性算法,使其获得2013年图灵奖。
Paxos由Lamport于1998年在《The Part-Time Parliament》论文中首次公开,最初的描述使用希腊的一个小岛Paxos作为比喻,描述了Paxos小岛中通过决议的流程,并以此命名这个算法,但是这个描述理解起来比较有挑战性。后来在2001年,Lamport觉得同行不能理解他的幽默感,于是重新发表了朴实的算法描述版本《Paxos Made Simple》。
自Paxos问世以来就持续垄断了分布式一致性算法,Paxos这个名词几乎等同于分布式一致性。Google的很多大型分布式系统都采用了Paxos算法来解决分布式一致性问题,如Chubby、Megastore以及Spanner等。开源的ZooKeeper,以及MySQL 5.7推出的用来取代传统的主从复制的MySQL Group Replication等纷纷采用Paxos算法解决分布式一致性问题。
1.1 拜占庭将军问题
拜占庭帝国有一支庞大的军队,这只军队有多个小分队(A-B-C-D-E),分别位于帝国的各个角落。
一天,E分队的将军打算对某个倒霉国家发动进攻,但是必须要得到所有小分队的同意才能执行。无奈这座帝国太大,各个小分队之间的信息不得不依靠信使来传递。于是,E分队的将军分别派出a、b、c、d四个信使传递进攻任务的信息给其他各个分队。此时问题来了,没法保证信息准确可靠的传递给他们!假如d信使本就是个叛徒,亦或是a信使在路上被人劫持,再或是b信使走到一半无路可走,等等情况。这个其实非常类似于分布式系统中各节点间通信,尤其是网络问题,很容易导致通信在某两个节点间中断。该问题有没有解决办法呢?有!但不是今天的重点。那么,Paxos算法跟拜占庭将军问题之间是什么关系呢?答案就是:Paxos算法的前提,不存在拜占庭将军问题(即通信是保证可靠的不会被篡改,但可以存在丢失延迟等问题)。
现实中是否存在某个环境,不存在拜占庭将军问题呢?当然有,否则Paxos算法就没有用武之地了。我们知道zookeeper解决一致性问题,就是对Paxos算法进行了实现,而类似于zookeeper这类分布式系统,本身就被部署在了一个安全的局域网环境中,尤其是生产环境,出现该问题的概率非常小,可以简单的认为不存在就行了。
第二章 Paxos算法流程
Paxos算法解决的问题正是分布式一致性问题,即一个分布式系统中的各个进程如何就某个值(决议)达成一致。
Paxos算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。它利用大多数 (Majority) 机制保证了2F+1的容错能力,即2F+1个节点的系统最多允许F个节点同时出现故障。
一个或多个提议进程 (Proposer) 可以发起提案 (Proposal),Paxos算法使所有提案中的某一个提案,在所有进程中达成一致。系统中的多数派同时认可该提案,即达成了一致。最多只针对一个确定的提案达成一致。
2.1 系统角色
Paxos将系统中的角色分为提议者 (Proposer),决策者 (Acceptor),和最终决策学习者 (Learner):
- Proposer: 提出提案 (Proposal)。Proposal信息包括提案编号 (Proposal ID) 和提议的值 (Value)。
- Acceptor:参与决策,回应Proposers的提案。收到Proposal后可以接受提案,若Proposal获得多数Acceptors的接受,则称该Proposal被批准。
- Learner:不参与决策,从Proposers/Acceptors学习最新达成一致的提案(Value)。
在多副本状态机中,每个副本同时具有Proposer、Acceptor、Learner三种角色。
算法最终目标:每个Proposer、Acceptor和Learner都认为同一个Proposal中的value被选中。
2.2 提案编号
编号 (Proposal ID)由两部分组成。高位是整个提案过程中的轮数(Round),低位是我们刚才的服务器编号。每个服务器呢,都会记录到自己至今为止看到过,或者用到过的最大的轮数。
那么,当某一台服务器,想要发起一个新提案的时候,就要用它拿到的最大轮数加上 1,作为新提案的轮数,并且把自己的服务器编号拼接上去,作为提案号发放出去。并且这个提案号必须要存储在磁盘上,避免节点在挂掉之后,不知道最新的提案号是多少。
通过这个方式,我们就让这个提案号做到了两点:首先是不会有重复的提案号,不会存在两个服务器发出相同提案号的情况;其次是提案号能够按照数值大小,区分出先后和大小。即使是同一状态下不同服务器发出的提案,也能比较大小。
2.3 Prepare 阶段
Prepare 阶段那么,当提案者Proposer收到一条来自客户端的请求之后,它就会以提案者的身份发起提案。提案包括了前面的提案号,我们把这个提案号就叫做 M。这个提案会广播给所有的Acceptor接受者,这个广播请求被称为 Prepare 请求。(注意这里只发送编号没有内容)
而所有的 Acceptor 在收到提案的时候,会返回一个响应给提案者。这个响应包含的信息是这样的:
- 首先,所有的Acceptor接受者一旦收到前面的 Prepare 请求之后,都会promise承诺它接下来,永远不会接受提案号比当前提案号(Proposal ID) M 小的请求;
- 其次,如果Acceptor接受者之前已经接受过其他提案的内容(假设是 X)了,那么它要存储下已经接受过的内容和对应的提案号。并且在此之后,把这个提案号和已经接受过的内容 X,一起返回给Proposer提案者。而如果没有接受过,就把内容填为 NULL。
这样一个来回,就称之为 Paxos 算法里的 Prepare 阶段。要注意,这里的Acceptor接受者只是返回告知Proposer提案者信息,它还没有真正接受请求。这个过程,本质上是提案者去查询所有的Acceptor接受者,是否已经接受了别的提案。
2.4 Accept 阶段
当Proposer提案者收到超过半数的响应之后呢,整个提案就进入第二个阶段,也称之为 Accept 阶段。Proposer提案者会再次发起一个广播请求,里面包含这样的信息:
- 首先仍然是一个提案号,这个提案号就是刚才的 Prepare 请求里的提案号 M;
- 其次,是提案号里面的内容,一般我们也称之为提案的值。不过这个值,就有两种情况了。
第一种情况,是之前Acceptor接受者已经接受过值了。那么这里的值,是所有Acceptor接受者返回过来,接受的值当中,提案号最大的那个提案的值。也就是说,提案者说,既然之前已经做出决策了,那么我们就遵循刚才的决策就好了。
而第二种情况,如果所有的Proposer提案者返回的都是 NULL,那么这个请求里,Proposer提案者就放上自己的值,然后告诉大家,请大家接受我这个值。
那么接受到这个 Accept 请求的接受者,在此时就可以选择接受还是拒绝这个提案的值。通常来说:
- 如果Acceptor接受者没有遇到其他并发的提案,自然会接受这个值。一旦提案者收到超过半数的接受者“接受”的请求。那么它就会确定,自己提交的值被选定了。
- 但也有可能,接受者刚才已经答应了某个新的提案者说,不能接受一个比提案号 N 早的请求。而 N>M,所以这个时候Acceptor接受者会拒绝 M。
- 不管是接受还是拒绝,这个时候Acceptor接受者都会把最新的提案编号 N,返回给Proposer提案者。
- 还是要注意,这个时候接受者接受了请求,并不代表这个请求在整个系统中被“选择”了。
提案者还是会等待至少一半的接受者返回的响应。如果其中有人拒绝,那么提案者就需要放弃这一轮的提案,重新再来:生成新的提案号、发起 Prepare 请求、发起 Accept 请求。而当超过一半人表示接受请求的时候,提案者就认为提案通过了。当然,这个时候我们的提案虽然没有变,但是提案号已经变了。而当没有人拒绝,并且超过一半人表示接受请求的时候,提案者就认为提案通过了。
Paxos算法伪代码描述如下:
第三章 案例
这里还是以拜占庭将军问题为例,这里两个参谋作为Proposer,三个将军作为Acceptor
案例1
参谋1提出一个Prepare请求,三位将军收到提案后,进行了响应,因为之前没有接受过其他的提案,三位将军返回null,OK即可。
参谋1收到超过半数响应后,进入第二阶段,发送accept请求(包含提案编号、提案值-进攻时间),三位将军之前没有遇到其他提案,会接受这个值Accepted,提案达成了。
参谋2再提出一个Prepare请求,编号2,但三位将军已经接受过之前编号1的提案了,会将提案号和已经接受过的内容 返回给参谋2
这里参谋2收到多数响应,还会发送accept请求(编号2)。之前接受者已经接受过值了。那么这里的值,是所有接受者返回过来(进攻时间1)
案例2
来一个复制的并发例子
参谋1提出一个Prepare请求(编号1),将军1和将军2收到提案后,进行了响应,但到将军3的通讯中断了(通讯兵被俘虏),但参谋1收到超过半数响应后,进入Accept阶段。
这时参谋2也提出一个Prepare请求(编号2),将军2和将军3收到提案后,但到将军1的通讯中断了(通讯兵被俘虏)没有收到,将军2会承诺不再接受比编号2小的提案了,注意将军2这时没有接受提案内容。将军2和将军3也构成了相应的多数派,参谋2进入Accept阶段。
参谋1发送accept请求,将军1没有什么问题,接受了提案的值,但将军2刚才已经接受了编号2的提案,不能再接受比2小的编号1提案,给拒绝了。有人拒绝,那么提案者就需要放弃这一轮的提案,重新再来。
参谋2也发送accept请求,指定编号2,进攻时间2,将军2和将军3之前都没有接受过值,便接受了提案的进攻时间2,满足了多数派,达成了一致
参谋1之前传达失败,重新提出Prepare请求(编号3)。将军1已经接受过编号1的提案了,返回编号1进攻时间1;将军2已经接受过编号2的提案了,返回编号2进攻时间2,进入accept阶段。
将军1和将军2已经接受过值了,参谋1选取编号大的提案的值(既然之前已经做出决策了,那么我们就遵循刚才的决策就好了),发送accept请求(编号3,进攻时间2),将军1和将军2之前没有接受过比这个议案编号更大的议案了,所以选择接受,返回成功,整个系统达成了共识。
第四章 总结
在 Paxos 算法这个过程中,其实一直在确保一件事情,就是所有节点,需要对当前接受了哪一个提案达成多数共识。
但Paxos算法的开销太大了。无论是否系统里面出现并发的情况,任何一个共识的达成,都需要两轮 RPC 调用。而且,所有的数据写入,都需要在所有的接受者节点上都写入一遍。
所以,虽然 Paxos 算法帮助我们解决了单点故障,并且在没有单点的情况下,实现了共识算法,确保所有节点的日志顺序是相同的。但是,原始的 Paxos 算法的性能并不好。只是简单地写入一条日志,我们就可能要解决多个 Proposer 之间的竞争问题,有可能需要有好几轮的网络上的 RPC 调用。
当然,我们可以用各种手段在共识算法层面进行优化,比如一次性提交一组日志,而不是一条日志。这也是后续 Multi-Paxos 这些算法想到的解决方案。但是,如果我们往一个数据库同步写入日志都要通过 Paxos 算法,那么无论我们怎么优化,性能都是跟不上的。根本原因在于,在 Paxos 算法里,一个节点就需要承接所有的数据请求。虽然在可用性上,我们没有单点的瓶颈了,但是在性能上,我们的瓶颈仍然是单个节点。