Lab 2B难度为hard,但是有了之前2A的经历,于我而言,2B的难度倒是远低于2A的难度。2B需要实现的功能就只是log replication。
在写2B的过程中,我还找到了之前2A写的一些bug,说实话,多线程的程序,有些bug真的很难发现,你会发现突然某次运行就有一个raft节点发生了死锁,没有任何响应了,大概率是因为锁设置的太多了,导致某处出现了死锁,同时此写这个lab对于我debug能力的提升也挺大。
完成过程中,又学到了很多写多线程的注意点。
注意:代码中的注释都是我用中式英语写的,大家可能会看不懂。还有就是之前Lab 2A中的部分代码还是有问题的,在此次实验中发现并修改了。
Lab 2B就是让我们实现log replication,这块功能是和投票、心跳机制紧密关联的,因此我们在实现这部分功能的时候肯定也会适当调整这些代码。
go test -race -run 2B #这个就是用来进行Lab 2B的测试指令
由于实验说明除了在2A处说了注意事项,就没说了,因此这里的注意事项,是我过程中踩的坑。
1、如果进入临界区之前需要同时获取多个锁,那么确保整个代码的其他部分用到这些锁的获取顺序是一致的,不然可能会出现死锁。
举例:需要查看并修改的变量涉及两个互斥锁,mutex1和mutex2。应该确保都是一直的获取顺序,例如先获取mutex1再获取mutex2
A:
mutex1.Lock()
defer mutex1.Unlock()
mutex2.Lock()
defer mutex2.Unlock()
Code...
B:
mutex2.Lock()
defer mutex2.Unlock()
mutex1.Lock()
defer mutex1.Unlock()
Code...
如果,goroutine A 先获取mutex1,goroutine B先获取了mutex2,然后就发生了死锁。
一般来说,这个地方知识大部分人都应该知道,但是有时候急了,进行复制粘贴进行代码复用的时候,一不注意就会导致上面的发生。
2、每个goroutine尽量只使用当前协程中的局部变量,若使用rf.nextIndex这种多协程共享的变量,那么该共享变量如果在协程外被改变了,多个协程之间的不确定性地执行,给这个变量带来不具有确定性的值,这大概率会带来bug。因此,goroutine如果需要使用共享变量,应该用一个局部变量将这个时刻的共享变量保存下来使用。
3、在进行RPC调用的时候,不要复用reply变量,每次进行RPC调用时都应该用新创建的reply变量来接受RPC的调用结果,不然在本实验中,你也将接受到一个警告---labgob warning: Decoding into a non-default variable/field %v may not work。
4、每个goroutine需要注意自身是否已经过时,例如:该协程是raft1作为leader发起的,但是过了一会儿raft1就已经不是Leader了,那么此时goroutine就已经过时了,不应该将完成的结果提交上去,而是选择结束例程。亦或是,一个最新的协程先提交了结果,将matchIndex[id]的数值更新为了n,此时过时的协程提交结果,想要将matchIndex[id]的数值更新为n-1就是不合理的。
5、在本实验中,我们使用sendAppendEntreis函数向followers进行log replication时,调用sendAppendEntreis函数可能会失败,因此需要设定失败重调的机制,确保能够成功向followers发出log。
6、在本实验中,可能followers收到的ae包,里面的Log为index:a-b,但是自身的已有的log的index:c
c>b,那么这种情况有以下几种可能:(1)这个ae包由于网络延迟而比后发的ae包都迟了,导致这个ae包已经过时了;(2)由于leader同时收到大量command,由于大量的Log replication协程是并发进行,可能负责index比较小的那个协程执行分配到的cpu资源很少,导致一直没执行,从而导致发出的ae包时,这个ae包已经过时了;(3)这个follower本身log中包含大量uncommit的无效log,因此需要把那些无效Log记录给删掉。
1、继续完善Raft结构体内的信息,并设计Log的结构体。
2、实现Start()函数
3、继续完善AppendEntries()函数
4、继续完善heartBeats()函数
5、继续完善ticker()函数
6、继续完善Make()函数
继续完善Raft结构体,设计Log的结构体
首先,实验说明中可以知道raft节点对于每一个commit的log都需要把该log放到applyCh管道中,而这个管道在raft节点创建的时候作为Make函数中的参数给予的,因此仅需在结构体中添加一个变量来保存管道即可。
log结构体的设计,里面肯定要能保存Command,同时还需要保存这个log对应的index和term。同时Command的数据类型,可以看到ApplyMsg结构体中指定Command为Interface{},我们也就和它一样即可。
代码如下所示:
type LogEntry struct {
Index int
Term int
Command interface{}
}
type Raft struct {
mu sync.Mutex // Lock to protect shared access to this peer's state
peers []*labrpc.ClientEnd // RPC end points of all peers
persister *Persister // Object to hold this peer's persisted state
me int // this peer's index into peers[]
dead int32 // set by Kill()
// Your data here (2A, 2B, 2C).
// Look at the paper's Figure 2 for a description of what
// state a Raft server must maintain.
peerNum int
// persistent state
currentTerm int
voteFor int
log []LogEntry
// volatile state
commitIndex int
lastApplied int
// lastHeardTime time.Time
state string
lastLogIndex int
lastLogTerm int
// send each commited command to applyCh
applyCh chan ApplyMsg
// Candidate synchronize election with condition variable
mesMutex sync.Mutex // used to lock variable opSelect
messageCond *sync.Cond
// opSelect == 1 -> start election, opSelect == -1 -> stay still, opSelect == 2 -> be a leader, opSelect == 3 -> election timeout
opSelect int
// special state for leader
nextIndex []int
matchIndex []int
}
Start()作为一个接口提供给外界,将指令发送给raft节点,请求其进行执行。下面讲解这个函数的执行流程:
函数收到一个command,首先判断自身是否为Leader,若不是则返回,若是继续。
leader收到一个command后,需要将该命令保存到本地的log中,并更新matchIndex、nextIndex、lastLogIndex、lastLogTerm等变量。
调用多个goroutine来并发地给其余follower发起Log replication请求。在每个goroutine中,注意查看注意事项中的第二条。例如:本实验中,负责log replication的goroutine中,将rf.nextIndex[id]这个共享变量的值赋给了局部变量nextIndex;想要获取发起goroutine时rf.currentTerm的值,就去查看args.Term值即可。
负责id=n号follower节点的log replication的goroutine中,首先需要检查该n号raft节点的nextIndex,将nextIndex和index进行比较,index为最新来的Log的索引值,如果两者存在差值表明,n号Raft节点和leader之间差了不止index号这么一个Log值,我们需要将nextIndex+1~index之间的Log也需要打包到log数组中一块发送给follower。后续,leader将nextIndex指定的Index号的log包也添加到Log数组中。
这里leader将nextIndex位置的log继续打包到log数组中,然后通过sendAppendEntries RPC调用将log数组发送给Follower。该RPC调用返回值为true则表明Log同步成功,后续更新matchIndex[id]即可,更新的时候,注意查看注意事项中的第四条,协程想要matchIndex[id]更新为n,但是发现matchIndex[id]的数值大于n,这则表明该协程已经过时了,则不更新matchIndex[id]的值了。
若该RPC调用返回值为false,那么就要分情况来进行处理了:
reply.Term > args.Term:则表明该goroutine已经过时了,需要停止该goroutine并检查raft节点是否及时更新了Term值,若没有则将rf.currentTerm更新为reply.Term。
reply.Term <= args.Term:则表明,follower发现preLogTerm和preLogIndex并不匹配,需要leader发送更早的log。这种情况下,leader可以逐步一个个将前面的log加进去和follower进行确认是否匹配,但是一个个加log并询问follower的效率太低了,应该利用reply中的follower的term值,来加速定位,因为Follower中的log的term值一定都不会大于follower的term值,所以那些term> follower.term的log必然是follower缺失的。后续再尝试一个个加更早的log。直到follower返回true,表明找到了两者log相同的地方了,并将后续的log都同步上了。
leader完成了给一个follower的同步后,进行matchIndex检查是否又有新的Log已经同步到大多数的节点上,这个检查是通过收集各个节点的matchIndex,对matchIndex的出现次数进行一个计数形成一个key-val的数组,key为matchIndex,value为处于这个matchIndex的节点数量,这个数组按照key从高到低的排序,然后遍历数组进行累加value的值,直到value累加的值刚好超过raft节点的半数,则这个key值即为此时的半数节点都已经同步了的最新的log的index值。
将这个index值和commitIndex进行比较,若大于则表明有新的log同步到半数节点上了,可以进行提交,更新commitIndex值,并将提交的指令发送到applyCh管道中以及更新lastApplied变量的值。
注意:leader每次更新commitIndex并提交执行新的log的时候,并不会立刻通知节点去提交,为了减轻网络流量压力,这个通知可以让周期的heartbeat去顺带通知。这里heartbeat通知follower更新commitIndex的细节将放在heartbeat函数那儿讲解。
代码如下:
//
// the service using Raft (e.g. a k/v server) wants to start
// agreement on the next command to be appended to Raft's log. if this
// server isn't the leader, returns false. otherwise start the
// agreement and return immediately. there is no guarantee that this
// command will ever be committed to the Raft log, since the leader
// may fail or lose an election. even if the Raft instance has been killed,
// this function should return gracefully.
//
// the first return value is the index that the command will appear at
// if it's ever committed. the second return value is the current
// term. the third return value is true if this server believes it is
// the leader.
//
func (rf *Raft) Start(command interface{}) (int, int, bool) {
index := -1
term := -1
isLeader := true
// Your code here (2B).
_, isLeader = rf.GetState()
if !isLeader {
return index, term, isLeader
}
rf.mu.Lock()
defer rf.mu.Unlock()
rf.lastLogTerm = rf.currentTerm
rf.lastLogIndex = rf.nextIndex[rf.me]
index = rf.nextIndex[rf.me]
term = rf.lastLogTerm
var peerNum = rf.peerNum
var entry = LogEntry{Index: index, Term: term, Command: command}
rf.log = append(rf.log, entry)
fmt.Printf("%v leader%d receive a command, index:%d term:%d\n", time.Now(), rf.me, index, term)
rf.matchIndex[rf.me] = index
rf.nextIndex[rf.me] = index + 1
for i := 0; i < peerNum; i++ {
if i == rf.me {
continue
}
go func(id int, nextIndex int) {
var args = &AppendEntriesArgs{}
rf.mu.Lock()
args.Entries = make([]LogEntry, 0)
if nextIndex < index {
for j := nextIndex + 1; j <= index; j++ {
args.Entries = append(args.Entries, rf.log[j])
}
}
args.Term = rf.currentTerm
args.LeaderId = rf.me
// we update the nextIndex array at first time, even if the follower hasn't received the msg.
if index+1 > rf.nextIndex[id] {
rf.nextIndex[id] = index + 1
}
rf.mu.Unlock()
for {
var reply = &AppendEntriesReply{}
rf.mu.Lock()
args.PrevLogIndex = rf.log[nextIndex-1].Index
args.PrevLogTerm = rf.log[nextIndex-1].Term
args.Entries = rf.log[nextIndex : index+1]
// args.Entries = append([]LogEntry{rf.log[nextIndex]}, args.Entries...)
rf.mu.Unlock()
for {
// if sendAE failed, retry util success
if rf.sendAppendEntries(id, args, reply) {
break
}
}
rf.mu.Lock()
if reply.Term > args.Term {
if reply.Term > rf.currentTerm {
rf.currentTerm = reply.Term
rf.state = "follower"
rf.voteFor = -1
fmt.Printf("%v raft%d find a higher term, turn back to follower, term:%d\n", time.Now(), rf.me, rf.currentTerm)
rf.mu.Unlock()
break
}
fmt.Printf("%v goroutine (term:%d, raft%d send log to raft%d) is out of date. Stop the goroutine.\n", time.Now(), args.Term, rf.me, id)
rf.mu.Unlock()
break
}
if !reply.Success {
if rf.log[nextIndex-1].Term > reply.Term {
for rf.log[nextIndex-1].Term > reply.Term {
nextIndex--
}
} else {
nextIndex--
}
// nextIndex--
if nextIndex == 0 {
fmt.Printf("Error:leader%d send log to raft%d, length:%d \n", rf.me, id, len(args.Entries))
rf.mu.Unlock()
break
}
rf.mu.Unlock()
} else {
fmt.Printf("%v leader%d send log from %d to %d to raft%d\n", time.Now(), rf.me, nextIndex, index, id)
if rf.matchIndex[id] < index {
rf.matchIndex[id] = index
}
// we need to check if most of the raft nodes have reach a agreement.
var mp = make(map[int]int)
for _, val := range rf.matchIndex {
mp[val]++
}
var tempArray = make([]num2num, 0)
for k, v := range mp {
tempArray = append(tempArray, num2num{key: k, val: v})
}
sort.Slice(tempArray, func(i, j int) bool {
return tempArray[i].key > tempArray[j].key
})
var voteAddNum = 0
for j := 0; j < len(tempArray); j++ {
if tempArray[j].val+voteAddNum >= (rf.peerNum/2)+1 {
if rf.commitIndex < tempArray[j].key {
fmt.Printf("%v %d nodes have received %d mes, leader%d update commitIndex from %d to %d\n", time.Now(), tempArray[j].val+voteAddNum, tempArray[j].key, rf.me, rf.commitIndex, tempArray[j].key)
rf.commitIndex = tempArray[j].key
for rf.lastApplied < rf.commitIndex {
rf.lastApplied++
var applyMsg = ApplyMsg{}
applyMsg.Command = rf.log[rf.lastApplied].Command
applyMsg.CommandIndex = rf.log[rf.lastApplied].Index
applyMsg.CommandValid = true
rf.applyCh <- applyMsg
fmt.Printf("%v leader%d insert the msg%d into applyCh\n", time.Now(), rf.me, rf.lastApplied)
}
break
}
}
voteAddNum += tempArray[j].val
}
rf.mu.Unlock()
break
}
time.Sleep(10 * time.Millisecond)
}
}(i, rf.nextIndex[i])
}
return index, term, isLeader
}
由于实验2B中,AE RPC调用不再仅仅是heartbeat了,因此,我在这里将log replication和heartbeat这两者进行了区分处理,heartbeat调用AppendEntries时,传来的参数中是没有log数组的,而如果是进行log replication那么必然会发送来Log数组,因此我将此作为区分点。
以下是这个函数的处理流程:
Term的比较以及对应的处理,在lab2A中已经讲述,此处不赘述。
若args.Entries数组为nil,那么表明为heartbeat调用的;反之,表明为log replication调用的。
heartbeat调用的AppendEntreis时:
如果args.LeaderCommit > rf.commitIndex,则更新rf.commitIndex = args.LeaderComiit,并进入步骤2。反之就返回true即可。
更新了rf.commitIndex表明,有新的log可以提交并执行了,随后把那些log发送到applyCh管道中,并更新lastApplied变量的值。
Log replication调用的AppendEntreis时:
log同步应该在双方Log一致的index点开始,因此这里着重点在于回馈给leader一致的index在哪儿。
当然里面还有很多特殊情况需要考虑。
follower需要检查发送来的ae包是否满足本节点的缺失情况,也就是检查args.PreLogIndex和rf.lastLogIndex。
如果args.PreLogIndex > rf.lastLogIndex表明肯定不满足本节点的缺失情况,则返回false;反之进入步骤3
来到这儿表明args.PreLogIndex <= rf.lastLogIndex,这依旧不能确定这个ae包是满足本节点的缺失情况的,可能本节点存在部分无效的Log,举例来说:一个leader加了好多log,但是断开了连接这些新加的log都没有来得及发送给任何一个节点,后续重连回来就成为follower了,那么这些独自拥有的的log就是无效Log。
raft算法的特点之一是,如果两个Log的index和term都是一样的,那么这两个log必然是一致的。因此Follower仅需判断args.PrevLogTerm == rf.log[args.PreLogIndex]是否成立,如果成立表明,在args.PrevLogIndex这里以及之前的Log都是同步的了。否则,就是args.PreLogIndex < rf.lastLogIndex,但是这个索引值为args.PreLogIndex处的log,两个节点的log依旧不一致,需要返回false,让leader继续往前找到一致的index。
注意:follower发现发来的这个ae包中的log的PrevLogIndex和PrevLogTerm都是符合条件的,但是依旧不一定就要同步上来。我们需要注意部分ae是过时的,例如:1号ae包负责将follower同步到index=n处的Log;2号ae包负责而将follower同步到index=n+1处的log。但是1号ae比2号还迟,2号ae包已经将各个Follower的log同步到了index=n+1处,当过时的1号ae包被follower接受到时,follower依旧按照包中指示同步到index=n处,那么同步进度就倒退了,将会引发bug。因此follower还需要检测这个ae是否过时。
5. 到这里,表明这个ae包是合法的,同步的开始Log处,双方也都是一致的,但是需要判断这个ae是否是过时的。follower仅需检测,发来的ae包中,args.Entreis中最新的log,本节点中有没有,若有的话,表明无需同步,这个包是过时的了。若没有则进入下一步进行同步。
6. 更新本地的rf.log ,rf.lastLogIndex,rf.lastLogTerm等变量。
代码如下:
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
if args.Term < rf.currentTerm {
reply.Success = false
} else {
// fmt.Printf("raft%d receive ae from leader%d\n", rf.me, args.LeaderId)
if args.Entries == nil {
// if the args.Entries is empty, it means that the ae message is a heartbeat message.
if args.LeaderCommit > rf.commitIndex {
fmt.Printf("%v raft%d update commitIndex from %d to %d\n", time.Now(), rf.me, rf.commitIndex, args.LeaderCommit)
rf.commitIndex = args.LeaderCommit
for rf.lastApplied < rf.commitIndex {
rf.lastApplied++
var applyMsg = ApplyMsg{}
applyMsg.Command = rf.log[rf.lastApplied].Command
applyMsg.CommandIndex = rf.log[rf.lastApplied].Index
applyMsg.CommandValid = true
rf.applyCh <- applyMsg
fmt.Printf("%v raft%d insert the msg%d into applyCh\n", time.Now(), rf.me, rf.lastApplied)
}
}
reply.Success = true
} else {
// if the args.Entries is not empty, it means that we should update our entries to be aligned with leader's.
if args.PrevLogIndex <= rf.lastLogIndex {
if args.PrevLogTerm == rf.log[args.PrevLogIndex].Term {
// Notice!!
// we need to consider a special situation: followers may receive a older log replication request, and followers should do nothing at that time
// so followers should ignore those out-of-date log replication requests or followers will choose to synchronized and delete lastest logs
var length = len(args.Entries)
var index = args.PrevLogIndex + length
reply.Success = true
if index < rf.lastLogIndex {
// check if the ae is out-of-date
if args.Entries[length-1].Term == rf.log[index].Term {
fmt.Printf("%v raft%d receive a out-of-date ae and do nothing. prevLogIndex:%d, length:%d from leader%d\n", time.Now(), rf.me, args.PrevLogIndex, length, args.LeaderId)
return
}
}
fmt.Printf("%v raft%d receive prevLogIndex:%d, length:%d from leader%d\n", time.Now(), rf.me, args.PrevLogIndex, length, args.LeaderId)
rf.log = rf.log[:args.PrevLogIndex+1]
rf.log = append(rf.log, args.Entries...)
// fmt.Printf("%v raft%d log:%v\n", time.Now(), rf.me, rf.log)
var logLength = len(rf.log)
rf.lastLogIndex = rf.log[logLength-1].Index
rf.lastLogTerm = rf.log[logLength-1].Term
}
} else {
reply.Success = false
}
}
if rf.currentTerm < args.Term {
fmt.Printf("%v raft%d update term from %d to %d\n", time.Now(), rf.me, rf.currentTerm, args.Term)
}
rf.currentTerm = args.Term
rf.state = "follower"
rf.changeOpSelect(-1)
rf.messageCond.Broadcast()
}
}
这里给heartBeats函数多加的一个功能就是通知follower更新commitIndex。
注意:我们需要考虑部分节点尚未同步log,若leader无条件地向那些follower通知自身的commitIndex更新了,要求它们也同步更新,将会导致混乱。因此leader应该需要根据follower的情况来进行选择性的通知。
Leader应该通知那些同步的进度超过commitIndex的节点,Leader处有commitIndex这个数组记录了各个follower的同步进度,因此可以利用这个数组来进行判断即可。
本实验中,若leader要通知节点进行更新commitIndex,仅需在发给follower的ae包中赋值args.LeaderCommit = rf.commitIndex,随后follower就能收到最新的commitIndex。
若leader发现节点的同步进度不够,并不通知节点进行更新commitIndex,那么在发给follower的ae包中不赋值args.LeaderCommit即可,这个参数的默认值为0,并不会对follower的commitIndex造成任何影响。
由于代码变动较小,仅放出更新的地方:
go func(index int) {
rf.mu.Lock()
var args AppendEntriesArgs
args.LeaderId = rf.me
args.Term = rf.currentTerm
if rf.matchIndex[index] >= rf.commitIndex {
args.LeaderCommit = rf.commitIndex
}
rf.mu.Unlock()
...
}(index)
在本实验中,需要进行log replication,Leader需要使用nextIndex和matchIndex这两个变量,因此每个raft节点成为leader后都需要对这两个变量进行初始化。
按照paper中的说法,leader开始需要将matchIndex数组全部初始化为0,nextIndex数组的初始化则按照自身的nextIndex初始化。为什么这么初始化,去看paper即可,此处不赘述。
由于代码变动较小,仅放出更新的地方:
if rf.opSelect != -1 && rf.opSelect != 4 && term == rf.currentTerm && times == currentTermTimes {
// suceess means the node successfully becomes a leader
rf.opSelect = 2
rf.state = "leader"
var length = len(rf.log)
// reinitialize these two arrays after election
for i := 0; i < rf.peerNum; i++ {
rf.nextIndex[i] = rf.log[length-1].Index + 1
rf.matchIndex[i] = 0
}
fmt.Println("leader's nextIndex array:", rf.nextIndex)
fmt.Println("leader's matchIndex array:", rf.matchIndex)
rf.messageCond.Broadcast()
}
本实验中,在raft结构体中多了applyCh这个管道,以及需要使用nextIndex和matchIndex这两个切片,因此在raft节点初始化时,也需要对这些变量初始化。
注意:实验说明中提到,index应当从1开始,为了index的值和log数组的索引值对应起来,我将log数组的0号索引位用一个index和term均为0的log来占用了,这样的做了以后,index为1的log就存放在log[index]处。而非log[index-1]处。同时这个index和term均为0的log作为数组的头部,在log数组遍历时可以以此来判断是否到了数组的头部。
applyCh的值直接从Make函数中的参数直接拿来赋值即可。
nextIndex和matchIndex由于都是切片需要用make进行初始化,并且这些切片的长度应该和raft节点数一样。
由于代码变动较小,仅放出更新的地方:
rf.log = make([]LogEntry, 1)
rf.log[0].Index = 0
rf.log[0].Term = 0
rf.applyCh = applyCh
rf.nextIndex = make([]int, rf.peerNum)
rf.matchIndex = make([]int, rf.peerNum)
由于实验过程中插桩了大量调试用的fmt.Printf指令,我最后也懒得把那些指令给删除了,因此图片中就我截取了最后一行的成功输出。