MIT 6.824 Lab2A: Leader Election
简介
从 Lab2A 开始到 Lab2D 要逐步的实现 Raft 算法:
-
Lab2A:领导人选举
-
Lab2B:日志复制
-
Lab2C:持久化
-
Lab2D:快照
完成了前两个 Lab 之后,其实后两个需要写的内容非常少,但是细节非常多,大多数 bug 都是因为 A、B 中的实现出现了问题,并且 A、B 的测试全部通过了,但是隐藏的 bug 可以被后面的测试检测出来。
Lab2A 的实验目标是实现 Raft 领导人选举和心跳(暂时不需要添加日志)。选出一名Leader,如果 Leader 没有宕机,那么它继续担任 Leader,如果旧 Leader 宕机,或者 Follower 在选举间隔内没有收到旧 Leader 的心跳包,将发起一轮新的选举,选出新的 Leader。
在写 Lab2 的时候一定要按照论文中给出的规则来实现,不能自己随意的更改,并且课程说明给出的提示同样非常重要。
实现
思路梳理
-
根据图二将实现领导选举需要的结构体中的属性补充好(见结构体设计部分)
-
需要实现两个 RPC 调用:
-
RequestVote RPCs,用于 Candidate 向其他所有服务器发起投票请求
-
AppendEntries RPCs,用于 Leader 向其他所有服务器发送心跳包(暂时不需要附带日志)
-
-
需要实现两个计时器:
-
心跳时间:当心跳时间超时,Leader 会发起一轮新的心跳。心跳时间固定,我选的是 50 ms
-
选举时间:在选举时间超时,Follower 会转变成 Candidate 并发起一轮新的选举。选举时间需要选取一个随机数,按照课程说明上应该从 150 - 300 ms 的范围选取,这样可以避免多个服务器同时发起选举,导致一直无法选出领导人。
-
-
方法中具体的逻辑和服务器状态的转换要严格遵守图二中的规则,并且要结合整个第五节的内容来实现
结构体设计
Raft
type Raft struct {
mu sync.Mutex
peers []*labrpc.ClientEnd
persister *Persister
me int
dead int32
currentTerm int
voteFor int
log []*Entry
commitIndex int
lastApplied int
nextIndex []int
matchIndex []int
state State
electionTimeout *time.Timer
heartBeatTimeout *time.Timer
applyCh chan *ApplyMsg
}
全都是根据论文中的描述来进行结构体的设计,添加了几个必要的属性:
- 服务器状态
state - 选举时间计时器
electionTimeout - 心跳时间计时器
heartBeatTimeout - 提交通道
applyCh
注:实验说明中并不建议使用 timer 来实现超时检查。
Entry
type Entry struct {
Command interface{}
Term int
Index int
}
日志结构体中有:
- 指令
- 这条日志所处的任期
- 日志的索引
剩下的两种 RPC 请求的参数结构体就按照图二来设计即可,此处不再列出。
实现细节
Ticker
我在Ticker中完成了超时选举和发送心跳两个功能,利用timer.C结合for-select结构实现。如果心跳时间超时,则判断是否是 Leader,是的话就发送心跳,并且重置心跳时间;如果选举时间超时,则判断是否是 Leader,如果不是就发一起一轮新的选举。
func (rf *Raft) ticker() {
for rf.killed() == false {
select {
case <-rf.electionTimeout.C:
rf.mu.Lock()
// ... 省略部分代码
rf.mu.Unlock()
case <-rf.heartBeatTimeout.C:
rf.mu.Lock()
// ... 省略部分代码
rf.mu.Unlock()
}
}
}
Leader 定期发送心跳
在心跳时间超时后,Leader 就会发起一轮心跳,用于同步日志或者维持 Leader 的地位。在 Lab2A 中要处理回复的情况只有当回复的任期大于当前任期,该 Leader 转变为 Follower。
func (rf *Raft) broadcastHeartBeat() {
for peer := range rf.peers {
if peer == rf.me {
rf.electionTimeout.Reset(rf.getElectionTimeout())
continue
}
args := &AppendEntriesArgs{
// ... Leader 实现选举只需要发送任期和自己的服务器 Id 即可
}
go 使用协程来批量发送并处理 RPC {
reply := &AppendEntriesReply{}
// ...
if rf.sendAppendEntries(peer, args, reply) {
// ... 根据图二处理回复
}
// ...
}(peer)
}
}
AppendEnries RPC
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Success = false
// 如果 term < currentTerm 就返回 false
if rf.currentTerm > args.Term {
return
}
if args.Term > rf.currentTerm {
// 如果心跳的任期大于当前的任期,则更新状态至 Follower
}
reply.Term = rf.currentTerm
if args.Term == rf.currentTerm {
if rf.state == Candidate {
rf.state = Follower
}
rf.electionTimeout.Reset(rf.getElectionTimeout()) // 重置选举时间
// ... 2B,2C,2D
}
}
Follower 转变为 Candidate 并发起选举
func (rf *Raft) beginElection() {
rf.state = Candidate
rf.currentTerm = rf.currentTerm + 1 // 增加任期
rf.votedFor = rf.me // 给自己投票
votes := 1
for peer := range rf.peers {
if idx == rf.me {
continue
}
args := &RequestVoteArgs{
// 暂时只需要任期和自己的Id
}
go 使用协程来发送并处理RPC {
reply := &RequestVoteReply{}
// ...
if rf.sendRequestVote(peer, args, reply) {
// ... 根据图二处理回复
}
}(peer)
}
}
RequestVote RPC
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.VoteGranted = false
if args.Term > rf.currentTerm {
// 如果请求的任期大于当前任期,则转变为 Follower 状态
}
if rf.currentTerm == args.Term && (rf.votedFor == -1 || rf.votedFor == args.CandidateId) {
// 如果任期相同,并且当前服务器的 voted 为空或等于 CandidateId 则投票
// 重置选举时间
// 暂时不考虑日志一样新的条件
}
reply.Term = rf.currentTerm
}
注意点
Lab2A的细节不是特别多,一定要多使用DPrint来打日志,因为没有办法打断点来调试。难点在于各种状态之间的转换,要仔细阅读第五章的内容,因为图二也只是一个概括,有些细节在其中并没有体现。
还有一个要十分注意的地方,就是每次发送、接收 RPC 时,一定要检查 RPC 请求里的任期跟当前的任期是否相同,还要检查身份是否发生变化,比如说发送心跳 RPC 的时候,身份一定是 Leader。如果任期或者身份转变了,那么就要抛弃这一批中的后面的 RPC,否则会造成很多 Bug 。