Paxos算法是由Leslie Lamport在1990年代提出的一种基于消息传递共识算法。在讨论分布式算法时,Paxos几乎是一个绕不开的话题。在过去的几十年中,它已经成为分布式共识的象征,许多流行的共识算法都是基于Paxos进行改进的,比如Fast Paxos、Raft、ZAB等协议。虽然Paxos算法可以认为是一些共识算法的基础,但是其本身也相对较复杂,理解起来有一定的难度。
Paxos 算法包括两个主要部分:
-
Basic Paxos 算法
- 分布式系统中的多个节点如何就某个数据值达成共识
-
Multi-Paxos 算法
- Multi-Paxos 是在 Basic Paxos 的基础上扩展而来,用于在分布式系统中就一系列的值达成共识
1. Basic Paxos
在正式介绍Basic Paxos算法之前,首先来看一个问题。假设有一个分布式的key-value存储集群,有A,B,C三个节点,对外提供只读服务。也就是说,key-value键值对一旦被创建,就不能再被修改。
假设此时,有两个客户端client1和client2同时发起创建键值对的请求,且创建的键值对key都为"Name",client1试图创建“Name:张三”,client2试图创建“Name:李四”,在这种情况下,这个分布式集群如何达成共识,实现在A,B,C各个节点上,Key为"Name"的值是一致的呢 这里其实就需要一种共识机制,保证集群中的A,B,C三个节点能达成一致,每个节点写入一样的值,Paxos算法就能保证这样一种共识
1.1 Paxos 涉及的概念
Basic Paxos的工作机制主要分为三个角色和两个主要阶段
1.1.1 提案(proposal)
提案指的是需要在多个节点之间达成一致的某个值或操作,提案是由提案编号(n)和提案的值(v)组成的,可以表示为[n, v]。每个提案的提案编号是唯一的
1.1.1.1 提案编号
提案编号一般不是由Paxos算法生成的,而是由外部传入的。所以不同的业务场景可以按照自身业务需求,自定义提案编号的生成逻辑,只需要保证提案编号全局唯一并且单调递增即可。
比如在只有一个Processer的环境中可以简单地使用自增 ID 或时间戳作为提案编号。例如,使用时间戳1693702932000。在有两个Processer的环境中,可以为不同提议者分配不同的编号序列。例如,一个提议者使用奇数(1, 3, 5...),另一个提议者使用偶数(2, 4, 6...)。在有多个Processer的环境中,可以为每个Processer分配一个固定的ServerId,并将自增序号或时间戳与ServerId组合,生成唯一的提案编号。例如,1.1 或者1693702932000.1表示Processer1生成的第一个提案编号。每个Proposer在发起Prepare请求后如果没有得到超半数响应时,会更新自己的提案号,再重新发起新一轮的Prepare请求
1.1.1.2 提案值
在Paxos算法中,提案值的具体内容也是根据实际业务需求来定义的。提案值可以是数值、字符串、命令(cmd),甚至是一些操作。比如在分布式数据库的场景中,可以将数据的插入、更新、删除操作等作为提案值。这种灵活性允许Paxos算法适应各种不同的应用场景
1.1.2 三个角色
在 Paxos 算法中,角色分为提议者(Proposer)、接受者(Acceptor)和学习者(Learner),它们的关系如下:
-
提议者(Proposer):
- 处理客户端请求,主动发起提案(proposal)给所有的接受者(Acceptor)。提议者的角色通常由集群中的某些节点担任
-
接受者(Acceptor):
- 被动接受提案,对提案进行投票,并存储已经接受的值, 返回投票结果给 Proposer 以及发送通知给 Learner
-
学习者(Learner):
- 不参与提案和投票,只被动接收提案结果
一个节点,既可以是提议者,也可以是接受者 在实际的分布式业务场景中,一个服务器节点或进程可以同时扮演其中的一种或几种角色,而且在分布式环境中往往同时存在多个 Proposer、多个 Acceptor 和多个 Learner
1.1.3 两个阶段
1.1.3.1 准备阶段(prepare)
提议者(Proposer) :生成一个唯一的提案编号
n,并向所有的接受者(Acceptor)发送一个准备请求(Prepare Request),请求内容是编号n,注意在准备阶段请求只会包含请求编号,而不会包含提案值。
接受者(Acceptor) :在收到准备请求后,接受者(Acceptor)会做出如下承诺: 如果接受者(Acceptor)收到过比此次提案编号n更大的准备请求,将丢弃这次准备请求,不作响应,否则:
- 接受者(Acceptor)承诺不再通过编号小于等于n的提案的准备(Prepare)请求
- 接受者(Acceptor)承诺不再通过编号小于n的提案的接收(Accept)请求,也就是不再通过编号小于N的提案
- 如果接受者(Acceptor)已经通过某一提案,则承诺在准备请求的响应中返回已经通过的最大编号的提案内容,即提案值。如果没有通过任何提案,则在prepare请求的响应中返回空值,即尚无提案
从prepare流程可知:集群中的每个 Acceptor 会存储自己当前已接受(Accept)的最大提案编号和提案值。
1.1.3.2 接受阶段(accept)
提议者(Proposer) :在收到大多数接受者(Acceptor)的准备响应后,提议者将正式发起一个带有提案编号 n和提案值 v 的接受请求[n,v]给所有接受者
注意:这里提议者(Proposer)设置提案值v有一定的规则:如果在准备(prepare)请求的响应中,部分acceptor已经批准过的提案值,则V为prepare请求的响应中编号最大的提案值,否则可以由proposer任意指定。
接受者(Acceptor): 接受者(Acceptor)会根据准备阶段的响应情况作出如下承诺:
- 如果此时接受者没有通过编号大于n的准备请求,则会批准通过提案[n,v],并返回已通过的编号最大的提案(也就是n )
- 如果此时接受者已经通过了编号大于n的准备请求,则会拒绝提案[n,v],并返回已通过的编号最大的提案(大于n的编号,比如m)
提议者(Proposer)会统计收到的accept请求的响应,如果响应中的编号等于自己发出的编号,则认为该acceptor批准通过了该提案。如果存在大多数acceptor批准了该提案,则认为该提案已达成共识,即该提案被通过。如果没有大多数acceptor批准该提案,则重新回到prepare阶段进行协商
需要注意的是:在准备请求中,proposer只会发提案编号n, 。而accept请求,proposer会发送提案编号和提案值,也就是[n,v]
1.1.4 算法流程
先明确几个变量的意思:
- minProposal:当前acceptor在prepare请求中通过的最大提案编号
- acceptedProposal:当前acceptor在accept请求中通过的最大提案编号
- acceptedValue:当前acceptor在accept请求中通过的最大提案编号的提案值
Acceptor需要持久化存储minProposal、acceptedProposal、acceptedValue这3个值,算法流程如下:
第一阶段:Prepare 阶段
- 为提案生成一个全局唯一且递增的提案编号n
- Proposer 会向所有 Acceptor 节点发送一个包含提案编号 n 的 准备请求(Prepare(n))
- 当 Acceptor 接收到准备请求(Prepare(n))时,会将 n 与其已知的最小提案编号 minProposal 进行比较,如果 n >minProposal,则更新 minProposal 为 n,并返回其当前已经接受的提案编号 acceptedProposal 和对应的值 acceptedValue给Proposer,如果 n 小于或等于 minProposal,则该请求将被拒绝,不作处理
- Proposer 接收到大多数 (过半)Acceptor 的响应后,如果发现有 Acceptor 返回了 acceptedValue,那么 Proposer 将选择所有响应中编号最大的acceptedProposal 对应的 acceptedValue 作为本次提案的值。如果所有 Acceptor 都没有返回 acceptedValue,Proposer 可以自由设置本次提案的值
第一阶段:Prepare 阶段
- 在确定提案的值后,Proposer 将向所有 Acceptor 节点广播接收请求(Accept(n, value))
- Acceptor 接收到 Accept(n, value) 请求后,再次比较 n 与其当前的 minProposal,如果 n >= minProposal,则 Acceptor 更新 minProposal 和 acceptedProposal 为 n,并将 value 设置为 acceptedValue,然后持久化该提案并返回确认。如果 n<minProposal,则该请求将被拒绝,并返回当前的 minProposal
- Proposer 接收到大多数 Acceptor 的确认后,若发现有返回值result(minProposal) > n,表示有更新的提议,跳转到步骤1,否则认为当前提案已经达成一致
1.1.5 最终只值选定
Acceptor在每次同意新的提案值后,会将结果同步给Learner。Learner通过汇总各个Acceptor的反馈,判断是否已获得多数同意(超过半数)。如果达成共识,即获得了多数同意,Learner会向所有Acceptor和Proposer发送广播消息,并结束提案。在实际应用中,Learner通常由多个节点组成,每个Learner都需要接收到最新的投票结果。对于Learner的实现,Lamport在其论文中提供了两种方案:
- 主从Learner架构:选定一个Learner作为主节点,专门接收投票结果(Accepted消息),其他Learner节点作为备份节点。主节点接收数据后再将其同步到其他Learner节点。这种方案的缺点在于可能产生单点故障问题,如果主节点宕机,将无法获取投票结果。
- 分布式Learner同步:Acceptor同意提案后,将结果同步到所有Learner节点,然后每个Learner节点再将结果广播给其他Learner节点。尽管这种方式避免了单点故障,但由于涉及多次消息传递,效率相对较低。
1.1.6 算法模拟
还是以开篇的例子来进行分析,在实际应用中,通常提议者(Proposer)是集群中的某些节点,接收客户端请求,将其封装成提案(proposal)。这里为了方便演示,将Client1和Client2看作提议者,并不会影响Paxos算法的本质
准备阶段(prepare):
假设Client1的提案编号是1,Client2的提案编号是6,Client1和Client2分别向所有的接受者发送准备请求 紧接着,节点A和节点B收先到提案者Client1的准备请求,编号为1,节点C先收到提案者Client2的准备请求,提案编号为6
分析各个节点在接收到第一个准备请求的处理过程
- 节点A,B:由于之前没有通过任何提案,所以节点A和节点B都将返回“尚无提案”的准备提案请求响应,并承诺,后续不再响应编号小于1的准备请求,也不会通过编号小于1的提案
- 节点C:由于之前没有通过任何提案,所以节点A和节点B都将返回“尚无提案”的准备提案请求响应,并承诺,后续不再响应编号小于6的准备请求,也不会通过编号小于6的提案
接下来,节点A和节B收到提议者Client2发出的编号为6的提案,而节点C会收到提议者Client1发出的编号为1的提案
- 节点A,B:此时收到的准备请求提案编号为6,大于之前响应的准备请求的提案编号1,并且节点A和节点B都还未通过(accept)任何提案,所以均返回“尚无提案”的准备提案请求响应,并承诺,后续不再响应编号小于6的准备请求,也不会通过编号小于6的提案
- 节点C:由于节点C此时接收到的提案编号1小于之前响应的准备请求的提案编号6,所以丢弃该准备请求,不作响应
接收阶段(accept):
Client1和Client2收到大多数节点的准备响应之后,开始发送接收请求(accept)
- Client1:Client1在接收到大多数的接受者(节点 A,B)的准备响应之后,会根据响应中的提案编号最大的提案值来设置接受请求的值。由于节点 A, B 均返回“尚无提案”,即提案值为空,所以Client1会自己设置一个提案值“张三”,把自己的提议值 “张三”作为该提案的值,发送接受请求[1, “张三”]给 A, B, C 三个acceptor
- Client2:同理Client2在接收到大多数接受者的准备响应后,也会根据响应中的提案编号最大的提案的来设置接受请求的值。由于节点 A, B, C 均返回“尚无提案”,即提案值为空,所以Client2会自己设置一个提案值“李四”,把自己的提议值 “李四”作为该提案的值,发送接受请求[6, “李四”]
节点A,B,C收到Client1和Client2的接受请求之后
由前面的准备阶段响应可知,节点A,B,C承诺可以通过的最小提案编号为6,而此时节点A,B,C 接收到的Client1发出的接受请求为[1, “张三”],提案编号1小于承诺的提案编号6,所以[1, “张三”]被拒绝,并向Client1返回当前accepter在准备请求中通过的最大提案编号6
节点A,B,C收到Client2发出的接受请求为[6, “李四”],提案编号6不小于承诺的提案编号6,所以提案[6, “李四”]被通过,节点A,B,C达成了共识,接收Name的值为“李四”假设集群中还有学习者,在接受者通过了某个提案后,会通知所有学习者。一旦学习者确认多数接受者都同意了这个提案,它们也会同意并采纳提案的值
1.1.6.1 接受者存在已通过提案的情况
在上述例子中,在准备阶段和接受阶段均不存在已通过提案的情况,准备阶段接受者的请求响应都是“尚无提案”,假设有节点已经通过了提案点又是什么场景呢?想象出这样一个场景:
假设节点A,B已经通过了[6, “李四”]提案,而节点C尚未通过任何提案,此时,新增一个提议者Client3,Client3的提案为[8,“王五”],Client3向节点A,B,C发送提案编号为8的准备请求 节点 A 和 B 将接收Client3 的准备请求,由于节点 A 和 B 已经通过了编号为 [6, “李四”]的提案,所以它们在准备响应中会包含这个提案的详细信息。而节点 C 因为之前没有通过任何提案,因此它返回的是‘尚无提案’的准备响应。
在接收到来自节点 A、B、C 的准备响应后,Client3随即向这些节点发送接受请求。特别要注意的是,Client3 会根据响应中提案编号最大的提案值来确定接受请求的值(1.1.3.2小节的注意事项) 。由于准备响应中包含了提案 [6, “李四”],因此Client3将接受请求的提案编号设为8,提案值设置为“李四”,即客户端 3 发送的接受请求为 [8, “李四”]
节点 A, B, C 接收到提议者Client3的接受请求,由于提案编号8不小于三个节点承诺可以通过的最小提案编号6,因此均通过提案 [8, “李四”]。
1.1.7 活锁问题
先看一个例子,这里Server1和Server4既作为Processer,又作为Acceptor,Server1即processer1,Server4即processer4。所有的server都是acceptor。P1.1表示processer1发起的编号为1的prepare请求,P2.4表示processer4发起的编号为2的prepare请求,以此类推。A1.1 X表示processer1发起的accept请求,提案编号为1,提案值为X,A2.4 Y表示processe4发起的accept请求,提案编号为2,提案值为Y,以此类推 client1向server1即processer1发起写入X的请求,client2向server4即processer4发起写入Y的请求。
随着时间线从左往右看,server3即acceptor3先收到processor1发起的prepare请求P1.1,由于没有通过任何提案,所以尚无提案,承诺不通过提案编号小于1的提案,随后acceptor3收到了processor4发起的prepare请求P2.4,由于此次天的编号2大于之前承诺的提案编号1,所以向processor4返回,承诺不再通过提案编号小于2的提案。
紧接着acceptor3收到processor1发起的accept请求A1.1 X,由于之前承诺了不再通过提案编号小于2的提案,而此次收到的accept提案编号为1,所以拒绝。processor1发起的accept提案被拒绝了,所以它加大编号,又发起了P3.1的prepare请求,此次提案编号为3,大于acceptor3承诺的最大提案编号2,所以做出响应,回应承诺不再通过提案编号小于3的提案,随后processor4发来accept请求A2.4 Y,此时由于提案编号为2,小于acceptor3刚刚承诺的最大提案编号3,所以这个accept请求也会被决绝。processor4又加大prepare的提案编号,如此循环往复.....一直通过多个processer的prepare请求,但是不能通过accept请求,导致一直没有提案通过,这样就形成了活锁。
1.1.7.1 活锁的定义
在多个提议者同时提出提案时,由于出现竞争,这几个提议者不断的更新提案编号,发起新的提案,导致一直没有accept请求被通过,导致提案一直不能通过,而陷入这样的死循环,就是活锁问题
1.1.7.2 活锁的解决方案
- 随机延迟重试:当一个Proposer(提议者)发现支持它的Acceptor(接受者)数量小于半数时,Proposer并不会立即更新编号并再次发起提案,而是会随机延迟一小段时间。这样做的目的是为了错开多个Proposer之间的冲突
- 设置Proposer的Leader:可以在系统中选举一个Proposer作为Leader,让这个Leader负责发起所有的提案。其他Proposer不再主动提案,只在需要时响应Leader的请求
2. Multi-Paxos
Basic Paxos算法虽然能一定程度解决分布式系统的共识问题,但是存在很多的局限性。它只能对一个值形成决议,而且提案的最终确定至少需要两次网络来回,在高并发情况下可能需要更多的网络来回,因此性能低下。并且当存在多个process二的时候,极端情况下甚至会形成活锁。因此Basic Paxos算法几乎只是用来做理论研究,并不直接应用在工程实践中。
有没有更好的算法策略能够有效解决Basic Paxos算法带来的这些问题呢?基于这种目的随即出现了 Multi-Paxos 算法
2.1 Multi-Paxos算法概念
Multi Paxos算法其实是Lamport 提出的一种思想,而并非具体的算法。可以认为,Multi Paxos算法是一类算法的总称,这类算法都基于Multi Paxos思想,实现了一系列值共识
2.2 Multi Paxos思想
总的来说,multi-Paxos思想基于basic-paxos算法做了两点改进:
2.2.1 领导者选举
在所有 Proposers 中选举出一个 Leader,让这个 Leader 唯一地提交提案(Proposal)给 Acceptors 进行表决。这样一来,就没有多个 Proposer 之间的竞争,从而解决了活锁问题。在只有一个 Leader 提交提案的情况下,Prepare 阶段可以被跳过,从而将原本的两阶段过程简化为一阶段,从而显著提高了系统的效率
2.2.2 优化 Basic Paxos 执行过程
准备阶段的意义在于让 Proposer 通过发送 Prepare 请求来了解各个 Acceptor 节点上已通过的提案,但有了领导者(Leader)后,只有领导者才可发送提议,领导者独自负责提出提案,所以领导者能够保证提案的最新性和唯一性。因此,领导者的提案就已经是最新的了。所以,不再需要通过准备阶段来发现之前被大多数节点通过的提案。领导者可以直接跳过准备阶段,进入接受阶段(Accept Phase),从而减少不必要的通信开销和 RPC 调用次数
3. 小节
Paxos 算法是解决分布式系统中一致性问题的经典算法之一,它是众多工程实践中所运用的一致性协议的基石。但Paxos 算法本身难以理解和实现,所以只用于理论研究,未用于工程实践。Paxos 算法通过提议者、接受者和学习者等角色,在分布式系统中通过多数节点达成一致性,保证了在不可靠网络环境下仍能确保一致性结果。
交流学习
如果您觉得文章有帮助,点个关注哦。可以关注公众号:IT杨秀才,秀才后面会在公众号分享分布式:理论 》实战 》设计 》面试的系列知识。也会持续更新更多硬核文章,一起聊聊互联网那些事儿!