MIT6.824-Raft笔记5:leader选举约束及持久化

本文主要是leader选举相关的一些约束条件,以及为了满足这些约束条件,需要将部分重要数据进行持久化。

1. 选举约束(Election Restriction)

为了保证系统的正确性,并非任意节点都可以成为Leader。不是说第一个选举定时器超时了并触发选举的节点,就一定是Leader。Raft对于谁可以成为Leader,谁不能成为Leader是有一些限制的。

拥有最长日志的节点不一定就是leader

为了证明并非任意节点都可以成为Leader,我们这里提出一个例子来证伪。在这个反例中,Raft会选择拥有最长Log记录的节点作为Leader,这个规则或许适用于其他系统,实际上在一些其他设计的系统中的确使用了这样的规则,但是在Raft中,这条规则不适用。我们这里需要研究的问题是:为什么不选择拥有最长Log记录的节点作为Leader?如果我们这么做了的话,我们需要更改Raft中的投票规则,让选民只投票给拥有更长Log记录的节点。

很容易可以展示为什么这是一个错误的观点。我们还是假设我们有3个服务器,现在服务器1(S1)有任期5,6,7的Log,服务器2和服务器3(S2和S3)有任期5,8的Log。

这里的第一个问题是,这个场景可能出现吗?

让我们回退一些时间,在这个时间点S1赢得了选举,现在它的任期号是6。它收到了一个客户端请求,在发出AppendEntries之前,它先将请求存放在自己的Log中,然后它就故障了,它没能发出任何AppendEntries消息。之后它很快就故障重启了,又一次选举成为Leader。然后它收到了一个任期7的客户端请求,将这个请求加在本地Log之后,它又故障了。

S1故障之后,又有了一次新的选举,这时S1已经关机了不能再参加选举,S2被选为Leader。如果S2当选,而S1还在关机状态,S2会使用什么任期号呢?

S1在任期6,7能当选,它必然拥有了过半节点的投票,过半服务器至少包含了S2,S3中的一个节点。如果你去看处理RequestVote的代码和Raft论文的图2,当某个节点为候选人投票时,节点应该将候选人的任期号记录在持久化存储中。所里在这里,S2或者S3或者它们两者都知道任期6和任期7的存在。当S1故障了,它们中至少一个知道当前的任期是8。这里,只有知道了任期8的节点才有可能当选,如果只有一个节点知道,那么这个节点会赢得选举,因为它拥有更高的任期号。如果S2和S3都知道当前任期是8,那么它们两者中的一个会赢得选举。下一个任期必然为8这个事实,依赖于不同任期的过半服务器之间必然有重合这个特点。同时,也依赖任期号会通过RequestVote RPC更新给其他节点,并持久化存储,这样出现故障才不会丢失数据。下一个任期号将会是8,S2或者S3会赢得选举。不管是哪一个,新的Leader会继续将客户端请求转换成AppendEntries发给其他节点。

假设S1重新上线了,并且我们又有了一次新的选举,可以选择拥有最长Log记录的节点作为Leader可以吗?明显,答案是不可以的。如果S1是Leader,它会通过AppendEntries机制将自己的Log强加给2个Followers。如果我们让S1作为Leader,它会发出AppendEntries消息来覆盖S2和S3在任期8的Log,并在S2和S3中写入S1中的任期6和任期7的Log,这样所有的节点的Log才能与S1保持一致。

为什么我们不能认可这样的结果呢?因为S2和S3可以组成过半服务器,任期8的Log已经被commit了,对应的请求很可能已经执行了,应用层也很可能发送一个回复给客户端了。我们不能删除任期8的Log。因此,S1也就不能成为Leader并将自己的Log强制写入S2和S3。大家都明白了为什么这对于Raft来说是个坏的结果吗?正因为这个原因,我们不能在选举的时候直接选择拥有最长Log记录的节点。当然,最短Log记录的节点也不行。

在Raft论文的5.4.1,Raft有一个稍微复杂的选举限制(Election Restriction)。这个限制要求,在处理别节点发来的RequestVote RPC时,需要做一些检查才能投出赞成票。节点只能向满足下面条件之一的候选人投出赞成票:

  1. 候选人最后一条Log条目的任期号大于本地最后一条Log条目的任期号
  2. 或者,候选人最后一条Log条目的任期号等于本地最后一条Log条目的任期号,且候选人的Log记录长度大于等于本地Log记录的长度

回到我们的场景,如果S2收到了S1的RequestVote RPC,因为S1的最后一条Log条目的任期号是7,而S2的最后一条Log条目的任期号是8,两个限制都不满足,S2和S3都不会给S1投赞成票。即使S1的选举定时器的超时时间更短,并且先发出了RequestVote请求,除了它自己,没人会给它投票,它只能拿到一个选票,不能凑够过半选票。如果S2或者S3成为了候选人,它们中的另一个都会投出赞成票,因为它们最后的任期号一样,并且它们的Log长度大于等于彼此(满足限制2)。S2或者S3中的任意一个都会为另一个投票。S1会为它们投票吗?会的,因为S2或者S3最后一个Log条目对应的任期号更大(满足限制1)。

在这里,Raft更喜欢拥有更高任期号记录的候选人,或者说更喜欢拥有任期号更高的旧Leader记录的候选人。限制2说明,如果候选人都拥有任期号最高的旧Leader记录,那么Raft更喜欢拥有更多记录的候选人。

这里和候选人将自己的TermID+1容易混淆

  1. 这里的TermID是log里持久化的信息,用以保存最完备的日志
  2. Candidate+1的TermID是逻辑时钟,会根据这个的大小分别处理收到的任何Msg
    1. 小,直接拒绝消息(过时消息)
    2. 大,职级听从,自己变成Follower;但是是否投票,还是需要再比较日志
    3. 相同,判断

2. 持久化(Persistence)

可以从Raft论文的图2的左上角看到,有些数据被标记为持久化的(Persistent),有些信息被标记为非持久化的(Volatile)。持久化和非持久化的区别只在服务器重启时重要。当你更改了被标记为持久化的某个数据,服务器应该将更新写入到磁盘,或者其它的持久化存储中,例如一个电池供电的RAM。持久化的存储可以确保当服务器重启时,服务器可以找到相应的数据,并将其加载到内存中。这样可以使得服务器在故障并重启后,继续重启之前的状态。断电的时候,整个集群都同时停止运行,这种场景下,如果我们希望我们的服务是容错的, 我们需要能够得到之前状态的拷贝,这样我们才能保持程序继续运行。至少为了处理同时断电的场景,我们不得不让服务器能够将它们的状态存储在某处,这样当供电恢复了之后,还能再次获取这个状态。这里的状态是指,为了让服务器在断电或者整个集群断电后,能够继续运行所必不可少的内容。这是理解持久化存储的一种方式。

在Raft论文的图2中,有且仅有三个数据是需要持久化存储的:Log、currentTerm、votedFor。

这里没有commitID,这个值是每次统计所有副本的水位,算出来的。

Apply ID可能需要应用层自己持久化,避免有些类型+1的操作多次执行。

Log是所有的Log条目。当某个服务器刚刚重启,在它加入到Raft集群之前,它必须要检查并确保这些数据有效的存储在它的磁盘上。Log需要被持久化存储的原因是,这是唯一记录了应用程序状态的地方。假如我们运行了一个数据库或者为VMware FT运行了一个Test-and-Set服务,根据Raft论文图2,实际的数据库或者实际的test-set值,并不会被持久化存储,只有Raft的Log被存储了。当服务器重启时,唯一能用来重建应用程序状态的信息就是存储在Log中的一系列操作,Log必须要被持久化存储。

currentTerm和votedFor都是用来确保每个任期只有最多一个Leader。在一个故障的场景中,如果一个服务器收到了一个RequestVote请求,并且为服务器1投票了,之后它故障。如果它没有存储它为哪个服务器投过票,当它故障重启之后,收到了来自服务器2的同一个任期的另一个RequestVote请求,那么它还是会投票给服务器2,因为它发现自己的votedFor是空的,因此它认为自己还没投过票。现在这个服务器,在同一个任期内同时为服务器1和服务器2投了票。因为服务器1和服务器2都会为自己投票,它们都会认为自己有过半选票(3票中的2票),那它们都会成为Leader。现在同一个任期里面有了两个Leader。这就是为什么votedFor必须被持久化存储。

避免先投票之后挂掉,起来之后再次投票,导致同一个term里存在了两个leader

currentTerm的情况要更微妙一些,但是实际上还是为了实现一个任期内最多只有一个Leader,如果(重启之后)我们不知道任期号是什么,很难确保一个任期内只有一个Leader。 在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。

这些数据需要在每次你修改它们的时候存储起来。只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。

向磁盘写数据是一个代价很高的操作。如果是一个机械硬盘,我们通过写文件的方式来持久化存储,向磁盘写入任何数据都需要花费大概10毫秒时间。因为你要么需要等磁盘将你想写入的位置转到磁针下面, 而磁盘大概每10毫秒转一次。要么,就是另一种情况更糟糕,磁盘需要将磁针移到正确的轨道上。这里的持久化操作的代价可能会非常非常高。对于一些简单的设计,这些操作可能成为限制性能的因素,因为它们意味着在这些Raft服务器上执行任何操作,都需要10毫秒。而10毫秒相比发送RPC或者其他操作来说都太长了。如果你持久化存储在一个机械硬盘上,那么每个操作至少要10毫秒,这意味着你永远也不可能构建一个每秒能处理超过100个请求的Raft服务。这就是所谓的synchronous disk updates的代价。

设计人员花费了大量的时间来避开synchronous disk updates带来的性能问题。为了让磁盘的数据保证安全,文件系统对于写入操作十分小心,有时需要等待磁盘(前一个)写入完成。这(优化磁盘写入性能)是一个出现在所有系统中的常见的问题,也必然出现在Raft中。

如果你想构建一个能每秒处理超过100个请求的系统,这里有多个选择。其中一个就是,你可以使用SSD硬盘或者某种闪存。SSD可以在0.1毫秒完成对于闪存的一次写操作,这里性能就提高了100倍。更高级一点的方法是,你可以构建一个电池供电的DRAM,然后在这个电池供电的DRAM中做持久化存储。这样如果Server重启了,并且重启时间短于电池的可供电时间,这样你存储在RAM中的数据还能保存。可以每秒写DRAM数百万次,那么持久化存储就不再会是一个性能瓶颈。

synchronous disk updates是为什么数据要区分持久化和非持久化(而非所有的都做持久化)的原因(越少数据持久化,越高的性能)。Raft论文图2考虑了很多性能,故障恢复,正确性的问题。

学生提问:当你写你的Raft代码时,你实际上需要确认,当你持久化存储一个Log或者currentTerm,这些数据是否实时的存储在磁盘中,你该怎么做来确保它们在那呢?

Robert教授:在一个UNIX或者一个Linux或者一个Mac上,为了调用系统写磁盘的操作,你只需要调用write函数,在write函数返回时,并不能确保数据存在磁盘上,并且在重启之后还存在。几乎可以确定(write返回之后)数据不会在磁盘上。如果在UNIX上,你调用了write,将一些数据写入之后,你需要调用fsync在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。之后如果机器重启了,这些信息还能在磁盘上找到。fsync是一个代价很高的调用,这就是为什么它是一个独立的函数,也是为什么write不负责将数据写入磁盘,fsync负责将数据写入磁盘。因为写入磁盘的代价很高,你永远也不会想要执行这个操作,除非你想要持久化存储一些数据。

你可以使用一些更贵的磁盘。另一个常见方法是,批量执行操作。如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。因为Leader必须要commit那个请求,并且不能忘记这个请求。实际上,在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。

有些数据在Raft论文的图2中标记为非持久化的。这里值得思考一下,为什么服务器重启时,commitIndex、lastApplied、nextIndex、matchIndex,可以被丢弃?例如,lastApplied表示当前服务器执行到哪一步,如果我们丢弃了它的话,我们需要重复执行Log条目两次(重启前执行过一次,重启后又要再执行一次),这是正确的吗?为什么可以安全的丢弃lastApplied?

这里综合考虑了Raft的简单性和安全性。之这些数据是非持久化存储的,是因为Leader可以通过检查自己的Log和发送给Followers的AppendEntries的结果,来发现哪些内容已经commit了。如果因为断电,所有节点都重启了。Leader并不知道哪些内容被commit了,哪些内容被执行了。但是当它发出AppendEntries,并从Followers搜集回信息。它会发现,Followers中有哪些Log与Leader的Log匹配,因此也就可以发现,在重启前,有哪些被commit了。

另外,Raft论文的图2假设,应用程序状态会随着重启而消失。图2认为,既然Log已经持久化存储了,那么应用程序状态就不必再持久化存储。因为在图2中,Log从系统运行的初始就被持久化存储下来。,当Leader重启时,Leader会从第一条Log开始,执行每一条Log条目,并提交给应用程序。重启之后,应用程序可以通过重复执行每一条Log来完全从头构建自己的状态。这是一种简单且优雅的方法,但是很明显会很慢。

  • 19
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值