一、概述

  在实现Raft算法的过程中遇到了一些比较极端的情况,即由于 网络分区 等原因造成了LeaderFollower之间大量的数据不一致,而使用基础的Raft回退机制每次只能回退一个日志条目,有的时候可能需要发送上百个AppendEntries RPC才能完成一次数据同步,后来通过阅读论文发现了论文中提供的这种Rollback Optimization机制,优化后果然解决了问题,所以特地写博文来记述一下Raft中的这种Rollback Optimization

二、论文描述

  If desired, the protocol can be optimized to reduce the number of rejected AppendEntries RPCs. For example, when rejecting an AppendEntries request, the follower can include the term of the conflicting entry and the first index it stores for that term. With this information, the leader can decrement nextIndex to bypass all of the conflicting entries in that term; one AppendEntries RPC will be required for each term with conflicting entries, rather than one RPC per entry. In practice, we doubt this optimization is necessary, since failures happen infrequently and it is unlikely that there will be many inconsistent entries.

   对于Rollback Optimization优化方式论文中的说法是这样的,在有必要的情况下可以时使用这种优化方式来减少被拒绝的AppendEntries RPC的数量,具体的做法就是让Follower根据产生冲突日志项的Term,在其日志中查找该Term所对应的第一个日志项的索引,通过这个索引的信息Leader就可以在递减nextIndex的过程中一次性跳过发生冲突Term的所有日志项,因此Leader每次发送给FollowerAppendEntries RPC就可以一次性的跳过发生冲突的一整个Term而不再仅仅是跳过一个日志项。但是正如论文中所述,因为导致不一致的失败很少发生,所以不太可能会出现有很多不一致条目的情况,因此是否需要使用这样的优化方式也是有待考虑的。

三、优化前提

   在正式开始分析这种优化方式之前,我们有必要先了解一下Raft为什么要使用这样的优化方式和其为什么可以使用这样的优化方式,如果想要搞清楚这些就要从Raft的数据同步方式开始讲起。

3.1 Raft 的 Log Replication

   Raft一致性算法是一个比较完善的一致性同步算法,它具备完善的Leader ElectionLog Replication以及Log Compaction等机制,其中比较重要的就是其Log Replication机制,整个的同步过程比较复杂所以这里也不打算讲解太多的细节,我们就大致总结一下整个的日志数据同步过程,而整个的Log Replication需要从Leader以及Follower两方来看。
  首先是Raft Log ReplicationLeader的流程:

  1. ClientLeader发送写请求;
  2. Leader将写请求解析成操作指令追加到本地日志文件中(append log);
  3. Leader对每一个Follower广播发送AppendEntries RPC
  4. Follower通过一致性检查,选择从哪个位置开始追加Leader发送的日志条目;
  5. 一旦日志项Commit成功,Leader就将该日志条目对应的指令Apply到本地状态机,并向Client返回操作结果。
  6. Leader后续通过AppendEntries RPC将已经成功Commit(大多数Follower已经成功接收到该日志条目)的日志项告知Follower
  7. Follower收到提交的日志项后,将其Apply到本地状态机;

  其次是Raft Log ReplicationFollower的流程:

  1. 如果接收到来自LeaderAppendEntries RPC中的term参数小于当FollowercurrentTerm属性,即Leader的任期号小于Follower当前的任期号,则直接返回Follower的任期号和false表示拒绝接收该次AppendEntries RPC
  2. 如果Follower在参数中prevLogIndex位置的日志任期号与参数中prevLogIndex不相等,则返回参数中的term以及返回false表示日志不匹配所以拒绝本次AppendEntries RPC
  3. 如果上面两步都正常进行,则可以确认Follower中从起始位置到索引prevLogIndex位置的所有日志项都是与Leader一致的,此时Follower应将prevLogIndex后面的所有无效日志项全部清除(这里可以先简单理解为一致性检查是从后向前进行的,如果匹配到合适的日志项,那么就可以认为该日志项后面的日志都是不匹配的);
  4. Follower删除掉无效的日志条目后就可以认为已经完成了一致性检查,因此将Leader发送AppendEntries RPC中的日志条目全部添加到Follower的日志条目后,完成日志数据的同步;
  5. 最后如果AppendEntries RPC中的参数LeaderCommit大于当前FollowercommitIndex,则将FollowercommitIndex更新为LeaderCommitFollower本地日志长度的最小值;
  6. 最后可以Follower可以判断当前其维护的lastApplied属性值是否小于其commitInde属性值,如果小于则更新lastApplied使其与commitIndex一致,同时Apply相应的日志项;

   到这里我们就大概了解了Raft Log Replication的实现方式,如果你看完这些还是觉得很模糊也不要紧,因为这里涉及了许多的AppendEntries RPCRaft节点中的属性和参数,所以可能听起来有点云里雾里,但是不要紧后面会有博文来仔细分析这里面每一步的流程,现在的话你只需要有个大概的印象,知道在Raft Log Replication有一步是专门进行一致性检查的就足够了,下面我们就来分析一下这个一致性检查的具体流程。

3.2 Log Replication 中的一致性检查

• If two entries in different logs have the same index and term, then they store the same command.
• If two entries in different logs have the same index and term, then the logs are identical in all preceding entries.

  首先如果想要理解Raft一致性检查的方式原理及其与Raft的关系,就需要理解在Raft算法论文中对于Raft Log Replication所描述的两条特性。

  1. 如果在不同的日志中两个条目有着相同的索引和任期号,则它们所存储的命令是相同的;
  2. 如果在不同的日志中两个条目有着相同的索引和任期号,则它们之前的所有条目是完全一样的;

  那么问题来了,为什么在Raft算法中会存在这两条特性呢?

   首先对于第一条,在论文中明确指出Leader Append-Only,即Leader永远不会覆盖或者删除日志条目,只会添加日志条目,并且在一个给定的日志索引上只会存在一个日志条目,那么也就意味着一旦日志条目在一个任期内被创建后,那么该日志条目在日志中的索引就永远不会改变。因此如果两个日志中某条日志条目的任期term一致,那么可以确定它们是在同一个任期内被同一个Leader添加的(首先任期号是递增的,其次Raft Leader Election可以保证同一任期仅存在一个Leader),又因为它们的索引是一样的,根据Leader Append-Only,所以那么它们的数据一定是一致的。

   其次对于第二条,这条与Raft的一致性检查就密切相关了,根据第一条和Leader Append-Only,我们可以确定如果在不同的日志中两个条目有着相同的索引和任期号,则它们所存储的命令是相同的,因此我们就可以借助每次的AppendEntries RPC来完成LeaderFollower的一致性检查,下面就来谈一谈Raft中一致性检查的实现方式。

   首先思考最简单的情况,即当首次选出Leader后日志文件全部为空,此时FollowerLeader的日志一定是一致的(全部为空)。其次当出现网络故障或程序故障导致Raft节点同步日志丢失时,此时Leader获选后可能存在Follower的日志多于(无效的未提交日志)或少于(因故障丢失日志)情况,那么此时Leader就需要找到Follower中第一个与它日志条目不一致的位置,然后让Follower清除掉该位置之后所有不一致的日志条目来实现日志对齐,最后Leader再将该位置后面的所有日志发送给Follower让其与自己保持一致。

   具体的数据对齐方式就是利用AppendEntries RPC中的prevLogIndexpreLogTerm两个参数,首先prevLogIndex参数对应的是Leader要发送日志起始位置的前一个位置的索引,而prevLogTerm参数对应的是prevLogIndex所对应的任期号,通过上面提到的第一条特性我们可以利用这个两个参数确定两者日志prevLogIndex处的日志条目是否相等。当Follower接收到Leader发送的AppendEntries RPC后,就会检查自己日志中prevLogIndex索引处日志条目的term是否与prevLogTerm相同,如果相同则接受该AppendEntries RPC清除该位置后所有不一致的日志条目并回复true告知Leader它已经完成了数据一致性检查并正常接收了数据

  但是如果不相同,那么就证明Follower中的prevLogIndex处的日志条目与Leader中不一致,所以它不会接收该AppendEntries RPC中的日志并会返回false告知Leader一致性检查失败,因此Leader接收到消息后就会将prevLogIndex进行 减一 后再次发送,直至Follower返回true表示数据一致性检查成功,所以会一次性将该位置往后的所有日志条目一次性发送给FollowerprevLogIndex是确认一致的最后一个日志条目索引,所以应该从它的下一个位置开始发送),而这时在Follower中可以认为从该位置往后的所有数据都是与Leader不一致(冲突)的(因为一致性检查是从后向前检查的,Leader起始时会默认认为所有Follower的日志都跟自己一样新,所以其实Leader发送的第一条AppendEntries RPC中的prevLogIndex就是自己最后一条日志条目的索引),所以在添加Leader发送的新日志条目之前会清除掉自己prevLogIndex索引后所有的日志条目来与Leader实现同步。
6.824 Fault-tolerant key/value storage system v1.0(二)(Rollback Optimization)

四、优化思路

4.1 基础版本回退操作存在的问题

  在上面的叙述中我们已经对Raft的一致性同步方式有了一个大概的了解,但是正如论文中所提这个一致性同步的过程存在着可以优化的空间,思考一种情况即当网络条件较差时,可能会出现大量Follower的数据与Leader的数据不一致的情况,且这个时候一般还会伴随着频繁选举的可能性,在这个频繁更换Leader的过程中就会进行多次的数据同步操作,如果按照基础版的Raft进行实现那么当LeaderFollower进行数据一致性检查的时候每次只能够回退一个日志条目(prevLogIndex--),这样就会造成Leader频繁的发送AppendEntries RPC来进行同步,不但减慢了一致性同步的速度,同时也占用了大量的带宽。

4.2 具体优化方式

   那我们可以思考一下真的每个AppendEntries RPC仅仅只能回退一个日志条目吗?显然不是的,按照论文中提供的思路,实现优化的方式即可以让每个AppendEntries RPC都完成一整个term的回退。具体的实现方式就是让Follower的回复中带上刚刚发生冲突的日志条目的任期在日志中第一个日志条目索引index,这样下一次Leader再发送AppendEntries RPC的时候就会发送这个日志条目的前一个日志条目的索引和任期(这里可以暂时理解为Leader所做的还是prevLogIndex--操作,只不过这次是首先将Follower返回的index赋给了prevLogIndex,然后又进行了prevLogIndex--操作),换句话说下次Leader再发送的AppendEntries RPC就已经跳过了Follower中刚刚发生冲突的日志条目任期的所包含的所有日志。

4.3 优化分析

   上面介绍了大致的具体优化方式,但是不知道你们有没有这样的疑问,这种回退方式不再是每次回退一个日志条目,而是一次回退一整个任期的日志条目,那会不会漏掉LeaderFollower中明明可匹配的日志条目,导致Follower把明明已经同步了的日志条目也给清除掉了?

  我觉得首先我们需要确定的一点,即这是一种 比较激进的优化方式 ,但虽然不能证明这个冲突的term中的日志条目都是不正确的(需要被回退的),但是这种优化方式在 一定的场景 下是确实可以提高效率。

  对于这个问题我是这么考虑的,我们考虑最坏的情况即这种大范围的回退方式真的有可能会漏掉明明已经同步完成的日志条目,也就是这个条目之前已经在LeaderFollower中同步完成了,但是由于回退是回退一整个任期内的所有日志条目,所以这个其实已经完成了同步的日志条目也被回退了(被Follower清除了,然后再次由Leader发送给Follower)。但是我们可以思考一下这种情况,就算真的漏掉了已完成同步的日志条目从而导致其被重新同步,这样好像也没有什么太大的问题,说白了至少从始至终LeaderFollower中的数据是可以保持一致的,并且使用重发一个同步日志条目的代价减少了可能上百次的AppendEntries RPC的操作,所以这么看来还是很划算的,但这是在LeaderFollower大量不一致数据 的情况下,即需要进行数据同步而发送的AppendEntities RPC数量太多,这个时候我们与其一个一个的发送AppendEntries RPC去进行一步一步的回退,还不如一次性的回退一整个term然后再将后面需要同步的数据一次性的补发过去。

  但是,当LeaderFollower仅存在少数的数据不一致时,这种激进的优化方式可能会因为仅仅最后的几个日志条目的不一致而回退整个term的日志,导致这个term内包括所有已经完成同步的日志全部被重新同步,带来了不必要的资源消耗,所以我想这也就是论文中说在 必要情况 下再使用这种优化方式的原因吧,因为毕竟大多数情况下LeaderFollower之间的数据一致性还是比较高的。

  对于不必要的回退可以举这样一个例子,即假设当前存在一个Leader正在对Follower进行数据同步,此时Leader日志的最后三个条目为[...4,5,5,6],而当前FollowerLeader的日志长度相同,且其日志中最后三个条目为[...4,5,5,5],这个时候LeaderFollower的数据一致性其实已经很高了,如果使用未优化前的回退方式只需回退一次即可,发送的同步数据也仅只需发送一个6 term处的日志条目,但是如果使用优化后的回退方式,因为第一次判断Follower中的一致性失败,所以根据规则会回退一整个term的日志条目,也就是会直接将Follower中的term为 5 的日志条目全部回退,之后再让Leader全部重新发送,而这个时候再发送就是需要发送三个日志条目了。

五、内容总结

  在这篇博文里我们分析了Raft算法论文中提到的Rollback Optimization,但是正如论文中所讲当我们在实现Raft算法时到底有没有必要使用这种优化还是需要仔细考虑的,总体来说它是一种比较激进的优化方式,这主要是因为这种优化机制使得在LeaderFollower数据一致性较高的情况下可能会进行不必要的日志回退。

六、项目源码

https://github.com/TIYangFan/DistributedSystemBaseGolang ( 如果可以帮到你,Please Star ^ _ ^ ~ )

相关文章: