一致性协议:Paxos
拜占庭将军问题
拜占庭位于如今的土耳其的伊斯坦布尔,是东罗马帝国的首都。由于当时拜占庭罗马帝国国土辽阔,为了达到防御目的,每个军队都分隔很远,将军与将军之间只能靠信差传消息。在战争的时候,拜占庭军队内所有将军和副官必须达成一致的共识,决定是否有赢的机会才去攻打敌人的阵营。但是,在军队内有可能存有叛徒和敌军的间谍,叛徒可以任意行动以达到以下目标:欺骗某些将军采取进攻行动;促成一个不是所有将军都同意的决定,如当将军们不希望进攻时促成进攻行动;或者迷惑某些将军,使他们无法做出决定。如果叛徒达到了这些目的之一,则任何攻击行动的结果都是注定要失败的,只有完全达成一致的努力才能获得胜利。
这时候,在已知有成员谋反的情况下,其余忠诚的将军在不受叛徒的影响下如何达成一致的协议,拜占庭问题就此形成 。
拜占庭将军问题的核心在于在缺少可信任的中央节点和可信任的通道的情况下,分布在不同地方的各个节点应如何达成共识。我们可以将这个问题延伸到分布式计算领域中。
在分布式计算领域中,试图在异步系统和不可靠的通道上达到一致性状态是不可能的,因此在对一致性的研究过程中,都往往假设信道是可靠的,而事实上,大多数系统都是部署在同一个局域网中,因此消息被篡改的情况非常罕见;另一方面,由于机器硬件和网络原因导致的消息不完整问题,也仅仅只需要一套简单的校验算法即可避免。
那么当我们假设拜占庭问题不存在(所有消息都是完整的,没有被篡改),那么这种情况下需要什么算法来保证一致性呢?拜占庭将军问题的提出者Lamport提出了一种非拜占庭将军问题的一致性解决方案——Paxos算法
Paxos
问题描述
与拜占庭将军问题一样,Lamport同样使用故事的方式来提出Paxos问题
希腊岛屿Paxon上的议员在议会大厅中表决通过法律,并通过服务员传递纸条的方式交流信息,每个议员会将通过的法律记录在自己的账目上。问题在于执法者和服务员都不可靠,他们随时会因为各种事情离开议会大厅,并随时可能有新的议员进入议会大厅进行法律表决,使用何种方式能够使得这个表决过程正常进行,且通过的法律不发生矛盾。
不难看出故事中的议会大厅就是分布式系统,议员对应节点或进程,服务员传递纸条的过程就是消息传递的过程,法律即是我们需要保证一致性的值。议员和服务员的进出对应着节点/网络的失效和加入,议员的账目对应节点中的持久化存储设备。上面表决过程的正常进行可以表述为进展需求:当大部分议员在议会大厅呆了足够长时间,且期间没有议员进入或者退出,那么提出的法案应该被通过并被记录在每个议员的账目上。
Paxos算法需要解决的问题就是如何在上述分布式系统中,快速且正确地在集群内部对某个数据的值达成一致,并且保证不论发生以上任何异常,都不会破坏整个系统的一致性。
为了能够使得表决过程正常进行,且通过的法律不发生矛盾,那么我们需要保证以下几点
- 在这些被提出的提案中,最后只能有一个被选定
- 如果没有提案被提出,那么就不会有被选定的提案
- 当一个提案被选定后,节点们应该可以获取被选定的提案信息
在Paxos算法中,主要有以下三种角色,并且一个节点可能同时担任几种角色
- 提议者(Proposer):负责提出提议;
- 接受者(Acceptor):对每个提议进行投票;
- 告知者(Learner):被告知投票的结果,不参与投票过程。
同时,假设不同参与者之间通过收发消息来进行通信,那么我们需要具备以下条件
- 每个参与者可能会因为出错而导致停止、重启等情况,
- 虽然消息在传输过程中可能会出现不可预知的延迟、重复、丢失等情况,但是消息不会被损坏或篡改(即不存在拜占庭问题)
执行过程
首先我们规定一个提议包含两个字段:[n, v],其中 n 为序号(具有唯一性),v 为提议值。
Paxos执行过程主要分为两个阶段,与之前讲过的2PC(二阶段提交协议)类似。
- Prepare阶段
- Accept阶段
如下图
Prepare阶段
- 首先,每个Proposer都会向所有的Acceptor发送一个Prepare请求。
- 当Acceptor接收到一个Prepare请求,提议内容为[n1,v1],由于之前还未接受过Prepare请求,那么它会返回一个[no previous]的Prepare响应,并且设置当前接受的提议为[n1,v1],同时保证以后不会再接收序号小于n1的提议。
- 如果Acceptor再次收到一个Prepare请求,提议内容为[n2,v2],此时会有两种情况。
- 如果n2 < n1,此时Acceptor就会直接抛弃该请求
- 如果n2 >= n1,此时Acceptor就会发送一个[n1,v1]的Prepare响应,设置当前接受到的提议为[n2,v2],同时保证与上一步逻辑相同,保证以后不会再接受序号小于n2的提议。
Accept阶段
- 当一个Proposer接收到超过一半的Acceptor的Prepare响应时,此时它就会发送一个针对[n,v]提案的Accept请求给Acceptor。
- 如果一个Acceptor收到一个编号为n的提案的Accept请求,此时有两种情况。
- 如果该Acceptor没有对编号大于n的Prepare请求做出过响应,它就会接受该提案,并发送Learn提议给Learner
- 如果该Acceptor接受过编号大于n的Prepare请求,那么它就会拒绝、不回应或回复error。(如果一个Proposer没有收到过半的回应,那他就会重新进入第一阶段,递增提案号后重新提出Prepare请求)
在上述过程中,每一个Proposer都有可能会产生多个提案。但只要每个Proposer都遵循如上述算法运行,就一定能保证算法执行的正确性。
Learner获取提案
在Accept阶段之后Acceptor选定提案后,根据具体的应用场景不同,Learner主要采用以下三种方案来学习选定的提案
方案 | 优点 | 缺点 |
---|---|---|
Acceptor直接将提议发给所有的Learner | Learner能够快速获取被选定的提议 | 通信次数过多,每个Acceptor都要和Learn产生通信(M * N) |
Acceptor接受提议后将提议发给主Learner,主Learner再通知其他Learner | 通信次数减少(M + N - 1) | 单点问题(主Learner出现故障就会崩溃) |
Acceptor将提议发一个Learner集合,Learener集合再发给其他Learener | 解决了单点问题,集合中Learner个数越多就越可靠 | 网络通信复杂度变高 |
当Learner们发现有大多数的Acceptor接受了某一个提议,那么该提议的提议值则就是Paxos最终选择出来的结果。
活锁问题
在Paxos算法实际运作的时候还存在这样一种极端的情况——当有两个Proposer依次提出了一系列编号递增的提案,此时就会导致陷入死循环,无法完成第二阶段,也就是无法选定一个提案,如以下场景。
- Proposer P1发出编号为n1的Prepare请求,收到过半响应,完成了阶段一的流程。
- 同时,Proposer P2发出编号为n2的Prepare请求(n2 > n1),也收到了过半的响应,完成了阶段一的流程,并且Acceptor承诺不再接受编号小于n2的提案。
- P1进入第二阶段时,由于Acceptor不接受小于n2的提案,所以P1重新回到第一阶段,递增提案号为n3后重新发出Prepare请求
- 紧接着,P2进入第二阶段,由于Acceptor不接受小于n3的提案,此时它也重新回到第一阶段,递增提案号后重新发出Prepare请求
- 于是P1、P2陷入了死循环,谁都无法完成阶段二,这也就导致了没有value能被选定。
为了保证Paxos算法的可持续性,以避免陷入上述提到的死循环,就必须选择一个主Proposer,并规定只有主Proposer才能够提出提案。这样一来,只要主Proposer和过半的Acceptor能够正常进行网络通信,那么肯定会有一个提案被批准(第二阶段的accept),则可以解决死循环导致的活锁问题。