六.共识
回到全序广播问题。我们在 5.3 节中看到,总订单广播对于启用状态机复制非常有用。如幻灯片 86 所述,实现 total order broadcast 的一种方法是指定一个节点作为领导者,并通过它路由所有消息。然后领导者只需要通过 FIFO 广播分发消息,这足以确保所有节点以相同的顺序传递相同的消息序列。然而,这种方法的最大问题是领导者是单点故障:如果它变得不可用,整个系统就会停止运行。克服这个问题的一种方法是通过手动干预:如果领导者不可用,可以通知人工操作员,然后此人重新配置所有节点以使用不同的节点作为其领导者。这个过程称为故障转移,它实际上在许多数据库系统中使用。在提前计划领导者不可用的情况下,故障转移可以正常工作,例如当需要重新启动领导者以安装软件更新时。然而,对于突然和意外的领导者中断(例如崩溃、硬件故障或网络问题),故障转移受到以下事实的影响:人类执行此过程的速度有限。即使在最好的情况下,操作员也需要几分钟的时间才能做出响应,在此期间系统无法处理任何更新。这就提出了一个问题:在旧领导者不可用的情况下,我们能否自动将领导权从一个节点转移到另一个节点?答案是肯定的,这正是共识算法所做的
6.1共识介绍
共识问题传统上表述如下:几个节点想要就一个值达成一致。一个或多个节点可能会提出一个值,然后共识算法将决定其中一个值。该算法保证所决定的值是建议值之一,所有节点都决定相同的值(故障节点除外,它可能不决定任何事情),并且决定是最终的(节点不会改变它的记住一旦它决定了一个值)。已经正式表明共识和全序广播是等价的——也就是说,一个算法可以变成另一个算法,反之亦然。
共识和全序广播:
传统的共识公式:多个节点希望就一个值达成一致;在全序广播的上下文中,这个值是下一条要传播的消息;一旦一个节点决定了某个消息顺序,所有节点将决定相同的顺序;共识和 total order broadcast 在形式上是等价的。
常见共识算法:
Paxos:单值共识
Multi-Paxos:泛化到 total order broadcast
Raft,Viewstamped Replication,Zab:FIFO-默认total order broadcast(全序广播、全订单广播??)
两个最著名的共识算法是 Paxos [Lamport, 1998] 和 Raft [Ongaro and Ousterhout, 2014]。在其最初的表述中,Paxos 仅提供对单个值的共识,MultiPaxos 算法是 Paxos 的泛化,提供 FIFO-total order 广播。另一方面,Raft 旨在提供“开箱即用”的 FIFO-total order 广播。
共识系统模型
Paxos、Raft 等采用部分同步、崩溃恢复系统模型。为什么不采用异步?
FLP结果:没有确定性的共识算法可以保证在异步崩溃停止系统中终止。
Paxos、Raft 等使用仅用于超时/故障检测器的时钟来确保进度。安全(正确性)不依赖于时间。
还有部分同步拜占庭系统模型的共识算法(用于区块链)。
如第 2.3 节所述,共识算法的设计关键取决于系统模型。 Paxos 和 Raft 假设系统模型具有公平损失链接(幻灯片 33)、节点的崩溃恢复行为(幻灯片 34)和部分同步(幻灯片 35)。
对网络和节点行为的假设可以弱化为拜占庭,这样的算法被用于区块链。然而,拜占庭容错共识算法比非拜占庭算法复杂得多,效率也低得多。我们现在将专注于公平损失、崩溃恢复算法,它们在许多实际环境中都很有用(例如具有可信私有网络的数据中心)。第三部分中的 L47 单元详细介绍了拜占庭共识。
另一方面,不能将部分同步的假设弱化为异步。原因是共识需要一个故障检测器(幻灯片 40),而这又需要一个本地时钟来触发超时 [Chandra and Toueg, 1996]。如果我们没有任何时钟,那么确定性共识算法可能永远不会终止。事实上,已经证明,没有确定性的异步算法可以在保证终止的情况下解决共识问题。这一事实被称为 FLP 结果,它是分布式计算中最重要的定理之一,以其三位作者 Fischer、Lynch 和 Paterson 的名字命名
可以通过使用非确定性(随机)算法绕过 FLP 结果。然而,大多数实际系统通过使用时钟超时来避免非终止。然而,回想一下,在一个部分同步的系统中,我们不能假设有界的网络延迟或有界的节点执行速度。出于这个原因,共识算法需要保证它们的安全属性(即每个节点以相同的顺序决定相同的消息),无论系统中的时间如何,即使消息被任意延迟。只有活跃度(即消息最终被传递)取决于时钟和时间。
领导者选举
Multi-Paxos、Raft等使用领导者对消息进行排序:
.使用故障检测器(超时)来确定可疑的崩溃或者领导者不可用。
.在怀疑leader崩溃时,选出新的leader。
.避免同时出现两个leader。
保证任期内一个leader:
.每次领导人选择开始时,任期都会增加
.一个节点任期内只能投票一次。
.要求一个规定人数的节点在任期内选举领导者。
大多数共识算法的核心是在现有领导者因任何原因变得不可用时选举新领导者的过程。以Raft为例,当其他节点怀疑当前领导者失败时,就会发起领导者选举,通常是因为他们已经有一段时间没有收到来自领导者的任何消息。其他节点之一成为候选人,并要求其他节点投票决定他们是否接受候选人作为他们的新领导者。如果一个法定人数(第 5.2 节)的节点投票支持候选人,它将成为新的领导者。如果使用多数法定人数,只要大多数节点(3 个中的 2 个,或 5 个中的 3 个等)正在工作并且能够通信,该投票就可以成功。
如果有多个领导者,他们可能会做出不一致的决定,从而导致违反全序广播的安全特性(这种情况称为脑裂)。因此,我们想要进行领导人选举的关键是任何时候都应该只有一个领导人。在 Raft 中,“在任何时候”的概念是通过一个任期号来捕获的,它只是一个整数,每次开始选举领导者时都会增加一个整数。如果选举出领导者,投票算法保证它是该特定任期内的唯一领导者。
然而,回想一下幻灯片 41,在部分同步的系统中,基于超时的故障检测器可能不准确:它可能怀疑节点已经崩溃,而实际上节点运行良好,例如由于网络延迟的峰值。例如,在幻灯片 109 上,节点 1 是任期 t 的领导者,但它与节点 2 和 3 之间的网络暂时中断。节点 2 和 3 可能会检测到节点 1 发生故障,并在 t + 1 期间选举新的领导者,即使节点 1 仍然正常运行。此外,节点 1 可能甚至没有注意到网络问题,也不知道新的领导者。因此,我们最终得到了两个都认为是领导者的节点。
出于这个原因,即使在节点被选为领导者之后,它也必须谨慎行事,因为在任何时候系统都可能包含另一个尚未听说过的具有较晚任期的领导者。领导人单方面采取行动是不安全的。相反,每次领导者想要决定下一条要传递的消息时,它都必须再次向法定人数的节点请求确认。幻灯片 110 对此进行了说明:
1. 在第一次往返中,由于其他两个节点的投票,左边节点被选举为领导者。 2. 在第二次往返中,领导者提出下一条要传递的消息,追随者承认他们不知道有任何任期晚于 t 的领导者。 3. 最后,leader 实际传递 m 并将这个事实广播给追随者,以便他们可以做同样的事情。如果选举了另一位领导人,旧领导人将在第二次往返中至少有一个确认中找到,因为第二轮法定人数中至少有一个节点也必须投票给新领导人。因此,即使可能同时存在多个领导者,旧领导者将不再能够决定要传递的任何进一步消息,从而使算法安全。
6.2 Raft共识算法
为了理解算法,需要记住幻灯片 111 上的状态机。节点可以处于以下三种状态之一:领导者、候选者或追随者。当一个节点第一次开始运行时,或者当它崩溃并恢复时,它会以跟随者状态启动并等待来自其他节点的消息。如果在一段时间内没有收到leader或candidate的消息,follower就会怀疑leader不可用,它可能会尝试自己成为leader。检测leader失败的超时时间是随机的,以减少多个节点同时成为候选并竞争成为leader的概率。
当节点怀疑领导者失败时,它会转换到候选状态,增加任期号,并在该任期内开始领导者选举。在这次选举中,如果节点收到另一个候选人或领导者的更高任期,它会回到跟随者状态。但是,如果选举成功并且它收到来自法定人数的节点的投票,则候选人将转换为领导者状态。如果在一段时间内没有获得足够的选票,则选举超时,候选人以更高的任期重新开始选举。
一旦一个节点处于领导者状态,它就一直是领导者,直到它被关闭或崩溃,或者直到它从领导者或候选者那里收到比自己的任期更高的消息。如果网络分区使领导者和另一个节点无法通信足够长的时间以致另一个节点开始选举新领导者,则可能会出现这样的更高期限。在听到更高的任期后,前任领导人下台成为追随者。
幻灯片 112 显示了用于启动和开始选举的伪代码。初始化块中定义的变量构成节点的状态。其中四个变量(currentTerm、votedFor、log 和 commitLength)需要保存在稳定的存储中(例如在磁盘上),因为它们的值不能在崩溃的情况下丢失。其他变量可以在易失性内存中,崩溃恢复功能会重置它们的值。每个节点都有一个唯一的 ID,我们假设有一个全局常量 nodes,包含系统中所有节点的 ID 集合。此版本的算法不处理重新配置(在系统中添加或删除节点)。
变量 log 包含一个条目数组,每个条目都有属性 msg 和 term。每个数组条目的 msg 属性包含我们希望通过总订单广播传递的消息,term 属性包含广播它的术语编号。日志使用从零开始的索引,因此 log[0] 是第一个日志条目,而 log[log.length-1] 是最后一个。日志通过在末尾追加新条目来增长,Raft 跨节点复制此日志。当一个日志条目(以及它的所有前辈)被复制到一个法定节点时,它就会被提交。在我们提交日志条目的那一刻,我们还将其 msg 传递给应用程序。在提交日志条目之前,它可能会发生变化,但 Raft 保证一旦提交日志条目,它就是最终的,并且所有节点将提交相同的日志条目序列。因此,按照日志顺序从提交的日志条目中传递消息为我们提供了 FIFO-total order 广播
当一个节点怀疑一个leader失败时,它开始一个leader选举,如下所示:它增加currentTerm,将自己的角色设置为候选人,并通过将votedFor和votesReceived设置为自己的节点ID来为自己投票。然后它向每个其他节点发送一个 VoteRequest 消息,要求它投票决定这个候选人是否应该是新的领导者。该消息包含候选人的 nodeId、其 currentTerm(递增后)、其日志中的条目数以及其最后一个日志条目的 term 属性。
幻灯片 113 显示了当节点收到来自候选人的 VoteRequest 消息时会发生什么。仅当候选人的日志至少与选民的日志一样最新时,节点才会投票给候选人;这可以防止具有过时日志的候选人成为领导者,这可能导致丢失已提交的日志条目。如果候选人的最后一个日志条目的期限高于收到 VoteRequest 消息的节点上的最后一个日志条目的期限,则该候选人的日志是可接受的。此外,如果条款相同并且候选人的日志包含至少与收件人日志一样多的条目,则该日志也是可以接受的。此逻辑反映在 logOk 变量中。
此外,如果节点在同一任期或以后的任期内已经投票给另一个候选人,则不会投票给该候选人。如果可以根据此规则为候选人投票,则变量 termOk 为真。 votedFor 变量跟踪当前节点在 currentTerm 中的任何先前投票。
如果可以根据此规则为候选人投票,则变量 termOk 为真。 votedFor 变量跟踪当前节点在 currentTerm 中的任何先前投票。如果同时有 logOk 和 termOk,则节点将其当前任期更新为候选人的任期,将其投票记录在 votedFor 中,并向候选人发送包含 true(表示成功)的 VoteResponse 消息。否则,节点发送一个包含 false 的 VoteResponse 消息(表示拒绝为候选人投票)。除了成功或失败标志外,响应消息还包含发送投票的节点的 nodeId 和投票期限。
回到候选人,幻灯片 114 显示了处理 VoteResponse 消息的代码。我们忽略与早期条款相关的任何回复(由于网络延迟可能会迟到)。如果响应中的任期高于候选人的任期,则候选人取消选举并转换回追随者状态。但是,如果该术语是正确的并且授予的成功标志设置为 true,则候选人会将选民的节点 ID 添加到收到的选票集合中。
如果这组选票构成法定人数,则候选人转换为领导者状态。作为领导者的第一个动作,它更新 sentLength 和 ackedLength 变量(解释如下),然后为每个跟随者调用 ReplicateLog 函数(在幻灯片 116 中定义)。这具有向每个追随者发送消息的效果,告知他们新的领导者。
幻灯片 115 显示了当应用程序希望通过总订单广播来广播消息时如何将新条目添加到日志中。领导者可以简单地继续并在其日志中附加一个新条目,而任何其他节点都需要通过 FIFO 链接请求领导者代表其执行此操作(以确保 FIFOtotal 订单广播)。然后领导者将自己在 ackedLength 中的条目更新为 log.length,表明它已经确认自己添加到日志中,并为每个其他节点调用 ReplicateLog。
此外,即使没有新消息要广播,领导者也会定期为每个其他节点调用 ReplicateLog。这有多种用途:它让追随者知道领导者还活着;它用作从领导者到追随者的任何可能丢失的消息的重传;它会更新跟随者关于可以提交哪些消息,如下所述。
ReplicateLog 函数如幻灯片 116 所示。其目的是将任何新的日志条目从领导者发送到 ID 为 followerId 的跟随者节点。它首先将变量条目设置为以 index sentLength[followerId] 开头的日志后缀(如果存在)。也就是说,如果 sentLength[followerId] 是已发送到 followerId 的日志条目数,则 entries 包含尚未发送的剩余条目。如果 sentLength[followerId] = log.length,则变量条目设置为空数组。
ReplicateLog 然后向 followerId 发送一条 LogRequest 消息,其中包含条目以及其他几个值:领导者的 ID;现任任期;条目之前的日志前缀的长度;条目之前的最后一个日志条目的期限;和 commitLength,它是已提交的日志条目数,从日志开始计算。更多关于稍后提交日志条目的信息。
当追随者从领导者那里收到这些 LogRequest 消息之一时,它会处理该消息,如幻灯片 117 所示。首先,如果该消息的期限比追随者之前看到的要晚,它会更新其当前期限并接受作为领导者的消息。
接下来,follower 会检查其 log 是否与 leader 一致。 logLength 是 LogRequest 消息中包含的新条目之前的日志条目数。 follower 要求其日志至少与 logLength 一样长(即不遗漏任何条目),并且 follower 日志的 logLength 前缀中最后一条日志条目的 term 与同一日志的 term 相同进入领导者。如果这两项检查都通过,则 logOk 变量设置为 true。
如果 LogRequest 消息是针对预期期限的并且如果 logOk,则追随者接受该消息并调用 AppendEntries 函数(在幻灯片 118 上定义)以将条目添加到其自己的日志中。然后它向领导者回复一条 LogResponse 消息,其中包含跟随者的 ID、当前期限、收到的日志条目数量的确认以及指示 LogRequest 成功的值 true。如果消息来自过时的术语或 logOk 为 false,则跟随者回复包含 false 的 LogResponse 以指示错误。
幻灯片 118 显示了 AppendEntries 函数,跟随者调用该函数以使用从领导者接收到的条目来扩展其日志。 logLength 是新条目之前的日志条目数(从日志开头开始计数)。如果追随者的日志已经包含 log[logLength] 及以后的条目,它会将现有条目的期限与从领导者那里收到的第一个新条目的期限进行比较。如果它们不一致,则跟随者通过截断日志来丢弃这些现有条目。如果现有的日志条目来自以前的领导者,则会发生这种情况,而该领导者现在已被新的领导者取代。
接下来,任何尚未出现在关注者日志中的新条目都将附加到日志中。在消息复制的情况下,此操作是幂等的,因为新条目被理解为从日志中的索引 logLength 开始
最后,follower检查LogRequest消息中的整数leaderCommit是否大于其局部变量commitLength。如果是这样,这意味着新记录已准备好提交并交付给应用程序。领导者将其 commitLength 向前移动,并在适当的日志条目中执行消息的总顺序广播传递。
从追随者的角度来看,这完成了算法。剩下的就是切换回领导者,并展示它如何处理来自追随者的 LogResponse 消息(参见幻灯片 119)。
接收到 LogResponse 消息的领导者首先检查消息中的任期:如果发送者的任期晚于接收者的任期,则意味着已经开始了新的领导者选举,因此该节点从领导者过渡到跟随者。带有过时术语的消息将被忽略。对于具有正确术语的消息,我们检查成功布尔字段以查看关注者是否接受了日志条目。
如果success = true,leader更新sendLength和ackedLength以反映follower确认的日志条目数,然后调用CommitLogEntries函数(幻灯片120)。如果success = false,我们知道follower 不接受日志条目,因为它的logOk 变量为false。在这种情况下,领导者会减少该追随者的 sentLength 值,并调用 ReplicateLog 以重试从较早的日志条目开始发送 LogRequest 消息。这可能会发生多次,但最终领导者将向追随者发送一组条目,这些条目干净地扩展了追随者的现有日志,此时追随者将接受 LogRequest。 (可以优化该算法以减少重试次数,但在本课程中,我们将避免使其过于复杂。)
最后,幻灯片 120 展示了领导者如何确定要提交的日志条目。我们将函数 acks(length) 定义为一个整数,即从日志开始计数的日志条目数。此函数返回已确认收到或更多长度日志条目的节点数。
CommitLogEntries 使用此函数来确定有多少日志条目已被多数或更多法定节点确认。变量 ready 包含准备提交的日志前缀长度集,如果 ready 为非空,max(ready) 是我们可以提交的最大日志前缀长度。如果这超过了 commitLength 的当前值,则意味着有新的日志条目现在可以提交,因为它们已被足够多的节点确认。现在,每个日志条目中的消息都被传递到领导者上的应用程序,并且 commitLength 变量被更新。在领导者发送给追随者的下一条 LogRequest 消息中,将包含新的 commitLength 值,从而导致追随者提交和传递相同的日志条目。