名词 | 概念 |
停车场事件 | 将停车场日常发生的事情抽象成为一个一个的事件,目前有两类事件需要处理: 每一个事件都有全局唯一的编号;事件之间通过编号确定全序顺序关系。 每一个事件包含了以下三种状态: 进行中:事件只是被提出,但还未在大家之间形成结论; 已通过:事件已形成结论,并且被记录到至少一个保安的笔记本上; 已完成:在某个保安的视角,事件已被记录到自己的笔记本上,且编号小于这个事件的所有事件也都记录到了自己的笔记本上。
|
停车场状态 | 这里的状态特指停车场中,某一个时刻空闲的车位数量。 通过初始状态 + 事件的方式,就能计算每个事件发生之后的新状态,例如: |
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
通过建立这两个概念,从理论上来说,每个人具有相同初始状态的情况下,再顺序地执行一组相同的事件,一定能得到相同的新状态。停车场的初始状态是明确的,即 1000 个空闲车位。那么大家要解决的问题就转变为:如何在众人之间,记录完全相同(包括顺序)的事件。
回想一下教会协议里做的事情——就某个事情在众人间达成共识。那么我们是不是可以为每一个事件,发起一次完整的教会协议?在这个教会协议的实例中,将事件作为提出的法令。那么一旦这个实例完成(即法令被通过),事件是不是就在众人之间达成共识了。
比如现在有一辆车想从东门离开停车场,那么 E 可以为这个“出场事件”发起一次新的教会协议(顺带复习一下教会协议一轮表决的 5 个步骤):
1)E 通过对讲机,分别给 W 和 N 说:“我要开始一个新实例的第一轮表决了”(NextBallot);
2)W 和 N 的保安分别回答 E 道:“我参加表决,并且之前没有给这个实例投任何过票”(LastVote);
3)E 收到回复之后,将这个车辆的出场事件作为这个教会实例的法令,开始表决。分别给 W 和 N 说:“开始这个实例第一轮表决:X 车辆准备从东门驶离停车场”(BeginBallot);
4)W 和 N 分别回答:“收到”(Voted);
5)E 在笔记本上记录下这个事件,然后分别告诉 W 和 N :“X 车辆于 h 时 m 分,从东门驶离停车场”;W 和 N 听到后,也在自己的笔记本上记录下这个事件(Success)。
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
单独的一个事件可以通过一次独立的教会协议达成共识。但对于多个事件,如何在所有人之间达成共识呢?即:多个事件如何在所有人之间保证具有相同的顺序?
回想一下完整版的教会协议,里面有一个选生活委员的过程。对应到停车场的场景,我们可以先选出一个值班长,由值班长接受所有新的事件,给事件一个唯一且递增的编号,然后给这些事件发起对应的教会协议实例。这样就能够保证多个事件在众人间是一致的。理论可行,实践开始!
随着设计和研发阶段的完成,议会协议也基本成型。众人商量决定,先投入实际中试运行一段时间。梳理出来的议会协议过程整理出来如下:
什么时候? | 做什么? |
| 所有人 |
选举 | 每过 Y 秒 | 通过对讲机告诉其他人“我是 X,我在岗”;一旦听到其他人的在岗信息后,把这个人的名字和当前时间记录下来(如果之前已经记录过这个人了,那么可以将之前的记录的时间更新为当前时间)。 |
每过 Z 秒 | 检查自己记下的所有记录。先筛选出最近 Z 秒内还在岗的人,然后在这些人中:如果有名字顺序比自己大的,则将他当做值班长,自己则不是值班长;如果没有这样的人(最近 M 秒内都没其他人在岗,或其他在岗人名字顺序都比自己小),那就认为自己当选为值班长,其他人未当选。 |
| 值班长 | 非值班长 |
准备 | 新值班长当选时 | 从其他人处抄写缺失的已通过事件 | 从值班长处抄写缺失的已通过事件 |
初始化事件号计数器 |
服务 | 有车辆进出时 | 给事件一个编号,并且为它发起一次新的教会协议实例 | 通过对讲机将事件转达给值班长 |
事件进行中 | 在教会协议中发起表决,推进事件通过 | 参与表决,进行投票 |
事件通过后 | 等待前一个事件完成后,进行下一步 |
事件完成时 | 如果事件不发生在自己的大门,则记录不作后续处理。 如果事件发生在自己的大门,则: 如果是出场事件,事件记录完成后,即放行车辆出场; 如果是入场事件,事件记录完成后,即放行车辆入场。
|
经过几周的试运行,大家或多或少的发现了一些问题(第一个版本嘛,难免的嘛)。但大方向是没有问题的,于是大家撸起了袖子,准备把这些发展中的小问题给逐个击破。
试运行的议会协议中,对每个事件都要用上一次完整的教会协议。实在是有点冗余,需要说好多话浪费好多口水:
1)作为值班长的保安(对于每个事件的每一轮表决需要说 5 句话):
2 * “我要开始 xxx 表决了”(NextBallot) + 1 * “开始 xxx 表决:……准备进/出停车场”(BeginBallot) + 2 * “……已经进/出停车场了”(Success);
如果每个事件,平均发生了 轮表决,那他需要说 5* n 句话;
如果共发生了 m 个事件,那么总共需要说 5*n*m 句话。
2)作为非值班长的保安,相应的,总共需要说 2*n*m 句话(LastVote + Voted)。
两者相加,那就要说 7*n*m 句话。这不仅费口水,还有点费对讲机的电。
严格来说这并不是一个缺陷,但为了绿色低碳环保,大家思考了很久很久……突然 E 灵光一闪,发现值班长和非值班长说的第一句话,其实并没有包含任何与当前事件相关的信息。
如果把第一句话理解为用来占位的,那可不可以一次性占多个位呢?
值班长一旦当选后,给大家说“我要开始第 m 号事件之后所有事件的第 b 轮表决了”(m 是值班长已完成事件中最大的事件号)。因为教会协议只是要求表决号唯一且可比较,所以我们可以用一个表决号 b,作为多个事件的表决号进行占位并发起表决。
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
非值班长的保安们在收到这个消息之后,随即回复:“我参与 m 号实例之后的所有表决。并且在 m+1 号实例的第 a 轮投票了 xx 事件;在 m + 2号……”。省略号部分是这个保安在 m 号实例之后,投过票的进行中的事件和已通过的事件,已方便。
值班长收到这个回复之后:一方面可以继续推进进行中的事件;另一方面从其他人那里学习到m 号之后已通过的事件;最后还对大于 m 所有教会协议实例的第 b 轮表决进行了占位,获得了其他人的承诺。
口水是省了,但是又引入了一个新的问题,表决号还需要自增么?
正常情况下,因为只有一个值班长 E,他在当选后即用 b 这个表决号为后续所有事件进行了占位,所以他对于后续的教会协议实例,都不用修改占位用的表决号 b。
但是如果 E 和 W 同时认为自己是值班长,且 W 的占位的表决号 b' 比 b 更大,那么 E 发起的所有教会实例都无法通过(这会影响算法的活性,但不影响安全性)。
解决这个问题的关键在于教会协议的第4步,因为 W 用 b' 在 N 那儿站位了,所以 N 在第 4 步不会给 E 的任何教会实例投任何的票,但 N 可以告诉 E “我不给你投票的原因是因为你的表决号 b 太小了,我已经给 b' 承诺了”。
这时,E 可以根据 N 的回复,调整自己的表决号,并重新开始当选值班长之后的所有步骤。
(这个时候值班长的表决号 b 有点类似于 Raft 的 term 和 ZAB 的 epoch 了)
在试运行期间,大家还是意外的收到一起投诉。
事情是这样的,在一个风和日丽的下午,一辆车想从东门进入停车场,大家按照试运行的议会协议对入场事件达成了共识,然后放行其入场。但是车主在停车场找了半天也没找到一个空闲车位,于是就愤愤离场,立马打电话给商场投诉。
大家在复盘这件事情的时候,发现了一个致命的问题,通过执行这辆车入场之前的所有事件,发现此时停车场的空闲车位已经是 0 了,也就是说没有空闲车位了。再加上这辆车的入场事件,空闲车位数变成了 -1……不被投诉就奇怪了。
这个时候大家才开始重新审视整个方法过程,找出了问题症结所在:出场事件和入场是事件并不是等同的。出场事件只需要在当前状态下简单的 +1 即可,但入场事件却不能简单的将空闲车位 -1,它还需要先判断当前的空闲车位数是否 > 0。要攻克这个问题,等效于解决下面的两个问题:
1)如何拿到精准的当前状态?
议会协议能保证事件在众人间是一致性,但是它不保证时效性。
也就是说在某一个时刻,每个人记录的事件集可能是不一样的。有的人记到了 110 号事件,有的人只记录到了 108 号事件,有的人 100 号事件是空缺的,有的人 99 号和 101 号事件是空缺的。
要攻克这个问题,一种可行的方式是出入口对应的保安把“读取停车场当前状态”作为一个读取事件,为它也发起一次教会协议。在这个读取事件完成后,就可以通过执行所有事件,算出直到这个读取事件的这一刻,停车场的空闲车位数是多少。这样就精准的完成了“判断当前状态”。如果此时已经没有空闲车位了,那就拒绝车辆入场。
2)如何保证判断当前状态的“读取事件”和放行车辆的“入场事件的”是原子的?
假设这两个事件间,被插入了其他的事件,那就有可能导致错误的结果。
比如停车场的当前状态还有 1 个空闲车位。在完成入场事件之前,又来了一个车辆要入场,并且更快地完成了它的读取事件和入场事件。那前一个车辆入场事件就不应该也不能够被执行了。我们在解决前一个问题的时候引入了读取事件,这个读取事件一定是有一个事件号的。
假设读取事件是 99 号,那么可以把接下来的入场事件赋予 100 的事件号,为它发起一次教会协议,如果 100 号实例通过了,并且事件确实就是本次的入场事件(而非并发的其他事件),这样就能保证 99 号事件和 100 号事件之间没有发生其他事件。换句话说,它们就是原子的。
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
经过了设计、研发、测试之后,大家期待已久的终版议会协议终于迎来了上线。整个过程梳理出来如下:
什么时候? | 做什么? |
| 所有人 |
选举 | 每过 Y 秒 | 通过对讲机告诉其他人“我是 X,我在岗”;一旦听到其他人的在岗信息后,把这个人的名字和当前时间记录下来(如果之前已经记录过这个人了,那么可以将之前的记录的时间更新为当前时间); |
每过 Z 秒 | 检查自己记下的所有记录。先筛选出最近 Z 秒内还在岗的人,然后在这些人中:如果有名字顺序比自己大的,则将他当做值班长,自己则不是值班长;如果没有这样的人(最近 M 秒内都没其他人在岗,或其他在岗人名字顺序都比自己小),那就认为自己当选为值班长,其他人未当选。 |
| 值班长 | 非值班长 |
准备 | 新值班长当选时 | 从其他人处抄写缺失的已通过事件 | 从值班长处抄写缺失的已通过事件 |
初始化事件号计数器 |
发起统一的教会协议一阶段,为后续的事件进行占位。 | 根据历史的投票情况进行响应,并进行对应的承诺。 |
服务 | 有车辆进出时 | 给事件一个编号,并且为它发起一次新的教会协议实例 | 通过对讲机将事件转达给值班长 |
事件进行中 | 在教会协议中发起表决,推进事件通过 | 参与表决,进行投票 |
事件通过后 | 等待前一个事件完成后,进行下一步 |
事件完成时 | 如果事件不发生在自己的大门,则仅记录,不作后续处理; 如果事件发生在自己的大门,则: 如果是出场事件,事件记录完成后,即放行车辆出场; 如果是入场事件:
|
大家在严格地按照上述步骤执行之后,停车场收到的投诉也大大的减少了。
(别问为什么还有投诉,问就是你不懂服务行业)
上文通过拆解不同的阶段,以及分析不同的问题,将 Paxos 议会协议的整个过程进行了讲解。议会协议可以看做是教会协议的多实例版本。每个教会协议实例就某一个事件在参与者中达成共识,实例之间又通过额外的机制在参与者之间保证连续且有序,从而使得每个参与者都以相同的顺序记录了相同的事件。每个参与者分别执行这些事件,从而得到一个一致的结果。
通过完整的 Paxos 议会协议,我们在分布式环境下对多个进程按需进行协调,包括但不限于以下应用场景:
Paxos 论文的特点就是隐晦难懂,在很多地方都进行了留白(例如选举)。所以在具体落地的过程中,不同的人带入不同的理解,会有不同的实现,包括本文的很多过程和解法都是笔者按照自己的理解和经验进行填充的。
那有没有一种更严谨并且经过大量实践的实现呢?
当然有了,毕竟计算机学科已经发展这么多年,Paxos 也出现了这么多年。这就是系列下一篇文章 Raft 算法要讲解的内容了,对比 Paxos 和 Raft,你会发现它两惊人的相似,不同的是 Raft 通过更“计算机”的语言,将整个算法更为详细地进行了阐述,方便大家以更标准、更精确的方式来落地实现。
正文中提到了一种简单的选值班长的方式,但是这样会带来一个问题:
“假设工号最大的人 E 临时有事请了半天假,其他两个人正常的工作了半天之后,E 又回来上班了。这时,E 重新当选为值班长,但是他已经落后其他两个人很多事件了,需要从他们那里抄写这半天所有的事件到自己的笔记本上,在他抄写期间,没办法处理新的出入场事件。”
为了解决这个问题,大家改进了选值班长的过程:从“始终选工号最大的人做值班长”,到“选事件最多的人(即笔记本上记录事件最多的人)做值班长”。采用这种方式,可以发生换届时,不会花费大量时间在事件抄写上,从而影响到停车场的服务。作为补充,如果多个人记录的事件数一样,那么就选其中工号最大的人。
解决方案正式发布后,随着实际的执行,大家发现笔记本上记录的事件越来越多了。每次车辆入场前,都需要从头计算一次出入场事件,才能知道还有没有空位。最近越演越烈,需要前后翻几十页,计算十几分钟才能放车辆入场,大大的影响了效率。
大家在实际的过程中,渐渐的发现了问题所在:为什么每次计算空闲车位,都需要从最开始的状态开始依次计算每个出入场事件呢?如果计算今天的空闲车位,可以从昨天下班后的空闲车位(假设下班后不会再有车辆进出)再加上今天记录的事件,就能省掉大量的计算和时间。对于大家来说,计算一天的事件,还是可以接受的。
说干就干,大家现在在每天下班后,多了一件事情,就是通过笔记本上已完成的事件,计算出今天下班后停车场的空闲车位数,并记录下来。除了第一次计算需要从头到尾的计算所有出入场事件以外,后面每天只需要基于昨天下班后的空闲车位数 + 今天所有的出入场事件,就可以计算出今天下班后的空闲车位数。每天计算并记录下班后的空闲车位数之后,以前记录的事件就算是归档了,即使不慎遗失了,也不影响后续的工作。
这个过程还有一个场景需要处理。
假设 W 请了一周假之后回来上班了,他虽然没能当选值班长,但是也需要从 E 和 N 处抄写最近一周的所有事件。但是因为咱们改进了流程之后,E 和 N 对于记录的历史事件没那么上心了,不小心弄丢了。这个时候 W 就不能像原来那样抄写这一周的所有事件,而是从 E 或者 N 出抄写昨天下班后的空闲车位数,以及今天上班后新发生的所有事件。
上面这个过程只适用于需要抄写今天以前的事件,如果 W 只是去上了个厕所,那它还是需要从 E 或 N 处抄写(今天的)这段时间新发生的所有事件。
随着服务的提升,停车场的生意越来越好了,三个门偶尔都排起了队。管理层决定新招两名保安,把一直未开放的南一门和南二门也修葺一下,正式开放。
这时又引入了新的问题:
最直接的方式就是在某天下班后,大家聚在一起开个会、吃个饭,然后约定明天就是 5 个人一起上班了。
这样在现实生活中当然没问题,但是假设停车场是 24 小时轮班制的,没有一个时间点能把 5 个门的保安都聚在一起。或者是这两个门并不是某天一上班就立马开始投入使用,而是中午后的某个不确定的时间点投入使用呢?
为了解决这个问题,我们得先来看一下事件的并发度问题“最大允许多少个在表决中的事件”。
如果这个数是无限大的话,一方面会增大事件间出现 gap 的概率和分布,另一方面也不利于解决新人加入的问题。具体到停车场的场景,这个并发度为 3 是比较合理的,因为最多只会有 3 辆车同时从 3 个门出入停车场。
明确了 3 这个并发度之后,新人到岗的问题也有相应解法了,就是增加一类新的事件“员工变更” — 这个事件中包含了变更后新的全量员工的集合。
那么这个事件多久生效呢?
假设是这个事件通过后立即生效,那么会存在这种情况,假设 100 号事件是员工变更事件,新在岗员工变更为 E、W、N、S1 和 S2,它已经通过。并且 102 号事件也已经通过,但是 101 号事件因为某些原因还未通过。值班长 N 需要对 101 号事件进行重试或者填补空缺。但是此时如果使用 100 号事件并更后的人员列表的话,就可能存在原始表决和重试的标记的多数派不一致,如果刚好是 S1、S2 和一个未参与原始表决的人形成了多数派,那么就会导致另一个的事件被通过,从而出现安全性问题(不一致)。
所以 100 号事件对于可能在进行中的事件不能造成影响,这里就引出了并发度的作用了,因为有它的存在,所以 100 号事件提出之后,至多只有 101 号和 102 号事件在并发,那么 101 和 102 必须沿用之前的员工名单,而 103 以及之后的事件,则可以使用变更之后的员工名单 —— 即 100 号事件通过后,在 100 + 3 号及后续事件中生效。
[01]《架构成长之路 | 图解分布式共识算法Paxos教会协议》
https://blog.csdn.net/AlibabaTech1024/article/details/125168719[02]《OSDI '06 Paper》https://www.usenix.org/legacy/event/osdi06/tech/full_papers/burrows/burrows_html/
[03]《Spanner: Google’s Globally Distributed Database》
https://dl.acm.org/doi/pdf/10.1145/2491245
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)