6.824 Lab2 深入理解Raft与实战 (2) 选举实现与2A测试详解

486 阅读9分钟

选举实现

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运行,tickerticker函数则是负责运行周期性任务,比如超时检查、角色切换、心跳发送、记录提交等

接着是RequestVote函数,RequestVote是向其他Raft暴露的rpc接口,拉票请求都会走向这里,你需要对RequestVote实现投票判断逻辑

sendRequestVote函数则是用于调用远程RaftRequestVote接口,也就是发起拉票的接口

代码实现

首先要创建一个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
}

接着我们要实现RequestVoteArgsRequestVoteReply结构体,这两个结构体严格按照论文来实现

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我们会讨论具体实现
}

接着我们实现sendHeartbeatheartbeatSender方法

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恢复连接,保证过程中最多一个领导