笔者在复盘raft共识算法时,发现自己对一些边界情况的理解还不够,以下问题是笔者在学习时做的笔记。
- 在raft投票限制中,候选人除非日志中包含所有已提交的条目,否则无法赢得选举,假如我的leader更新了
commitIndex之后但是leader单独和其他节点发生了分区,其他节点不知道最新commitIndex,此时会发生什么?
在我的设计中,leader更新commitindex是在收到appendentriesrpc调用的回复之后,如果响应成功的话就表示节点成功进行了append日志,此时更新对应节点的matchIndex,再通过matchIndex判断每个节点已经匹配的日志索引,找到最靠后且已经有半数节点以上匹配的日志索引更新为commitIndex,所以leader更新之后就直到已经有半数以上节点拥有对应commitIndex处的日志。
所以在发生分区之后,其他节点会发生选举超时,然后进行选举,比较谁的日志更加权威。由前面我们可以得知,多数节点都包含了已提交日志,所以肯定会让包含已提交日志的节点选为新的leader。然后让新leader负责已提交日志和后续的日志复制,所以可以确保leader完备性,已提交的日志一定会出现在后续的leader中。
- 接上一个问题,新的leader其实不知道此时最新的commitIndex是多少,因为它没收到旧leader提交日志时的commitIndex,此时不是会有不一致性吗。
由于领导者完备性,新的leader肯定包含了已提交的所有日志,但是新的leader还不知道最新的commitIndex,因为可能会发生上个问题的情况。如果此时对新leader进行读取的话可能会出现问题,因为它可能会返回旧的数据。因为旧leader已经对commitIndex处的日志提交到状态机了,但是新leader不知道,可能还没有将对应日志提交,可能返回旧数据,所以新的leader应该尝试获取最新的commitIndex。
解决方法是:在新leader当选时往其中插入一个空的,任期为leader的curTerm的日志条目,此时leader会发送该日志条目给其他节点,新leader收到回复之后就可以通过各个节点的matchIndex判断每个节点的日志情况,还是根据上面说到的多数节点日志仲裁,从而获取最新的,旧leader已经提交的日志的commitIndex。然后将其应用到状态机,就可以保证数据的一致性。
- 在raft的读请求中,是不是每次在leader读都需要发送一个no-op来确定最新的commitIndex,将对应下标日志应用到状态机之后再读取。
在leader处理读请求之前,必须确保两件事情:首先自己仍然是有效的Leader,其次自己的commitIndex也一定要反映集群中最新的提交进度,这样才能维护读一致性。上个问题解决方案中,leader当选时提交的no-op日志条目的目的就是这两点,强制Leader与多数派节点通信,确认所有节点都对当前Leader达成共识,并借此机会更新Leader自身的 commitIndex 到集群中实际的最新值。
一旦 No-Op 提交,Leader 的状态机就是最新的。就可以极大简化后续的读流程,读时只需要检查 commitIndex 处的日志条目任期是否等于当前 Leader 的任期。如果满足条件的话就意味着 Leader 在当前任期内已经与多数派达成了至少一次日志提交。所有比这更旧的已提交日志条目 Leader 一定拥有,此时Leader可以直接读取本地状态机并且响应客户端,不需要额外的no-op日志。
如果不满足条件的话就这意味着 Leader 自当选以来,尚未与多数派成功提交过任何日志条目,Leader 无法确认自身的 commitIndex 或 Leader 身份是否过期。此时,Leader 必须执行一次完整的日志提交过程(例如提交一个 No-Op)来刷新和确认其 Leader 身份,然后才能安全地读取本地状态机。
- 上述问题还有一个边界问题,就是我leader提交
no-op之后发生了分区,之后选出新leader,在新leader上面提交新的日志项,但是旧leader感知不到这一点,所以之前客户端在旧leader上的读取会返回旧值。
其实关键就是在客户端读取leader时要确认leader的合法性,读取时leader向多数派多发送一轮心跳,强制要求多数派 Follower 确认 Leader 身份,Leader 必须收到多数派Follower 的心跳成功响应,成功的话之后就可以安全读取。
但是这种方法要求每次读取时都发送心跳,性能取决于网络延迟,我们可以采用租约优化。Leader 在赢得选举后,会假设自己在一个短时间间隔T内都是 Leader,在租约期内,Leader 可以安全地直接服务本地读请求。每隔一段时间,Leader 必须通过心跳或日志提交来刷新租约。
租约的核心思想就是Leader 假定在它的租约到期时间,集群中不可能选出新的leader。即使发生了网络分区,旧leader仍然可以在租约未到期时服务客户端读取,因为此时集群中不可能选出新leader,所以旧leader的信息还是最权威的。这主要是保证租约过期时间小于随机选举超时时间,所以租约过期了才会进行选举,才可能选出新leader。
- 租约可以完全替代readindex吗。
租约只是一种优化,并不能完全替代readindex,因为分布式系统中我们需要考虑网络的无界延迟,考虑如下情况。选举超时为400ms,租约为100ms,理想情况下,leader获取租约时肯定不会有节点发送选举,但是由于网络延迟,可能一个节点就差一票变为leader时,有一个节点给这个即将变为leader的节点投票,但是网络发生波动,这个reply经过了350ms才到,之后leader就获取了100ms的租约,但是还有50ms就选举超时了,会开始下一轮选举。假如此时leader发生分区,客户端还认为当前节点持有租约,但实际上另一个分区已经选举出新的leader并且提交了日志,此时就会发生读不一致。
所以租约只可以是一种不稳定的优化手段,在不需要读一致性的前提下可以使用,但是如果需要保证读一致性,就需要readindex,每次读时发送心跳确保当前leader权威,这样可以保证读到最新提交的命令。对于租约和readIndex的优化,可以参考PingCAP的博客
- 既然每次一致性读时都需要leader给follower发送心跳,有没有什么优化手段呢?
要保证一致性读就免不了在leader读取时发送一轮心跳,通过这轮心跳来确认leader的权威。在实际实现中,大多数系统会将只读查询进行批处理来提高效率。你不需要为每个只读请求都发送并行的心跳。如果当前已经有一个未完成的心跳,那么领导者可以将新的只读请求加入队列,等该心跳完成后一起处理。当某次心跳完成后,如果仍有新的只读请求排队,则领导者会再发起下一次心跳。这样做的效果是将线性一致只读请求进行批处理,从而提升效率。
- 为什么leader只可以提交自己任期的日志条目。
考虑下面边界情况,五个节点,其中a被选为leader,任期为2提交了一条日志x,被复制到b之后就崩溃了,之后e被选为leader,任期为3,提交了一条日志y,但是日志y没被复制到任何节点就崩溃了。之后e崩溃,a重新被选为leader,a发现c没有日志x,就把日志x复制给c,c也收到了,此时a发现日志x被多数以上节点应用了,就标记为已提交,让状态机执行。之后a崩溃了,发送投票的rpc只看节点最后一项日志的任期和索引,发现日志Y比日志X更权威,所以e重新被选为leader。之后就进行正常流程,但是此时日志Y会覆盖掉X的位置,造成了已提交日志的缺失。
解决方法就是leader只可以提交自己任期的日志条目,这样a(term4)即使看到 Term 2 的日志已经在多数派了,也不能直接提交它。a必须在自己的当前任期内,创建一条新的日志Z,只有当Z被复制到多数派并且提交时,才会一并把X也提交,因为commitIndex是顺序提交的。这样可以保证e不会当选leader,就算当选了,日志X也不会被应用到状态机,所以可以解决上述问题。
- 如果leader给其他节点发送appendEntries后被应用了,但是没收到节点的成功响应,此时leader重新发送时会发生什么,怎么保证幂等性?
在实现AppendEntries时,我是先判断有没有日志冲突:len(rf.log) > args.PrevLogIndex+1 && rf.log[args.PrevLogIndex+1].Term != args.Entries[0].Term,之后再append发送参数中的日志项,但是这样其实是有问题的。因为这段代码是看参数中的prevLogIndex位置进行比较,下面解释发送问题的情况。
Leader先发送了AppendEntries RPC, 我们记为RPC1,Follower收到RPC1, 发生上述代码描述的冲突, 将冲突部分的内容清除, 并追加RPC1中的日志切片。由于并发程度高, Leader在RPC1没有收到回复时又发送了下一个AppendEntries RPC, 由于nextIndex和matchIndex只有在收到回复后才会修改, 因此这个新的AppendEntries RPC, 我们记为RPC2, 与RPC1是一致的。Follower收到RPC2, 由于RPC2和RPC1完全相同, 因此其一定不会发生冲突, 结果是Follower将相同的一个日志项切片追加了2次!
解决方法是逐个检查prevLogIndex位置之后的日志条目,检测是否匹配,如果冲突的话就覆盖,如下
for i, entry := range args.Entries {
index := args.PrevLogIndex + 1 + i
if index < len(rf.log) && rf.log[index].Term != entry.Term {
rf.log = rf.log[:index]
break
}
}
- 对于follower而言,leader的日志是权威的,那是不是意味着每次检测到
prevlogIndex位置或者prevlogTerm不匹配时就直接无条件覆盖自己的呢。
其实除了如果follow只是部分冲突,无条件覆盖的话会导致不断增删的性能开销,还会造成问题,考虑如下场景。 Leader 可能会因为网络延迟等原因,向 Follower 发送一个过期 (outdated)的 AppendEntries RPC。这个过期的 RPC 可能只包含了 Follower 部分已有的日志。如果 Follower 此时错误地截断了,它就会删除那些它已经成功复制给 Leader 并被 Leader 记录在 中的有效日志。这会导致 Follower 需要重新接收这些日志,造成不必要的开销和逻辑错误。
- 节点应该在什么时候重置自己的随机选举超时器。
只有以下三种情况要求重启选举计时器。首先就是收到来自当前leader的appendEntries时,但是如果当前leader的任期是过期的,该节点不应该重置选举计时器,因为此时意味着网络波动,可能进行下一次选举。然后节点在选举超时之后也要重置自己的计时器,这是为了退避。最后在RequestVote函数中,你投票给其他节点时也需要重置你的选举计时器。
最后这条规则尤为重要,在不可靠的网络中,Follower可能拥有不同的日志副本,这可能导致只有少数拥有足够新日志的服务器才能赢得多数选票。如果在任何服务器请求您投票时都重置计时器,那么拥有过时日志的服务器和拥有较新日志的服务器都有相同的机会发起选举。后果就是那些拥有更长、更新日志的服务器,很可能会被过时服务器不断发起的选举所中断,导致它们重置计时器并无法在自己的计时器到期时开始选举。
所以通过仅在授予投票时才重置计时器,可以确保如果被请求投票,但拒绝了(因为请求者的日志不如自己新),自己的计时器不会重置,从而能更及时地开始自己的选举。那些拥有过时日志的服务器(即多数服务器会拒绝投票给它们的服务器)的选举将不太可能干扰到那些有资格成为领导者的服务器的选举,使得后者更有可能成功完成选举并当选领导者。