简单地理解Paxos算法

1

Paxos算法是Lamport在1990年提出的一种基于消息传递的一致性算法,这个算法被公认为类似算法中最有效的。像zookeeper的ZAB协议、raft协议、ViewStamp协议都是基于Paxos算法变化而来的。

Paxos算法难以理解的程度跟其知名度也是不相上下的,难以理解的原因并不是这个算法太过于高深,而是大神的表达太过于晦涩、简洁,很多大神觉得明显的地方,我们的内心OS是“明显你妹啊”,再加上没有一个应用场景做参考,导致我们理解Paxos算法的难度成倍上升。下面我会通过一个实际的场景,尝试对Paxos算法进行通俗易懂的解释。

假如我们有一个日志服务器集群,我们的日志全部通过MQ发送到这个日志服务器集群里面存储,用集群的原因假设就是为了冗余备份,那我们必须要保证每一台服务器上的日志是一样的,包括内容和顺序;其实MQ本身就有排序的能力,只要进入Queue,那么MQ就能保证log服务器收到的消息的顺序是一样的。但是一个MQ是存在单点问题的,此时我们就必须要使用MQ集群。Client可以连接任意一个MQ,每个MQ上面的数据和顺序就可能不一样,这显然是不行的,我们就必须解决这个MQ集群内的数据一致性问题。
那paxos是怎么解决这个问题的呢?

日志系统说明1

Paxos对这类问题的解决就是试图对各Server上的状态进行全局编号,如果能编号成功,那么所有操作都按照编号顺序执行,一致性就不言而喻。当Cluster中的Server都接收了一些数据,如何进行编号?就是表决,让所有的Server进行表决,看哪个Server上的哪个数据应该排第一,哪个排第二...,只要多数Server同意某个数据该排第几,那就排第几。很显然,为了给每个数据唯一编号,每次表决只能产生一个数据,否则表决就没有任何意义。Paxos的算法的所有精力都放在如何在一次表决只产生一个数据。再进一步,我们称表决的数据叫Value,Paxos算法的核心和精华就是确保每次表决只产生一个Value,下文中的编号不是这里的编号,下文中的编号是为了只产生一个value的这次(其实是一轮或者多轮)表决中的不同次数提交议案(value)的流水号。
。也就是说,每一次Paxos算法只决定上图Queue中一个位置的数据。

一次Paxos算法流程如下:

预提案阶段:

Proposer:向所有Acceptor广播预提案,并附带接下来要提出的提案的编号proposal_id。
Acceptor:收到预提案后更新a_proposal_id = max(proposal_id,a_proposal_id),如果预提案的proposal_id>a_proposal_id,Acceptor回复记录的接受过的proposal_id最大的提案。a_proposal_id 是Acceptor此前收到过的最大的编号。

正式提案阶段:

Proposer:等待直到收到大多数Acceptor对预提案的回复,从所有回复的提案组成的法定数目的提案集合K中挑选proposal_id最大的提案,以该提案的值作为本次提案的值。如果K是空集,那么可以给提案任意赋值。然后把该提案广播给Acceptor们,提案和预提案共享同一个proposal_id。
Acceptor:如果收到的提案的proposal_id>= a.proposal_id,那么接受这个提案,更新a_proposal_id = max(proposal_id,a_proposal_id),更新记录的提案。

这个过程看起来可能会比较抽象,结合前文都日志系统来看一下就会比较清晰了:图片描述
步骤1、2 是预提案阶段,步骤3、4是正式提案阶段。

那么,这个流程是怎么得来的,又是怎么保证数据都最终一致性的呢?
从刚刚的日志系统来看,要保证消息的有序性,就必须满足下面几个条件:
1、 v达成一致的值,肯定是某个进程提出的。如果直接规定所有值最后都是0,那这个一致性没有任何意义。
2、 在一次paxos算法中,v只能有一次达成一致。(如果有多次达成一致,那不就相当于选择了多个值吗。)
3、 V总能就某个值达成一致。
我们规定V达成一致的条件:N个进程中大多数都认为V等于同一个值

一个分布式系统肯定是由N(N>=3)个进程组成的,为了简单点,我们先假设这样一个场景

场景1:三个进程的场景P1,P2,P3,P1尝试令v的值被决定为a,P2尝试令v被决
定为b。
假设它们都先改写了自身的v值,然后发送消息尝试改修P3的v值。显然如果P3收到两个消息后都满足了它们的企图,那么v就会两次被决定为不同的值,这破坏了之前的定义。因此P3必须得拒绝掉其中一个进程的请求,如何拒绝也是我们最先考虑的问题。一个最简单的拒绝策略是先来后到,P3只会接受收到的第一个消息,拒绝之后的消息,即只会改写v一次。按照这个策略,如果P1发送的消息首先到达P3,那么P3接受该消息令v=a,拒绝掉后到的来自P2的消息。但是这个策略会引入一个另外的问题;在场景1的基础上考虑这样的场景1',P3也尝试决定v的值,P3尝试令v被决定为c,那么P1,P2,P3都尝试修改v的值,首先P1令v=a,P2令v=b,P3令v=c(相当于自己给自己发消息),按照之前的策略,每个进程只会改写v的值一次,那么将永远不会出现两个进程的v值相等的情况,即v永远无法被决定。
这样可以得出一个结论:进程没办法拒绝他收到的第一个消息,因为没有任何理由来拒绝第一个消息。
先来后到不行,那就得换一个拒绝策略,比如说给每个消息一个独立id,我们可以接受id最小的,或者id最大的。
假如选择接受id更大的方案(id更小的方案其实是行不通的,有兴趣的童鞋可以试着证明一下),那么回到场景1,消息到达的顺序有两种情况:

  1. P3先收到P1的消息,记做场景1-1。由于P1的消息是P3收到的第一个消息,P3接受该请求,令v=a;同时为了能对之后收到的消息作出是否接受的判断,P3需要记录该消息的ID作为判断的依据。之后P3又收到P2的消息,该消息的ID小于P3记录的ID(即P1的消息ID),因此P3拒绝该消息,这样我们的目的就达到。
  2. P3先收到P2的消息,记作场景1-2。同样P3接受该消息,令v=b,记录该消息的ID。之后P3收到P1的消息,由于P1的消息ID大于P3记录的ID,因此P3无法拒绝该消息,V的值还是会有两次达成一致。

我们现在仍然无法保障一致性,但是已经有了一些思路,在进一步推导之前要引入一些概念,有一些是之前提到过的:

假如有一个进程P,发广播说我提议把V的值改成1,那这么这就是一个提案,
记作Proposal;提案的ID记作proposal_id;提案中会附带一个值,如果一个进程接受一个提案,则修改自身的v值为该提案中的值。如果一个提案被大多数进程所接受,那么称提案被通过,此时显然v被决定为提案中的值。进程P1记录的接受的提案ID记做a_proposal_id。
当P收到一个提案Proposal-i时,可能已经收到过多个提案,Proposal-i.proposal_id该和其中哪个提案的proposal_id比较,我们并未定义。不妨定义为其中的最大者(稍后会提到为什么这么做),这样实际上进程P只需维护一个a_proposal_id即可,当收到一个Proposal时,更新a_proposal_id = Max(Proposal.proposal_id,a_proposal_id)。同时在之前的描述中我们应当注意到实际上一个进程存在两个功能:

  1. 进程主动尝试令v的值被决定为某个值,向进程集合广播提案。
  2. 进程被动收到来自其它进程的提案,判断是否要接受它。

因此可以把一个进程分为两个角色,称负责功能1的角色是提议者,记作Proposer,负责功能2的角色是接受者,记作Acceptor。由于两者完全没有耦合,所以并不一定需要在同个进程,而实际工程中一般是一个进程同时担任两种角色的。
接着来分析刚刚场景1-2的问题。P2.proposal_id<P1.proposal_id,P2的消息先到,那么,我们只有3种可能的解决方案。

  1. P3能够拒绝掉P2的提案。
  2. P3能够拒绝掉P1的提案。
  3. 限制P1提出的提案中的值,如果P1的提案中的值与P2的提案一致,那么接受P1也不会破坏一致性。

先来看看第一种情况,要想拒绝P2 的提案,就必须得有前置的消息来提供判断依据。P2肯定不会先发一条消息来让P3拒绝自己,所以只能是P1在正式发送消息前先发送了自己的proposal_id, P3收到后更新了a_propersal_Id。这样,当P2 的正式提案到达后,P3就能以我已经接受了更高proposal_id的理由拒绝P2.。

但是考虑这样一种场景:
场景1-3-1:P2的提案先到达,P3接受P2的提案,然后P3的预提案到达。此时和原始的场景1-3存在同样的问题。V值还是会有两次达成一致。归根结底,预提案阶段能否使得P3拒绝该拒绝的,也依赖消息到达的顺序,和提案阶段的拒绝策略存在相同的问题,但是我们排除了一种假设,还引出了一个新的思路:预提案。

再来看第二种情况,我们目前是根据proposal_id的大小来作为拒绝策略的,所以不可能拒绝P1.

那就只剩下第三条路可以走了。我们的目的是最终一致性,且V的值只能就一个值达成一致,那么只要保证P1的提案中V的值跟P2一样,那么通过P2的提案后再通过P1的提案也不会破坏一致性。
那么有一个问题就是,P1如何获得其他进程提出过的提案呢,这里就可以利用分析第一种情况时得到的预提案方案,在发出正式提案之前,先发送一个预提案,发送的信息包括本次提案的proposal_id,同时请求已经批准的提案。那么P2和P3都会把当前自身的已经批准过的提案的proposal_id和V的值b发送给P1.P1收到后就以b为V值发出提案。这样看起来就解决了1-3-1中的问题。

但目前分析的一直是三个进程的情况,现在必须把场景放大到N个进程的情况,N>=3 ,还是有P1和P2 分别提出提案设置V值为a和b,由于提案通过的条件是多数派同意,那么假设N个进程中同意了P1提案的进程的集合是Q1,同意了P2提案的集合是Q2,N个进程组成的集合是Q, Q1,Q2都是Q的子集,且Q1、Q2都是多数派,那么Q1和Q2必然存在一个非空交集 Q3 ,这个Q3其实就相当于我们之前的P3的地位,我们只要保证Q3内的进程只批准一个V的值,就能保证V值在整个集合Q内,只有一个多数派,即只有一个V值被批准。图片描述
我们现在提出一个新的场景2-3-1,与场景1-3-1对应,我们需要限制P1的提案的值,使得P1.proposal.v=P2.proposal.v=b,与之前三个进程的场景相同,Proposer需要通过预提案来询问其他进程已经批准过的提案,但是与之前不同的是,可能有多个进程都会提出提案,也就是说集合Q1可能已经批准了多个提案了,那么现在有一个问题就是,Q1内的进程应该回复哪一个提案呢,我们现在暂时还无法确定,比较稳妥的办法就是回复收到的全部提案。
现在P1收到了来自Q1的所有进程批准过的全部提案,那我们应该选择哪一个作为P1新提案的V值呢?我们只要保证proposal-P1.V等于第一个被多数派批准的提案的V值即可。
由于预提案必须由多数派同意后,正式提案才能提出,那么假设第一个被多数派批准的提案为proposal-f ,其值为b那么proposal的下一个提案必然能在预提案阶段收到proposal-f,如果我们在提出正式提案时选择收到的proposal-id最大的提案的V值作为新提案的V值,那么此后的所有预提案收到的proposal-id最大的提案的V值一定是b。因此,acceptor只需要回复记录收到的预提案中最大的proposal-id,和已经批准的提案中proposal-ID最大的提案。
到现在,整个流程就已经清晰了。
预提案阶段:
Proposer:向所有Acceptor广播预提案,并附带接下来要提出的提案的编号proposal_id。
Acceptor:收到预提案后更新a_proposal_id = max(proposal_id,a_proposal_id),如果预提案的proposal_id>a_proposal_id,Acceptor回复记录的接受过的proposal_id最大的提案。a_proposal_id 是Acceptor此前收到过的最大的编号。

正式提案阶段:

Proposer:等待直到收到大多数Acceptor对预提案的回复,从所有回复的提案组成的法定数目的提案集合K中挑选proposal_id最大的提案,以该提案的值作为本次提案的值。如果K是空集,那么可以给提案任意赋值。然后把该提案广播给Acceptor们,提案和预提案共享同一个proposal_id。

你可能感兴趣的

载入中...