转自不正直的绅士,因百度空间迁移,无法注明出处,我从其google搜索引擎中的cache进行的copy.
不正直的绅士 是跟我一起工作过的非常有才的一个青年才俊。
Paxos的使用非常广泛。sanlock也使用了paxos。
共研究Paxos算法的程序猿参考。
Paxos算法小结
1 Paxos算法的背景
1.1 State Machine Approach与一致性算法
1.2 CAP理论与一致性算法
2 Paxos算法
2.1 Paxos算法的角色
2.2 Paxos算法的描述
2.3 Paxos算法的简单论证
2.4 Paxos里两个阶段的必要性
3 Disk Paxos算法
3.1 Disk Paxos算法的正式描述
3.2 Disk Paxos的简单论证
3.3 Disk Paxos和Paxos的对应关系
3.4 Sanlock
4 Google Chubby
5 Amazon Dynamo
6 Paxosd
7 小结
1 Paxos算法的背景
最近读了几篇关于Paxos算法和应用的论文,整理思路一下以免遗忘。
Paxos是一个分布式一致性算法,作用是保证整个集群对某件事情达成一致。它是一个相当简练的算法,不过却不太好懂。很多介绍Paxos的文章一上来就介绍算法原理、证明和应用,却没有好好的谈谈它的大背景,初次接触它的人往往会落入各种细节,不得要领。我一开始看了Paxos之后,不知道它是想解决什么问题,为什么要解决这种问题,和以前看过的分布式系统又有什么联系。在初步了解过State Machine Approach,搞清Paxos在CAP分类中的位置之后,才霍然开朗。
1.1 State Machine Approach与一致性算法
看Paxos算法之前,应该先了解State Machine Approach,因为Paxos算法的主要应就是实现一个分布式的状态机。State Machine Approach是一种实现容错的分布式系统的一般性的方法和模型。比如说,如果某个电子商务网站只有一台数据库服务器,那么这个数据库服务器崩溃之后,整个系统就不能运行了,所以需要多台数据库服务器以便互相替换。问题是如何保持所有的数据库服务器上的数据都是一样的呢?使用State Machine Approach,首先把每台数据库服务器看作一台状态机,它把用户的请求作为输入,转换自身的状态,再输出结果。比如账户余额为0,用户存入100,余额状态变为100,取出20,余额状态变为80。这种状态机有一个特点,就是确定性,只要给出的每个输入和输入的顺序一样,不管计算多少次,输出和最终状态都一样。因此,只需要在每个节点上都创建一个状态机,设置相同的初态,把用户的输入都按同样的顺序分发到这些状态机上,它们最终就能得到一样的状态。如果有其中一台状态崩溃了,其他的状态机还能继续响应服务,并在再崩溃的机器恢复后,可以继续把错过的输入再次在其上重放,它就又能同步到和大家一样的状态。如果这台状态机因为磁盘损坏之类的问题导致状态错误,那么和其他状态比对一下就能发现不同,在一个有N个状态机的集群里面,只要N/2 + 1台状态机正常,也就是多数状态机正常,就不会丢失正确的状态。常见的数据库都有主从热备功能,主数据库的日志会在从数据库上重放,主服务器秀逗的时候,从服务器就顶上。用分布式的状态机模型考虑一下这个例子,感觉十分的贴切。
State Machine Approach的一个关键点是,分发到每台状态机的每笔用户输入的顺序都必须是一样的,在有一个单点专门接受请求并复制分发时,是可以保证这点的。但是我们不喜欢单点,那么如何实现有多个节点(甚至每个节点)都可以接受在自不同用户的不同请求,并且这些节点能对用户请求的顺序达成一致呢?这就是一致性算法要解决的问题。使用一致性算法,每个节点都可以发表提案,竞选第i号请求的值,接着多数节点同意后,批准这个值,提案失败的节点可以用自己刚才没通过的值,继续竞选第i+1号请求。这样反复下去,每个节点依次把自己收到的用户请求提交到集群批准,通过后就继续提案下一个用户请求,没通过就再次竞争下一个编号的请求。一般的数据库只能实现主从热备,用这种方式,就可以实现主主互备了。二阶段提交和三阶段提交都是这样的一致性算法。但是一般的算法比如二阶段提交,要求所有的参与节点都通过了才能完成事务,任意节点失去联系都会导致整个事务的阻塞。三阶段提交为事务设置了超时阶段,即使一段时间内没有来自Coordinator的指示,也能自行提交事务,三阶段提交的问题是无法应对网络分区。网络分区在一个线上系统的实际运行中是很常见的,比如交换机的某个口坏了,或者路由器配错了,再或者网络拥塞导致时延变大,对网络应用来说,是无法区别只是时延变大了还是网络断了。分区发生时,数据中心的整个网络暂时被分成几块互相不能通信的小网络,这个时候三阶段提交有可能在不同的小网络里提交不同的事务,导致不一致的出现。而且三阶段提交对每个事务都需要3次交互,时延太长。Paxos算法的先进之处在于,当且仅当大多数成员达成一致时提交事务,即使网络被分区,每个分区内的集群成员不足整个集群的多数时,就不会提交事务,反之只要分区内的节点能达到多数要求,系统就能继续运行下去。Paxos算法既不会出现二阶段提交的阻塞情况,也没有三阶段提交的不一致的风险。
1.2 CAP理论与一致性算法
除了基于一致性算法的分布式系统,还有其他类型的吗?本节考虑一致性算法在CAP理论中的分类。CAP是分布式领域著名的理论,C代表Consistency一致性,A代表Availability可用性,P代表Partition Tolerance分区容忍性。CAP理论认为,一个分布式系统是不可能同时满足一致性、可用性和分区容忍性的。所谓一致性,就是某个变量的值在整个集群中的唯一性,比如在所有的服务器上,账户余额X都为100,不存在服务器A上X为200,服务器B上X为100的情况,或者在外地的朋友往我们账号里汇了100块,那么本地ATM机上再查寻必然能查到多了这笔转帐。CAP理论中的可用性,和平时谈的可用性不太一样,这里可用性指的是一个请求能在确定的时间内得到服务器的响应,无论响应的内容是“请求执行成功”还是“请求执行失败”,都算数,只有服务器没有响应,或者请求被无限期阻塞,才说违背了可用性。分区容忍性指的是在网络出现分区的时候,集群上的服务是否还能够正常运行。CAP理论成立的理由很简单,假设有2台服务器,运行着主主互备的数据库应用,A和B通过某种同步协议来保证一致性。假设某个时间点上网络出现故障(也即分区),这时用户向服务器A发送请求,更新自己的账户余额,如果A接受更新,那么该用户的余额在A和B服务器上出现不一致,如果A阻塞用户的请求,等待网络恢复并和B通信后才提交事务,那就不满足可用性了,因为不知道网络何时恢复,也许该请求永远被阻塞住了。
典型的CP系统的例子就是是传统的分布式数据库。在没有分区的情况下,CP系统能够保证数据的一致性,可以在任何一个节点写,再从其他节点读,数据必定是一致的,出现分区时,通过把自己变得不可用以保证数据不会出现不一致。典型的AP系统例子包括某些NoSQL数据。AP系统在出现分区时仍然能响应用户请求,可是无论是否出现分区,都不保证数据的一致性,在出现冲突的时候,可能使用某种启发性的冲突消除算法,比如,用最后一次写入的值作为有效值,或者对多笔写操作进行累计和叠加。亚马逊的Dynamo数据库就是一个这样的AP系统。最后一种是CA系统,这种系统则在不分区时能够保证数据的一致性,并且所有节点都能提供服务,可是出现分区时无论是C还是A都未必能够保证,节点未必有响应,就算有响应,数据也可能出现不一致。一般在设计分布式系统时,都不会放弃P,然后根据系统的实际需求,会在C和A之间作取舍。
CAP理论的应用可以很灵活,在实际的系统中,取舍的粒度可能非常小。不同的子系统可以采用CP或者AP,甚至同一个子系统内部的不同事务,也可以分别采用CP或AP。另外C和A也不是绝对的,近年流行起来的最终一致性系统就是这样的例子。根据应用的语义,可能要求非常严格的C,也可以相当的宽松。比如用户在不同的服务器上发起向在购物车中添加商品的请求,只要最后能把所有的请求合并,就能得到正确的结果。对A的要求也是很灵活的,比如二阶段提交要求所有参与事务的节点都有响应,而Paxos算法只要求多数节点可用。有的应用要求节点的响应延迟在毫秒级,有的只要求在若干秒内响应即可。
所有的一致性算法,包括Paxos,都属于CP这一大类。在出现分区时,如果某个分区能够满足节点数过半的条件,Paxos算法就能够继续执行,否则就会阻塞。
2 Paxos算法
有了之前的讨论,我们已经知道,一个分布式状态机,每个节点都可以接受请求,然后用Paxos算法决定请求执行的顺序,并且这样的系统是一个CP系统,优先保证一致性。用Paxos算法保证请求执行的顺序,具体来说,指的是
(1)是每次决定采用的请求,必定是从参加竞选的提案里挑出来的——非平凡性。
(2)一旦第i个请求采用哪个提案已经决定好了,就不会再采用内容不同的提案了——一致性。
(3)只要能联系上的节点超过总节点数的一半,并且这些节点都正常工作,那么一次竞选肯定能产生一个结果——非阻塞性。
只要能保证这三个性质,Paxos算法就可以说是一个正确的算法了。下面一边介绍算法,一边要简单说明这三个性质是如何成立的。
2.1 Paxos算法的角色
Paxos算法里,每个节点的关系都是对等的,都可以发起提案,也可以接收提案,在Lamport的The Part-Time Parliament这篇论文里,就是采用这种模型。但是如果能够将提案的发起方、接收方和事务的最终执行者这些角色分开来讨论,会更容易明白。Lamport在Paxos Made Simple这篇文章里采用了这种角色分离的模型。把所有的角色合并到同一个节点上之后,就得到了原来的Paxos算法的模型,所以两者实际是一样的,我们采用分角色的模型来讨论。
(1)Proposer:接收客户的请求,代表客户向Acceptor发起提案。
(2)Acceptor:监听来自Proposer的提案,并决定是否接受和回复。
(3)Learner:提案被接受后,提交并执行提案的内容。
2.2 Paxos算法的描述
Paxos要求Proposer的每个提案,都有一个唯一编号。所有的编号必须能形成线序。做到这一点很简单,比如为每个节点编号host_id:1,2,3……,然后每个节点发出的提案号n = x * 100 + host_id,其中x为自然数。这样就能得到101、102、201、202这样的提案号了,这样的提案号能够满足线序的要求。提案号不必是整数,他们的实际值没有用处,只要求互相能比大小。另外每个Proposer发出的提案编号应该是递增的,新发出的提案的编号要比旧的大。多数票通过的提案,可称法案。为了决定某一个法案的值,需要两个阶段。
阶段1:竞争提案编号。
(a)想要发起提案的Proposer自行选择一个提案编号n,向过半数的Acceptor发送Prepare消息,消息的内容只包含提案编号n,并不包含想要提议的值。
(b)Acceptor记录自己接收过的Prepare消息的提案编号的最大值。如果接收到的Prepare消息的提案编号n,小于等于有记载的最大值,就忽略这个消息。如果n大于有记载的最大值,那么Acceptor就要向Proposer回复一个Promise消息。如果Acceptor曾对某个提案运行到了阶段2,那么Promise消息的内容是在阶段2中曾被接受的提案值及其对应提案编号。如果Acceptor未曾对任何一个提案运行到阶段2,那么Promise消息的内容就是“未决”。Promise消息的意思是,告知Proposer其选择的提案编号是目前见过的最大的,并且保证将来不会接受小于等于这个提案编号的消息了。
阶段2:提交提案。
(a)如果Proposer在一段时间内,没能够得到多数的Acceptor回应的Promise消息,就表明竞争提案编号失败了,递增自己的提案编号,开始新一轮的阶段1。如果得到多数Acceptor的Promise,下一步就是要实际的提交提案。首先要决定提交提案的值。找出收到的Promise消息中带的最大的提案提案编号,把其提案值作为要提交提案的值,如果所有的Promise消息内容都是“未决”,那么Proposer就可以按自己的需要决定提案的值,这个值当然就是客户本次提交的请求了。接着Proposer向多数Acceptor发送Accept消息,消息的内容是之前竞争成功的提案编号n,以及刚才决定好提案值。
(b)Acceptor收到Accept消息之后,检查其中的提案编号n,如果之前针对更大的提案编号发送过Promise消息,那么就忽略这个Accept消息,否则这个Accept消息就是目前见过的最大编号的提案,应当接受为法案。一旦一个提案接受为法案,Acceptor就向Proposer和所有的Learner发送Accepted消息,消息的内容自然是法案的编号和其法案值了。Learner在收到Accepted消息后,就可以提交法案了。
在State Machine Approach中,第i个法案的值对应了第i个请求的值。每个节点是一个Proposer兼Acceptor,都尝试把自己收到的客户请求提案为第i个法案,如果没通过就尝试提为第i+1个法案。Paxos保证,只要第i个法案的值被多数票通过,后续对同一个法案再提起的提案永远都等于原来已通过的法案值。因此即使一个提案成功结束,但是法案也可能未采纳当前节点的提案值,而是早有其他节点捷足先登,这时也要尝试继续竞争第i+1个法案的值。Paxos算法的巧妙之处在于,在没进入第阶段2之前,Proposer的提案值都还未确定,进入阶段2时,并不要求Acceptor去接受新的法案值,而是要求Proposer屈从已有的法案值。
2.3 Paxos算法的简单论证
由于所有的法案值都是从提案值中选出的,因此非平凡性不言自明。
要说明一致性,首先需要换个比较好具体的等价陈述。一致性:假设任意两个编号不同提案都能通过Paxos算法的阶段2,那么这两个提案的内容肯定是一样的。
对两个编号不同的提案B1和B2,他们的多数派Q1和Q2的具体成员可能是不同的,但是在总成员数不变的情况下,两个多数派里至少有一个成员是相同的。不失一般性,可以假设B1小于B2,并且B1和B2是两个序号紧挨着的提案。并且定义P1是B1的发起者,P2是B2的发起者,多数派的交集里那个相同成员是Q。综合起来首先可推出一点:Q向P1发出Accepted消息的时刻,要早于Q收到P2的Prepare时刻。反之,如果Q收到P2的Prepare比较早,因为B1小于B2,Q就不会给P1发Accepted了,这与B1成功通过阶段2相矛盾。因为一个Accepter总是先收到Prepare,才发出Promise,所以按照时刻的早到晚排列,时间发生的顺序是,Q向P1发出Accepted < Q收到P2的Prepare < Q向P2发出Promise。也就是说,Q向P2发出Promise的内容,肯定包含了之前P1已经通过的法案内容。按照Paxos算法的阶段2的步骤(a),P2遍历所有收到的Promise消息时,会发现Q发出的Promise消息带有的提案编号最大,因此把其提案内容作为将来发送Accept消息的提案值。
对于B1大于B2的情况,可以把上面的证明的P1和P2角色对调一下,还是成立的。对于B1和B2不是紧挨着的情况,那么肯定有一个提案B在B1和B2的中间,用同样的办法证明B1和B的提案内容一样,因此B和B2的提案内容也一样。
非阻塞性实际上分为2种情况,(1)只有一个Proposer,(2)有多个Proposer在竞争同一提案。情况(1)是显然的,但是对于情况(2),Leslie Lamport所写的The Part-Time Parliament、Paxos Made Simple、Disk Paxos和Fast Paxos这几篇文章里,都没有证明非阻塞性。The Part-Time Parliament只证明了,当在线节点足够多时,通过法案的可行性,实际上是证明了情况(1)。Disk Paxos的附录中说,非阻塞性是很明显的,所以不需要正式证明,其实也是指情况(1)是明显的。其实情况(2)并不明显。比如,有两个Proposer竞争同一个法案,P1发起Prepare提案编号1,Acceptor接受并回应Promise。接着在P1发送Accept之前,P2发起Prepare提案编号2,因此Accptor也接受并回应Promise。等到P1发送Accept提案编号1的时候,Acceptor发现P2的编号2比较大,因此没有接受Accept消息。接着P1将提案编号自增到3,发送Prepare消息,因此Acceptor也接受并Promise。可是等到P2发送Accept编号2的时候,Acceptor发现P1的提案编号3比较大,不接受,因此P2也自增提案编号。这样周而复始,陷入活锁。
解决的方法有两个。在Proposer发现提案没被接受时,休眠一段时间,时长随机,并且每次失败后,休眠的时长倍增。这样就能互相避开。或者按照Lamport的建议,通过某个其他的算法,确定一个领导节点,让领导节点优先,其他节点暂时退避,或者只允许领导节点发起提案。但是Paxos算法本身就时一个一致性算法,用来实现领导节点选举算法,所以在这种场合下就形成了对领导算法的循环依赖,是没办法实现的。再说,如果已经存在一个其他的选举算法,那么也就几乎用不着Paxos算法了。因此各节点随机退避的做法比较可行。
从这些简单论证中可以看出Paxos的关键点有两个。第一个在它的假设里,Paxos要求任意两个提案的投票节点的集合必须有交集,这一点可以通过要求投票节点数目过半达到。第二个关键点在算法本身里,Proposer发起的真正的提案的内容,在阶段(1)结束时才决定,而不是一开始就决定的。这两个关键点连在一起,决定了一个较早的提案只要通过了阶段2,就肯定会被其他的多数派感知,而Proposer要服从多数派的意见,较晚的提案的值总是和较早提案的值一致。Paxos算法的一致性,不是要求所有的成员都通过同一个值,而只要求多数派通过,那么,想要查询这个值的时候怎么办呢?如果只向单个节点查询,这个节点可能不在多数派里,查到的值可能是错误的。可以通过一次新的提案来实现对法案X的内容的查询,提案内容是“未决”。这样如果原来法案X不存在,那么提案成功后,它仍然没有内容,如果法案X已经有值了,那么Paxos算法保证了本次提案通过的值是一致的,Proposer在阶段1结束的时候就已经知道法案的值是多少了。
形式化的论证请参考The Part-Time Parliament。
2.4 Paxos里两个阶段的必要性
Paxos算法要求先竞争提案编号,再竞争提案值,看上去比较复杂。是否可以简化一下,直接竞争提案的值呢?比如,所有的Acceptor都实行“先到先得”策略,只通过首次接收到的提案,拒绝其他所有提案。这个策略的问题在于,某些Proposer依次或者同时发起不同的提案,由于网络故障,提案的请求可能在中途部分丢失,每种提案都可能都只有不同少数派Acceptor接收到,每个提案都无法形成多数派,形成死锁。如果反过来,所有的Acceptor都实行“后来居上”策略,总是接受请求,以最后接收的为准,也会出现问题。当一个Proposer通过发送提案确保了大多数通过之后,其他的Proposer又可以在多数派里推翻提案,失去了一致性。Paxos算法没有这种问题,阶段1是竞争提案号,策略是“后来居上”,在这期间如果没有其他的Proposer出现,就进入阶段2的“先到先得”。而即使在“先到先得”阶段发生了丢包,也不会形成死锁,因为在阶段1结束的时候新的“后来居上”的多数派成员要么(1)与之只前的少数派没有交集,因此自由提交不形成死锁;要么(2)有交集,因此少数派的意见会通过Promise消息返回,而Proposer会按照算法要求,选用少数派已通过的法案进行阶段2的提交,于是少数派成为多数派,整个算法得以运行下去。
从以上讨论可以看出,后来居上的阶段1和先到先得的阶段2,都是必须的。同时阶段2还需要保证一点,就是提案的存储必须是持久的,否则一旦进程崩溃,信息就丢失了,因此Acceptor的信息,包括法案号、法案内容、见过的最大提案号,都需要在收到消息或者处理结束时即时写入磁盘。
3 Disk Paxos算法
Disk Paxos适用于SAN环境。通过把Paxos算法中需要记录的信息从本地磁盘转移到SAN上,把收发消息改变为读写LUN上的数据,可以得到Disk Paxos算法。在SAN环境下,使用专门的光网访问存储,时延小,吞吐量大。SAN环境中的LUN的后端可以配置成磁盘阵列,有校验和冗余性保证,稳定性很好,相比之下,计算节点(进程或者OS)的稳定性没有那么高。所以从实际应用的角度来说,Disk Paxos比基于计算节点和消息传递的Paxos更可靠。
Disk Paxos和Paxos一样,都分为两个阶段。参与Disk Paxos的所有进程都有一个属于自己的磁盘区域,发起提案的时候,首先写自己的磁盘区域,然后读其他进程的磁盘区域。如果发现读到其他进程也发起提案并且编号比自己的大,就放弃。第一阶段只竞争提案号,不涉及提案的值。如果没有读到比自己大的提案号,就可以准备进入阶段2。在阶段1的读操作结束的时,即使自己的提案号可能比其他进程的提案号大,但是如果发现已经有其他进程通过阶段2提交过的提案内容,那么本进程选择的提案内容必须是一样的,如果没有发现其他进程提交过,就可以自由选择提交的内容。阶段2也是一个写操作和一个读操作,写操作把提案编号和内容写入自己的磁盘区域,再读其他进程的磁盘区域,如果没有发现比自己大的提案编号,就结束阶段2,认为系统达到了一致。容错是通过同时读写多块磁盘上的同一区域来实现的,假设有N块盘,对于一个写操作,如果能够写入大于等于N/2+1块盘,就是确保了大多数盘都写入成功。至于其他进程是否在运行,还是崩溃,都没有关系,所有的信息都以磁盘上的为准。如果有节点崩溃了,恢复后读一下LUN,如果多数盘的结果能一致,就回到同步好的状态了。Disk Paxos实际上是把SAN当成了一块共享的黑板,把原来需要保存在本地磁盘和内存中的数据转移到了SAN上保存。
3.1 Disk Paxos算法的正式描述
3.1.1 符号
dblock:某个LUN上专门划分出来的一块区域,用来存放Disk Paxos算法的运行时的信息。每个参与Disk Paxos算法的计算节点都能够从dblock上获得一块区域,存放自己的信息。节点也可以从dblock上读到为其他节点分配的磁盘区域。
p:参与算法的某个节点或者进程。
dblock[p]:参与算法的节点p在dblock里维护的磁盘区域,这个区域里又包含了下面的信息。
dblock[p].mbal:本次提案的提案号
dblock[p].bal:p进入阶段2时的最大提案号
dblock[p].inp:p以dblock[p].bal进入阶段2时,提交的提案的内容
dblock[p]有2个副本,一个在进程p的内存里,一个写入到了磁盘上。写入到磁盘上的dblock才算是真正的生效了,才能被其他进程读取。在下面的算法描述中,凡是没显式说明读写磁盘的,dblock[p]都是指在内存中的那份副本。
3.1.2 算法
(1) 进程p的初始化算法
接收到用户输入input[p]
dblock[p].mbal自增
创建集合blocksSeen,初值为{dblock[p]}
进入阶段1
(2) 进程p的阶段1和2算法
对SAN里分配给算法用的每块磁盘d,同时执行:
将dblock[p]写入disk[d][p]
对每个参与的进程q,且q不等于p者,执行
读取disk[d][q],将读取的结果插入blocksSeen
如果发现disk[d][q].mbal > dblock[p].mbal,则取消本次提案
如果已经对多数磁盘都执行成功了,可以直接成功终止循环
如果p处于阶段1
令dblock[p].bal等于dblock.mbal
定义集合nonInitBlks为 {bk | bk属于blockSeen,且bk.inp不等于“未决”}
如果nonInitBlks为空
那么令dblock[p].inp等于input[p]
否则,令dblock[p].inp等于bkMax.inp,其中bkMax是nonInitBlks里bk.bal最大者
进入阶段2
否则提交dblock[p].inp为法案
(3) 节点崩溃重启后的恢复算法
创建集合tempSet,初值为空集
对SAN里分配给算法用的每块磁盘d,同时执行:
读取disk[d][p],并插入tempSet
如果已经对多数磁盘都执行成功了,可以直接成功终止循环
令dblock[p]等于bkMax,其中bkMax是tempSet里bk.mbal最大者
进入进程p的初始化算法
以上就是Disk Paxos的正式描述了。如果觉得有点绕,可以结合之前的非正式描述品一品。对于Disk Paxos来说阶段1和阶段2都是一个写操作、一个读操作。Disk Paxos的正确的关键就是写和读的顺序,读必须在写之后,节点才能确认自己的提案号是否是最大的。
3.2 Disk Paxos的简单论证
Disk Paxos的正确性证明也有三点,非平凡、一致、非阻塞。论证的方式基本上和原始的Paxos算法一样。详细的过程可以参考Disk Paxos的论文。下面谈一谈我自己的分析,从和Disk Paxos论文不同的角度,讨论Disk Paxos是如何让同时发起的两个提案达成一致的。
假设有两个节点A和B同时准备提交提案,第一阶段的3种情况
情况1:A写 A读 B写 B读
情况2:A写 B写 A读 B读
情况3:A写 B写 B读 A读
在一个阶段里那么可能发生的顺序就有上面三种,还有三种情况是对称的,可以忽略。情况2和情况3里,A和B都能读到对方的提案号,肯定有一个进程因为提案号小而放弃并稍后重试,不会都认为自己成功了。只有情况1会可能会出现A都觉得自己成功了,都进入阶段2的情况。因为在情况1里,A的读在B的写之前,所以A不知道B可能有一个更大的编号,这样就会出现有多个进程进入阶段2的情况。下面只需要针对2个进程都在情况1里进入阶段2来讨论就清楚了。
从情况1进入阶段2的时候,又有三种情况,其中A写'代表A在阶段2的写操作
情况1.1:A写 A读 A写' B写 B读
情况1.2:A写 A读 B写 A写' B读
情况1.3:A写 A读 B写 B读 A写'
情况1.1这个大情况里,如果A的提案号比B的大,B读以后能发现这一点,B放弃并稍后重试,只有A继续。根据算法的定义,B将来提交的提案值将采用A写'写进去的值,系统保证了一致。
下面只讨论A的提案号比B的小的情况,那么情况1.1又可以细分出2种情况。
情况1.1.1:A写 A读 A写' A读' B写 B读
情况1.1.2:A写 A读 A写' B写 A读' B读
即使A的提案号比B的小,1.1.1里A是可以完成阶段二并且提交的,这时因为B读在A写'之后,B会选择与A写'写入值一样的提案内容继续下去,系统保证了一致。
情况1.1.2里A在阶段二的读时会发现B提议了一个更大的编号,A放弃,B继续,系统保证了一致。A重试时将落入情况1.1.1,只不过AB身份对调。
下面讨论1.2和1.3,如果A的提案号比B的大,那么1.2和1.3的B读就能检测到这个情况,B放弃,A继续。B重试时将落入情况1.1.1。
下面讨论A的提案号比B小的情况。1.2和1.3的共同点是A写'发生在B写之后,因此A读'肯定也在B写之后,如果A的提案号小,那么A读'就能检测到这个情况,因此A放弃,B继续。A重试时落入情况2或者3,只不过AB身份对调。
3.3 Disk Paxos和Paxos的对应关系
在阶段1,Disk Paxos的写操作与Paxos的Prepare消息是对应的,Disk Paxos的读操作与Paxos的Promise消息是对应的。向磁盘写dblock[p],其实相当于向所有的节点发送了Prepare消息,而读其他节点的dblock[q],其实就是接收所有节点的Promise消息。唯一的区别在于,提案节点p必须自行判断读到的dblock[q].mbal里是否存在比dblock[p].mbal大者,而在Paxos里,如果Acceptor发现见过的提案号比收到的Prepare消息的大,就直接不回应了。对于判断是否自己的mbal最大的这个逻辑,Disk Paxos是在发起提案节点上自行判断的,Paxos是由Acceptor判断的,实际上只要是执行对了这个判断,判断在哪一端执行的是无所谓的。对阶段2,情况也是类似的。这样一来,可以发现实际上Paxos算法里的Acceptor只是充当了一个过滤器的角色,Acceptor永远都只同意提案,但是它总是滤掉那些提案号比较小的提案,主要的约束都是针对Proposer的。这样一思考就发现Disk Paxos算法里没有Acceptor也没有问题,只要把过滤器相关的逻辑移动到Proposer身上就可以了。
3.4 Sanlock
接触到Paxos算法是因为我参与的oVirt项目的开发用到了Sanlock。oVirt是一个开源虚拟化管理平台,简而言之,就是用户配置好一群主机后,纳入到oVirt的管理中,oVirt有一个统一的Web界面让用户自助的在集群的主机上启动虚拟机,创建存储池,创建虚拟网络。在oVirt中,每台物理主机都可以直接访问集群存储,为了保证集群存储的元数据的一致性,需要一个领导节点来统一执行所有影响存储元数据的操作(比如分配新的卷)。在领导节点崩溃或者失去联系的时候,需要选举出新的领导节点,这样才能保证集群存储的可用性。选举新领导节点的时候又需要保证,所有节点对哪个节点是领导节点达成一致,否则,出现两个领导节点,同时读些集群存储的元数据,迟早会造成元数据的损坏。oVirt项目使用Sanlock保证领导节点的唯一性,Sanlock,顾名思义,就是以存储区域网络为中心的分布式锁。Sanlock项目需要用户为每台主机分配一个id,并且在存储区域网络中分配一些空间用于存放分布式锁的记录信息,每台主机都可以尝试对某个假想的资源上锁,Sanlock保证只有一台主机能成功拿到对这个资源的锁。至于这个假想的资源具体代表什么,就由调用Sanlock的应用自行决定。它语义和常见的Mutex很相似,都是对某个资源上锁,用这个锁去保护什么则由程序员自己决定。锁也是君子锁,不上锁而直接访问资源也是可以的,后果自负。Sanlock是把常见mutex的语义扩展到分布式领域。Sanlock的分布式锁就是基于Disk Paxos算法实现的。
3.4.1 租约的获得、超时和更新
Sanlock中的锁实际上是一种类似租约(lease)的机制。在分布式环境下,某个进程对资源上锁后可能会崩溃,永远不会恢复,那么资源就被占住了,所以分布式的锁不是一劳永逸的,获得租约后需要刷新。如果刷新不上超时了,租约就自动被释放了。当某个节点要获取一个租约,首先利用Disk Paxos算法,发起一个提案,提案的内容是这个节点自己的ID,以及当前的时间戳。提案通过后,其他的节点通过读取法案的内容,就知道谁是租约的所有者,以及租约的生效时间。如果经过比如60秒,租约还没有更新,其他的节点就认为原来的所有者已经崩溃,或者网络故障导致它无法续租,就可以竞争成为新的所有者。
要更新一个租约,其实是更新法案的时间戳。按照State Machine Approach的思路,要决定租约的当前状态,只需要重放到目前为止所有状态更新请求,所以要更新租约,只需要针对一个编号更大的状态发起一次提案。状态的编号和提案的编号mbal是两回事。状态的编号指的是State Machine Approach中一串输入的各自编号,由于输入决定了状态,所以对状态和输入可以用同一个编号。每个的输入都是一个不同的法案,每个独立法案有自己的bal,用来记录对这个法案本身发起的最高的提案编号,每个法案的决定都是一次独立的Disk Paxos的运行。这里可以进行一些不伤害算法正确性的化简。首先,租约与一般意义上的状态(比如账号余额)不同,租约的历史刷新请求对当前租约的实际归属没有影响,只有最近一次成功的刷新才决定了租约的归属,所以所有旧的法案都不会有节点去关心,我们只保存针对最新的请求的法案就可以了。其次,算法没有规定法案和提案mbal、bal的初始值,只要求编号之间可以比大小。因此,对新的输入提起的法案完全可以以上一个法案的mbal、bal作为初始值。第三,输入值的编号要求是单掉增长的,mbal和bal也都是单掉增长的,所以可以直接以bal作为最新的输入值的编号,以mbal为下一个输入值的编号。合并这三点,就得到一个结论:简化Disk Paxos算法和状态机模型后,可以仅使用一个法案来表示一个租约。只需要修改一下阶段1末尾决定inp的算法就能达到这个效果。阶段1要求只有读到的所有的dblock[q].inp都是“未决”时,才能够自由决定inp的内容。我们可以修改为,检查inp里的时间戳,如果inp的时间戳没有超时,但是租约的所有者和提案的发起者是同一个节点时,认为inp等价于“未决”。如果时间戳离当前时间已经超过60秒,也可以认为其等价于“未决”。这样租约的所有者就可以在没过期之前自由的更新时间戳,租约的竞争者在租约到期之后也能够有机会成为新的所有者。
3.4.2 Sanlock的其他特性
Sanlock在运行时,首先需要分配一个锁空间,这个锁空间就是Disk Paxos里的dblock存储。此外,锁空间还包含了所有的节点ID信息,节点ID在Sanlock的作用很大,用来标记租约的所有者,而节点ID本身是可以动态分配的,因此在分布式环境下也需要用租约来保护。节点ID的租约没法再用Disk Paxos算法,而是用了另外一个基于共享存储的租约算法“Light-Weight Leases for Storage-Centric Coordination”,节点ID的租约在Sanlock里被称为Delta Lease,而用Disk Paxos获得的租约称为Paxos Lease。Delta Lease用到的算法本质上也是写后读,首先把自己的信息写到共享磁盘上,然后等待相当长的一段时间再读,看读到的内容是否有变化。如果没有变化,就表示没有冲突,租约成功获取。这个算法导致Delta Lease的获取时间特别长,因此只用来保护节点ID,一般的租约都用Disk Paxos算法,速度很快。
除此之外,Sanlock还自带了一个Watch Dog,可以操作/dev/watchdog设备。在一个节点更新租约失败之后,常常需要把它从集群里fence出去,通过操作/dev/watchdog,节点发现自己更新不了租约时,可以自己把自己fence出去。所谓fence,就是发现一个进程/主机的工作状态秀逗之后,就彻底断开它的网络,或者把这个节点关闭、重启。原因是在高可用的集群中,一个服务器秀逗时往往会有替代服务器自动启动,而我们一般是无法很好的区分高时延、节点崩溃、网络拥塞、网络故障这几种不同的故障模式的,如果只是短暂的拥塞,替代的服务器启动了,原来的服务器又没关闭,两个服务器都以为自己独占了某一个LUN,就会同时发起读写,破坏掉应用数据。因此凡是发现服务器秀逗的时候,就要把它从集群中隔离出去。Sanlock的后台程序会打开系统的看门狗设备,并且不时的喂狗,一旦某个客户进程租约更新失败,会尝试杀死进程,如果杀不死就不再喂狗,看门狗就会自动重启整个服务器,以此达到自我fence的目的。
4 Google Chubby
Google的Chubby项目主要的设计目标是为分布式系统提供一个可靠的粗粒度的锁服务。它和Sanlock的目标很像,不同之处在于Sanlock需要SAN作为基础设施,而Chubby则使用经典的Paxos算法,只通过消息传递来实现锁。在分布式系统中,实现全分布式的细粒度的锁的开销很大,因此无论是Chubby还是Sanlock,都主要用于实现粗粒度的锁,常见的用途比如选举领导节点。粗粒度的锁一旦被获取,在较长的时间内都不会释放,这样上锁的开销就可以忽略。细粒度的锁需要频繁的获取和释放,因此锁的开销太大会使得它失去意义。可以先选举出领导节点,然后可以再由领导节点自己实现细粒度的锁。因此粗粒度锁服务常常是分布式系统中的基础服务之一。Chubby开发出来之后,提供给Google的其他服务使用,客户包括GFS和Bigtable。Chubby的核心是一个用Paxos算法和State Machine Approach实现的日志分发和同步层,之上是高容错的分布式数据库层,支持快照和日志的截断,再往上就是Chubby的RPC协议层了。
据说Lamport的The Part-Time Parliament论文因为过于文艺,被各种拒绝发表,编辑都要求将文艺的部分移除。Lamport认为编辑们太没幽默感,拒不修改。后来Chubby的团队需要一个一致性算法,Lamport把自己的论文给他们看了,这些工程师马上心领神会,设计实现了Chubby。于是Lamport找了个有幽默感的编辑再次投稿,终于发表。Chubby在实现Paxos算法的过程中解决了很多在生产环境中才能遇到的问题,并且对Paxos进行了优化和补充。比如对于集群的成员的管理,本身就是一个需要一致的过程,新节点加入或者旧节点退出,会导致总节点数变化,因此法案的多数派的成员数也会发生变化。Lamport认为Paxos算法本身就可以用来实现成员管理的一致性要求。实际上操作起来不是那么简单的,因此需要进一步的研究和讨论。在生产环境中,Chubby必须面对这样的问题,Chubby是很好的分布式锁服务的研究范例。
5 Amazon Dynamo
Amazon的Dynamo是一个分布式的key-value数据库。主要的设计目标是高可用。Dynamo使用常见的Consistent Hashing来对数据和节点进行分区,并且为每份数据在多个节点上保存副本,以达到高可靠、高可用、高性能的要求。Dynamo为了达到高可用,必然要对一致性做出一些牺牲。Dynamo没出错一切都好,出现错误时数据可能会出现冲突。为了解决冲突,Dynamo对数据标记版本,对不同版本的数据需要客户端程序自行解决冲突并合并。另外,为了保证同一份数据在多个副本节点上的一致性,Dynamo提出了一种RWN机制。R、W、N都是可配置的参数,N代表节点总数,R代表一次读操作要求有多少个节点参与并且成功,W代表写操作在多少个节点上成功。配置系统参数为R+W>N时,系统的行为更偏向追求一致性,但是在出现网络故障时就没有可用性了,配置R+W<=N时,更偏向可用性,网络故障时就失去一致性。RWN提供了一种比较灵活的调整系统的CAP权重的机制。对比RWN和和Paxos可以发现,当R>=N/2+1且W>=N/2+1时,RWN系统的行为有点像只有一个阶段的Paxos。RWN并不是一种一致性算法,它也没有解决同时有多个写者时怎么达成一致。RWN和Paxos有各自的适用场合。
6 Paxosd
不自己实现一遍算法,就不算真的学会了这个算法。Paxosd就是我看完Paxos相关的几篇论文之后,自己用Erlang实现的一个分布式的锁服务(https://github.com/edwardbadboy/paxosd)。已有的Erlang实现Paxos算法的还有gen_paxos项目(gen_是Erlang中Behavior的名字的常见前缀,可以把Behavior类比成超类),有兴趣的读者可以研究一下这个更成熟的项目。实现Paxosd时,采用了Proposer、Acceptor和Learner分角色的算法,每个角色有自己的进程,通过Erlang的消息机制通信。Paxos算法中的操作都是通过收发消息进行的,所以用Erlang实现起来特别简单,主要的代码只有1200多行。首先在Paxosd里实现了基于Paxos的提案机制,然后在此基础之上,用实现了用Paxos协议更新的租约。对外公布的API包括:
joinCluster/0:让当前节点加入集群。
waitNodes/1:等待集群的可联系成员达到大多数的要求。
propose/2:对某个话题发起一个提案。
learn/1:查询某个法案的值。
leaseGet/1:获得一个租约。
leaseRefresh/1:续约。
leaseRelease/1:释放租约。
leaseWhose/1:查询租约的所有者。
leaseWait/1:等待一个被其他节点占有的租约可用后,再次竞争租约,直到成功为止。
(在Erlang中,函数名/X表示函数有X个参数)
新加入的节点都是通过两个Joker节点加入集群的。要启动一个Paxosd集群,首先启动两个Joker节点,并让他们互相连接上,接着后面启动的节点调用joinCluster/0,就会向Joker节点连接,由Joker将其介绍进集群。另外还基于Common Test写了分布式的功能测试。该测试自动启动Joker节点,并且启动若干Paxosd节点,然后让他们加入集群,接着在每个Paxosd节点都尝试获取一个租约,获取成功者对一个在集群间共享的变量进行自增,重复执行这个动作若干次,测试结束的时候,看共享变量的终值是否正确。
7 小结
Paxos的最经典的论文当属Lamport的The Part-Time Parliament。这篇论文过于文艺,不是直接介绍Paxos算法,而是作了一个比喻,虚构出一个古希腊的小岛,用小岛上的兼职议会的投票问题影射分布式领域的一致性问题,读者得有一定的幽默感和文艺细胞才能更好的欣赏这篇论文。并且作者为了让论文看起来更像考古学文本,用了很多稀奇的符号,举例时用的人名全由大写的希腊字母组成,如果没有耐心简直读不下去。不过真的读下来,还是很有趣的。如果觉得The Part-Time Parliament太难啃,可以先读Paxos Made Simple,非常简练的介绍了Paxos算法,堪称秒懂。然后再结合维基百科上总结的几个变种和优化,看看Chubby的论文和一些Paxos协议的参考实现,能清楚很多。
还有一些话题没有详细介绍,比如,Paxos的消息序列图解、用Paxos对集群的成员进行管理、Paxos的其他变种和各种优化手段、其他几个用Erlang实现Paxos算法的项目。不过相关的资料在网络上都很容易查到,大多数的话题看一遍Paxos的维基百科(中、英文版内容不同,互相补充)基本都能找到一些引文链接或者关键词,进行深入研究。
参考文献主要是Lamport自己写的几篇Paxos的论文,在前文都提到了名字。Sanlock在网上能搜到幻灯,也可以读一下它的代码。Google Chubby和Amazon的Dynamo也都公布了论文,都能搜到。具体哪些地方引了哪些文章在第几页就不标了,估计你们也不会去看的,我打字也有点累了。。。