绿色健康小清新

耐得住寂寞,守得住繁华

MIT6.824-lab2B-日志复制(log replication)

2B(日志复制)

任务

在2B部分,相比于2A部分刚开始写,这一部分要实现的内容也不少,相比于2A部分,条件判断更多,坑很多,有时条件写多了,某一部分就忘记解锁了,真的是痛呀!

这一部分要完成的就是日志复制功能,其实在2A中,我们就使用AppendEntries PRC实现了基本的心跳功能,在这个基础上,我们要做的其实就三个部分:

  • 在AppendEntries PRC的心跳功能基础上加上日志的发送和接收;
  • 实现**Start(command interface{})**函数,该函数是接受一个command,如果当前节点是leader,则将该command加入到日志中,并立马发送一次AppendEntries RPC进行日志同步。
  • 对日志的接收情况进行判断,满足多数派就提交,并且发送给ApplyCh进行应用。

任务须知

  • 测试代码中会调用one(cmd interface{}, expectedServers int, retry bool)函数向leader提交一个命令,并判断此后此命令是否同步到>=expectedServers个数的节点上,如果运行结果中出现了failed to reach agreement可以到这里来看看。而在one()函数中,就会调用每一个raft节点的Start函数发起一条命令;(这个one函数在你完成2B部分并且调试的过程中,我相信你会非常熟悉,别问我怎么知道的)

  • 在完成该任务之前或期间,你一定要深入了解过config.go文件,这样在调试过程中看test_test.go文件才能找到问题;

  • 在ApplyCh的相关实现上,我并不会一发现有新的提交日志就立马发送给ApplyCh,而是采用了一种异步的思想来实现来保证。

  • 在我完成3B的过程中,发现了一个问题:AppendEntries RPC日志内容的深拷贝问题,这个问题不会影响该部分的测试结果。切片默认是进行浅拷贝的,因此leader在向某个follower发送AppendEntries RPC时,在生成args到发送rpc这段时间内(这段时间没有上锁,上锁就太浪费资源了),如果进行一次Snapshot(生成快照),那么就会导致args的logEntries发生变化,原本存在的数据,进行一次快照后可能会进行删除,进而导致发送给follower的logEntries的某些日志的Command变为nil了。在kvServer应用这个命令的时候,进行接口的转化(op := cmd.Command.(Op))就会报错。

    具体的代码可以看getAppendLogs函数。

代码

raft基本函数

主要就是start函数和对ApplyCh的处理:

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
//客户端请求,leader添加日志
func (rf *Raft) Start(command interface{}) (int, int, bool) {
index := -1
term := -1
isLeader := false
// Your code here (2B).
rf.mu.Lock()
defer rf.mu.Unlock()

if rf.role != Role_Leader {
return index, term, isLeader
}
rf.logs = append(rf.logs, LogEntry{
Term: rf.currentTerm,
Command: command,
})
_, lastIndex := rf.getLastLogTermAndIndex()
index = lastIndex
rf.matchIndex[rf.me] = lastIndex
rf.nextIndex[rf.me] = lastIndex + 1

term = rf.currentTerm
isLeader = true

rf.resetAppendEntriesTimersZero()

return index, term, isLeader
}

start函数主要是先进行leader的判断,只有leader才能接收当前的command,并写入日志。最后重置AppendEntries RPC时间,立马发送该RPC。

接下来再看下ApplyCh的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (rf *Raft) ticker() {
go func() {
for {
select {
case <-rf.stopCh:
return
case <-rf.applyTimer.C:
rf.notifyApplyCh <- struct{}{}
case <-rf.notifyApplyCh: //当有日志记录提交了,要进行应用
rf.startApplyLogs()
}
}
}()
...
}

在raft的ticker中,其中创建的一个goroutine就是用来处理ApplyCh,每隔一段时间、notifyApplyCh有消息就调用一次startApplyLogs来处理新的提交日志:

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
//处理要应用的日志,快照的命令比较特殊,不在这里提交
func (rf *Raft) startApplyLogs() {
defer rf.applyTimer.Reset(ApplyInterval)

rf.mu.Lock()
var msgs []ApplyMsg
if rf.lastApplied < rf.lastSnapshotIndex {
//此时要安装快照,命令在接收到快照时就发布过了,等待处理
msgs = make([]ApplyMsg, 0)
} else if rf.commitIndex <= rf.lastApplied {
// snapShot 没有更新 commitidx
msgs = make([]ApplyMsg, 0)
} else {
msgs = make([]ApplyMsg, 0, rf.commitIndex-rf.lastApplied)
for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {
msgs = append(msgs, ApplyMsg{
CommandValid: true,
Command: rf.logs[rf.getStoreIndexByLogIndex(i)].Command,
CommandIndex: i,
})
}
}
rf.mu.Unlock()

for _, msg := range msgs {
rf.applyCh <- msg
rf.mu.Lock()
rf.lastApplied = msg.CommandIndex
rf.mu.Unlock()
}
}

其实主要的处理就是比较commitIndex和lastApplied,如果有新的提交日志没有应用,就依次发送给applyCh,从而进一步的应用处理。

这里有一个快照的判断,可以不用管,因为在我的实现中,快照命令和日志中记录的命令是两种不同的命令,并不会占用日志的index,在follower接收到一份快照后,会单独的发送快照命令给applyCh。

append entries rpc参数和回复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type AppendEntriesArgs struct {
Term int
LeaderId int
PrevLogIndex int
PrevLogTerm int
Entries []LogEntry
LeaderCommit int
}

type AppendEntriesReply struct {
Term int
Success bool
NextLogTerm int
NextLogIndex int
}

这个没什么好说的,都是根据论文来设计的。

append entries rpc 基本函数

在介绍rpc的发送和接收的相关处理之前,先来讲下要用到的几个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//立马发送
func (rf *Raft) resetAppendEntriesTimersZero() {
for _, timer := range rf.appendEntriesTimers {
timer.Stop()
timer.Reset(0)
}
}

func (rf *Raft) resetAppendEntriesTimerZero(peerId int) {
rf.appendEntriesTimers[peerId].Stop()
rf.appendEntriesTimers[peerId].Reset(0)
}

//重置单个timer
func (rf *Raft) resetAppendEntriesTimer(peerId int) {
rf.appendEntriesTimers[peerId].Stop()
rf.appendEntriesTimers[peerId].Reset(HeartBeatInterval)
}

这三个函数主要是在不同的情况下进行调用来重置全部或某个节点的AppendEnties定时器。

比如:

  • 在start中,leader接收到一条command,需要调用resetAppendEntriesTimersZero重置对所有节点的定时器,立马发送该日志;
  • 在leader发送一次rpc后,如果收到节点的回复信息,发现该节点并没有接收log的日志,且NextLogIndex表明了要接受的下一条日志,为了一致性,需要调用resetAppendEntriesTimerZero立马在进行一次发送;
  • 在leader发送一次rpc后,就需要调用resetAppendEntriesTimer重置对该节点的下一次rpc发送间隔时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//判断当前raft的日志记录是否超过发送过来的日志记录
func (rf *Raft) isOutOfArgsAppendEntries(args *AppendEntriesArgs) bool {
argsLastLogIndex := args.PrevLogIndex + len(args.Entries)
lastLogTerm, lastLogIndex := rf.getLastLogTermAndIndex()
if lastLogTerm == args.Term && argsLastLogIndex < lastLogIndex {
return true
}
return false
}

//获取当前存储位置的索引
func (rf *Raft) getStoreIndexByLogIndex(logIndex int) int {
storeIndex := logIndex - rf.lastSnapshotIndex
if storeIndex < 0 {
return -1
}
return storeIndex
}
  • isOutOfArgsAppendEntries函数主要是判断在相同term的情况下,当前节点的最后一条日志的index是否大于leader发送来的最后一条日志index。这个函数主要是用来判断能够进行日志全覆盖,在接收方的处理中会有大用;
  • getStoreIndexByLogIndex函数主要是根据日志的index来获取在logs中真正的存储index。

其他的相关函数会在后续进行介绍。

append entries rpc

先来看一下rpc的发送函数:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
func (rf *Raft) sendAppendEntriesToPeer(peerId int) {
if rf.killed() {
return
}

rf.mu.Lock()
if rf.role != Role_Leader {
rf.resetAppendEntriesTimer(peerId)
rf.mu.Unlock()
return
}
DPrintf("%v send append entries to peer %v", rf.me, peerId)

//获取发送的日志信息
prevLogIndex, prevLogTerm, logEntries := rf.getAppendLogs(peerId)
args := AppendEntriesArgs{
Term: rf.currentTerm,
LeaderId: rf.me,
PrevLogIndex: prevLogIndex,
PrevLogTerm: prevLogTerm,
Entries: logEntries,
LeaderCommit: rf.commitIndex,
}
reply := AppendEntriesReply{}
rf.resetAppendEntriesTimer(peerId)
rf.mu.Unlock()

//发送rpc
rf.sendAppendEntries(peerId, &args, &reply)

DPrintf("%v role: %v, send append entries to peer finish,%v,args = %+v,reply = %+v", rf.me, rf.role, peerId, args, reply)

rf.mu.Lock()
if reply.Term > rf.currentTerm {
rf.changeRole(Role_Follower)
rf.currentTerm = reply.Term
rf.resetElectionTimer()
rf.persist()
rf.mu.Unlock()
return
}

if rf.role != Role_Leader || rf.currentTerm != args.Term {
rf.mu.Unlock()
return
}

//响应:成功了,即:发送的数据全部接收了,或者根本没有数据
if reply.Success {
if reply.NextLogIndex > rf.nextIndex[peerId] {
rf.nextIndex[peerId] = reply.NextLogIndex
rf.matchIndex[peerId] = reply.NextLogIndex - 1
}
if len(args.Entries) > 0 && args.Entries[len(args.Entries)-1].Term == rf.currentTerm {
//每个leader只能提交自己任期的日志
rf.tryCommitLog()
}
rf.persist()
rf.mu.Unlock()
return
}

//响应:失败了,此时要修改nextIndex或者不做处理
if reply.NextLogIndex != 0 {
if reply.NextLogIndex > rf.lastSnapshotIndex {
rf.nextIndex[peerId] = reply.NextLogIndex
//为了一致性,立马发送
rf.resetAppendEntriesTimerZero(peerId)
} else {
//发送快照
go rf.sendInstallSnapshotToPeer(peerId)
}
rf.mu.Unlock()
return
} else {
//reply.NextLogIndex = 0,此时如果插入会导致乱序,可以不进行处理
}

rf.mu.Unlock()
return

}

主要步骤如下:

  1. 如果当前节点不是leader,则重置发送时间并返回;
  2. 根据当前的信息生成AppendEntriesArgs,其中要根据getAppendLogs函数来获取要发送节点的日志信息:prevLogIndex, prevLogTerm, logEntries;
  3. 调用sendAppendEntries函数发送rpc;
  4. 根据rpc的返回结果,进行相应的处理:
    • 如果返回的term大于当前节点的term,就立马切换成follower;
    • 如果相应成功了,说明发送的数据全部接收了,或者根本没有数据,如果有发送数据,可以修改nextIndex和matchIndex,以及调用tryCommitLog尝试提交一次日志;
    • 如果相应失败了,且NextLogIndex不为0,则判断是立马再发送一次rpc还是发送一次快照(sendInstallSnapshotToPeer);如果NextLogIndex为0,则不进行处理,此时如果插入会导致乱序。

上面涉及了三个函数调用,具体看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//获取要向指定节点发送的日志
func (rf *Raft) getAppendLogs(peerId int) (prevLogIndex int, prevLogTerm int, logEntries []LogEntry) {
nextIndex := rf.nextIndex[peerId]
lastLogTerm, lastLogIndex := rf.getLastLogTermAndIndex()
if nextIndex <= rf.lastSnapshotIndex || nextIndex > lastLogIndex {
//没有要发送的log
prevLogTerm = lastLogTerm
prevLogIndex = lastLogIndex
return
}
//这里一定要进行深拷贝,不然会和Snapshot()发生数据上的冲突,在2B中不会影响最终结果
//logEntries = rf.logs[nextIndex-rf.lastSnapshotIndex:]
logEntries = make([]LogEntry, lastLogIndex-nextIndex+1)
copy(logEntries, rf.logs[nextIndex-rf.lastSnapshotIndex:])
prevLogIndex = nextIndex - 1
if prevLogIndex == rf.lastSnapshotIndex {
prevLogTerm = rf.lastSnapshotTerm
} else {
prevLogTerm = rf.logs[prevLogIndex-rf.lastSnapshotIndex].Term
}

return
}

getAppendLogs是获取要发送给对应节点的日志信息。如果下一条信息的index小于快照所包含的最后一条日志index,或者大于最后一条日志index,则说明没有要发送的日志;否则,进行计算。

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
//发送端发送数据
func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) {
rpcTimer := time.NewTimer(RPCTimeout)
defer rpcTimer.Stop()

ch := make(chan bool, 1)
go func() {
//尝试10次
for i := 0; i < 10 && !rf.killed(); i++ {
ok := rf.peers[server].Call("Raft.AppendEntries", args, reply)
if !ok {
time.Sleep(time.Millisecond * 10)
continue
} else {
ch <- ok
return
}
}
}()

select {
case <-rpcTimer.C:
DPrintf("%v role: %v, send append entries to peer %v TIME OUT!!!", rf.me, rf.role, server)
return
case <-ch:
return
}
}

sendAppendEntries会尝试10次向节点发送rpc,直到成功或者超过了此次rpc超时时间。

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
//尝试去提交日志
//会依次判断,可以提交多个,但不能有间断
func (rf *Raft) tryCommitLog() {
_, lastLogIndex := rf.getLastLogTermAndIndex()
hasCommit := false

for i := rf.commitIndex + 1; i <= lastLogIndex; i++ {
count := 0
for _, m := range rf.matchIndex {
if m >= i {
count += 1
//提交数达到多数派
if count > len(rf.peers)/2 {
rf.commitIndex = i
hasCommit = true
DPrintf("%v role: %v,commit index %v", rf.me, rf.role, i)
break
}
}
}
if rf.commitIndex != i {
break
}
}

if hasCommit {
rf.notifyApplyCh <- struct{}{}
}
}

tryCommitLog是从已经提交的index+1开始遍历后面的日志,只要一个节点的matchIndex大于等于当前日志的index,就表明该节点已经接收了该日志,如果日志达到了多数派,就可以提交了。并且将新提交的日志进行应用。

遍历的过程中,只要有一条日志没有达到多数派,后面的日志也可以不用计算了。

最后一个部分就是接收方的处理:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
//接收端处理rpc
//主要进行三个处理:
// 1. 判断任期
// 2. 判断是否接收数据,success:数据全部接受,或者根本就没有数据
// 3. 判断是否提交数据
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
DPrintf("%v receive a appendEntries: %+v", rf.me, args)
reply.Term = rf.currentTerm
if args.Term < rf.currentTerm {
rf.mu.Unlock()
return
}
rf.currentTerm = args.Term
rf.changeRole(Role_Follower)
rf.resetElectionTimer()

_, lastLogIndex := rf.getLastLogTermAndIndex()
//先判断两边,再判断刚好从快照开始,再判断中间的情况
if args.PrevLogIndex < rf.lastSnapshotIndex {
//1.要插入的前一个index小于快照index,几乎不会发生
reply.Success = false
reply.NextLogIndex = rf.lastSnapshotIndex + 1
} else if args.PrevLogIndex > lastLogIndex {
//2. 要插入的前一个index大于最后一个log的index,说明中间还有log
reply.Success = false
reply.NextLogIndex = lastLogIndex + 1
} else if args.PrevLogIndex == rf.lastSnapshotIndex {
//3. 要插入的前一个index刚好等于快照的index,说明可以全覆盖,但要判断是否是全覆盖
if rf.isOutOfArgsAppendEntries(args) {
reply.Success = false
reply.NextLogIndex = 0 //=0代表着插入会导致乱序
} else {
reply.Success = true
rf.logs = append(rf.logs[:1], args.Entries...)
_, currentLogIndex := rf.getLastLogTermAndIndex()
reply.NextLogIndex = currentLogIndex + 1
}
} else if args.PrevLogTerm == rf.logs[rf.getStoreIndexByLogIndex(args.PrevLogIndex)].Term {
//4. 中间的情况:索引处的两个term相同
if rf.isOutOfArgsAppendEntries(args) {
reply.Success = false
reply.NextLogIndex = 0
} else {
reply.Success = true
rf.logs = append(rf.logs[:rf.getStoreIndexByLogIndex(args.PrevLogIndex)+1], args.Entries...)
_, currentLogIndex := rf.getLastLogTermAndIndex()
reply.NextLogIndex = currentLogIndex + 1
}
} else {
//5. 中间的情况:索引处的两个term不相同,跳过一个term
term := rf.logs[rf.getStoreIndexByLogIndex(args.PrevLogIndex)].Term
index := args.PrevLogIndex
for index > rf.commitIndex && index > rf.lastSnapshotIndex && rf.logs[rf.getStoreIndexByLogIndex(index)].Term == term {
index--
}
reply.Success = false
reply.NextLogIndex = index + 1
}

//判断是否有提交数据
if reply.Success {
DPrintf("%v current commit: %v, try to commit %v", rf.me, rf.commitIndex, args.LeaderCommit)
if rf.commitIndex < args.LeaderCommit {
rf.commitIndex = args.LeaderCommit
rf.notifyApplyCh <- struct{}{}
}
}

rf.persist()
DPrintf("%v role: %v, get appendentries finish,args = %v,reply = %+v", rf.me, rf.role, *args, *reply)
rf.mu.Unlock()

}

主要分为三个步骤:

  1. 判断任期。如果当前rpc的term小于自身的term,可以不进行处理;
  2. 判断是否接收数据,返回success表明数据全部接受,或者根本就没有数据。分为以下五种情况:
    • 要插入的前一个index小于快照index,几乎不会发生;
    • 要插入的前一个index大于最后一个log的index,说明中间还有log;
    • 要插入的前一个index刚好等于快照的index,说明可以全覆盖,但要判断是否是全覆盖。如果无法全覆盖,就设置NextLogIndex = 0,代表插入会乱序;如果可以全覆盖,就全部接收;
    • 中间的情况:索引处的两个term相同。也要判断后面的日志是否可以全覆盖,如果无法全覆盖,就设置NextLogIndex = 0,代表插入会乱序;如果可以全覆盖,就全部接收;
    • 中间的情况:索引处的两个term不相同,跳过一个term,然后由发送方再次尝试。
  3. 判断是否提交数据。如果自身记录的commitIndex小于leader记录的commitIndex,就可以更新commitIndex,然后发送命令到ApplyCh,表示可以应用了。

测试结果

-------------本文结束感谢您的阅读-------------
六经蕴籍胸中久,一剑十年磨在手

欢迎关注我的其它发布渠道