笔记03 - Lab 3: Fault-tolerant Key/Value Service

综述

raft snapshot

随着客户端请求的不断增加,raft的日志项不断增加。为了压缩日志,raft采取快照snapshotting的方式,以某个日志项为基准,将之前的整个系统状态写入到快照文件中,然后删除该日志项之前的所有日志。快照内容通常是所有命令操作过的对象的最新值,作为快照基准的日志项必须是已提交的数据项。

raft的服务器中,无论是leader还是follower,一旦触发快照条件,服务器都会独立采取快照措施以压缩日志。此外,如果leader采取快照措施压缩了自身的日志,但是被丢弃的日志还未发送给follower;这种情况下,leader还需要发送快照到follower以更新follower的日志和快照。幸运的是,这种情况在正常操作中是不可能发生的,因为能与leader通信的follower一般已经保存了这些被丢弃的日志项。但是,网络原因或者follower响应慢或者新服务器加入了raft集群的话,都可能导致被丢弃的日志还未发送给follower。此时,为了保证这些follower的日志up-to-date,必须由leader在网络上向他们发送快照。

快照文件可以分块发送,follower接收到完整的快照文件之后,将丢弃快照所对应的日志部分,但保留快照文件之后的日志。快照文件也可能包含了follower日志不曾有过的内容,这时follower将丢弃整个日志。若follower有未提交的日志项(如被隔离的旧leader接收了请求但无法提交,最后恢复通信变成follower并接收了快照),也将被快照文件所替代。

快照策略偏离了raft的strong leader原则,因为follower本身也可以执行该快照策略,但这并没有破坏raft的状态一致性。follower仍然只能从leader接收日志数据,只不过follower现在可以重新组织他们的数据,但数据来源依然是leader。当然,follower可能先后接收到日志项和快照,因此可能出现先接收了更新的快照,而后才接收到旧的日志项,这种情况下follower应当忽略这些旧日志项。倘若只由leader来创建快照,然后一并发送给follower,将会产生两个问题:1、浪费网络带宽,降低快照处理速度,follower在满足日志压缩的条件下自行压缩日志将大大高效于从网络中接收快照文件并进行存储;2、leader的实现将更复杂,需要并行处理新日志发送和快照文件发送,以不阻塞接收新的客户端请求。

影响快照策略的性能问题有:1、服务器决定进行快照处理的时机。快照太过频繁将浪费磁盘带宽和资源,太过稀少则可能耗尽存储容量,并且重启的时候将消耗大量的时间来恢复日志。一个简单的解决设置阈值,如果该阈值比预期的快照大小大得多,则快照带来的磁盘带宽开销将会很小。2、写快照文件将会花费大量的时间,但我们不希望因此延迟了正常的操作。解决这个问题的方法是使用copy-on-write策略,可以利用操作系统的copy-on-write支持为整个状态机创建一个内存快照。

实验内容

Part 3A: Key/value service without log compaction

本实验部分将基于raft实现一个可容错的kv存储服务。kv存储服务将被构建为复制状态机,包含了多个kv服务器,每个kv服务器都基于raft日志来协调本身的活动。尽管可能发生网络分区或者其他错误,但只要大多数raft服务器是活跃的并且能够互相通信,kv存储服务就必须能够持续处理客户端的请求。

客户端-服务器的布局

kv存储服务体系包括了客户端和kv服务器,每个kv服务器关联到一个raft服务器。客户端发送PutAppend()、Get()的RPC请求到kv服务器上,kv服务器将客户端请求按次序追加到raft日志上。一个客户端可以向任何kv服务器发送rpc,如果kv服务器关联的raft服务器不是leader或者出现错误,客户端需要重新向其他服务器发送请求;各个kv服务器可面向多个客户端,并维护一致的key-value存储。一旦raft提交了对应的请求操作(并因此应用到kv状态机上),kv服务器需要向客户端发送回复。如果kv服务器发现请求操作无法被提交(例如更换了leader),kv服务器需要向客户端发送报告,客户端再重新向其他服务器发送请求。
相关的数据结构是:

1
2
3
4
5
6
7
type Clerk struct {
servers []*labrpc.ClientEnd //客户端可联系的kv服务器
}
type RaftKV struct {
me int
rf *raft.Raft //kv服务器关联的raft服务器
}

kv服务器职责

kv服务器底层借助raft进行实现,因此,kv服务器本身需要长时间等待raft的日志提交通知。针对已提交的操作,kv服务器需要存储key-value对的内容。客户端提供Put()、Append()、Get()三种接口,实际上转换为调用kv服务器可接受的PutAppend()、Get()两种RPC请求。kv服务器需要处理客户端的重复请求。kv服务器之间不进行通信,由其关联的raft服务器之间会保持通信。
相关的数据结构是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 客户端发送Put、Append请求
type PutAppendArgs struct {
Key string
Value string
Op string // "Put" or "Append"
ClerkId int64
ReqId int64
}
// 客户端发送Put、Append请求的回复
type PutAppendReply struct {
WrongLeader bool
Err Err
}
// 客户端发送Get请求
type GetArgs struct {
Key string
ClerkId int64
ReqId int64
}
// 客户端发送Get请求的回复
type GetReply struct {
WrongLeader bool
Err Err
Value string
}
// 客户端参数
type Clerk struct {
lastLeader int //存储已知关联到leader的kv服务器索引
reqId int64 //客户端发送请求序号
clerkId int64 //客户端标志序列
}
// kv服务器参数
type RaftKV struct {
applyCh chan raft.ApplyMsg //用于等待raft的日志提交通知
maxraftstate int //用于压缩raft日志的快照阈值
rpcResult map[int]chan Op //用于等待新追加的日志项的提交
kvMap map[string]string //用于存储kv键值对
ackMap map[int64]int64 //用于识别客户端的重复请求
}
// 日志项参数,kv服务器将客户端的请求转换为该结构,并追加到raft日志中
type Op struct {
Key string
Value string
Op string
ClerkId int64
ReqId int64
}

raft的日志项

raft的日志项内容是一个Op结构体,该结构体内容来自于客户端的请求参数,包括键、值、操作类型、客户端标识符、客户端请求序号。

kv服务器监听日志项提交

kv服务器接到客户端的请求后,将请求参数内容保存到Op结构体中,调用raft.Start()方法尝试将Op追加到raft日志中。如果raft.Start()返回参数中指明该kv服务器的raft服务器不是leader,则kv服务器直接回复客户端,回复参数WrongLeader为true,客户端再次向其他服务器发送请求。如果该kv服务器的raft服务器是leader,则根据返回的日志index,尝试使用select阻塞读取日志项提交结果。kv服务器使用了rpcResult map[int]chan Op数据结构来通知日志项已被raft提交的结果:首先,kv服务器启动时使用go routine监听raft的日志提交通知,即阻塞读取applyCh,一旦有读出,将读出结果Op写入到rpcResult[index]中,键是该日志项的索引index;其次,kv服务器每次成功调用raft.Start()之后(成功指的是服务器为leader),根据raft.Start()返回的日志项索引阻塞读取rpcResult[index],一旦读出成功,表明完成了客户端的请求操作(该表述不一定正确,因为网络分区可能造成在index索引处读取到的日志项并不是所等待的预期日志项,详见后面分析)。这样就完成了kv服务器监听raft日志项提交和通知日志项提交结果的工作。

kv服务器追加Get请求到日志

kv存储服务的一个设计原则是:当客户端发送请求到kv服务器时,如果kv服务器关联的raft服务器不是majority的一部分,则不应该完成该请求。当客户端发送Get请求时,kv服务器没有办法直接判断其关联的raft服务器是不是majority的一部分,因此,一个简单的解决方法是调用raft.Start()将Get请求操作追加到raft日志中,如果能够收到raft的日志项提交通知,说明其关联的raft服务器是majority的一部分,并且该Get请求可完成。

kv服务器对重复请求的处理

kv服务器如何标识客户端请求
由于一个客户端可以向任何kv服务器发送rpc请求,因此kv服务器识别客户端请求时,不仅需要识别该请求是来自于哪个客户端,还需要识别某个客户端的不同请求。因此,本实验使用了两个字段来识别客户端请求,分别是客户端id和请求id。客户端id通过随机数产生,每个客户端在初始化的使用将请求id置为0,之后每次发送请求时,都将请求id加1。
kv服务器处理客户端重复请求的必要性
客户端的重复请求应当被忽略。由于网络分区、网络丢包等原因,客户端可能多次发送同一请求,如果kv服务器不忽略这些重复请求,则写请求会破坏kv键值对的正确存储,造成数据破坏。
kv服务器如何识别重复的客户端请求
kv服务器使用ackMap map[int64]int64结构来确认客户端请求,该map的key值是客户端id,value值是已确认的最大请求id。本实验使用了累加确认的方式,即一旦发现客户端请求id小于或等于已确认的最大请求id,则判断该请求是重复的,这要求客户端有序发送请求,而不能一次性发送多个请求。
kv服务器何时确认客户端请求
原则:当客户端发送请求到kv服务器时,如果kv服务器关联的raft服务器不是majority的一部分,则不应该完成该请求。因此,kv服务器本可以在Get()和PutAppend()两个函数的入口处判断该请求是否是重复请求,以减少服务器的不必要开销,但这违反了上述原则。因此,本实验的方案是尝试将请求操作追加至raft日志中,一旦kv服务器收到raft的日志提交通知,则判断该请求是否是重复请求,如果是则不进行kv存储(对于重复请求的处理:仍需要通过channel通知日志项提交结果,且kv服务器按照请求操作成功进行回复即可,这样客户端收到预期回复后停止重发),否则根据已提交日志项进行kv存储。
kv存储的具体细节是:使用kvMap map[string]string数据结构进行存储,如果操作是Put,则kv.kvMap[args.Key] = args.Value;如果操作是Append,则kv.kvMap[args.Key] += args.Value;如果操作是Get,无需存储;最后,将该请求的请求id登记到对应的客户端id上(kv.ackMap),表明当前对该客户端id已确认的最大请求id。注意到,不需要对Get请求进行存储,但需要对Get请求进行确认。 另一个需要注意的问题是:判断是否是重复请求以及进行kv存储这两个操作需要在同一个锁范围内执行,否则,对于两个相同的请求,可能导致在进行kv存储前执行了两次判断操作,并一致认为该请求不重复,最终对同一请求追加了两次值

补充:客户端如何发送请求

kv服务器处理请求但回复丢失(Call的调用结果被置为false),或者客户端超时重传(如果有),都可能造成客户端重复发送请求的情况。
设计1:客户端只有在本次发送请求并成功收到预期回复时才可以进行下次发送(本实验采取的设计)。对于这种情况,kv服务器接收到的每个客户端的请求将是有序的。那么,由于网络原因导致客户端重发请求时,kv服务器可以采用累加确认结果来判别是不是重复请求(当前请求id小于等于已确认的最大请求id则表明是重复请求)。累加确认方法只记录每个客户端的最高确认项。
设计2:客户端可并发发送多个请求。对于这种情况,请求id小的请求不一定先被kv服务器接收到,这意味着先被提交的请求操作,其请求id不见得更小。因此,为了能识别重复请求,应当对每个客户端的每个请求进行单独记录,即不再采用累加确认方法,该方案存在的缺点是kv服务器需要维护的数据量大大增加。另一种解决方案是仍旧采用累加确认方法,并且需要在确认更大的请求id之前提示客户端重传之前尚未确认的数据包,该方案需要重新设计客户端-kv服务器的通信协议,并且可能加大网络负载。

kv服务器等待日志项提交结果的通知时设置超时

kv服务器接收客户端请求并调用raft.Start()方法尝试将请求操作追加至raft日志中,如果调用成功且kv服务器所关联的raft服务器是leader,则kv服务器会阻塞读取rpcResult[index],以等待日志项提交结果的通知。在此,是否需要设置超时等待呢?
kv服务器等待的是索引index所对应日志项的提交结果通知,而没有针对具体的leader。raft可能在kv服务器等待期间更换了leader,考虑以下场景:
raft集群启动后,客户端向kv服务器发送rpc请求,kv服务器将请求操作发送到所关联的raft服务器leader A,然后等待提交结果通知。leader A将请求操作追加到日志,并且复制到其他服务器。其他服务器都成功接收到了日志,但此时leader A还未commit该日志项。这时leader A宕机,B成为新的leader。kv服务器若没有设置等待超时,则客户端会一直等待回复,不会发送新的请求;然而,leader B在当前新任期内倘若没有追加新的日志项,是不会提交旧日志项的(未提交的部分),导致kv服务器一直收不到日志项提交结果的通知,造成死锁。
因此,kv服务器需要在等待日志项提交结果的通知设置超时等待。这里有两种设计方案:1、一旦超时则直接告知客户端是wrong leader,客户端尝试从下一个服务器开始重发请求。2、使用for无限循环和select机制,阻塞等待日志项提交结果的通知和进行超时处理,一旦超时就检查当前所关联raft服务器的状态,一旦发现不是leader,则跳出循环,告知客户端是wrong leader,否则继续下一次select。

kv服务器收到日志项提交结果的通知后判断是否是预期结果/网络分区对kv服务器的影响

kv服务器接收客户端请求并调用raft.Start()方法尝试将请求操作追加至raft日志中,如果调用成功且kv服务器所关联的raft服务器是leader,则kv服务器会阻塞读取rpcResult[index],以等待日志项提交结果的通知。接收到通知后,是否可以直接断定该请求已完成了呢?
考虑以下场景:
一开始,kv服务器A所关联的raft服务器是leader,之后发生了网络分区,kv服务器A所关联的raft服务器处于少数机子可连通的分区,此时kv服务器A所关联的raft服务器仍然以为自己是leader。kv服务器A接收了客户端的请求,raft.Start()仍表明是leader,日志项的索引index为1,然后kv服务器A等待raft的日志项提交。大多数机子可连通的分区选举了新的leader,新leader接收了新日志项并成功提交(index为1)后,网络分区恢复了。这时候,kv服务器A所关联的raft服务器接收了来自于新leader的日志项并提交,kv服务器A收到了index为1的日志项提交通知,但此时该日志项并不是预期的日志项。这种情况下,应当告知客户端是wrong leader,让客户端重新发送请求。因此,kv服务器接收到通知后,应当判断接受结果是否等同于预期的请求操作。

客户端解决非leader问题

kv服务器发现是非leader的时机:
1、kv服务器调用raft.Start()追加请求操作到raft日志时,返回参数指明raft服务器是否是leader。
2、kv服务器使用for无限循环和select机制来阻塞等待日志项提交结果的通知和进行超时处理时,通过raft.GetState()查看当前raft服务器是不是leader。
客户端对非leader的处理:
1、客户端使用了lastLeader来记录已知的kv服务器索引。每次调用Call向服务器发送请求后,调用成功并且回复的wrongLeader为false时,更新lastLeader。
2、在网络可靠的情况下,这种做法很大几率地避免了轮询服务器发送请求的情况。但在网络不可靠的情况下,很有可能出现以下情况:调用Call向关联leader的kv服务器发送请求,由于Call失败转而向非leader重新发送请求;如果客户端支持并发发送请求,某个程序对于同个客户端的lastLeader的修改可能会影响其他程序对于lastLeader的读取,进而影响其他程序轮询服务器发送请求。

kv服务器重启的影响及数据持久化

kv服务器重启意味着之前的kv键值对数据丢失、确认数据丢失,后续请求可能获取不到正确的数据。在本实验中,kv服务器重启意味着使用相同的配置条件重新Make一个kv服务器,所以对应的raft服务器也是重新Make出来的。raft的启动将使用其持久化数据,对于kv服务器而言,有没有必要考虑数据持久化呢?
对于这个问题有两个解决方案,其一是仿照raft为kv服务器进行持久化数据存储,一旦更新kv服务器的数据则进行持久化存储,kv服务器启动时使用其持久化数据;其二是借助raft的底层实现,kv服务器本身不采取数据持久化的做法,具体方案是:
raft持久化数据不能包括已提交的最高索引和已应用的最高索引!当kv服务器重启时,其数据为空,如果raft持久化了已提交的最高索引和已应用的最高索引,则已提交过的日志项数据无法重构到kv服务器中。相反,若raft服务器重新启动时读取了持久化数据,并设置已提交的最高索引和已应用的最高索引均为0,则其所有日志项都会按索引顺序通知到kv服务器,kv服务器按顺序读出日志项内容后,对kv键值对和确认数据进行重构即可。当kv服务器重启后,对于新接收的请求操作,假如其日志项index为i,根据日志项提交通知的实现,只有当kv服务器收到index小于等于i的所有日志项的提交通知后才会对该请求操作进行回复,因此保证了数据的完整性。

Part 3B: Key/value service with log compaction

直至目前为止,kv服务器重启时将借助整个raft日志来恢复状态。但是,对于长时间运行的raft服务器来说,不可能一直保持完整的日志。本实验将借助raft和kv服务器之间的协作来节省空间:kv服务器时不时将当前状态持久化存储到快照中,raft则根据快照覆盖范围来丢弃日志。当kv服务器重启或者远远慢于leader的进度且必须跟进时,服务器首先安装快照,然后从创建快照的基准点之后开始恢复日志(实际上本实验没有进行硬盘操作,因此是先恢复了持久化的日志数据,然后根据快照对日志进行截断)。

参考In Search of an Understandable Consensus Algorithm一文,可大致了解到,raft的快照形式为:

结合到本实验中,实际上snapshot是由kv服务器提供的,而不是raft服务器自身产生,raft服务器的日志实现为业务无关型,当然也没有办法收集到所有命令操作过的对象的最新值。kv服务器接收到raft的日志项提交通知后,判断是否达到快照阈值,如果达到,则将本身的kv数据和请求确认数据写入到snapshot后,发送给对应的raft服务器,之后raft服务器再获取快照文件对应的最后一个日志项的index和term,然后和kv服务器传来的snapshot一起持久化到自身的snapshot中,并且压缩自身的日志。

倘若leader丢弃的日志还未复制给follower,则leader会通过rpc将最新的snapshot发送给follower,具体格式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
快照请求数据:
term: leader的任期号
leaderId: leaderId用于选民对客户机请求重定向
lastIncludedIndex: 快照文件所囊括的日志项上限的索引(包括lastIncludedIndex)
lastIncludedTerm: lastIncludedIndex对应的日志项任期号
offest: 数据块在整个快照文件中的偏移(byte)
data[]: 快照文件的某个字节码数据块,从offest开始
done: 是否是最后一个数据块

快照回复数据:
term: 当前任期,用于leader更新自己的任期

快照的接收实现:
1、如果leader的任期号term < 当前任期currentTerm,直接回复
2、如果是第一个数据块(offest为0),创建snapshot
3、将data写入到snapshot的offest处
4、如果不是最后一个数据块,回复并等待其他数据块
5、保存snapshot文件,丢弃任何已存在的或局部的index更小的snapshot
6、如果已存在的日志项拥有snapshot囊括的最后一个日志项,丢弃该日志项之前的所有日志,维护之后的日志,回复
7、丢弃整个日志
8、使用snapshot的数据重设状态机(加载snapshot的集群配置)。

本实验没有将snapshot分块传输,而是直接一整块发送。raft的follower接收到leader的snapshot之后,将该snapshot数据回传给上一层的kv服务器,而kv服务器使用该snapshot更新自身的kv数据和请求确认数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
快照请求数据:
term: leader的任期号
lastIncludedIndex: 快照文件所囊括的日志项上限的索引(包括lastIncludedIndex)
lastIncludedTerm: lastIncludedIndex对应的日志项任期号
data[]: 快照文件的某个字节码数据块,从offest开始

快照回复数据:
term: 当前任期,用于leader更新自己的任期
nextIndex: 回复者告知leader下次发送的日志项索引

快照的接收实现:
1、如果leader的任期号term小于当前任期currentTerm,直接回复
2、如果当前任期currentTerm小于请求参数中leader的任期号,更新当前任期号,立即将状态转换为选民。
3、返回参数中附上当前任期号。
4、往chanHeartBeat写入true。
5、判断当前commitIndex是否大于lastIncludedIndex,是则将返回参数的nextIndex赋值commitIndex+1,返回。
6、保存snapshot文件。
7、如果已存在的日志项拥有snapshot囊括的最后一个日志项,丢弃该日志项之前的所有日志,维护之后的日志,否则丢弃整个日志。
8、更新commitIndex和lastApplied为lastIncludedIndex。
9、使用snapshot的数据重设状态机(加载snapshot的集群配置)。
10、将返回参数的nextIndex赋值lastIncludedIndex+1,返回。

快照策略执行的流程

本实验的快照策略执行流程为:1、tester在创建kv服务器时绑定了快照阈值maxraftstate;2、kv服务器接收客户端请求并发送给对应的raft服务器,然后等待日志提交结果的通知,每接收到一个通知之后,判断maxraftstate与关联的raft服务器的持久化数据的大小(包括日志),一旦超出maxraftstate,kv服务器将自身的kv数据和请求确认数据打包到snapshot中,连同刚通知的日志项index发送给raft服务器;3、raft服务器根据index获取已提交日志项的任期号,压缩自身日志,然后将index、term、kv服务器的snapshot打包存储为自身新的snapshot中;4、任何raft服务器均可以响应对应kv服务器的快照并压缩自身的日志和进行snapshot存储。5、leader心跳包发送日志项期间,假如发现有未发送的日志项被丢弃,则采取将持久化的snapshot发送到follower的措施;6、follower接收到snapshot后,以该snapshot为准进行日志压缩和snapshot存储,并将snapshot发给kv服务器以更新kv服务器自身的kv数据和请求确认数据。7、raft服务器重启时,首先读取状态持久化数据,恢复日志,然后读取快照数据,进行日志截断,最后将snapshot发给kv服务器以更新kv服务器自身的kv数据和请求确认数据。

raft的日志压缩

raft的日志没有使用磁盘存储,而是采用了slice实现,因此为了能够保证日志截断后的正常索引,raft的日志项需要存储index。每次截取日志后,raft将lastIncludedIndex和lastIncludedTerm对应的日志项作为log[0],该日志项已经被丢弃,将其作为当前第一个日志项在某些场景下可以带来计算方便,比如InstallSnapshot RPC参数中的lastIncludedIndex和lastIncludedTerm的获取。

快照内容的构成

kv服务器在监听到raft的日志项提交通知后,判断是否需要执行快照策略。对于kv服务器来说,其需要识别重复的客户端请求,也需要存储kv键值对内容,因此由kv服务器发给raft服务器的快照内容包括了kv数据和请求确认数据,同时还包括此时被通知的日志项的index。raft服务器则是根据index获取到对应的日志项的term,然后将这两者作为lastIncludedIndex和lastIncludedTerm,连同kv服务器发来的快照内容一起打包进了snapshot中。
由此也可看出,快照的内容针对的是已提交的日志项及其导向的kv数据,未提交的日志项数据不会存储在快照中。

快照策略的时机

三个时机:
1、kv服务器根据快照阈值maxraftstate和raft服务器的持久化数据的大小通知其所关联的raft服务器执行快照策略;
2、leader在发送日志项的过程中,根据nextIndex[i]和log[0].Index判断是否有未发送的日志项被丢弃。如上所述,log[0]对应的是最新的快照文件所囊括的最大的日志项;而nextIndex[i]的更新时机是在leader成功向follower追加日志并接收到回复;因此,如果nextIndex[i] <= log[0].Index,说明对于该follower有未发送的日志项被丢弃的现象,leader应该将其snapshot发送给follower,而不是发送日志。由此也可看出,leader发送snapshot是在广播发送心跳包/日志项的过程中额外添加的处理,如果发送失败,则在后面的心跳包期间会继续尝试发送。另一方面,由于网络原因,可能出现leader发送追加日志后又发送更新的snapshot,而后follower先接收处理snapshot,再处理过时的追加日志,这种时候应该将过时的追加日志丢弃(判断条件是参数中PrevLogIndex小于follower快照中的最后一个日志项的index)
leader成功发送snapshot之后,应当及时更新nextIndex[i]和matchIndex[i];follower成功接收到snapshot后,应当及时更新commitIndex和lastApplied为lastIncludedIndex。
注意:考虑以下情景:1、leader发送日志和commitIndex N到follower A,A追加日志,并将日志提交到索引N;2、A回复leader,回复丢失,leader无法更新nextIndex[];3、leader压缩日志,根据nextIndex[]和快照内容,判断出该follower有未发送的日志项被丢弃的现象,leader发送snapshot和lastIncludedIndex M;4、A接收snapshot,压缩日志,更新commitIndex和lastApplied为M;5、由于M < N,(M,N]范围内的日志项再次被提交。上述场景导致日志项被重复提交,在我们的实验中,raft通知kv服务器(写入channel ch1)的时候是需要加锁的,而kv服务器读取通知后,会将通知结果写入到channel ch2中,kv服务器会有另外的程序等待读取该channel ch2以判断请求是否完成;因此,如果日志项被重复提交,kv服务器读取通知后会再次将结果写入到channel ch2中,但可能没有程序读出该channel ch2(因为这不是一次新请求),从而导致kv服务器读取通知程序被阻塞,进而导致raft通知阻塞,没办法释放锁。因此,follower在接收到leader的快照时,应当判断commitIndex是否大于lastIncludedIndex,如果是,则直接忽略该snapshot,并将返回参数的nextIndex赋值为commitIndex+1。
3、raft重启后,恢复持久化数据,然后读取快照数据,进行日志截断,最后将snapshot发给kv服务器以更新kv服务器自身的kv数据和请求确认数据。raft读取快照后,应当及时更新commitIndex和lastApplied。

快照处理请求过时

raft服务器每次更新commitIndex之后,都会将(lastApplied,commitIndex]区间内的日志项逐一通知到kv服务器,这期间需要加锁;而kv服务器在监听到每一个日志项提交通知后都会判断是否需要发送快照数据,一旦需要则会反过来通知raft服务器进行快照处理,raft服务器进行快照处理也需要进行加锁。因此,kv服务器通知raft服务器进行快照处理需要通过go runtine实现,而不能阻塞等待,否则会造成raft服务器死锁。
采取go runtine通知raft服务器进行快照处理后,则可能出现多个快照处理的通知都被阻塞,直到(lastApplied,commitIndex]区间内的日志项逐一通知完毕后raft释放了锁,然后随机竞争。因此,raft执行快照处理前需要判断kv服务器传递的index是否已经小于log[0].Index(lastIncludedIndex),一旦是,则表明已经有更新的快照被保存了,当前的快照处理是过时请求,应当忽略。

测试结果

测试:

1
2
$ cd "$GOPATH/src/kvraft"
$ go test

结果:

显示 Gitment 评论