何谓共识 —— 我理解的 Paxos

前言

网上关于 Paxos / Raft 的文章已经很多了,大部分文章都太过于侧重算法流程描述,始终无法让我在脑子里对 Paxos 和它解决的问题形成一个较好的认知。
查阅了更多资料后,总算是对 Paxos 形成了一个比较清晰的初步认知,特写博一篇记录一下。
如果你已经看过一些 Paxos 文章,大致了解这个算法的流程,但是却对这个算法仍然感到一些迷惑,那么这篇文章应该比较适合你。

何谓共识 —— Paxos 诞生的背景

假设我想设计一个“美国总统查询服务”,功能很简单,请求“美国总统是谁”,服务器将响应一个人名。
当然,服务器刚刚启动的时候并不知道美国总统是谁,我们允许从外部发送请求初始化服务器,告诉他美国总统是谁。
简单地说,我们的服务器支持以下两个 RPC 接口:

  1. Init(name): 如果服务器还不知道谁是美国总统,就设置 name 作为美国总统,否则的话,忽略这次请求。换言之,Once set, can't be modified。
  2. Query():服务器返回它所认知的美国总统。

听起来是一个最简单不过的 Echo 服务加强版,用一个内存变量记一下美国总统是谁就可以了。

但是,我们知道,单机系统无法实现容灾和高可用,如果这个服务我们想升级一下怎么办?如果这个服务主机崩溃了,断网了怎么办?
届时服务将存在不可用期。

因此,我们需要一个分布式的“美国总统查询服务”。

但是这就引入了另外一个问题,也就是 Paxos 要解决的问题 —— 如何让这多台服务器对“美国总统”这个值达成共识?

一个听起来简单实际也可行的做法是:只有一个 Client 在尝试 Init(),并且它会不断Init(),直到 3 台主机都表示 Init() 成功。
但实际上,我们不应该假设我们具备 “只有一个 Client 在尝试 Init()” 这样的能力,这等价于我们在代码里 Hardcode 了 name 是什么,而不是将设置它的权力交给外界 RPC。

何谓共识 —— 我理解的 Paxos
因此,我们需要图上所示的从多个不可控的外部触发 Init() 的能力。

那么,该听谁的呢?不同服务器对“谁是总统”这个问题可能存在不同的答案。这就很糟糕了。

我们可以容忍服务器们接受一个错误的人作为总统,但不能容忍它们认为存在多个不同的总统。
为什么呢,因为二者的核心区别在于,服务器是否具备达成共识的能力,对于前者,即使服务器们一时状态有误,我们也完全可以通过外部感知,并发送指令去调整服务器达到一个期望的状态。
但是你刚才不是说,Once set, can't be modified 吗?怎么现在又可以调整了。
这里的“调整”和刚刚的“modified”并不是同一回事,“modified”是指共识一旦达成,就不能改变,而“调整”是指,我们知道服务器们有了一个错误的共识,我们需要让他们形成一个新的共识,这并不影响它们之前的错误共识,也就是 List<共识>。

List<共识>() 是一个非常非常强大的能力。
因为共识可以是很多东西,可以是一条指令,可以是一条文本信息。
List<文本信息>,我们就得到了一个文件。
List<指令>,我们就得到了一个CPU。
List<binlog>,我们就得到了一个MySQL。

List<共识(binlog)>,我们就得到了一个具备容灾和高可用能力的分布式 MySQL。
List<共识(text)>,我们就得到了一个具备容灾和高可用能力的分布式文件系统。

这就是共识的力量,它让多台通过不可靠网络连接的机器,Work As One!而共识的力量,正是来源于 Paxos。对于有状态的服务,你几乎可以把 Paxos 和多机容灾高可用画上等号。
共识,共识,还是共识。

走向共识

前面介绍了 Paxos 解决了什么问题以及它的意义,现在还是回到问题本身。
那么,该听谁的呢?
首先,再重复一下重点,我们可以容忍服务器们接受一个错误的人作为总统,但不能容忍它们认为存在多个不同的总统。
因此,我们现在研究的问题是让多机达成一致,听谁的无所谓,但一定要听一个人的。

让我们先来随便想一些办法看看可不可行:

  1. 策略一. 外部触发 Init() 的时候,必须同时 Init() 所有机器,才算 Init() 成功,否则会一直重试直至都成功。
    显然,在外部并发 Init() 的场景下,你极有可能先成功了一台,在尝试第二台的时候,发现这一台已经被其他人 Init() 过了,根据 Once set, can't be modified,你将永远不可能成功。并且此时集群状态已经乱掉,无法达成共识了。

  2. 策略二. 多次Init()原子化
    策略一的问题出在哪儿呢?对的,多个 Init 不是原子的,要是有把互斥锁一样的东西,能让我们做到 “要么都成功,要么都失败” 就好了。
    怎么做到这一点呢?对的,事先告知一下服务器们是不是就可以了?
     a. 发起 Init() 前,先发起一轮 Informal(),通知服务器我们马上要发 Init() 过来了,现在开始你只能听我的。
     b. 如果所有服务器都表示 ok,此时再做策略一描述的过程。否则的话,放弃 Init()。
    这个策略的确可以保证让多机达成一致,但它却存在两个问题:
     a. 根据策略中对 Informal() 流程的描述,同一时刻,不同服务器可能被不同 Client Informal(),因此可能永远也没有 Client 成功走到步骤二。
     b. 要是有一个 Server 宕机了呢?难道 Client 要一直阻塞在那儿疯狂重试?

  3. 策略三. 允许 Server 被多次 Informal(),同时在 Informal() 和 Init() 之间插入一个 PreInit() 阶段
    让我们看看这个策略是怎么解决上面的问题的。
    允许 Server 被多次 Informal
    也就是说,如果 Server 先被一个 Client Informal() 了,如果后面来了其他 Client 的 Informal(),那么就接受新的 Client 的 Informal(),抛弃旧的。
    有人会说,你这不是扯淡吗?假如有一个 Client 先全部 Informal() 成功了,然后又 Init() 了一部分成功,结果剩下那一部分 Server 被其他 Client 给 Informal() 了,根据 Once set, can't be modified,这不就不一致了?
    别着急,策略里不是还有一句话吗?
    在 Informal() 和 Init() 之间插入一个 PreInit() 阶段
    Informal() -> PreInit() -> Init()
    PreInit() 是干什么的呢?你可以理解为,它和 Informal() 一模一样,区别在于,它带上了 Init(name) 的 name 给服务器,但是让服务器不真正接受这个 name,而是告诉他 “我马上要提交这个 name 了,万一待会有其他人给你发 Informal(),你就告诉它你已经接受了这个 name 了,让它改用这个 name”
    重新回忆一下刚刚那句话,我们可以容忍服务器们接受一个错误的人作为总统,但不能容忍它们认为存在多个不同的总统。
    因此,这个阶段的作用其实就是让后来的 Client “舍小家为大家”,放弃自己要设的 name,改设先前已经有过的 value,从而达成一致。

策略三是不是就天衣无缝了呢?其实不是的。
想象这样一个场景:由于时间差,两个 Client 交替 Informal() 成功,各实际拉拢了一半 Server,但两个 Client 都认为自己已经全部 Informal() 成功,因此进入 PreInit() 阶段,各自带入了 name 为 A 和 B。毫无疑问,此时双方只能各成功了一半 Server,因此准备发起整个流程的重试(还记得之前说的吗,“直到 3 台主机都表示 Init() 成功”)。
重试时,尴尬的情况就来了,Informal() 一波后,有一半 Server 告知 Client 用 A 值做 name,有一半 Server 告知 Client 用 B 值做 name。Client 到底应该用哪个值作为自己的立场呢?

没错,聪明的你或许想到了,反正达成共识就行,Client 的每轮 Informal() 都带一个随机生成的 ID,谁的 ID 大就听谁的不就可以了吗?当然,由于信息的时效性和避免 ID 重复,这个 ID 我们不一定要随机,而是采用时间戳来生成,实现“以最新的提议为准”。

有没有发现,把 Informal() 改名为 Prepare(),PreInit() 改名为 Accept(),Init() 改名为 Learn(),这个策略越来越像你在论文和其他文章里看到的 Paxos 了。

到这里,共识的问题算是解决了。从策略二进化到策略三,我们解决了策略二存在的第一个问题,现在还有第二个问题,Server 宕机了怎么办?

  1. 策略四. Informal() / PreInit() / Init() 每次不一定要全部通过才进入下一阶段,超过多数就可以
    为什么这么做可以?因为一个集群只有一个多数派,每个 Server 只认可一个值,你不可能找到两个交集为空的 Server 集合,使得它们各认可一个值而导致共识矛盾。
    这一步其实算是水到渠成,有了策略三的铺垫,不费太多功夫我们就可以进化到策略四。

结尾

现在,把 Informal() 改名为 Prepare(),把 PreInit() 改名为 Accept(),Init() 改名为 Learn() 吧。你已经获得了 Paxos 的雏形。

关于 Paxos 的详细算法流程,网络上的文章已经很多也很详细,这里就不再赘述了,这篇文章的出发点,更多的还是提供一个理解 Paxos 背景与目标的视角。
毕竟,人脑不是 CPU,程序员不是服务器。比形式化的算法流程更重要的,是背后的思想。

上一篇:搜索与回溯:装载问题(load)


下一篇:OAuth 2.0 四种方式的简单介绍