共识算法——Paxos

193 阅读15分钟

Paxos 是用来解决分布式的系统中存在故障(crash fault),但不存在恶意(corrupt)节点的场景(即可能消息丢失或重复,但无错误消息)下如何达成共识。这也是分布式共识领域最为常见的问题。因为最早是 Leslie Lamport 用 Paxos 岛的故事对该算法进行描述的,因而得名。

1. Paxos 的诞生

Paxos算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一。现已是当今分布式系统最重要的理论基础,几乎就是“共识”二字的代名词。Google的很多大型分布式系统都采用了Paxos算法来解决分布式一致性问题,如Chubby、Megastore以及Spanner等。开源的ZooKeeper,以及MySQL 5.7推出的用来取代传统的主从复制的MySQL Group Replication等纷纷采用Paxos算法解决分布式一致性问题。

Paxos 是首个得到证明并被广泛应用的共识算法,其原理类似于两阶段提交算法,进行了泛化和扩展,通过消息传递来逐步消除系统中的不确定状态。

在分布式架构下多个服务通过非可靠的网络进行通信,如何让服务之间高效地通信和协作,如何解决系统之间状态的不一致

我们首先来明确下 paxos 解决了什么问题,简而言之就是在分布式的环境中,存在多个值,需要从中选定出一个值,达成共识。

共识的语义是让一个系统不受局部的网络分区,崩溃故障,执行性能或其它因素影响,最终能表现出整体一致性的过程。共识和一致性的区别:一致性是指数据不同副本之间的差异,而共识是指达成一致性的方法与过程。

Lamport 提出的 Paxos 算法包括两个部分:

  • Basic Paxos 算法:多节点如何就某个值达成共识
  • Multi Paxos 思想:执行多个 Basic Paxos ,就一系列的值达成共识

2. 问题产生的背景

在常见的分布式系统中,总会发生诸如机器宕机或网络异常(包括消息的延迟、丢失、重复、乱序,还有网络分区)等情况。

Paxos算法需要解决的问题就是如何在一个可能发生上述异常的分布式系统中,快速且正确地在集群内部对某个数据的值达成一致,并且保证不论发生以上任何异常,都不会破坏整个系统的一致性。

注:这里某个数据的值并不只是狭义上的某个数,它可以是一条日志,也可以是一条命令(command)。。。根据应用场景不同,某个数据的值有不同的含义。

3. Paxos 算法中的角色

Paxos 算法将分布式系统中的节点分为三类:

  • 提案节点(Proposer):提出对某个值进行设置操作的节点,设置值这个行为就被称之为 提案(Proposal),值一旦设置成功,就是不会丢失也不可变的。请注意,Paxos 是典型的基于操作转移模型而非状态转移模型来设计的算法,这里的“设置值”不要类比成程序中变量赋值操作,应该类比成日志记录操作,在后面介绍的 Raft 算法中就直接把“提案”叫作“日志”了。
  • 决策节点(Acceptor):是应答提案的节点,决定该提案是否可被投票、是否可被接受。提案一旦得到过半数决策节点的接受,即称该提案被 批准(Accept),提案被批准即意味着该值不能再被更改,也不会丢失,且最终所有节点都会接受该它。
  • 记录节点(Learner):不参与提案,也不参与决策,只是单纯地从提案、决策节点中学习已经达成共识的提案,譬如少数派节点从网络分区中恢复时,将会进入这种状态。

在具体的实现中,一个进程可能同时充当多种角色。比如一个进程可能既是Proposer又是Acceptor又是Learner。

其实,这三种角色,在本质上代表的是三种功能:

  • 提议者代表的是接入和协调功能,收到客户端请求后,发起二阶段提交,进行共识协商;
  • 接受者代表投票协商和存储数据,对提议的值进行投票,并接受达成共识的值,存储保存;
  • 学习者代表存储数据,不参与共识协商,只接受达成共识的值,存储保存。

使用 Paxos 算法的分布式系统里的,所有的节点都是平等的,它们都可以承担以上某一种或者多种的角色,不过为了便于确保有明确的多数派,决策节点的数量应该被设定为奇数个,且在系统初始化时,网络中每个节点都知道整个网络所有决策节点的数量、地址等信息。

在分布式环境下,如果我们说各个节点“就某个值(提案)达成一致”,指的是“不存在某个时刻有一个值为 A,另一个时刻又为 B 的情景”。解决这个问题的复杂度主要来源于以下两个方面因素的共同影响:

  • 系统内部各个节点通信是不可靠的,不论对于系统中企图设置数据的提案节点抑或决定是否批准设置操作的决策节点,其发出、收到的信息可能延迟送达、也可能会丢失,但不去考虑消息有传递错误的情况。
  • 系统外部各个用户访问是可并发的,如果系统只会有一个用户,或者每次只对系统进行串行访问,那单纯地应用 Quorum 机制,少数节点服从多数节点,就已经足以保证值被正确地读写。

第一点是网络通信中客观存在的现象,也是所有共识算法都要重点解决的问题。

第二点即使先不考虑是不是在分布式的环境下,只考虑并发操作,假设有一个变量 i 当前在系统中存储的数值为 2,同时有外部请求 A、B 分别对系统发送操作指令:“把 i 的值加 1”和“把 i 的值乘 3”,如果不加任何并发控制的话,将可能得到“(2+1)×3=9”与“2×3+1=7”两种可能的结果。因此,对同一个变量的并发修改必须先加锁后操作,不能让 A、B 的请求被交替处理,这些可以说是程序设计的基本常识了。

在分布式的环境下,由于还要同时考虑到分布式系统内可能在任何时刻出现的通信故障,如果一个节点在取得锁之后,在释放锁之前发生崩溃失联,这将导致整个操作被无限期的等待所阻塞,因此 算法中的加锁就不完全等同于并发控制中以互斥量来实现的加锁,还必须提供一个其他节点能抢占锁的机制,以避免因通信问题而出现死锁。

4. 算法流程

  • Paxos算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。它利用大多数 (Majority) 机制保证了2F+1的容错能力,即2F+1个节点的系统最多允许F个节点同时出现故障。
  • Paxos描述的就是在由多个Proposer和Acceptor构成的系统中如何让多个Acceptor针对Proposer提出的多种提案达成一致的过程。
  • Proposer可以提出(propose)提案;Acceptor可以接受(accept)提案;如果某个提案被选定(chosen),那么该提案里的value就被选定了。

paxos 分为两个阶段运行,Prepare 阶段、Accept 阶段。即准备(Prepare)阶段和接受(Accept)阶段。

Prepare 阶段

  • 如果某个提案节点准备发起提案,必须先向所有的决策节点广播一个许可申请。
  • Proposer生成全局唯一且递增的ProposalID,向Paxos集群的所有机器发送Prepare请求,这里不携带value,只携带N即ProposalID,可以记作Prepare(N)请求。
  • Acceptor收到 Prepare(N)请求后,判断收到的是否比之前已响应的所有提案的ID大
    • 如果是

      • 在本地持久化N,可记为Max_N
      • 回复请求,并带上已经Accept提案中N最大的value,如果此时还没有已经Accept的提案,则返回value为空
      • 做出承诺,不会Accept任何小于Max_N的提案
    • 如果否,即收到的提案 ID 并不是决策节点收到过的最大的,那允许直接对此 Prepare 请求不予理会。

决策节点收到后,将会给予以下个承诺

  1. 承诺不会再接受提案 ID 小于或等于 n 的 Prepare 请求。
  2. 承诺不会再接受提案 ID 小于 n 的 Accept 请求。

所以,Acceptor 需要持久化存储 max_n、accepted_N 和 accepted_VALUE 。

Accept阶段

proposer 在收到多数 acceptor 的成功响应后,如果这些acceptor中有返回议案,那么就从中选出那个编号最大的议案的值,作为接下来要提交的。如果没有返回议案,那么将本身的议案作为提交议案。

在确定好提交议案后,proposer 会向至少多数的 acceptor 发起 ACCEPT 请求,提交议案。注意下这里的多数 acceptor 可以不和 prepare 阶段的多数 acceptor 相同。

acceptor 当收到 ACCEPT 请求后,会检查议案编号。因为acceptor 在 prepare 阶段,已经承诺过不会接受议案编号小于 N 的请求,如果 ACCEPT 请求的议案编号小于 N,那么就会拒绝接受此议案。否则,就会接受该议案。

Accept阶段继续可以被拆为P2a,P2b,P2c。

P2a

Proposer发送Accept

Proposer 向多数派的 Acceptor 发起 Accept(n, value) 请求并带上提案编号和值。

关于值 value 的选择:如果前面的 Promise 响应有返回 accepted_VALUE,那就使用这个值作为 value。如果没有返回 accepted_VALUE,那可以自由决定提案值 value。

经过一段时间后,Proposer收集到一些Prepare回复,有下列几种情况:

  • 若回复数量>一半的Acceptor数量,且所有回复的value都为空时则Porposer发出accept请求,并带上自己指定的value
  • 若回复数量>一半的Acceptor数量,且有的回复value不为空时则Porposer发出accept请求,并带上回复中ProposallD最大的value,作为自己的提案内容
  • 若回复数量<=一半的Acceptor数量时,则尝试更新生成更大的ProposallD,再转到Prepare(准备)阶段执行

P2b

Proposer应答Accept Accpetor收到Accpet请求后,判断

  • 若收到的N>=Max_N(一般情况下是等于),则回复提交成功,并持久化N和value
  • 若收到的N< Max_N,则不回复或者回复提交失败

P2c

Proposer统计投票

经过一段时间后,Proposer会收集到一些Accept回复提交成功的情况,比如

  • 当回复数量>一半的Acceptor数量时,则表示提交value成功。此时可以发一个广播给所有的Proposer,learner,通知它们已commit的value
  • 每当 acceptor 接受了一个议案,就会立即通知 learner。learner 会记录每个 acceptor 接收的议案,如果一个议案被多数 acceptor 接受了,那么就决定选定该议案。注意到 leaner 选定了议案,才是整个 paxos 算法的结束。

案例

我们假设客户端 1 的提案编号为 1,客户端 2 的提案编号为 5,并假设节点 A、B 先收到来自客户端 1 的准备请求,节点 C 先收到来自客户端 2 的准备请求。

准备(Prepare)阶段

先来看第一个阶段,首先客户端 1、2 作为提议者,分别向所有接受者发送包含提案编号的准备请求:

你要注意,在准备请求中是不需要指定提议的值的,只需要携带提案编号就可以了,这是很多同学容易产生误解的地方。

接着,当节点 A、B 收到提案编号为 1 的准备请求,节点 C 收到提案编号为 5 的准备请求后,将进行这样的处理:

由于之前没有通过任何提案,所以节点 A、B 将返回一个 “尚无提案”的响应。也就是说节点 A 和 B 在告诉提议者,我之前没有通过任何提案呢,并承诺以后不再响应提案编号小于等于 1 的准备请求,不会通过编号小于 1 的提案。

节点 C 也是如此,它将返回一个 “尚无提案”的响应,并承诺以后不再响应提案编号小于等于 5 的准备请求,不会通过编号小于 5 的提案。

另外,当节点 A、B 收到提案编号为 5 的准备请求,和节点 C 收到提案编号为 1 的准备请求的时候,将进行这样的处理过程:

当节点 A、B 收到提案编号为 5 的准备请求的时候,因为提案编号 5 大于它们之前响应的准备请求的提案编号 1,而且两个节点都没有通过任何提案,所以它将返回一个 “尚无提案”的响应,并承诺以后不再响应提案编号小于等于 5 的准备请求,不会通过编号小于 5 的提案。

当节点 C 收到提案编号为 1 的准备请求的时候,由于提案编号 1 小于它之前响应的准备请求的提案编号 5,所以丢弃该准备请求,不做响应。

接受(Accept)阶段

第二个阶段也就是接受阶段,首先客户端 1、2 在收到大多数节点的准备响应之后,会分别发送接受请求:

当客户端 1 收到大多数的接受者(节点 A、B)的准备响应后,根据响应中提案编号最大的提案的值,设置接受请求中的值。因为该值在来自节点 A、B 的准备响应中都为空(也就是图 5 中的“尚无提案”),所以就把自己的提议值 3 作为提案的值,发送接受请求[1, 3]。

当客户端 2 收到大多数的接受者的准备响应后(节点 A、B 和节点 C),根据响应中提案编号最大的提案的值,来设置接受请求中的值。因为该值在来自节点 A、B、C 的准备响应中都为空(也就是图 5 和图 6 中的“尚无提案”),所以就把自己的提议值 7 作为提案的值,发送接受请求[5, 7]。

当三个节点收到 2 个客户端的接受请求时,会进行这样的处理:

当节点 A、B、C 收到接受请求[1, 3]的时候,由于提案的提案编号 1 小于三个节点承诺能通过的提案的最小提案编号 5,所以提案[1, 3]将被拒绝。

当节点 A、B、C 收到接受请求[5, 7]的时候,由于提案的提案编号 5 不小于三个节点承诺能通过的提案的最小提案编号 5,所以就通过提案[5, 7],也就是接受了值 7,三个节点就 X 值为 7 达成了共识。

如果集群中还有学习者,当接受者通过一个提案,就通知学习者,当学习者发现大多数接受者都通过了某个提案,那么学习者也通过该提案,接受提案的值。

接受者存在已通过提案的情况

上面例子中,准备阶段和接受阶段均不存在接受者已经通过提案的情况。这里继续使用上面的例子,不过假设节点 A, B 已通过提案 [5, 7],节点 C 未通过任何提案。
增加一个新的提议者客户端 3,客户端 3 的提案为 [9,9] 。

接下来,客户端 3 执行准备阶段和接受阶段。

客户端 3 向节点 A, B, C 发送提案编号为 9 的准备请求:

节点 A, B 接收到客户端 3 的准备请求,由于节点 A, B 已通过提案 [5, 7],故在准备响应中,包含此提案信息。
节点 C 接收到客户端 3 的准备请求,由于节点 C 未通过任何提案,故节点 C 将返回“尚无提案”的准备响应。

客户端 3 接收到节点 A, B, C 的准备响应后,向节点 A, B, C 发送接受请求。这里需要特点指出,客户端 3 会根据响应中的提案编号最大的提案的值,设置接受请求的值。由于在准备响应中,已包含提案 [5, 7],故客户端 3 将接受请求的提案编号设置为 9,提案值设置为 "7" 即接受请求的提案为 [9, 7]:

节点 A, B, C 接收到客户端 3 的接受请求,由于提案编号 9 不小于三个节点承诺可以通过的最小提案编号,故均通过提案 [9, 7]。

总结

Basic Paxos 具有以下特点:

  • Basic Paxos 通过二阶段方式来达成共识,即准备阶段和接受阶段

  • Basic Paxos 除了达成共识功能,还实现了容错,在少于一半节点出现故障时,集群也能工作

  • 提案编号大小代表优先级。对于提案编号,接受者提供三个承诺:

    • 如果准备请求的提案编号,小于等于接受者已经响应的准备请求的提案编号,那么接受者承诺不响应这个准备请求
    • 如果接受请求中的提案编号,小于接受者已经响应的准备请求的提案编号,那么接受者承诺不通过这个提案
    • 如果按受者已通过提案,那些接受者承诺会在准备请求的响应中,包含已经通过的最大编号的提案信息