学生筏板指南 (30分钟读取)

发布于-在上共享黑客新闻 推特 龙虾

在过去的几个月里,我一直是麻省理工学院的助教6.824分布式系统类。该班传统上在帕克索斯上建造了许多实验室共识算法,但今年,我们决定。筏“设计得很容易理解”,我们希望这种改变能使学生生活更轻松。

这篇文章以及随行人员救生筏教练指南张贴,记录我们的旅程希望对Raft的实施者有用协议和学生试图更好地理解Raft的内部构件。如果您正在寻找Paxos与Raft的比较,或关于Raft的更多教学分析,你应该去读一下讲师的指南。这篇文章的底部通常包含一系列问题由6.824名学生提出,以及这些问题的答案。如果你遇到了本文主要内容中未列出的问题,查看问答。帖子是很长,但它提出的所有要点都是真正的问题6.824名学生(和助教)遇到。这是一本值得一读的书。

背景

在我们深入了解Raft之前,一些上下文可能会有用。6.824过去有一套基于Paxos实验室那是内置去吧; Go之所以被选中,是因为它是学生容易学习,因为非常适合编写并发的分布式应用程序(goroutine的出现特别方便)。在四个实验室的过程中,学生们建立了一个容错、分片键值存储。第一个实验室让他们建造了一个基于共识的日志库,第二个在而第三个则在多个容错之间划分了关键空间集群,具有容错碎片主处理配置变化。我们还有第四个实验室,学生们必须在其中处理机器的故障和恢复,包括带磁盘和不带磁盘的机器完整的。这个实验室是学生默认的期末项目。

今年,我们决定使用Raft重写所有这些实验室。第一个三个实验室都一样,但第四个实验室因Raft中已经内置了持久性和故障恢复功能。这个本文将主要讨论我们在第一个实验室的经验与Raft最直接相关的一个在筏板顶部构建应用程序(如第二个实验室)。

对于你们这些刚刚开始了解的人来说,筏是最好的由协议上的文本描述网状物网站:

Raft是一种共识算法,其设计易于理解。它在容错和性能。不同之处在于,它被分解为相对独立的子问题,它清晰地解决了所有主要问题实际系统需要。我们希望Raft能达成共识面向更广泛的受众,而这一更广泛的观众将能够开发各种高质量的基于共识的系统比今天可用的要多。

可视化,如这个很好地概述了协议的主要组成部分,以及这篇论文很好地解释了为什么需要各种各样的作品。如果你还没有读过加长筏纸张,你应该去读一下在继续这篇文章之前,我假设您已经非常熟悉了用筏。

与所有分布式一致性协议一样,魔鬼在很大程度上详细信息。在没有故障的稳定状态下,Raft行为很容易理解,可以直观地解释方式。例如,很容易从可视化中看到,假设没有失败,最终会选出一位领导人最终,发送给领导的所有操作都将由按照正确的顺序跟随。然而,当消息延迟时,网络引入了分区和故障服务器,每个if,但是,并且变得至关重要。特别是,有许多错误我们看到一次又一次的重复,仅仅是因为误解或阅读报纸时的疏忽。这个问题并非Raft独有,它出现在所有复杂的分布式系统中正确性。

实施筏

筏的最终指南如筏纸的图2所示。这个数字指定Raft服务器之间交换的每个RPC的行为,提供服务器必须维护的各种不变量,并指定何时应该采取某些行动。我们将讨论图2很多在本文的其余部分中。需要遵循不折不扣.

图2定义了每个服务器在任何状态下都应该为每个传入RPC,以及当某些其他事情应该发生时(例如当可以安全地在日志中应用条目时)。起初,你可能是试图将图2视为一种非正式的指南;你读过了吗一次,然后开始对大致遵循的实现进行编码按照它所说的去做。这样做,你会很快起床并开始跑步一个主要工作的Raft实现。然后问题开始了。

事实上,图2非常精确,每个语句就规范而言,其制造应被视为必须,不是作为应该例如,您可以合理地重置同级的选举计时器,只要您收到附加条目RequestVote(请求投票)RPC,作为这两者都表明其他同龄人要么认为自己是领导者,要么努力成为领导者。直觉上,这意味着我们不应该干扰。然而,如果你仔细阅读图2,它会说:

如果选举超时而未收到附加条目RPC(RPC)来自现任领导授予投票给候选人:转换为候选人。

这一区别非常重要,因为前一个实现在某些情况下会导致活跃度显著降低。

细节的重要性

为了使讨论更具体,让我们考虑一个例子绊倒了6.824名学生。Raft报纸提到心跳RPC在许多地方。具体来说,领导者将偶尔(每个心跳间隔至少一次)发出附加条目RPC到所有对等方,以防止他们启动新的选举。如果领导没有新条目要发送给特定的同事,这个附加条目RPC不包含任何条目,被视为心跳。

我们的许多学生都认为心跳在某种程度上是“特殊的”;当对等端收到心跳信号时,应该区别对待来自非心跳附加条目RPC。特别是,许多人会当他们收到心跳信号时,只需重置他们的选举计时器然后返回成功,而不执行中指定的任何检查图2。这是极其危险。通过接受RPC追随者含蓄地告诉领导者,他们的日志与领导日志,包括上一个日志索引包含在附加条目论据。收到回复后,领导可能然后确定(错误地)某些条目已复制到大多数服务器,并开始提交。

许多人遇到的另一个问题(通常是在解决上述问题后立即出现),就是说,一旦收到心跳信号,他们就会截断追随者的日志跟踪上一个日志索引,然后追加中包含的任何条目这个附加条目论据。这是不正确。我们可以一次再次转到图2:

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

这个如果这一点至关重要。如果跟随者拥有所有条目,则领导发送,追随者不能截断其日志。任何元素下列的领导者发送的条目必须保留。这是因为我们可能会收到过期的附加条目来自的RPC而截断日志意味着“取回”我们可能已经告诉领导我们的日志中有。

调试筏

不可避免的是,Raft实现的第一次迭代将是婴儿车。第二个也会。第三。第四。一般来说,每个将比上一次少一些小汽车,而且从经验来看,大多数您的错误将是不忠实地遵循图2的结果。

在调试Raft时,通常有四个主要的错误来源:活锁、不正确或不完整的RPC处理程序、未能遵循规则和术语混淆。死锁也是一个常见问题,但通常可以通过记录所有锁和解锁来进行调试,并且弄清楚你拿了哪些锁,但没有松开。让我们一起依次考虑以下每一项:

牲畜

当系统活动锁定时,系统中的每个节点一些东西,但您的节点整体处于这样一种状态:正在取得进展。这在Raft很容易发生,特别是如果你没有认真地遵循图2。一个活锁场景经常出现;没有领导人被选举,或者只有一次一个领导者当选,另一个节点开始选举,迫使最近当选的领导人立即辞职。

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

RPC处理程序不正确

尽管图2明确说明了每个RPC处理程序应该做什么,有些微妙之处仍然很容易被忽略。以下是我们保留的一些一遍又一遍地看,你应该留心您的实施:

未能遵守规则

虽然Raft文件非常明确地说明了如何实现每个RPC处理程序,它还留下了一些规则和不变量未指定。这些列在“服务器规则”中图2右侧的块。虽然其中一些相当不错自我解释,也有一些需要设计您的非常小心地应用,以免违反规则:

混淆的一个常见原因是下一个索引matchIndex(匹配索引)特别是,您可能会注意到matchIndex(匹配索引)=下一个索引-1,并且根本没有实现matchIndex(匹配索引)。这不安全。While期间下一个索引matchIndex(匹配索引)通常在同一时间更新时间达到类似值(具体来说,nextIndex=matchIndex+1),这两者的目的截然不同。下一个索引是一个猜测关于领导者与给定追随者共享的前缀是什么。一般来说相当乐观(我们分享一切),只在负面反应。例如,当一位领导人刚刚当选时,下一个索引设置为日志末尾的索引索引。在某种程度上,下一个索引用于性能–您只需发送这些给这个同龄人的东西。

matchIndex(匹配索引)用于安全。这是一个保守派测量属于领导者与给定追随者共享的日志前缀。matchIndex(匹配索引)不能设置为过高的值,因为这可能导致承诺索引向前移动得太远。这就是为什么matchIndex(匹配索引)初始化为-1(即我们同意没有前缀),并且仅当跟随者时更新积极承认一个附加条目RPC。

术语混淆

术语混淆是指服务器被来自旧条款。通常,在接收RPC时这不是问题,因为图2中的规则确切地说明了当您看到时应该做什么一个古老的术语。然而,图2通常没有讨论您应该做什么当你得到旧的RPC时答复根据经验,我们发现到目前为止,最简单的方法是首先在回复中记录该术语(可能高于您当前的任期),然后比较当前术语与您在原始RPC中发送的术语相同。如果两者都是不同的是,放弃回复并返回。仅限如果这两个术语是如果您继续处理回复,也是如此。可能还有更多这里可以通过一些巧妙的协议推理进行优化,但是这种方法似乎很有效。而且这样做会导致长时间,血、汗、泪和绝望的蜿蜒之路。

一个相关但不相同的问题是假设你的状态在发送RPC和收到回复。这方面的一个好例子是设置matchIndex=下一个索引-1,matchIndex=len(log)当您收到RPC的响应时。这个安全,因为这两个值都可以更新自您发送RPC以来。相反,正确的做法是更新matchIndex(匹配索引)成为prevLogIndex+len(条目[])从参数中您最初发送了RPC。

关于优化的旁白

筏纸包括两个有趣的可选功能。6.824,我们要求学生执行其中两项:原木压实(第7节)和加速日志回溯(页面左上角8). 前者是必要的,以避免原木无约束地生长,以及后者对于快速更新过时的追随者很有用。

这些功能不是“核心筏”的一部分,因此不能作为作为主要共识协议,本文备受关注。日志压实过程覆盖得相当彻底(在图13中),但省略了如果你读得太随便,可能会错过一些设计细节:

加速的日志回溯优化非常不明确,可能是因为作者认为这对大多数人来说都是不必要的部署。从文本中不清楚冲突是如何发生的领导应使用客户发回的索引和术语确定什么下一个索引使用。我们相信协议作者可能希望您遵循的是:

半途而废的解决方案是使用冲突索引(并忽略冲突条款),这简化了实现,但领导者有时会向追随者发送更多日志条目而不是让他们及时更新。

筏顶应用

在筏板顶部建造服务时(例如这个第二个6.824筏实验室,的服务和木筏日志之间的交互可能很难实现正确的。本节详细介绍了开发过程的一些方面您可能会发现在构建应用程序时很有用。

应用客户端操作

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

相反,应该将服务构造为状态机哪里客户端操作将机器从一种状态转换到另一种状态。应该在某个地方有一个循环,每次执行一个客户端操作(在所有服务器上以相同的顺序–这就是Raft的用武之地),以及按顺序将每一个应用于状态机。这个循环应该是只有代码中涉及应用程序状态的部分(6.824中的键/值映射)。这意味着您的客户端访问RPC方法只需将客户端的操作提交给Raft,然后等待此“applier循环”应用该操作。仅限当客户端的命令出现时,是否执行该命令,以及任何返回读取值。请注意这包括读取请求!

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

解决此问题的一个简单方法是记录筏式日志中的位置插入时将显示客户端的操作该索引被发送到应用(),您可以判断客户端的操作是否成功取决于出现的操作因为这个索引实际上就是你放在那里的那个。如果不是,那就是失败发生错误,可以向客户端返回错误。

重复检测

一旦客户端在出现错误时重试操作需要某种重复检测方案–如果客户端发送追加发送到服务器,没有回音,并将其重新发送到下一个服务器,您的应用程序()功能需要确保附录不是执行了两次。为此,您需要为每个客户端请求,以便您能够识别是否已看到,以及更重要的是,应用了过去的特定操作。此外,此状态需要成为状态机的一部分,以便所有Raft服务器都会消除相同的重复。

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

毛茸茸的护角

如果您的实现遵循上面给出的概要,那么你可能会遇到至少两个微妙的问题如果没有认真的调试,很难识别。为了节省时间,它们在这里:

重新出现的指数:说你的Raft图书馆有一些方法开始()这需要一个命令,并返回该命令所在的索引日志(以便您知道何时返回客户端,如上所述)。你可能会认为你永远看不到开始()返回相同的索引两次,或者至少一次,如果再次看到相同的索引首先返回该索引的命令一定失败了。事实证明即使没有服务器崩溃,这两件事都不是真的。

考虑以下五个服务器的场景,即S1到S5。最初,S1是前导,其日志为空。

  1. 两个客户端操作(C1和C2)到达S1
  2. 开始()C1返回1,C2返回2。
  3. S1发出附加条目至S2,包含C1和C2,但全部它的其他消息都丢失了。
  4. S3作为候选人向前迈进。
  5. S1和S2不会投票给S3,但S3、S4和S5都会投票,所以S3成为领导者。
  6. 另一个客户端请求C3进入S3。
  7. S3调用开始()(返回1)
  8. S3发送附加条目到S1,S1从其log,并添加C3。
  9. S3在发送前失败附加条目到任何其他服务器。
  10. S1向前推进,因为其日志是最新的,所以选择它领导者。
  11. 另一个客户端请求C4到达S1
  12. S1呼叫开始(),返回2(也为返回启动(C2).
  13. 所有S1附加条目则S2向前迈进。
  14. S1和S3不会投票给S2,但S2、S4和S5都会投票,所以S2成为领导者。
  15. 客户端请求C5传入S2
  16. S2呼叫开始(),返回3。
  17. S2发送成功附加条目到所有服务器,S2通过包含更新的领导委员会=在下一次心跳中。

因为S2的日志是[C1 C2 C5],这意味着提交的条目(并应用于所有服务器,包括S1),索引2为C2。这个尽管C4是最后一个返回的客户端操作S1处的索引2。

四向死锁:找到这个的所有功劳都归于史蒂文艾伦,另一个6.824 TA。他发现了以下内容建造时很容易陷入令人讨厌的四向僵局筏顶部的应用程序。

您的Raft代码,无论其结构如何,都可能具有开始()-像函数,该函数允许应用程序向Raft添加新命令日志。它也可能有一个循环,当承诺索引已更新,电话应用()关于日志中每个元素的应用上次应用时间承诺索引。这些例程可能都需要一些。在基于Raft的应用程序中,您可能会调用Raft的开始()函数的某个位置,并且您有一些在其他地方编写代码,以便Raft应用新日志时通知条目。由于这两者需要通信(即RPC方法需要要知道它放入日志的操作何时完成),它们都可能需要一些锁b条.

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

函数 ( *应用程序) RPC(RPC)(参数 接口{}, 回复 接口{}) {
    // ...
    .互斥.锁定()
     := ..起点(参数)
    //更新一些数据结构,以便apply知道以后再戳我们
    .互斥.解锁()
    //等待申请来戳我们
    返回
}
函数 (第页 *) 起点(cmd公司 接口{}) 整数 {
    第页.互斥.锁定()
    //采取行动就这个新命令达成协议
    //将索引存储在放置cmd的日志中
    第页.互斥.解锁()
    返回 指数
}
函数 ( *应用程序) 应用(指数 整数, cmd公司 接口{}) {
    .互斥.锁定()
    转换 cmd公司 := cmd公司.(类型) {
    案例 获取参数:
        //做得到的事
	//看看谁在收听这个索引
	//把手术结果告诉他们
    // ...
    }
    .互斥.解锁()
}
函数 (第页 *) 附加条目(...) {
    // ...
    第页.互斥.锁定()
    // ...
    对于 第页.上次应用时间 < 第页.承诺索引 {
      第页.上次应用时间++
      第页.应用程序.应用(第页.上次应用时间, 第页.日志[第页.上次应用时间])
    }
    // ...
    第页.互斥.解锁()
}

现在考虑系统是否处于以下状态:

我们现在陷入了僵局,因为:

有几种方法可以解决这个问题。最容易一个是采取a.互斥 之后打电话a.筏。起点在里面应用程序。RPC(RPC).然而,这意味着应用程序可能需要进行操作那个应用程序。RPC(RPC)刚打过电话筏。起点之前 应用程序。RPC(RPC)有一个记录其希望收到通知的事实的机会。另一个可能产生更整洁设计的方案是,专用线程调用r.app.应用。此线程可能是每次通知承诺索引更新,然后不需要持有锁以便应用,打破僵局。