原文传送门:Students’ Guide to Raft

此文章为原文翻译,非本人原创文章!此外,翻译内的人称均和原文保持一致!

在过去的几个月里,我一直是麻省理工学院 6.824: Distributed Systems 课程的助教。传统上,该课程有许多基于 Paxos 共识算法的实验,但今年,我们决定转向 Raft。Raft 的设计“易于理解”,我们希望这种改变可以让学生的生活更轻松。

这篇文章以及随附的 Instructors’ Guide to Raft 文章记录了我们使用 Raft 的旅程,希望对 Raft 协议的实现者和试图更好地理解 Raft 内部结构的学生有用。如果您正在寻找 Paxos 与 Raft 的比较,或者想要对 Raft 进行更多的教学分析,您应该阅读 Instructors’ Guide。这篇文章的底部包含 6.824 名学生常见的问题列表,以及这些问题的答案。如果您遇到本文主要内容中未列出的问题,请查看 Q&A。这篇文章很长,但它提出的所有观点都是很多 6.824 名学生(和助教)遇到的实际问题。这篇文章值得一读。

1. 背景

在我们深入研究 Raft 之前,一些上下文可能很有用。6.824 曾经有一组基于 Paxos 的实验,这些实验是用 Go 构建的;之所以选择 Go,是因为它对学生来说很容易学习,而且非常适合编写并发的分布式应用程序(goroutines 特别方便)。在四个实验的过程中,学生们构建了一个容错的分片键值存储。第一个实验让他们构建了一个基于共识的日志库,第二个实验室在此基础上添加了一个键值存储,第三个实验室在多个容错集群之间分片了键空间,并由一个容错分片主机处理配置更改。我们还有第四个实验,学生必须在其中处理机器的故障和恢复,无论磁盘是否完好,该实验室可作为学生的默认期末项目。

今年,我们决定使用 Raft 重写所有这些实验。前三个实验室都是一样的,但是第四个实验室被放弃了,因为持久性和故障恢复已经内置在 Raft 中。本文将主要讨论我们在第一个实验中的经验,因为它是与 Raft 最直接相关的一个,但我还将涉及在 Raft 之上构建应用程序(如在第二个实验中)。

对于那些刚刚了解 Raft 的人来说,Raft 最好由协议网站上的文本描述:

Raft is a consensus algorithm that is designed to be easy to understand. It’s equivalent to Paxos in fault-tolerance and performance. The difference is that it’s decomposed into relatively independent subproblems, and it cleanly addresses all major pieces needed for practical systems. We hope Raft will make consensus available to a wider audience, and that this wider audience will be able to develop a variety of higher quality consensus-based systems than are available today.

Raft 是一种旨在易于理解的共识算法。它在容错性和性能上与 Paxos 相当。不同之处在于它被分解为相对独立的子问题,并且清晰地解决了实际系统所需的所有主要部分。我们希望 Raft 能够将共识提供给更广泛的受众,并且这些更广泛的受众将能够开发出比现在可用的各种更高质量的基于共识的系统。

这样的可视化很好地概述了协议的主要组成部分,并且该论文对为什么需要各种部分提供了很好的直觉。如果你还没有阅读过 extended Raft 论文,那么在继续本文之前你应该先阅读它,因为我假设你对 Raft 相当熟悉。

与所有分布式共识协议一样,细节非常重要。在没有故障的稳定状态下,Raft 的行为很容易理解,并且可以用直观的方式来解释。例如,从可视化中很容易看出,假设没有失败,最终会选出一个领导者,最终发送给领导者的所有操作都将由跟随者以正确的顺序应用。但是,当引入延迟消息、网络分区和故障服务器时,每一个 if、but、和 and 都变得至关重要。特别是,由于阅读论文时的误解或疏忽,我们一遍又一遍地看到了许多错误。这个问题并不是 Raft 独有的,而是在所有提供正确性的复杂分布式系统中都会出现的问题。

2. 实现 Raft

Raft 的终极指南在 Raft 论文的图 2 中。该图指定了 Raft 服务器之间交换的每个 RPC 的行为,给出了服务器必须维护的各种不变量,并指定了何时应该发生某些操作。我们将在本文的其余部分大量讨论图 2。它需要信守承诺。

查看 Raft 中的图 2

中文翻译版:https://gukaifeng.cn/posts/raft-lun-wen-yue-du-bi-ji/Raft_Figure_2_Chinese.png
英文原版:https://gukaifeng.cn/posts/raft-lun-wen-yue-du-bi-ji/Raft_Figure_2.png

图 2 定义了每个服务器在任何状态下对每个传入的 RPC 应该做什么,以及何时应该发生某些其他事情(例如何时可以安全地应用日志中的条目)。起初,您可能倾向于将图 2 视为一种非正式指南;你读过一次,然后开始编写一个大致遵循它所说的实现的实现。这样做,您将快速启动并运行大部分工作的 Raft 实现,然后问题就开始了。

事实上,图 2 非常精确,它所做的每一个陈述都应该按照规范的术语来处理,就像必须,而不是应该。例如,当您收到 AppendEntriesRequestVote RPC 时,您可能会合理地重置对等点的选举计时器,因为两者都表明其他对等点要么认为它是领导者,要么正试图成为领导者。直观地说,这意味着我们不应该干涉。但是,如果您仔细阅读图 2,它会说:

If election timeout elapses without receiving AppendEntries RPC from current leader or granting vote to candidate: convert to candidate.

如果选举超时过去了,没有从当前领导者那里收到 AppendEntries RPC,也没有给候选人投票:转换为候选人。

事实证明,这种区别很重要,因为在某些情况下,前一种实现可能会导致活跃度显着降低。

2.1. 细节的重要性

为了使讨论更具体,让我们考虑一个让 6.824 名学生绊倒的例子。Raft 论文在很多地方都提到了心跳(heartbeat) RPCs。具体来说,领导者会偶尔(每个心跳间隔至少一次)向所有对等方发送 AppendEntries RPC,以防止它们开始新的选举。如果领导者没有新条目要发送给特定对等方,则 AppendEntries RPC 不包含任何条目,并被视为心跳。

我们的许多学生认为心跳在某种程度上是“特殊的”。当对等点收到心跳时,它应该与非心跳 AppendEntries RPC 区别对待。特别是,许多人会在收到心跳时简单地重置他们的选举计时器,然后返回成功,而不执行图 2 中指定的任何检查,这是非常危险的。通过接受 RPC,追随者隐含地告诉领导者它们的日志与领导者的日志匹配,直到并包括 AppendEntries 参数中包含的 prevLogIndex。在收到回复后,领导者可能会(错误地)决定某些条目已被复制到大多数服务器,并开始提交它。

许多人遇到的另一个问题(通常在解决上述问题后立即发生)是,在收到心跳后,他们会在 prevLogIndex 之后截断追随者的日志,然后附加任何包含在 AppendEntries 参数中的条目。 这也是不正确的。我们可以再次转向图 2:

If an existing entry conflicts with a new one (same index but different terms), delete the existing entry and all that follow it.

如果现有条目与新条目冲突(索引相同但任期不同),删除现有条目及其后面的所有条目。

这里的如果(if)很关键。 如果追随者拥有领导者发送的所有条目,则追随者不得截断其日志。追随者必须保留领导者发送的条目之后的任何元素。这是因为我们可能会从领导者那里收到一个过时的 AppendEntries RPC,并且截断日志意味着“收回”我们可能已经告诉领导者我们在日志中的条目。

3. 调试 Raft

不可避免地,您的 Raft 实现的第一次迭代将是错误的,第二个也会,以及第三。第四次都会。一般来说,每一个都会比前一个错误更少,并且根据经验,您的大多数错误将是由于不忠实地遵循图 2 造成的。

在调试 Raft 时,通常有四个主要的错误来源:活锁、不正确或不完整的 RPC 处理程序、未遵循规则以及任期混淆。死锁也是一个常见问题,但通常可以通过记录所有锁和解锁来调试它们,并确定您正在使用哪些锁,但不释放。让我们依次考虑每一个。

3.1. 活锁

当您的系统活锁时,系统中的每个节点都在做某事,但是您的节点总体上处于这样一种状态,即程序没有取得任何进展。这在 Raft 中很容易发生,特别是如果您不认真地遵循图 2。一种活锁场景尤其经常出现:没有领导者被选举,或者一旦选举了领导者,其他节点开始选举,迫使最近选举的领导者立即退位。

出现这种情况的原因有很多,但是我们看到许多学生犯了一些错误:

  • 确保在图 2 中正确重置选举计时器。具体来说,只有在以下情况下,您才应该重新启动选举计时器:a)您从当前领导者那里获得 AppendEntries RPC(即,如果 AppendEntries 参数中的任期已过时,则应重置计时器);b) 您正在开始选举;或 c) 您给另一个同行投票

    最后一种情况在不可靠的网络中尤其重要,因为网络中的追随者可能有不同的日志;在这些情况下,您通常会得到大多数服务器愿意投票支持的少数服务器。如果您在有服务器要求您为他们投票时重置选举计时器,这使得日志过时的服务器与日志较长的服务器一样有可能向前迈进。

    事实上,由于很少有服务器拥有足够的最新日志,这些服务器不太可能能够在足够平静的情况下举行选举以被选举。如果按照图 2 中的规则,日志更新的服务器不会被过时服务器的选举打断,因此更有可能完成选举并成为领导者。

  • 请按照图 2 的指示确定何时开始选举。特别要注意,如果您是候选人(即您当前正在进行选举),但选举计时器触发,您应该开始另一次选举。这对于避免由于 RPC 延迟或丢弃而导致系统停止很重要。

  • 确保在处理传入 RPC 之前遵循“服务器规则”中的第二条规则。第二条规则规定:

    If RPC request or response contains term T > currentTerm: set currentTerm = T, convert to follower (§5.1)

    如果 RPC 请求或响应中包含任期 T > currentTerm:设置 currentTerm = T,转换为追随者(§5.1)

    例如,如果你已经在当前任期内投票过了,但一个传入的 RequestVote RPC 的任期比你高,你应该下台并采用他们的任期(因此重置 votedFor),然后处理 RPC,这将导致你给予投票!

3.2. 不正确的RPC处理程序

尽管图 2 准确地说明了每个 RPC 处理程序应该做什么,但仍然很容易忽略一些细微之处。以下是我们一遍又一遍地看到的少数几个,你应该在你的实现中留意:

  • 如果某个步骤显示 “reply false”,这意味着您应该立即回复,并且不要执行任何后续步骤。
  • 如果你得到一个 AppendEntries RPC,它的 prevLogIndex 指向你的日志末尾之外,你应该像你有那个条目但是其任期不匹配一样来处理它(即,回复 false)。
  • 检查 2 是否应该执行 AppendEntries RPC 处理程序,即使领导者没有发送任何条目
  • AppendEntries 的最后一步(#5)中的 min必要的,它需要与最后一个条目的索引一起计算。当到达日志末尾时,仅让函数在 lastAppliedcommitIndex 之间停止应用日志中的内容是够的。这是因为在领导者发送给您的条目之,您的日志中的条目可能与领导者的日志不同(它们都与您的日志中的条目相匹配)。因为 #3 规定只有在条目冲突时才截断日志,这些条目不会被删除,如果 leaderCommit 超出了领导者发送给您的条目,您可能会应用不正确的条目。
  • 完全按照第 5.4 节中的描述实施“最新日志”检查非常重要。不作弊,只检查长度!

3.3. 未遵守规则

虽然 Raft 论文非常明确地说明了如何实现每个 RPC 处理程序,但它也未指定许多规则和不变量的实现。这些列在图 2 右侧的“服务器规则”块中。虽然其中一些是不言自明的,但也有一些需要非常仔细地设计您的应用程序,以免违反规则:

  • 如果在执行期间的任何时候 commitIndex > lastApplied,您应该应用特定的日志条目。立即执行并不重要(例如,在 AppendEntries RPC 处理程序中),但重要的是确保此应用程序仅由一个实体执行。具体来说,您将需要有一个专用的“应用器(applier)”,或者锁定这些应用程序,以使其他一些例程不会也检测到需要应用的条目并尝试应用。
  • 确保定期检查 commitIndex > lastApplied,或者在 commitIndex 更新后(即 matchIndex 更新后)。例如,如果您在向对等方发送 AppendEntries 的同时检查 commitIndex,您可能必须等到下一个条目追加到日志后,才能应用您刚刚发送并得到确认的条目。
  • 如果一个领导者发出一个 AppendEntries RPC,它被拒绝了,但不是因为日志不一致(这只有在我们的任期已过时才会发生),那么你应该立即下台,而不是更新 nextIndex。 如果您这样做,如果您立即重新选举,您可以使用重置的 nextIndex 竞争领导者。
  • 不允许领导者将 commitIndex 更新到上一个任期(或者,就此而言,未来任期)的某个地方。 因此,正如规则所说,您特别需要检查 log[N].term == currentTerm。 这是因为 Raft 领导者无法确定一个条目是否确实已提交(并且将来永远不会更改),如果它不是来自他们当前的任期。论文中的图 8 对此进行了说明。

一个常见的混淆来源是 nextIndexmatchIndex 之间的区别。 特别是,您可能会观察到 matchIndex = nextIndex - 1,而根本没有实现 matchIndex。 这是不安全的。 虽然 nextIndexmatchIndex 通常同时更新为相似的值(具体而言,nextIndex = matchIndex + 1),但两者的用途截然不同。nextIndex 是对领导者与给定追随者共享什么前缀的猜测。它通常非常乐观(我们分享一切),并且仅在负面回应时才向后移动。例如,当一个领导者刚刚被选举出来时,nextIndex 被设置为日志末尾的索引索引。在某种程度上,nextIndex 是用于执行的——你只需要将这些东西发送给这个对等点。

matchIndex 用于安全。它是对领导者与给定追随者共享日志前缀的保守度量matchIndex 永远不能设置为太高的值,因为这可能会导致 commitIndex 向前移动太远。这就是为什么 matchIndex 被初始化为 -1(即我们同意不使用前缀),并且仅在追随者肯定地确认 AppendEntries RPC 时更新。

3.4. 任期混淆

任期混淆是指服务器被来自旧任期的 RPC 混淆。一般来说,接收 RPC 时这不是问题,因为图 2 中的规则准确地说明了当您看到旧任期时应该做什么。但是,图 2 通常不会讨论在收到旧的 RPC 的回复时应该做什么。根据经验,我们发现目前为止最简单的做法是先记录回复中的任期(可能高于您当前的任期),然后将当前任期与您在原始 RPC 中发送的任期进行比较。如果两者不同,请放弃回复并返回。只有当这两个任期相同时,您才能继续处理回复。您可以在这里通过一些巧妙的协议推理进行进一步优化,这种方法似乎效果很好,这样做会导致一条漫长而曲折的血腥、汗水、眼泪和绝望之路。

一个相关但不完全相同的问题是,假设您的状态在您发送 RPC 和收到回复之间没有改变。一个很好的例子是在收到对 RPC 的响应时设置 matchIndex = nextIndex - 1matchIndex = len(log)。这是安全的,因为这两个值都可能在您发送 RPC 后已更新。相反,正确的做法是根据您最初在 RPC 中发送的参数将 matchIndex 更新为 prevLogIndex + len(entries[])

3.5. 说说优化

Raft 论文包括几个有趣的可选特性。在 6.824 中,我们要求学生实现其中两个:日志压缩(Log Compaction,论文第 7 节)和加速日志回溯(论文第 8 页左上角)。前者对于避免日志无限制增长是必要的,后者对于快速更新旧的追随者很有用。

这些特性不是“Raft 核心”的一部分,所以在论文中没有像主要共识协议一样受到关注。日志压缩被相当彻底覆盖(在图13中),但遗漏了一些设计细节,如果您过于随意地阅读,可能会错过这些细节:

  • 在给应用程序状态创建快照时,您需要确保应用程序状态与 Raft 日志中的某些已知索引相对应。这意味着该应用程序要么需要进行通信来知道快照对应的索引是什么,要么 Raft 需要延迟应用其他日志条目,直到快照完成为止。

  • 本文没有讨论服务器崩溃和恢复时的恢复协议,因为现在涉及到快照。特别是,如果分别提交 Raft 状态和快照,服务器可能会在持久化快照和持久化更新的 Raft 状态之间崩溃。这是一个问题,因为图 13 中的步骤 7 规定快照覆盖的 Raft 日志必须被丢弃

    当一个服务器恢复时,如果它读取更新的快照,但读取过期的日志,那么它可能最终应用快照中已经包含的一些日志条目。发生这个问题是由于 commitIndexlastApplied 没有持久化,所以 Raft 不知道这些日志条目已经应用了。解决这个问题的方法是向 Raft 引入一个持久状态,记录 Raft 持久日志中的第一个条目对应的“真实”索引。然后可以将其与加载的快照的 lastIncludedIndex 进行比较,以确定要丢弃日志头部的哪些条目。

加速日志回溯优化并没有详细说明,这可能是因为作者认为大多数部署都不需要它。从文本中并不清楚从客户端返回的冲突索引和任期应该如何被领导者用来确定使用什么 nextIndex。我们认为作者可能希望你遵循的协议是:

  • 如果追随者的日志中没有 preLogIndex,它应该返回 conflictIndex = len(log)conflictTerm = None
  • 如果追随者的日志中有 preLogIndex,但是任期不匹配,它应该返回 conflictTerm = log[preLogIndex].Term,然后在它的日志中搜索任期等于 conflictTerm 的第一个条目索引。
  • 在收到一个冲突响应后,领导者首先应该搜索其日志中任期为 conflictTerm 的条目。如果领导者在其日志中找到此任期的一个条目,则应该设置 nextIndex 为其日志中此任期的最后一个条目的索引的下一个。
  • 如果领导者没有找到此任期的条目,则应该设置 nextIndex = conflictIndex

一个折中的解决方案是,只使用 conflictIndex(忽略 conflictTerm),简化实现,但是这样领导者有时将最终向追随者发送更多的日志条目,而不是严格使它们保持最新的必要条件。

4. Raft 上的应用程序

当在 Raft 上构建服务时(例如 second 6.824 Raft lab 中的键/值存储,服务与 Raft 日志之间的交互可能很棘手。此部分详细介绍了您的开发过程的一些方面,您在构建应用程序时可能会发现有用。

4.1. 应用客户端操作

您可能会对如何根据复制日志来实现应用程序感到困惑。你可以从让你的服务开始,每当它接收到客户端请求时,将请求发送给领导者,等待 Raft 应用某些东西,执行客户端请求的操作,然后返回给客户端。虽然这在单客户端系统中会很好,但它不适用于并发客户端。

相反,服务应该被构建为一个状态机,客户端操作将状态机从一种状态转换到另一种状态。您应该在某处有一个循环,该循环同时接受一个客户端操作(在所有服务器上以相同的顺序 — 这就是 Raft 出现的地方),并按顺序将每个操作应用于状态机。这个循环应该是代码中唯一触及应用程序状态的部分(6.824 中的键/值映射)。 这意味着你的面向客户端的 RPC 方法应该简单地将客户端的操作提交给 Raft,然后等待该操作被这个“应用器(applier)循环”应用。只有当客户端的命令出现时,它才会被执行,并读出任何返回值。 请注意,这包括读取请求

这就引出了另一个问题:您如何知道客户端操作何时完成? 在没有失败的情况下,这很简单 — 您只需等待放入日志的内容返回(即传递给 apply())。发生这种情况时,您会将结果返回给客户端。但是,如果出现故障怎么办? 例如,当客户端最初联系您时,您可能是领导者,但后来又选举了其他人,您放入日志的客户请求已被丢弃。显然你需要让客户再试一次,但你怎么知道什么时候告诉他们错误呢?

解决此问题的一种简单方法是在记录在 Raft 日志插入客户端操作时该操作的索引。一旦该索引处的操作被发送到 apply(),您可以根据针对该索引执行的操作是否确实是您放在那里的操作来判断客户端的操作是否成功。如果不是,就说明发生了故障,可以将错误返回给客户端。

4.2. 复制检测

一旦您的客户端在遇到错误时重试操作,您就需要某种重复检测方案 — 如果客户端向您的服务器发送 APPEND,没有收到回复,然后将其重新发送到下一个服务器,您的 apply() 函数需要确保 APPEND 不会执行两次。为此,您需要为每个客户端请求提供某种唯一标识符,以便您可以识别过去是否见过,更重要的是,是否应用过特定操作。此外,此状态需要成为您的状态机的一部分,以便您的所有 Raft 服务器消除相同的重复项。

有许多分配此类标识符的方法。一种简单且相当有效的方法是给每个客户端一个唯一的标识符,然后让他们用一个单调递增的序列号标记每个请求。如果客户端重新发送请求,它会重新使用相同的序列号。您的服务器跟踪它为每个客户端看到的最新序列号,并简单地忽略它已经看到的任何操作。

4.3. Hairy corner-cases

如果您的实现遵循上面给出的一般大纲,那么您可能会遇到至少两个微妙的问题,如果不进行一些认真的调试,可能很难识别。为了节省您一些时间,这里有:

重新出现索引(Re-appearing indices) : 假设您的 Raft 库有一些方法 Start() 接受命令,并返回该命令在日志中放置的索引(以便您知道何时返回客户端,如上所述)。您可能会假设您永远不会看到 Start() 返回相同的索引两次,或者至少两次,如果您再次看到相同的索引,则第一次返回该索引的命令一定失败了。事实证明,这些假设不成立,即使没有服务器崩溃。

考虑下面的场景,有服务器 S1 ~ S5。起初,S1 是领导者,其日志是空的。

  1. 两个客户端操作(C1 和 C2)到达 S1。
  2. Start() 给 C1 返回 1,给 C2 返回 2。
  3. S1 发送包含 C1 和 C2 的 AppendEntries 给 S2,但是所有其他的消息都丢失了。
  4. S3 以候选人身份继续推进。
  5. S1 和 S2 不会给 S3 投票,但是 S3、S4 和 S5 会投票,所以 S3 成为了领导者。
  6. 另一个客户端请求 C3 到达,进入 S3。
  7. S3 调用 Start()(返回 1)。
  8. S3 发送 AppendEntries 给 S1,S1 丢弃 其日志中的 C1 和 C2,添加 C3。
  9. 在给其他服务器发送 AppendEntries 前,S3 故障了。
  10. S1 继续向前推进,因为它日志是最新的,它当选领导者。
  11. 另一个客户端请求 C4 到达 S1。
  12. S1 调用 Start(),返回 2(Start(C2) 也返回 2)。
  13. S1 的所有 AppendEntries 都被丢弃,S2 继续向前推进。
  14. S1 和 S3 不会给 S2 投票,但 S2、S4 和 S5 会,S2 成为领导者。
  15. 一个客户端请求 C5 进入 S2。
  16. S2 调用 Start(),其返回 3。
  17. S2 成发送了 AppendEntries 到所有服务器,S2 通过在下一个心跳中包含更新的 leaderCommit = 3 向各个服务器报告。

由于 S2 的日志是 [C1 C2 C5],这意味着在索引 2 处提交的条目是 C2(并且在所有服务器上已经应用的,包括 S1)。尽管 C4 是最后一次返回 S1 索引 2 的客户操作,但这是事实。

四向死锁(The four-way deadlock) : 发现这一点的所有功劳都归功于 Steven Allen,另一个 6.824 TA 。他发现了以下令人讨厌的四向死锁,当您在 Raft 之上构建应用程序时,您很容易遇到这些死锁。

您的 Raft 代码,无论其结构如何,都可能具有类似 Start() 的函数,允许应用程序向 Raft 日志添加新命令。它也可能有一个循环,当 commitIndex 更新时,在应用程序上为 lastAppliedcommitIndex 之间的每个元素调用 apply()。这些例程可能都需要一些锁 a。在你的基于 Raft 的应用程序中,你可能会在 RPC 处理程序中的某个地方调用 Raft 的 Start() 函数,并且你在其他地方有一些代码,当 Raft 应用新的日志条目时,这些代码就会被通知。由于这两者需要通信(即 RPC 方法需要知道它放入日志的操作何时完成),它们可能都需要一些锁 b

在 Go 中,这四个代码段可能看起来像这样:

1
2
3
4
5
6
7
8
9
func (a *App) RPC(args interface{}, reply interface{}) {
// ...
a.mutex.Lock()
i := a.raft.Start(args)
// update some data structure so that apply knows to poke us later
a.mutex.Unlock()
// wait for apply to poke us
return
}
1
2
3
4
5
6
7
func (r *Raft) Start(cmd interface{}) int {
r.mutex.Lock()
// do things to start agreement on this new command
// store index in the log where cmd was placed
r.mutex.Unlock()
return index
}
1
2
3
4
5
6
7
8
9
10
11
func (a *App) apply(index int, cmd interface{}) {
a.mutex.Lock()
switch cmd := cmd.(type) {
case GetArgs:
// do the get
// see who was listening for this index
// poke them all with the result of the operation
// ...
}
a.mutex.Unlock()
}
1
2
3
4
5
6
7
8
9
10
11
func (r *Raft) AppendEntries(...) {
// ...
r.mutex.Lock()
// ...
for r.lastApplied < r.commitIndex {
r.lastApplied++
r.app.apply(r.lastApplied, r.log[r.lastApplied])
}
// ...
r.mutex.Unlock()
}

现在考虑如果系统处于下面的状态:

  • App.RPC 只获得了 a.mutex 并调用了 Raft.Start
  • Raft.Start 正在等待 r.mutex
  • Raft.AppendEntries 正持有 r.mutex,且刚刚调用 App.apply

现在,我们的程序死锁了,因为:

  • Raft.AppenEntriesApp.apply 返回前不会释放锁。
  • App.apply 在其获得 a.mutex 前不会返回。
  • a.mutexApp.RPC 返回前不会被释放。
  • Raft.Start 返回前,App.RPC 不会返回。
  • 在获得 r.mutex 前,Raft.Start 不会返回。
  • Raft.Start 必须等待 Raft.AppendEntries

有几种方法可以解决这个问题。最简单的就是在 App.RPC 中调用 a.raft.Start 后获取 a.mutex。然而,这意味着在 App.RPC 有机会记录它希望被通知的事实之前,可能会为 App.RPC 刚刚调用 Raft.Start 的操作调用 App.apply。另一种可能产生更简洁设计的方案是有一个从 Raft 调用 r.app.apply 的单个专用线程。 每次更新 commitIndex 时都可以通知该线程,然后无需持有锁即可应用,从而打破死锁。