关于MIT 6.824的第二个有关RAFT的实验在这里做以记录,以防止在之后的学习中忘记。
这个lab的提示部分有包括RAFT原理动态展示、实验的分析指导以及论文,在这个上面的基础上对于RAFT会有一个更好的理解,对实验会更好理解。这个实验主要分为了4个部分,目前只做了2A部分。
2A 领导者选举部分
这部分的内容在动态展示中看起来很清晰,但是中间需要关注的细微的点很多,在阅读Raft-extension 这篇文章时要注意其中各个阶段的转换。
1 Hint分析
- 使用go test -run 2A来进行运行,在自己测试的时候,最好使用go test -race -run 2A检测下冲突
- 即上述提到的要注意论文中的各种规则
- 日志条目需要保存到各个节点中,所以需要有相应的结构体,但是在这个2A中没有太大用,在后续可以进行补充,不过这里给了初始定义,如下
type LogEntries struct {
term int
log string
}
- 这两个struct:RequestVoteArgs和 RequestVoteReply论文上给出的很详细,对应补充即可。修改Make()创建后台goroutine,这个提示是我们后续工作,在后续实现时具体展开。
type RequestVoteArgs struct {
// Your data here (2A, 2B).
Term int
CandidateID int
LastLogIndex int
LastLogTerm int
}
type RequestVoteReply struct {
// Your data here (2A).
Term int
VoteGranted bool
}
- 定义了AppendEntries,以及编写AppendEntriesRpc处理程序,结构和程序如下,这里的选举目前没有用到log结构,只需要进行领导者的选举即可,因此只需要传当前所在的阶段Term来进行心跳,注意和RPC相关的内部参数开头字母都需要大写。GetEntries是处理程序,主要看得到的日志阶段是否比自己当前阶段小,如果比自己还小,就不接收,否则接受并设置当前阶段为收到的阶段,并且这里要注意,根据规则,要将自己置为follower,这里很重要,因为如果是之前的leader获得了新的日志,不更改状态的话会导致脑裂。这里给Raft结构加入了标志位ch,如果收到了心跳则置为true,就不会参与选举。
type AppendEntriesArgs struct {
Term int
}
type AppendEntriesReply struct {
Term int
}
func (rf *Raft) GetEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
if args.Term < rf.currentTerm {
//fmt.Println(rf.me, "getentries not suit", args.Term, rf.currentTerm)
reply.Term = rf.currentTerm
} else {
reply.Term = rf.currentTerm
//fmt.Println(rf.me, "getentries")
rf.state = "follower"
rf.ch = true
}
}
- 确保不同节点不总是同时触发,用rand进行随机即可,实验模版有相应代码
- 每秒发送RPC不超过10秒,设置leader睡眠100ms+即可
- 这个5s之内一般都能实现,没有特别的关注这条hint
- 选举超时的时间我在实现的过程中也没有特别关注,但是设置了单个用户获得投票的时间,若RequestVote在15ms没有得到一个节点的reply时默认这个节点断开连接无法联系。并且leader的心跳时间间隔设置的为150ms。
- rand主要用于随机数产生选举时间
- 这条主要也和我们要实现的东西一致,在下面具体实现会说
- Guidance page可以看一看其中的建议,如用DPrintf调试以及再次提示你需要遵循Raft论文的图2
- 再次强调 图2的重要性
- 实现GetState()测试才能够跑通
- 给定的rf.kill和rf.killed方法,我在休眠结束后会调用killed方法检查是否该节点是否已经死亡,在循环中并没有进行处理处理,但通过了测试,最好还是添加。
- 最后一条强调Go语言RPC中结构体字段需要第一个大写
2 其他信息
在实验中有很多信息是要查看源代码才能够知道一些信息的,这里会给出一些通过2A实验我发现的一些条件:
- 对不同的节点发送消息时通过raft结构体中的peers[i].call进行访问,同样的如果网络中的一个节点寄了,可能请求和回复就会消失,所以这里我采用了固定时间请求不到达就判断节点断开网络从而判断节点数量。
- 具体的测试程序在test_test.go文件中,可以通过查看这个文件来了解具体的测试过程,以更好修改程序,不做修改原测试文件即可。以2A的第一个小测试来看,代码如下,首先用make_config建立了三个节点,随后每个步骤在上面都有展示,文中已经翻译成中文。其他的测试同样可以在这里找到,你也可以深入进去找每一个代码块的具体实现,可以更清晰地了解过程。
func TestInitialElection2A(t *testing.T) {
servers := 3
cfg := make_config(t, servers, false, false)
defer cfg.cleanup()
cfg.begin("Test (2A): initial election")
// leader是否选举出来了
cfg.checkOneLeader()
// 睡眠50ms,以避免与follower进行选举冲突,然后检查所有同行是否都同意相同的阶段
time.Sleep(50 * time.Millisecond)
term1 := cfg.checkTerms()
if term1 < 1 {
t.Fatalf("term is %v, but should be at least 1", term1)
}
// 网络没有失败这里leader和term是否保持相同
time.Sleep(2 * RaftElectionTimeout)
term2 := cfg.checkTerms()
if term1 != term2 {
//fmt.Printf("warning: term changed even though there were no failures")
}
// 这里仍然是否有一个leader
cfg.checkOneLeader()
cfg.end()
}
- 在第二个测试中提到了论文中没有提到的(也有可能我粗心没有看到的),当一个网络中只有一个节点时,他不认为自己是leader,而认为自己是follower,只有节点数大于2,才有leader的存在。
- 另外labgob包里面的文件是相关通信的,同样可以关注一下,也可以在实现过程中哪边有疑问再去看
3 具体实现
经过上面的分析,就算实现过程不一样其实自己完全可以写完成此次2A,后面是我的实现,可能略有不足,可以评论区指正。
3.1 结构体的定义
实现过程,首先是定义各种图2中的变量和结构体,在其中可以加入自己需要的字段,下面是我的各种结构体。上面已经有的LogEntries、AppendEntriesArgs、AppendEntriesReply、RequestVoteArgs、RequestVoteReply将不再赘述,可以在前面查看。只剩下Raft结构体,这里增加了图2上没有的变量:ch作为判断醒来之前有没有心跳的标志位;state存储当前的状态;
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.
logs []LogEntries //store log entries
currentTerm int //latest term
votedFor int //who received vote in current term
commitIndex int //index of highest log entry that be committed
lastApplied int //index of highest log entry applied to state machine
nextIndex []int //for each server,index of the next log entry to send to that server
matchIndex []int //for each server, index of highest log entry known to be replicated on server
state string //follower or candidate or leader
ch bool //if there have message
}
3.2 make函数的修改
make函数里面对节点进行初始化时,基本的初始化都已经完成,这里我们增加了state字段,所以初始化时需要把其状态初始为follower。
rf.state = "follower"
3.3 ticker函数实现
ticker函数是这个的重点,其中给了我们基本的随机生成睡眠时间的函数,可以通过这个来修改相应的时间进行处理。首先是for循环判断是否已经被kill,之后因为要访问rf.state,所以进行锁定,代码如下。
for rf.killed() == false {
rf.mu.Lock()
}
对于2A,主要对两个状态进行处理,分别是follower和leader,这里其实跳过了candidate的状态,如果需要的话后续在Raft其他实验中需要的话再修改。
3.3.1 follower处理
进入首先休眠随机的时间,这里是150~350ms之间,醒来之后如果被kill则直接退出,否则进行下一步判断,这里的判断是针对ch标志位,ch被更改为true有两种情况,第一种是先醒的节点进行竞选并且自己同意其为领导者,第二种是接受到领导者的心跳或者消息。如果没有这两种,则说明在自己休眠的过程中,没有节点成为领导者,或者之前有领导者但是寄了,所以自己要竞选。如果为true,那就将这一阶段的ch置为false,等待判断下一轮的消息。下面是follower处理过程。
if rf.state == "follower" {
rf.mu.Unlock()
ms := 150 + (rand.Int63() % 200)
time.Sleep(time.Duration(ms) * time.Millisecond)
if rf.killed() == true {
break
}
rf.mu.Lock()
//fmt.Println(rf.me, "wake")
//fmt.Println(rf.ch)
if !rf.ch {
chrp := make(chan RequestVoteReply)
chbool := make(chan bool)
//fmt.Println(rf.me, "candidate")
rf.currentTerm++
cout, all, tag := 0, 0, 0
for i := len(rf.peers) - 1; i >= 0; i-- {
if i == rf.me {
cout++
all++
rf.votedFor = i
} else {
//fmt.Println(rf.me, "now send to ", i)
rv := RequestVoteArgs{
Term: rf.currentTerm,
CandidateID: rf.me,
LastLogIndex: rf.commitIndex,
LastLogTerm: rf.currentTerm,
}
rp := RequestVoteReply{}
peer := rf.peers[i]
go func() {
peer.Call("Raft.RequestVote", &rv, &rp)
chrp <- rp
}()
go func() {
time.Sleep(15 * time.Millisecond)
chbool <- false
}()
select {
case rp := <-chrp:
//fmt.Println(i, "ok")
all++
if rp.VoteGranted {
cout++
} else {
if rf.currentTerm == rp.Term {
} else { //if rf.currentTerm != rp.Term {
rf.currentTerm = rp.Term
rf.state = "follower"
tag = 1
//fmt.Println("bigger term", rp.Term)
break
//}
}
}
case <-chbool:
//fmt.Println(i, "timeover")
}
}
if tag == 1 {
break
}
}
//fmt.Println(rf.me, "cout", cout, "all", all)
if tag == 1 {
rf.state = "follower"
} else if cout > all/2 && tag != 1 && all > 1 {
rf.state = "leader"
//fmt.Println(rf.me, "to leader", rf.currentTerm)
} else if all == 1 {
rf.state = "follower"
rf.currentTerm--
}
} else {
rf.ch = false
}
//fmt.Println("follower", rf.me, "over")
rf.mu.Unlock()
}
- 竞选过程
这个过程论文和动态演示其实都很清晰,首先醒的人将自己的term+1,然后向所有其他节点发送RequestVote消息,这里使用Raft结构体中的peer[i].call进行逐一发送,随后接受消息,这里对回复自己的和同意自己为leader进行计数all、count。如果在回复自己的消息中,存在term>自己当前term的,那么设置自己的term为reply的term,并且置自己为follower,退出竞选过程;如果计数为1,说明自己处于一个单独的网络,那么会将自己的term再减一回到之前的状态;如果计数大于1且cout>all/2,那么成为leader。在这个过程中要注意raft锁的使用,使用不当的话容易导致一个或多个线程的死锁,睡眠的时候要接触锁,否则无法接收消息。上述代码可以看到相应操作。
3.3.2 leader处理
在2A中leader处理主要就是定时发送心跳,进行宣告自己的存在即可。另外需要注意两个点:一是需要和follower一样需要计数,因为如果只剩自己一个则自己不为leader;二是在给其他节点发送log的时候需要判断reply的term是否大于自己的,如果大于自己的,就要置自己为follower。完整代码如下:
else if rf.state == "leader" {
rf.mu.Unlock()
time.Sleep(150 * time.Millisecond)
if rf.killed() == true {
break
}
rf.mu.Lock()
chrp := make(chan AppendEntriesReply)
chbool := make(chan bool)
all := 0
//fmt.Println(rf.me, "heart", rf.currentTerm)
if true {
//fmt.Println("peers", len(rf.peers))
for i := len(rf.peers) - 1; i >= 0; i-- {
if i != rf.me {
ae := &AppendEntriesArgs{Term: rf.currentTerm}
ap := &AppendEntriesReply{}
peer := rf.peers[i]
go func() {
peer.Call("Raft.GetEntries", ae, ap)
chrp <- *ap
}()
go func() {
time.Sleep(15 * time.Millisecond)
chbool <- false
}()
select {
case ap := <-chrp:
all++
if ap.Term > rf.currentTerm {
rf.state = "follower"
rf.currentTerm = ap.Term
break
}
case <-chbool:
}
}
}
}
if all == 0 {
rf.state = "follower"
}
rf.mu.Unlock()
}
3.4 RequestVote函数实现
在获得一个candidate的投票消息时:如果竞争者的term小于自己的,肯定就不同意,返回给他自己的term以及不同意;如果竞争者的term等于自己的,那肯定也不同意,因为这个情况下,自己可能是三种情况:已经投过票的leader、同样竞选的candidate、当前阶段的leader,这三种情况都不可能投票给他;如果candidate的term大于自己的,这时不管自己什么状态都要转换为follower并且投票给他,并且置自己的ch为true。完整代码如下:
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
// Your code here (2A, 2B).
rf.mu.Lock()
if args.Term < rf.currentTerm {
reply.VoteGranted = false
//fmt.Println("you are small")
reply.Term = rf.currentTerm
rf.mu.Unlock()
return
} else if rf.currentTerm == args.Term {
reply.VoteGranted = false
//fmt.Println("not choose you")
reply.Term = rf.currentTerm
rf.mu.Unlock()
return
}
rf.currentTerm = args.Term
rf.votedFor = args.CandidateID
reply.VoteGranted = true
//fmt.Println(rf.me, "getvote")
rf.ch = true
reply.Term = args.Term
rf.state = "follower"
rf.mu.Unlock()
}
3.5 GetEntries函数实现
这是自己定义的对于AppendEntries结构体的处理函数,因为当前没有日志文件,所以只看log里面的term:如果请求的term<自己的term,那就把自己的term返回回去,不做处理;其他情况就也返回自己的term,并且把自己的state置为follower,这个一定要做处理,否则测试会出问题,并且ch置为true,完整代码如下:
func (rf *Raft) GetEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
if args.Term < rf.currentTerm {
//fmt.Println(rf.me, "getentries not suit", args.Term, rf.currentTerm)
reply.Term = rf.currentTerm
} else {
reply.Term = rf.currentTerm
//if need to change own term
//fmt.Println(rf.me, "getentries")
rf.state = "follower"
rf.ch = true
}
}
4 运行截图
4 总结
2A部分其实感觉自己做的还有所欠缺,没有那么完美,但是一些过程可能要在后面的实现过程中进一步修改,这里先留一点缺憾吧。