lab2老师给的lab资料
老师给的阅读链接
Raft locking advice
想在6.824用锁,记住这些规则:
规则1:每当你有一个以上的goroutine使用的数据,并且至少有一个goroutine可能会修改这些数据,这些goroutine应该 使用锁来防止同时使用这些数据。Go的race检测器可以很好地检测出违反这一规则的情况(虽然它对下面的任何规则都没有帮助)。
规则2:每当代码对共享数据进行一连串的修改时,如果其他goroutine能共享这些数据,可能会发生错误。这时你应该在整个过程中使用一个锁。
例子:
如果另一个goroutine看到这些更新中的任何一个,那将是一个错误的更新(即the old state with the new term, or the new term with the old state)。所以我们需要在整个更新序列中持续持有锁。所有其他使用rf.currentTerm或rf.state的其他代码也必须持有锁,以确保所有使用的独占访问。
Lock() and Unlock()之间的代码通常被称为 "critical section"。程序员选择的锁定规则(例如,"一个goroutine必须持有rf.mu当使用rf.currentTerm或rf.state时")通常被称为被称为 "锁定协议"。
规则3:每当代码对共享数据进行一连串的读取(或读取或写入),并且如果另一个goroutine修改了该序列的数据,就会发生数据错误。你应该在这整个序列周围使用一个锁。(也就是让同一个锁锁住多个goroutine以此保护shared data)
一个可能发生在Raft RPC处理器中的例子:
这段代码需要在整个序列中持续保持锁。Raft要求currentTerm只能增加,而不能减少。另一个RPC处理程序可以在一个单独的goroutine中执行;如果允许它在if语句和更新到rf.currentTerm之间修改rf.currentTerm,这段代码可能最终会减少rf.currentTerm。因此,锁必须在整个序列中被持续持有。此外,对currentTerm的每一次使用都必须保持锁,以确保在我们的关键部分没有其他goroutine修改currentTerm。
真正的Raft代码需要使用比这些例子更长的critical sections。例如,Raft RPC 处理程序可能应该为整个函数保留lock.
规则4:在做任何可能wait的事情时持有锁通常是个bad idea:读取Go通道,在通道上发送,等待计时器,调用time.Sleep(),或者发送RPC(并等待回复)。一个原因是你可能希望其他goroutines在等待过程中取得进展。另一个原因是避免了死锁。想象一下,两个peers在持有锁的同时互相发送RPC;两个RPC处理程序都需要接收peer的锁;两个RPC处理程序都不能完成,因为它需要等待的RPC调用所持有的锁。(这就死锁了,就是说 c1->c2,c2->c1,他们都需要同一个mu,那就永远不能完成)
code that waits 应该首先释放锁。如果这行不通的话,创建一个单独的goroutine来进行wait是很有用的。
规则5:要小心假设跨越一个锁的drop和重新获取。一个可能出现这种情况的地方是避免在持有锁的情况下进行waiting。例如,这个发送投票RPC的代码是不正确的:
该代码在一个单独的goroutine中发送每个RPC。这是不正确的,因为args.Term可能与周围代码决定成为候选者的rf.currentTerm不一样。从周围的代码创建goroutine到goroutine读取rf.currentTerm之间可能会经过很多时间;例如,多个term可能会换来换去,而peer可能不再是一个候选人。解决这个问题的一个方法是,创建的goroutine使用在外层代码持有锁的时候制作的rf.currentTerm的副本。同样,在Call()之后的回复处理代码必须在重新获得锁之后重新检查所有相关的假设;例如,它应该检查rf.currentTerm在决定成为候选人之后没有改变。
解释和应用这些规则可能很困难。也许最最令人费解的是规则2和3中的代码序列不应该与其他goroutine的读或写交错的概念。要如何识别这种代码序列?
一种方法是,从没有锁的代码开始,然后仔细思考在哪里需要加锁以达到正确性。这种方法可能很困难,因为它需要对并发代码的正确性进行推理。
一个更好的方法是从观察开始的,如果没有并发性(没有同时执行的goroutine),你就根本不需要锁。但是,当RPC系统创建goroutines来执行RPC处理程序时,你的并发性是被迫的,而且因为你需要在不同的goroutines中发送RPC以避免waiting。你可以通过识别所有goroutine开始的地方(RPC处理程序,你在Make()中创建的后台goroutine,等等),在每个goroutine的最开始获得锁,并且只在该goroutine完全完成并返回时释放锁,从而有效地消除这种并发性。这种锁协议确保没有任何重要的东西是并行执行的;锁确保每个goroutine在任何其他goroutine被允许启动之前执行完毕。由于没有并行执行,很难违反规则1、2、3或5。如果每个goroutine的代码在孤立情况下是正确的(当单独执行时,没有并发的goroutine),那么当你使用锁来抑制并发时,它可能仍然是正确的。因此,你可以避免对正确性进行明确的推理,或明确识别关键部分。
然而,规则4很可能是一个问题。因此,下一步是要找到code waits的地方,并根据需要增加锁的释放和重新获取(and/or goroutine的创建)。注意在每次重新获取后都要小心地重新建立假设。你可能会发现,这个过程比直接识别序列更容易正确
(作为一个旁观者,这种方法所牺牲的是通过在多核上的并行执行来提高性能的任何机会。这种方法牺牲了通过在多核上并行执行来提高性能的机会:你的代码可能会在不需要的时候保持锁,因此可能会不必要地禁止goroutine的并行执行。另一方面,在一个单一的Raft peer中,并没有太多的机会实现CPU并行化。)。
Raft Structure Advice
Raft实例必须处理外部事件的到来(Start()调用、AppendEntries和RequestVote RPC以及RPC replies),并且必须执行定期任务(elections and heart-beats)。有很多方法可以构建你的Raft代码来管理这些活动;本文件概述了一些想法。
每个Raft实例都有一堆状态(log、current index等),必须根据并发goroutines中出现的事件进行更新。Go文档指出,goroutines可以直接使用共享数据结构和锁来执行更新,或者通过channel传递消息。经验表明,对于Raft来说,使用共享数据和锁是最直接的。
一个Raft实例有两个时间驱动的活动(time-driven activities):领导者必须发送心跳(heart-beats),如果从领导者那里听到的时间过长,其他人必须开始进行选举。最好的办法是用一个专门的长期运行的goroutine来驱动这些活动,而不是将多个活动合并到一个goroutine中。
选举超时的管理是一个令人头痛的常见原因。也许最简单的计划是在Raft结构中维护一个变量,其中包含peer从领导者那里听到的最后时间,并让选举超时goroutine定期检查自那时起的时间是否大于超时周期。最简单的方法是使用time.Sleep()和一个小的常数参数来驱动定期检查。不要使用time.Ticker和time.Timer;它们的正确使用很棘手。
你会希望有一个单独的长期运行的goroutine,在applyCh上按顺序发送已提交的日志条目。 它必须是独立的,因为在applyCh上发送可能会阻塞;而且它必须是一个单独的goroutine,否则可能很难保证你按照日志顺序发送日志条目。推进commitIndex的代码需要kick the apply goroutine;为此可以使用条件变量(Go的sync.Cond)。
每个RPC都应该在它自己的goroutine中发送(和处理它的回复),原因有二:这样,无法到达的peer就不会影响大多数回复的收集速度,而且,心跳和选举计时器可以一直持续计时。在同一个goroutine中进行RPC回复处理是最简单的,而不是通过一个通道发送回复信息。
请记住,网络可以延迟RPC和RPC的回复,当你发送并发的RPC时,网络可以重新安排请求和回复的顺序。图2很好地指出了RPC处理程序必须注意的地方(例如,RPC处理程序应该忽略带有old term的RPC)。图2对于RPC回复的处理并不总是明确的。在处理回复时,领导者必须小心;它必须检查term在发送RPC后是否发生了变化,并且必须考虑到从并发的RPC到同一跟随者的回复可能改变了领导者的状态(例如 nextIndex)。
Students' Guide to Raft
在过去的几个月里,我一直是麻省理工学院6.824分布式系统课的教学助理。这门课传统上有一些建立在Paxos共识算法上的实验,但今年,我们决定改用Raft。Raft是 "设计成易于理解的",我们希望这一改变能使学生的生活更轻松。
这篇文章,以及随之而来的《Raft讲师指南》,记录了我们使用Raft的历程,希望对Raft协议的实施者和试图更好地了解Raft内部的学生有所帮助。如果你正在寻找Paxos与Raft的比较,或者想对Raft进行更多的教学分析,你应该去阅读《指导者指南》。这篇文章的底部包含了6.824学生常问的问题列表,以及这些问题的答案。如果你遇到的问题没有在本帖的主要内容中列出,请查看问答。这个帖子相当长,但它提出的所有观点都是很多6.824学生(和助教)遇到的真实问题。这是一篇值得一读的文章。
背景介绍 在我们深入研究Raft之前,一些背景可能是有用的。6.824曾经有一套以Paxos为基础的lab,是用Go来构建的;选择Go是因为它对学生来说很容易学习,也因为它很适合编写并发的分布式应用(goroutines特别方便)。在四个实验的过程中,学生们建立了一个容错的、分片的键值存储。第一个lab让他们建立了一个基于共识的日志库,第二个lab在此基础上增加了一个键值存储,第三个lab在多个容错集群中分片存储键值空间,由一个容错分片主站处理配置变化。我们还有第四个lab,学生们必须处理机器的故障和恢复问题,包括磁盘完好无损的情况。这个lab可以作为学生的默认期末项目。
今年,我们决定使用Raft重写所有这些lab。前三个lab都是一样的,但第四个lab被放弃了,因为持久性和故障恢复已经内置于Raft中。本文将主要讨论我们在第一个实验中的经验,因为它是与Raft最直接相关的实验,尽管我也会涉及到在Raft之上构建应用程序(如第二个实验)。
对于那些刚刚了解Raft的人来说,该协议网站上的文字对它的描述是最好的。
Raft是一种共识算法,设计得很容易理解。它在容错和性能方面与Paxos相当。不同的是,它被分解成相对独立的子问题,并干净地解决了实际系统所需的所有主要部分。我们希望Raft能让更多的人了解共识,而这些人将能够开发出比现在更高质量的基于共识的系统。
像这样的可视化,对协议的主要组成部分有一个很好的概述,而该论文对为什么需要这些不同的部分给出了很好的intuition。如果你还没有读过扩展的Raft论文,你应该在继续这篇文章之前先去读一下,因为我将假设你对Raft有一定的熟悉程度。
就像所有的分布式共识协议一样,devil 在细节上非常重要。在没有故障的稳定状态下,Raft的行为很容易理解,并且可以用直观的方式解释。例如,从可视化中可以简单地看到,假设没有故障,最终会选出一个领导者,最终所有发给领导者的操作都会被跟随者按照正确的顺序应用。然而,当延迟信息、网络分区和失败的服务器被引入时,每一个如果、但是、和,都变得至关重要。特别是,我们看到有一些bug反复出现,仅仅是因为阅读论文时的误解或疏忽。这个问题不是Raft独有的,是所有提供正确性的复杂分布式系统都会出现的问题。
Implementing Raft
Raft的最终指南在Raft论文的图2中。该图规定了Raft服务器之间交换的每个RPC的行为,给出了服务器必须保持的各种不变性,并规定了某些动作应该在什么时候发生。在本文的其余部分,我们将经常讨论图2。它需要被严格遵守。
图2定义了每个服务器在任何状态下对每个传入的RPC应该做什么,以及某些其他事情应该在什么时候发生(比如什么时候在日志中应用一个条目是安全的)。一开始,你可能会想把图2当作一种非正式的指南;你读一遍,然后开始编写一个实现,大致按照它说的去做。这样做,你很快就会有一个基本可行的Raft实现。然后,问题就开始了。
事实上,图2是非常精确的,在规范方面,它的每一条语句都应该被视为必须的,而不是应该的。例如,每当你收到AppendEntries或RequestVote RPC时,你可能会合理地重置一个peer的选举计时器,因为这两者都表明其他peer要么认为自己是领导者,要么正试图成为领导者。直观地说,这意味着我们不应该干预。然而,如果你仔细阅读图2,它说:
如果选举超时,没有收到现任领导的AppendEntries RPC,也没有向候选人授予投票权:转换为候选人。
这个区别是很重要的,因为前者的实现在某些情况下会导致显著降低的有效性。
The importance of details
为了使讨论更加具体,让我们考虑一个绊倒了不少6.824学生的例子。Raft论文在很多地方都提到了心跳RPC。具体来说,领导者会偶尔(每个心跳间隔至少一次)向所有peer 发出AppendEntries RPC,以防止它们开始新的选举。如果领导者没有新的条目要发送给某个特定的peer,那么AppendEntries RPC就不包含任何条目,并被认为是一次心跳。
我们的许多学生认为,心跳在某种程度上是 "特殊的";当一个peer收到心跳时,它应该与非心跳的AppendEntries RPC区别对待。特别是,许多人在收到心跳时,会简单地重置他们的选举计时器,然后返回成功,而不执行图2中规定的任何检查。这是很危险的。通过接受RPC,跟随者隐含地告诉领导者,他们的日志与领导者的日志相匹配,包括AppendEntries参数中的prevLogIndex。在收到回复后,领导者可能会决定(错误地)某些条目已经被复制到大多数服务器上,并开始提交它。
许多人的另一个问题(通常是在修复上述问题之后)是,在收到心跳时,他们会在prevLogIndex之后截断跟随者的日志,然后追加AppendEntries参数中包含的任何条目。这也是不正确的。我们可以再一次转向图2:
if 一个现有的条目与一个新的条目相冲突(相同的索引,但不同的term),请删除现有的条目和后面所有的条目。
这里的if很关键。如果跟随者拥有领导者发送的所有条目,跟随者必须不截断其日志。任何跟随领导者发送的条目的元素都必须被保留。这是因为我们可能从领导者那里收到了一个过时的AppendEntries RPC,而截断日志将意味着 "收回 "我们可能已经告诉领导者我们的日志中的条目。
调试Raft
不可避免的是,你的Raft实现的第一次迭代将是错误的。第二次也会如此。第三次。还有第四次。一般来说,每一次的错误都会比前一次少,而且根据经验,你的大多数错误都是由于没有忠实于图2的结果。
在调试时,Raft通常有四个主要的bug来源:死锁、不正确或不完整的RPC处理程序、没有遵循规则以及term混乱。死锁也是一个常见的问题,但它们通常可以通过记录你所有的锁和解锁来调试,并找出你正在使用但未释放的锁。让我们依次考虑这些问题。
Livelocks 当你的系统活锁时,你系统中的每个节点都在做一些事情,但你的节点总体上处于这样一种状态,没有取得任何进展。这种情况在Raft中很容易发生,尤其是在你没有严格遵守图2的情况下。有一种活锁情况出现得特别频繁;没有选出leader,或者一旦选出了领袖,其他节点又开始选举,迫使最近选出的领袖立即退位。
这种情况出现的原因有很多,但有几个错误是我们看到许多学生犯的:
请确保你在图2所说的时候准确地重置你的选举计时器。具体来说,只有在以下情况下你才应该重启你的选举计时器:a)你从当前的领导者那里得到一个AppendEntries RPC(即,如果AppendEntries参数中的术语已经term,你不应该重启你的计时器);b)你正在开始一个选举;或者c)你授予另一个peer一个投票。
最后一种情况在不可靠的网络中特别重要,因为追随者很可能有不同的日志;在这些情况下,你最终往往只有少数服务器,而大多数服务器都愿意为其投票。如果你每当有人要求你为他投票时就重置选举计时器,这就使得一个有过时日志的服务器和一个有较长日志的服务器同样有可能站出来。
事实上,由于具有足够最新的日志的服务器太少,这些服务器很不可能在足够平静的情况下举行选举而当选。如果你遵循图2的规则,拥有更多最新日志的服务器就不会被过时的服务器的选举打断,因此更有可能完成选举,成为领导者。
按照图2的指示,你应该何时开始选举。特别要注意的是,如果你是一个候选人(即你目前正在进行选举),但选举的定时器到时了,你应该开始另一次选举。这一点很重要,可以避免系统因RPC的延迟或放弃而停滞。 在处理传入的RPC之前,确保你遵循 "服务器规则 "中的第二条规则。第二条规则指出:
如果RPC请求或响应包含term T > currentTerm:设置currentTerm = T,转换为follower(§5.1)。
例如,如果你已经在当前任期内投票,而传入的RequestVote RPC的term比你高,你应该首先下台,采用他们的term(从而重新设置 votedFor),然后处理RPC,这将导致你授予投票权
不正确的RPC处理程序
尽管图2清楚地说明了每个RPC处理程序应该做什么,但一些细微之处仍然容易被忽略。以下是我们反复看到的一些问题,你应该在你的实现中注意这些问题:
如果一个步骤说 "reply false",这意味着你应该立即回复,而不是执行任何后续的步骤。
如果你收到一个AppendEntries RPC,其prevLogIndex指向你的日志的末尾,你应该像你确实有那个条目但term不匹配那样处理它(即,回复false)。
AppendEntries RPC处理程序的检查2应该被执行,即使领导者没有发送任何条目。
AppendEntries的最后一步(#5)中的min是必要的,它需要用最后一个新条目的索引来计算。仅仅让应用lastApplied和commitIndex之间的日志内容的函数在到达日志的末尾时停止是不够的。这是因为你的日志中可能会有与领导者的日志不同的条目,在领导者发给你的条目之后(这些条目都与你的日志中的条目一致)。因为#3决定了你只有在有冲突的条目时才会截断你的日志,那些条目不会被删除,如果leaderCommit超出了领导发给你的条目,你就可能应用不正确的条目。 重要的是,要完全按照第5.4节中的描述来实现 "最新日志 "的检查。No cheating,只检查长度!。
Failure to follow The Rules 虽然Raft论文非常明确地说明了如何实现每个RPC处理程序,但它也没有明确规定一些规则和不变因素的实现。这些规则列在图2右侧的 "服务器规则 "部分。虽然其中有些是不言自明的,但也有一些需要非常仔细地设计你的应用程序,使其不违反规则。
如果在执行过程中的任何时候commitIndex>lastApplied,你应该应用一个特定的日志条目。你是否直接这样做并不关键(例如,在AppendEntries RPC处理程序中),但重要的是,你要确保这种应用只由一个实体完成。具体来说,你需要有一个专门的 "应用者",或者围绕这些应用进行锁定,这样其他的例程就不会检测到需要应用的条目,并且也试图进行应用。 确保定期检查 commitIndex > lastApplied,或者在 commitIndex 更新后(即 matchIndex 更新后)检查。例如,如果你在检查commitIndex的同时向peer发送AppendEntries,你可能不得不等到下一个条目被追加到日志中,然后再应用你刚刚发送并得到确认的条目。 如果一个领导者发出了AppendEntries RPC,并且它被拒绝了,但不是因为日志不一致(这只能发生在我们的term已过的情况下),那么你应该立即下台,而不是更新nextIndex。如果你这样做了,那么如果你立即重新当选,你可能会与nextIndex的重置发生冲突。 领导者不允许将commitIndex更新到前一个term的某个地方(或者,未来的term)。因此,正如规则所说,你需要特别检查log[N].term == currentTerm。这是因为,如果一个条目不是来自他们当前的任期,Raft领导就不能确定它真的被提交了(并且在未来不会被改变)。论文中的图8说明了这一点。 一个常见的混淆来源是 nextIndex 和 matchIndex 之间的区别。特别是,你可能会观察到matchIndex = nextIndex - 1,而干脆不实现matchIndex。这是不安全的。虽然nextIndex和matchIndex通常在同一时间被更新为类似的值(具体来说,nextIndex = matchIndex + 1),但两者的作用完全不同。它通常是相当乐观的(我们分享一切),并且只在消极的反应中向后移动。例如,当一个领导者刚刚当选时,nextIndex被设置为日志末尾的索引指数。在某种程度上,nextIndex是用于性能的--你只需要将这些东西发送给这个peer。
matchIndex是用于安全的。MatchIndex不能被设置为一个太高的值,因为这可能会导致commitIndex被向前移动得太远。这就是为什么matchIndex被初始化为-1(也就是说,我们不同意任何前缀),并且只在跟随者肯定地确认AppendEntries RPC时才更新。
*Term confusion**
term混淆是指服务器被来自旧term的RPC所迷惑。一般来说,在收到RPC时,这不是一个问题,因为图2中的规则确切地说明了当你看到一个旧术语时你应该做什么。然而,图2一般没有讨论当你收到旧的RPC回复时你应该做什么。根据经验,我们发现到目前为止,最简单的做法是首先记录回复中的term(它可能比你当前的term高),然后将当前term与你在原始RPC中发送的term进行比较。如果两者不同,就放弃回复并返回。只有当这两个term相同时,你才应该继续处理回复。也许你可以通过一些巧妙的协议推理在这里做进一步的优化,但这种方法似乎很有效。而不这样做会导致一条漫长而曲折的血汗、泪水和绝望的道路。
一个相关但不完全相同的问题是,假设你的状态在你发送RPC和你收到回复之间没有变化。这方面的一个很好的例子是,当你收到RPC的响应时,设置matchIndex = nextIndex - 1,或者matchIndex = len(log)。这并不安全,因为这两个值都可能在你发送RPC后被更新。相反,正确的做法是将 matchIndex 更新为 prevLogIndex + len( entries[]) ,从你最初在 RPC 中发送的参数开始。
关于优化的一个旁证
Raft论文中包括几个感兴趣的可选功能。在6.824中,我们要求学生实现其中的两个:日志压缩(第7节)和加速日志回溯(第8页的左上方)。前者对于避免日志无限制地增长是必要的,而后者对于使陈旧的追随者快速更新是有用的。
这些功能不是 "核心Raft "的一部分,因此在本文中没有得到像主要共识协议那样的关注。日志压缩的内容相当全面(在图13中),但遗漏了一些设计细节,如果你太随意地阅读,可能会错过。
当快照应用状态时,你需要确保应用状态与Raft日志中某个已知索引之后的状态相一致。这意味着应用程序需要向Raft传达快照所对应的索引,或者Raft需要延迟应用额外的日志条目,直到快照完成。 文中没有讨论当服务器崩溃并恢复时的恢复协议,因为现在涉及到快照。特别是,如果Raft状态和快照是分开提交的,服务器可能会在坚持快照和坚持更新的Raft状态之间崩溃。这是一个问题,因为图13中的第7步决定了快照所覆盖的Raft日志必须被丢弃。
如果当服务器重新启动时,它读取的是更新的快照,而不是过时的日志,那么它最终可能会应用一些已经包含在快照中的日志条目。这种情况会发生,因为commitIndex和lastApplied没有被持久化,所以Raft不知道这些日志条目已经被应用。解决这个问题的方法是在Raft中引入一个持久化状态,记录Raft持久化日志中的第一个条目所对应的 "真实 "索引。然后,这可以与加载的快照的lastIncludedIndex进行比较,以确定要丢弃日志头部的哪些元素。
加速日志回溯的优化是非常不明确的,可能是因为作者认为这对大多数部署来说是不必要的。文本中并没有明确说明领导者应该如何使用从客户端发回的冲突索引和术语来决定使用哪一个NextIndex。我们认为作者可能希望你遵循的协议是。
如果一个跟随者的日志中没有prevLogIndex,它应该以conflictIndex = len(log)和conflictTerm = None返回。 如果一个跟随者在其日志中确实有prevLogIndex,但是术语不匹配,它应该返回conflictTerm = log[prevLogIndex].Term,然后在其日志中搜索其条目中术语等于conflictTerm的第一个索引。 在收到冲突响应后,领导者应该首先搜索其日志中的conflictTerm。如果它在它的日志中找到一个具有该术语的条目,它应该将nextIndex设置为其日志中该术语的最后一个条目的索引之外的那个索引。 如果它没有找到该术语的条目,它应该设置 nextIndex = conflictIndex。 一个半途而废的解决方案是只使用conflictIndex(而忽略conflictTerm),这简化了实现,但这样一来,领导者有时会向跟随者发送更多的日志条目,而不是严格意义上所需要的,以使他们达到最新状态。
Applications on top of Raft
在Raft之上构建服务时(如第二个6.824 Raft lab中的键/值存储),服务与Raft日志之间的互动可能会很棘手,需要正确处理。本节详细介绍了开发过程中的一些方面,您可能会发现在构建您的应用程序时,这些方面是有用的。
Applying client operations
你可能对如何在复制的日志方面实现一个应用程序感到困惑。你可以先让你的服务在收到客户端请求时,将该请求发送给领导者,等待Raft应用一些东西,做客户端要求的操作,然后再返回给客户端。虽然这在单客户系统中很好,但对于并发的客户来说,这并不可行。
相反,服务应该被构建为一个状态机,客户操作将机器从一个状态过渡到另一个状态。你应该在某个地方有一个循环,每次接收一个客户操作(在所有服务器上的顺序是一样的--这就是Raft的作用),并将每个操作按顺序应用到状态机中。这个循环应该是你的代码中唯一接触到应用程序状态的部分(6.824中的键/值映射)。这意味着你面向客户端的RPC方法应该简单地将客户端的操作提交给Raft,然后等待该操作被这个 "应用者循环 "应用。只有当客户端的命令出现时,它才应该被执行,并读出任何返回值。请注意,这包括读取请求!
这就带来了另一个问题:你怎么知道客户端操作何时完成?在没有失败的情况下,这很简单--你只需等待你放进日志的东西再出来(即被传递给apply())。当这种情况发生时,你将结果返回给客户端。然而,如果有失败会怎样?例如,当客户最初与你联系时,你可能是领导者,但后来有其他人当选了,而你放在日志中的客户请求被丢弃了。很明显,你需要让客户再试一次,但你怎么知道什么时候告诉他们这个错误?
解决这个问题的一个简单方法是,当你插入客户端的操作时,记录它在Raft日志中出现的位置。一旦该索引的操作被发送到apply(),你就可以根据该索引出现的操作是否真的是你放在那里的操作来判断客户的操作是否成功。如果不是,就发生了失败,可以向客户返回一个错误。
重复检测
一旦你让客户在面对错误时重试操作,你就需要某种重复检测方案--如果一个客户向你的服务器发送了一个APPEND,但没有得到回音,并将其重新发送到下一个服务器,你的apply()函数需要确保APPEND不会被执行两次。要做到这一点,你需要为每个客户的请求提供某种唯一的标识符,这样你就可以识别出你是否在过去看到过,更重要的是,应用过某个特定的操作。此外,这种状态需要成为你的状态机的一部分,以便你的所有Raft服务器都能消除相同的重复内容。
分配这种标识符的方法有很多。一个简单且相当有效的方法是给每个客户端一个唯一的标识符,然后让他们用单调增加的序列号来标记每个请求。如果一个客户重新发送一个请求,它就重新使用同一个序列号。你的服务器会跟踪它为每个客户看到的最新序列号,并简单地忽略它已经看到的任何操作。
Hairy corner-cases 如果你的实现遵循上面给出的一般大纲,至少有两个微妙的问题你可能会遇到,如果不进行认真的调试,可能很难识别。为了节省你的时间,这里有两个问题:
重新出现的索引:假设你的Raft库有一些Start()方法,它接收一条命令,并返回该命令在日志中的索引(以便你知道何时返回客户端,如上所述)。你可能会认为,你永远不会看到Start()两次返回相同的索引,或者至少,如果你再次看到相同的索引,第一次返回该索引的命令一定是失败了。事实证明,这两件事都不是真的,即使没有服务器崩溃。
考虑以下有五个服务器的情景,S1到S5。最初,S1是领导者,它的日志是空的。
两个客户端操作(C1和C2)到达S1上 Start()对C1返回1,对C2返回2。 S1向S2发送了一个包含C1和C2的AppendEntries,但它的所有其他消息都丢失了。 S3作为候选人站了出来。 S1和S2不会投票给S3,但S3、S4和S5都会,所以S3成为领导者。 另一个客户端请求,C3来到了S3。 S3调用Start()(返回1)。 S3向S1发送了一个AppendEntries,S1从它的日志中丢弃了C1和C2,并添加了C3。 S3在向其他服务器发送AppendEntries之前就失败了。 S1站了出来,由于它的日志是最新的,它被选为领导者。 另一个客户端请求,C4,到达了S1 S1调用Start(),返回2(Start(C2)也返回了这个值)。 S1的所有AppendEntries都被丢弃,S2向前迈进。 S1和S3不会为S2投票,但S2、S4和S5都会,所以S2成为领导者。 一个客户端请求C5进入S2 S2调用Start(),返回3。 S2成功地向所有服务器发送了AppendEntries,S2通过在下一次心跳中加入更新的leaderCommit=3来向服务器报告。
由于S2的日志是[C1 C2 C5],这意味着在索引2处提交(并在所有服务器,包括S1处应用)的条目是C2。尽管C4是最后一个在S1返回索引2的客户端操作。
四个方面的僵局:这个问题的发现要归功于另一位6.824的TA--Steven Allen。他发现了以下讨厌的四向死锁,当你在Raft之上构建应用程序时,很容易陷入其中。
你的Raft代码,无论其结构如何,很可能有一个类似Start()的函数,允许应用程序向Raft日志添加新命令。它还可能有一个循环,当 commitIndex 被更新时,对日志中 lastApplied 和 commitIndex 之间的每个元素调用 apply()。在您基于Raft的应用程序中,您可能在RPC处理程序的某个地方调用Raft的Start()函数,并且在其他地方有一些代码在Raft应用新的日志条目时被通知。由于这两者需要进行通信(即RPC方法需要知道它放入日志的操作何时完成),它们可能都需要一些锁 b。
在Go中,这四个代码段可能看起来像这样:
现在考虑一下,如果系统处于以下状态:
App.RPC刚刚获取了a.mutex并调用Raft.Start Raft.Start正在等待r.mutex Raft.AppendEntries持有r.mutex,并且刚刚调用了App.apply。 我们现在有一个死锁,因为:
Raft.AppendEntries不会释放锁,直到App.apply返回。 App.application在获得a.mutex之前不能返回。 a.mutex不会被释放,直到App.RPC返回。 在Raft.Start返回之前,App.RPC不会返回。 Raft.Start在获得r.mutex之前不能返回。 Raft.Start必须等待Raft.AppendEntries。 有几种方法可以解决这个问题。最简单的方法是在App.RPC中调用a.raft.Start后获取a.mutex。然而,这意味着在App.RPC有机会 记录它希望被通知的事实之前,App.apply可能会被调用,用于App.RPC刚刚调用Raft.Start的操作。另一个可能产生更整洁的设计 的方案是,由一个专门的线程从Raft调用r.app.apply。这个线程可以在每次更新commitIndex时得到通知,这样就不需要为了应 用而持有一个锁,从而打破了死锁。
Last updated