MIT6.5840(6.824) Lec06笔记: raft论文解读2: 恢复、持久化和快照

课程主页: https://pdos.csail.mit.edu/6.824/schedule.html

本节课是介绍Raft共识算法的第一部分, 建议阅读论文, 如果要做Lab的话, 论文是一定要看的, 尤其是要吃透论文中的图2。

本节课介绍的内容包括: 日志恢复、选举限制、持久化、快照和一致性。

1 日志恢复

1.1 日志恢复的案例

为了举例说明Raft是如何进行日志恢复的, 我们假设有如下表格的情形:

节点\log索引 10 11 12 13
S1 3
S2 3 3 4
S3 3 3 5

表格中存在3个节点S1,S2,S3, 存放的值是每个日志槽位的Term。 假设其在日志槽位0-9都保持完全一致。以下说明了为什么某一时刻会出现上表的状态:

  1. 到槽位10的日志为止, 所有的节点网络正常且工作正常, 此时最高Term为3, LeaderS3
  2. 此后S1故障, 因此槽位11log只被复制在了S2S3
  3. 之后S2,S3之间的网络也出现了故障S2进行选举
  4. 恰好选举时S1恢复了, S1,S2都为S2投票, S2因为获得了超过半数投票而被选为新Leader, 新的Term为4
  5. Leader S2向自身槽位12处追加一个Term为4的log, 然后故障了, 因此槽位12处Term为4的log只存在于S2
  6. 此时S3触发选举超时, 并获取了S1S3的选票成为新Leader, 新的Term为5
  7. Leader S3向自身槽位12处追加一个Term为5的log, 然后准备开始广播, 并且广播前S2也恢复了

以上就是为什么会出现表中所示的内容的原因, 而Raft的日志恢复的目的是, 将Leader的日志强行复制到其他节点, 介绍这个机制前, 需要引入Leader维护的几个变量和AppendEntries RPC中的参数:

Leader维护的变量

  • nextIndex[]: Leader认为下一个追加的日志在每个节点的索引
  • matchIndex[]: Leader认为每个节点中已经复制的日志项的最高索引

AppendEntries RPC中的参数

  • term: Leader的任期
  • leaderId: Leaderid
  • prevLogIndex: 追加的新日志前的日志索引
  • prevLogTerm: 追加的新日志前的日志的Term
  • entries[]: 日志项切片
  • leaderCommit: Leader记录的已经commit的日志项的最高索引

因此, 按照论文Figure 2中的描述, 新的Leader将把nextIndex[]初始化为自身日志数组长度, 发送时的PrevLogIndex就是nextIndex[i] - 1, 因此Leader S3S1S2发送的AppendEntries RPC为:

1
2
3
4
5
6
7
8
args := &AppendEntriesArgs{
Term: 5,
LeaderId: 3,
PrevLogIndex: 11,
PrevLogTerm: 3,
LeaderCommit: 1,
Entrie: ...,
}

因此, Follower收到AppendEntries RPC会根据PrevLogIndex, PrevLogTerm进行检查:

  • S1发现自己没有槽位11log, 返回false
  • S2发现自己有槽位11log, 其Term为3也与AppendEntriesArgs匹配, 因此其使用AppendEntriesArgs中的覆盖原来槽位12处的log, 返回true

Leader S3收到S1S2回复的AppendEntries RPC后, 会做如下处理:

  1. 发现S2回复了true, 因此将S2matchIndex[S2]设置为PrevLogIndex+len(Entries), 将nextIndex[S2]设置为matchIndex[S2]+1
  2. 发现S1回复了false, 于是将其nextIndex[S1]自减, 再次发送的AppendEntries RPC为:
1
2
3
4
5
6
7
8
args := &AppendEntriesArgs{
Term: 5,
LeaderId: 3,
PrevLogIndex: 10,
PrevLogTerm: 3,
LeaderCommit: 1,
Entrie: ...,
}

这时S1发现自己有槽位10log, 其Term也与AppendEntriesArgs匹配, 因此进行追加并返回true, Leader S3按照相同的逻辑处理nextIndex[S1]matchIndex[S1]

1.2 日志恢复的逻辑

从上述的日志恢复的机制我们可以看出, Raft强制将Leader的日志条目覆盖到Follower上, 这一机制的根本前提是: Leader的日志是最新和完整的, 这一前提的实现就是接下来介绍了选举约束

2 选举约束

2.1 机制描述

Raft中选举约束的机制是:

  1. 如果Term更小, 直接拒绝投票
  2. Candidate的最后一条LogTerm大于本地最后一条LogTerm, 投票
  3. 否则, Candidate的最后一条LogTerm等于本地最后一条LogTerm, 且CandidateLog数组长度更长, 投票
  4. 否则, 拒绝投票

在之前的AppendEntries RPC中的参数中, 包含了Term, 其表示CandidateTerm, 为什么不使用CandidateTerm进行比较而实用最后一条LogTerm进行比较呢? 因为使用CandidateTerm进行比较会出现很多问题, 例如孤立节点:

  1. 某一时刻一个server网络出现了问题(称其为S), 其自增currentTerm(即记录自身的Term的字段)后发出选举, 经过多次选举超时后其currentTerm已经远大于离开集群时的currentTerm
  2. 后来网络恢复了正常, 这时其他的服务器收到了S的选举请求, 这个选举请求有更新的term, 因此都同意向它投票, S成为了最新的leader
  3. 由于S离开集群时集群其他的服务器已经提交了多个log, 这些提交在S中不存在, 而S信任自己的log, 并将自己的log复制到所有的follower上, 这将覆盖已经提交了多个log, 导致了错误

2.2 案例

此处还是举之前的那一个例子:

节点\log索引 10 11 12 13
S1 3
S2 3 3 4
S3 3 3 5

但我们假设这个到达这个状态(S3Leader, Term为5, 且向自身追加log前已经发送了心跳, 即同步了Term)后, S3又故障了, 然后其马上又恢复, 此时没有Leader, 将触发选举超时, 假设S1先触发选举超时, 其广播投票的RequestVote RPC, 其论文Figure2描述的请求参数为:

  • term: candidateTerm
  • candidateId: candidateid
  • lastLogIndex: candidate 最后一个日志项的索引
  • lastLogTerm: candidate 最后一个日志项的Term

因此, S1发出的RequestVote RPC参数为:

1
2
3
4
5
6
args := &RequestVoteArgs{
Term: 5,
candidateId: 1,
lastLogIndex: 10,
lastLogTerm: 3,
}

其余节点的反应为:

  • S2S3发现其RequestVoteArgsTerm为5, 进行下一步判断, 但发现lastLogTerm比自己的45更小, 因此拒绝投票

此后, S2发起了选举, RequestVote RPC参数为:

1
2
3
4
5
6
args := &RequestVoteArgs{
Term: 5,
candidateId: 2,
lastLogIndex: 12,
lastLogTerm: 4,
}

其余节点的反应为:

  • S1发现其RequestVoteArgsTerm为5, 进行下一步判断, 发现lastLogTerm为4, 比自己的3更大, 投票
  • S3发现其RequestVoteArgsTerm为5, 进行下一步判断, 发现lastLogTerm为4, 比自己的5更小, 拒绝投票

S2收获了自身和S1两张选票, 满足过半的要求, 成为新的Leader, 并在稍后将通过心跳将自己的Term为4的那个log覆盖掉S3中相同位置的log

3 快速恢复

3.1 快速恢复的需求

在之前日志恢复的介绍中, 如果有Follower的日志不匹配, 每次RPC中, Leader会将其nextIndex自减1来重试, 但其在某些情况下会导致效率很低(说的就是Lab2的测例), 其情况为:

  1. 某一时刻, 发生了网络分区, 旧的leader正好在数量较少的那一个分区, 且这个分区无法满足commit过半的要求
  2. 另一个大的分区节点数量更多, 能满足投票过半和commit过半的要求, 因此选出了Leader并追加并commit了很多新的log
  3. 于此同时, 旧的leader也在向其分区内的节点追加很多新的log, 只是其永远也无法commit
  4. 某一时刻, 网络恢复正常, 旧的Leader被转化为Follower, 其需要进行新的Leader的日志恢复, 由于其log数组差异巨大, 因此将nextIndex自减1来重试将耗费大量的时间

因此, 在上述情况下, 需要进行快速恢复的优化

3.1 快速恢复的机制

论文中描述如下:

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.

论文中的描述过于简略, 教授在课堂上进行了进一步的解释, 其思想在于:Follower返回更多信息给Leader,使其可以以Term为单位来回退

具体而言, 需要在AppendEntriesReplay中增加下面几个字段:

  • XTerm: Follower中与Leader冲突的Log对应的Term, 如果Follower在对应位置没有Log将其设置为-1
  • XIndex: Follower中,对应TermXTerm的第一条Log条目的索引
  • XLen: 空白的Log槽位数, 如果Follower在对应位置没有Log,那么XTerm设置为-1

Follower收到回复后, 按如下规则做出反应:

  1. 如果XTerm != -1, 表示PrevLogIndex这个位置发生了冲突, Follower检查自身是否有TermXTerm的日志项
    1. 如果有, 则将nextIndex[i]设置为自己TermXTerm的最后一个日志项的下一位, 这样的情况出现在Follower有着更多旧Term的日志项(Leader也有这样Term的日志项), 这种回退会一次性覆盖掉多余的旧Term的日志项
    2. 如果没有, 则将nextIndex[i]设置为XIndex, 这样的情况出现在Follower有着Leader所没有的Term的旧日志项, 这种回退会一次性覆盖掉没有出现在Leader中的Term的日志项
  2. 如果XTerm == -1, 表示Follower中的日志不存在PrevLogIndex处的日志项, 这样的情况出现在Followerlog数组长度更短的情况下, 此时将nextIndex[i]减去XLen

3.2 案例说明

如下所示为各个节点的状态, 此时LeaderS3, 其将要广播Term为6的AppendEntries RPCFollower:

节点\log索引 10 11 12 13
S0 4
S1 4 5 5
S2 4 4 4
S3 4 6 6 6

其请求的AppendEntriesArgs为:

1
2
3
4
5
6
7
8
args := &AppendEntriesArgs{
Term: 6,
LeaderId: 3,
PrevLogIndex: 12,
PrevLogTerm: 6,
LeaderCommit: 1,
Entrie: ...,
}
  1. 情况1: S0:
    • S0PrevLogIndex位置不存在log, 其返回XTerm=-1 && XLen=2
    • Follower收到回复后, 将nextIndex[S0]减去XLen=2, 下次发送时PrevLogIndex=10. 将进行正常的追加日志
  2. 情况2: S1:
    • S1PrevLogIndex位置的Term发生了冲突, 其返回XTerm=5 && XIndex=11
    • Follower收到回复后, 发现自己没有Term =5的日志项, 将nextIndex[S1]设置为XIndex=11, 下次发送时PrevLogIndex=10. 将进行正常的追加日志并覆盖掉Term=5的部分
  3. 情况3: S2:
    • S2PrevLogIndex位置的Term发生了冲突, 其返回XTerm=4 && XIndex=10
    • Follower收到回复后, 发现自己也有Term =4的日志项, 将nextIndex[S1]设置为Term=4的最后一个log的下一位, 即11, 下次发送时PrevLogIndex=10. 将进行正常的追加日志并覆盖掉多余的Term=4的部分

4 持久化

4.1 持久化的内容

持久化存储的目的是为了在服务器重启时利用持久化存储的数据恢复节点上一个工作时刻的状态。并且,持久化的内容仅仅是Raft层, 其应用层不做要求。

论文中提到需要持久花的数据包括:

  1. votedFor:
    votedFor记录了一个节点在某个Term内的投票记录, 因此如果不将这个数据持久化, 可能会导致如下情况:
    1. 在一个Term内某个节点向某个Candidate投票, 随后故障
    2. 故障重启后, 又收到了另一个RequestVote RPC, 由于其没有将votedFor持久化, 因此其不知道自己已经投过票, 结果是再次投票, 这将导致同一个Term可能出现2个Leader
  2. currentTerm:
    currentTerm的作用也是实现一个任期内最多只有一个Leader, 因为如果一个几点重启后不知道现在的Term时多少, 其无法再进行投票时将currentTerm递增到正确的值, 也可能导致有多个Leader在同一个Term中出现
  3. Log:
    这个很好理解, 需要用Log来恢复自身的状态

这里值得思考的是:为什么只需要持久化votedFor, currentTerm, Log

原因是其他的数据, 包括 commitIndexlastAppliednextIndexmatchIndex都可以通过心跳的发送和回复逐步被重建, Leader会根据回复信息判断出哪些Logcommit了。

4.2 什么时候持久化

由于将任何数据持久化到硬盘上都是巨大的开销, 其开销远大于RPC, 因此需要仔细考虑什么时候将数据持久化。

如果每次修改三个需要持久化的数据: votedFor, currentTerm, Log时, 都进行持久化, 其持久化的开销将会很大, 很容易想到的解决方案是进行批量化操作, 例如只在回复一个RPC或者发送一个RPC时,才进行持久化操作。

5 快照

5.1 为什么需要快照?

Log实际上是描述了某个应用的操作, 以一个K/V数据库为例, Log就是Put或者Get, 当这个应用运行了相当长的时间后, 其积累的Log将变得很长, 但K/V数据库实际上键值对并不多, 因为Log包含了大量的对同一个键的赋值或取值操作。

因此, 应当设计一个阈值,例如1M, 将应用程序的状态做一个快照,然后丢弃这个快照之前的Log

这里有两大关键点:

  1. 快照是Raft要求上层的应用程序做的, 因为Raft本身并不理解应用程序的状态和各种命令
  2. Raft需要选取一个Log作为快照的分界点, 在这个分界点要求应用程序做快照, 并删除这个分界点之前的Log
  3. 在持久化快照的同时也持久化这个分界点之后的Log

引入快照后, Raft启动时需要检查是否有之前创建的快照, 并迫使应用程序应用这个快照。

5.2 快照造成的Follower日志缺失问题

假设有一个Follower的日志数组长度很短, 短于Leader做出快照的分界点, 那么这中间缺失的Log将无法通过心跳AppendEntries RPC发给Follower, 因此这个确实的Log将永久无法被补上。

  • 解决方案1:
    如果Leader发现有FollowerLog落后作快照的分界点,那么Leader就不丢弃快照之前的Log

这个方案的缺陷在于如果一个Follower落后太多(例如关机了一周), 这个FollowerLog长度将使Leader无法通过快照来减少内存消耗。

  • 解决方案2:
    这也是Raft采用的方案。Leader可以丢弃Follower落后作快照的分界点的Log。通过一个新的InstallSnapshot RPC来补全丢失的Log, 具体来说过程如下:
    1. Follower通过AppendEntries发现自己的Log更短, 强制Leader回退自己的Log
    2. 回退到在某个点时,Leader不能再回退,因为它已经到了自己Log的起点, 更早的Log已经由于快照而被丢弃
    3. Leader将自己的快照发给Follower
    4. Leader稍后通过AppendEntries发送快照后的Log