选举实现
Lab2第一个任务2A,需要你实现Raft选举,这一步很重要,因为这决定接下来任务难度,如果选举逻辑存在漏洞,但是测试用例并不一定能检测出问题,会对接下来2B共识、2C持久化、2D快照有很大干扰
所有功能实现必须严格按照raft论文指引,没有妥协余地
所有raft实现代码在src/raft/raft.go文件下,因此你只需要编辑该文件,而无需修改其他文件
接口说明
首先讲解Raft结构体
package raft
type Raft struct {
mu sync.Mutex
peers []*labrpc.ClientEnd
persister *Persister
me int
dead int32
}
Raft结构体就相当于集群内部的机器,机器内存和状态都保存在Raft结构体里,me则是Raft的id
mu是互斥锁,可以用来互斥更新Raft状态,当需要更新Raft变量或者状态的时候一定要用rf.mu.Lock()
加锁,不要忘记rf.mu.Unlock()解锁,golang互斥锁不是可重入锁,所以不要在子程序重复加锁
peers是通信终端,每个Raft都有自己id保存在me变量里,通过id就可以拿到对应Raft通信终端,进而可以与其他Raft通信
dead则是一个信号变量,测试程序会调用Kill()方法改变dead变量值
接着讲解Make函数
package raft
func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft {
rf := &Raft{}
rf.peers = peers
rf.persister = persister
rf.me = me
rf.readPersist(persister.ReadRaftState())
go rf.ticker()
return rf
}
该函数用于创建Raft实例,参数peers是通信终端,参数me则是Raft的id,persister
则是负责状态持久化,实验2C、2D会用到,applyCh则是记录提交channel,实验2B会用到,Raft提交任何记录都会发送到applyCh
,因此Raft结构体需要额外属性字段保存该channel
Make函数创建goroutine运行,ticker,ticker函数则是负责运行周期性任务,比如超时检查、角色切换、心跳发送、记录提交等
接着是RequestVote函数,RequestVote是向其他Raft暴露的rpc接口,拉票请求都会走向这里,你需要对RequestVote实现投票判断逻辑
sendRequestVote函数则是用于调用远程Raft的RequestVote接口,也就是发起拉票的接口
代码实现
首先要创建一个Context结构体,Context结构体保存Raft状态,为什么要单独定义一个结构体而不是为Raft
结构体额外添加字段呢?因为后面实验2C需要实现持久化,单独定义能减少持久化的代码,能方便我们开发和调试
package raft
const (
FOLLOWER = 0
CANDIDATE = 1
LEADER = 2
)
type Raft struct {
mu sync.Mutex
peers []*labrpc.ClientEnd
persister *Persister
me int
dead int32
applyCh chan ApplyMsg
context Context //raft状态值
timeout int64 //超时时间戳
}
type Context struct {
Term int //保存回合值
VotedFor int //得到选票的Raft id
Voted bool //是否已经投过票
Log []LogEntry //记录数组
CommitIndex int //提交记录索引值
AppliedIndex int //应用记录索引值
NextIndex []int //后继索引值
MatchIndex []int //匹配索引值
Role int //角色,值在FOLLOWER、CANDIDATE、LEADER之间切换
}
type LogEntry struct {
Index int //记录索引值
Term int //记录回合值
Command any //写指令
}
接着我们要实现Make函数,我们需要正确初始化Raft
package raft
func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft {
rand.Seed(time.Now().UnixNano())
rf := &Raft{}
rf.peers = peers
rf.persister = persister
rf.me = me
rf.applyCh = applyCh
rf.context = Context{
Term: 0,
VotedFor: 0,
Voted: false,
Log: nil,
CommitIndex: 0,
AppliedIndex: 0,
NextIndex: make([]int, len(peers)),
MatchIndex: make([]int, len(peers)),
Role: FOLLOWER,
}
//初始化情况下,raft有一个索引值为0、回合值为0的记录,是为了后续实验方便
rf.context.Log = append([]LogEntry(nil), LogEntry{
Term: 0,
Index: 0,
Command: nil,
})
//根据角色判断超时时间戳类型
if rf.context.Role == LEADER {
rf.timeout = intervalTimeout(20) //领导采用周期时间戳,该函数返回20毫秒以后的时间戳
} else {
rf.timeout = randTimeout(150, 300) //其他角色采用随机时间戳,该函数返回150毫秒到300毫秒以后的时间戳
}
go rf.ticker()
return rf
}
接着我们要实现RequestVoteArgs和RequestVoteReply结构体,这两个结构体严格按照论文来实现
package raft
type RequestVoteArgs struct {
Term int //候选人回合值
CandidateId int //候选人id
LastLogIndex int //候选人最后记录索引值
LastLogTerm int //候选人最后记录回合值
}
type RequestVoteReply struct {
Term int //回合值,表示投票机器的当前回合值
VoteGranted bool //是否获得投票
}
然后就是实现RequestVote方法,这一步也完全按照论文来实现,本文在这里不详细展开,按照贴的代码注释顺序一步一步实现即可
package raft
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
//首先检查候选人回合值 args.Term
//如果小于本机回合值 rf.context.Term
//直接退出,并通过reply告知本机回合值
//如果回合值大于本机回合值
//角色退化为马仔,并重置投票状态
//不要忘记重设超时值
//检查本机投票状态 rf.context.Voted
//如果已投票
//直接退出
//检查候选人 最后记录回合值 args.LastLogTerm
//如果小于本机的 最后记录回合值
//直接退出
//如果大于本机的 最后记录回合值
//向该候选人投票并退出
//到这一步,则是候选人 最后记录回合值 与 本机的 最后记录回合值 相同
//检查候选人 最后记录索引值 args.LastLogIndex
//如果小于本机的 最后记录索引值
//直接退出
//否则
//向该候选人投票
return
}
为了后续实验方便,获取记录列表的最后记录索引值应该使用rf.context.Log[len(rf.context.Log)-1].Index
,而不是直接把len(rf.context.Log)-1当索引,因为后续实验2D快照功能时,后者是错误的,在实验2D会将已经提交的记录压缩,并削减内存保存记录的大小
接着是实现选举,选举本文实现是开启一个新的goroutine,goroutine向其他raft发送拉票请求,通过channel返回投票结果,检测到票数过半则升级为领导
package raft
/**
* term 选举回合
* lastLogIndex 最后一条记录的索引值
* lastLogTerm 最后一条记录的回合值
*/
func (rf *Raft) startElection(term int, lastLogIndex int, lastLogTerm int) {
candidateId := rf.me
respondCount := 0
voteCount := 1
voteCh := make(chan Vote, 10)
replyTerm := term
for i := range rf.peers {
if i == candidateId {
continue
}
//发起goroutine拉票
//避免阻塞
go func(server int) {
args := &RequestVoteArgs{
Term: term,
CandidateId: candidateId,
LastLogIndex: lastLogIndex,
LastLogTerm: lastLogTerm,
}
reply := &RequestVoteReply{}
ok := rf.sendRequestVote(server, args, reply)
//投票结果通过channel返回给上一级
vote := Vote{
term: reply.Term,
ok: ok,
voteGranted: reply.VoteGranted,
}
voteCh <- vote
}(i)
}
//收集子goroutine拉票结果
//如果票数过半,直接退出循环
//如果回复的回合值大于选举回合值
//则退出循环
for respondCount < len(rf.peers)-1 && voteCount <= len(rf.peers)/2 {
vote := <-voteCh
respondCount += 1
if !vote.ok {
continue
}
if vote.term > term {
voteCount = -1
replyTerm = vote.term
break
}
if vote.voteGranted {
voteCount += 1
}
}
//检查回复的回合值
//如果大于本机回合值
//则退化为马仔并重置超时时间戳
rf.mu.Lock()
defer rf.mu.Unlock()
if replyTerm > rf.context.Term {
rf.toFollower(rf.context.Term, replyTerm)
return
}
//如果票数过半,则升级为领导
//并立即发起心跳消息
if voteCount > len(rf.peers)/2 {
if rf.toLeader(term, term) {
go rf.sendHeartbeat(term)
}
}
}
然后是实现ticker函数,ticker只需要检查超时状态还有执行超时动作
package raft
func (rf *Raft) ticker() {
for rf.killed() == false {
func() {
rf.mu.Lock()
defer rf.mu.Unlock()
context := &rf.context
role := context.Role
term := context.Term
timeout := rf.timeout
//检查是否超时
if time.Now().UnixMilli() > timeout {
//在超时情况下,如果角色为马仔或候选人,则变成候选人,并发起选举
if role == FOLLOWER || role == CANDIDATE {
if rf.toCandidate(term, term+1) {
logLen := len(context.Log)
term = context.Term
role = context.Role
lastLogIndex := context.Log[logLen-1].Index
lastLogTerm := context.Log[logLen-1].Term
go rf.startElection(term, lastLogIndex, lastLogTerm)
} else {
return
}
}
//如果角色为领导,则发起心跳消息,并重置超时时间戳
if role == LEADER {
go rf.sendHeartbeat(term)
rf.timeout = intervalTimeout(40)
}
}
}()
time.Sleep(5 * time.Millisecond)
}
}
接着我们要对外提供心跳接口,在论文设计里,心跳消息与记录追加消息接口是一样的,在这里我们暂时不实现如何追加记录,如何实现该接口具体就不展开,按照注释顺序实现即可
package raft
type AppendEntriesArgs struct {
Term int //领导的回合值
LeaderID int //领导的id
PrevLogIndex int //前继记录索引值
PrevLogTerm int //前继记录回合值
Entries []LogEntry //追加的记录
LeaderCommit int //领导提交记录索引
}
type AppendEntriesReply struct {
Term int //回合值
Success bool //是否成功
ConflictTerm int //暂时不考虑,2C才需要实现,这个是优化策略一部分
ConflictIndex int //暂时不考虑,2C才需要实现,这个是优化策略一部分
}
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
//检查args携带的回合值信息Term
//如果小于本机回合值 rf.context.Term
//直接退出,并通过reply告知本机回合值
//在这里重置超时时间戳,并更新本机回合值,退化成马仔
//到这里直接退出
//因为测试并没有记录追加
//所以也没有记录对比的必要
//在2B我们会讨论具体实现
}
接着我们实现sendHeartbeat和heartbeatSender方法
package raft
//term表示发起心跳时候的回合
func (rf *Raft) sendHeartbeat(term int) {
rf.mu.Lock()
defer rf.mu.Unlock()
//检查回合值,如果相同则发起心跳,否则就退出
if term != rf.context.Term {
return
}
//检查角色,如果不是领导也退出
role := rf.context.Role
if role != LEADER {
return
}
//向每一个机器发送心跳
for i := range rf.peers {
if i == rf.me {
continue
}
go rf.heartbeatSender(i, term, 0, 0, nil, 0)
}
}
func (rf *Raft) heartbeatSender(server int, term int, prevLogIndex int, prevLogTerm int, entries []LogEntry, commitIndex int) {
if server == rf.me {
return
}
args := &AppendEntriesArgs{
Term: term,
LeaderID: rf.me,
PrevLogIndex: prevLogIndex,
PrevLogTerm: prevLogTerm,
Entries: entries,
LeaderCommit: commitIndex,
}
reply := &AppendEntriesReply{}
//发送心跳消息
ok := rf.sendAppendEntries(server, args, reply)
//如果失败直接返回
if !ok {
return
}
rf.mu.Lock()
defer rf.mu.Unlock()
//如果回复的回合值大于本机回合值
//则本机退化为马仔,并更新回合值和超时时间戳
if reply.Term > rf.context.Term {
rf.toFollower(rf.context.Term, reply.Term)
return
}
return
}
细心的人可能会注意到,对外暴露的rpc接口直接在整个方法里面加锁,而发出请求的函数则是在请求外加锁,并且是先加锁复制raft状态再解锁再发出请求
这样的好处是能避免死锁,假设加锁情况下发出请求,而对面raft也同时向本raft发出请求,这时候两者就会进入死锁状态
另外先复制状态再发送请求,可以避免并发下数据竞争,每当应用更改到raft状态时候,都会检查回合值,以确保更改有效
2A测试详解
第一个测试叫initial election,三个raft,检查初始化选举,保证只有一个领导,同时选举成功后一段时间不会触发新选举,总而言之是测试raft稳态
第二个测试叫election after network failure,三个raft,会先后分别测试领导挂机、旧领导恢复上线、一个领导和一个马仔挂机、马仔恢复上线、旧领导恢复上线,总而言之是测试回合值更新和判断和选举策略
第三个测试叫multiple elections,七个raft,九次迭代,每次迭代都会随机选三个raft挂机,然后再让挂机的raft恢复连接,保证过程中最多一个领导