Raft 实现笔记

1,616 阅读8分钟

介绍

原文参考Raft 实现笔记

Raft目前是最著名的分布式共识性算法,被广泛的应用在 etcd、k8s 中。

6.824中,Raft 作为第二个实验出现,是一系列实验的第一个,最终将实现一个容错的 KV 存储系统,类似于 Redis。

本文不对 Raft 做过多介绍,想要了解的可以阅读extended Raft paper论文。

6.824将 Raft 实现的实验分配了四个部分,分别是 2A、2B、2C 和 2D。每一部分将实现 Raft 中的一部分功能,最后实现出一个完整的 Raft 算法,如下:

  • 2A:领导者选举,难度等级适中;
  • 2B:日志,难度等级为难;
  • 2C:持久化,难度等级为难;
  • 2D:日志压缩,难度等级为难。

虽然将 2A 部分难度定为适中,但是笔者并不这样觉得,万事开头难,2A 作为第一部分,必须担起开拓的责任,给整个实验打下地基,相当于 Raft 骨架,而后面的部分则是丰富这个骨架。

由于每个实验部分都是基于上一个部分的,因此 2A 部分最为重要,这样才能推进后面的实验。

Part 2A

2A 部分需要实现领导者选举和心跳(AppendEntries RPC 请求,但无日志)。集群各节点选举出一个 leader,如果 leader 没有发生故障、网络未丢包等,则 leader 维持不变,否则将选举出新的 leader。

完成 2A 最重要的是多读多看论文上的 figure2,如下:

image.png

切记,一定要多看,遇到不会的点,一定要去看,因为上面就会有答案。

2A 可以分为两部分:leader 选举和心跳。其中选举涉及到超时选举和 RequestVote RPC 请求,而心跳涉及到定时心跳和 AppendEntries RPC 请求。

因此可讲 2A 拆分为如下四个小部分:

  • 超时触发选举,即 ticker,当节点未收到心跳时触发新的选举。
  • RequestVote,协调者向其它节点发送投票请求。
  • 定时心跳,即 pingLoop,leader 独有,leader 向其它节点宣誓主权,不准它们发起选举。
  • AppendEntries,leader 向其它节点发送心跳信息,目前不包含日志项。

结构体定义

Raft 节点由一个结构体定义,如下:

type Raft struct {
   mu        sync.Mutex 
   peers     []*labrpc.ClientEnd 
   persister *Persister 
   me        int                 // this peer's index into peers[]
   dead      int32               // set by Kill()
   // peer
   state PeerState
   // 2A
   currentTerm int // 当前任期
   votedFor    int // 给谁投过票
   leaderId    int // 集群 leader id
   // apply channel
   applyCh chan ApplyMsg
   // time
   electionTimer *time.Timer // 选举超时时间
   pingTimer     *time.Timer // 心跳超时时间
}

Raft 结构体本身就包含 mu、dead 等字段,实现 2A 我们就必须添加上需要的字段,如 state 字段用于表示当前节点的状态是 leader、follower 还是 candidate。新增的字段如下:

  • state:节点状态
  • currentTerm:当前节点任期
  • votedFor:给谁投过票,-1 表示未投票
  • leaderId:集群中的领导者 id
  • applyCh:日志应用通道,暂时不用
  • electionTimer:选举超时时间
  • pingTimer:心跳超时时间,leader 专用

再来看看 RPC 请求中所使用的结构体:

type RequestVoteArgs struct {
   Term        int
   CandidateId int
}
type RequestVoteReply struct {
   Term        int
   VoteGranted bool
}

由于在 2A 中,RequestVote 只承担选举作用,不需要日志相关的字段,因此参数和回复都比较简单,这些字段的说明都可以在上面的插图中找到。 同样的,对于 AppendEntries 来说,如果只承担心跳的作用,那么字段也非常简单和明了,详细可参考插图

type AppendEntriesArgs struct {
   Term     int
   LeaderId int
}
type AppendEntriesReply struct {
   Term    int
   Success bool
}

节点状态

由于节点存在 follower,candidate 和 leader 三个状态,可将这三个状态抽象为三个函数,方便在不同的状态中切换:

func (rf *Raft) becomeCandidate() {
   rf.state = Candidate // 改变节点状态
   rf.currentTerm++
   rf.votedFor = rf.me
   DPrintf("peer: %d become candidate\n", rf.me)
}
func (rf *Raft) becomeFollower(term int) {
   rf.state = Follower
   rf.currentTerm = term
   rf.votedFor = -1
   rf.electionTimer.Reset(getRandElectTimeout())
   DPrintf("peer: %d become follower\n", rf.me)
}
func (rf *Raft) becomeLeader() {
   rf.state = Leader
   rf.leaderId = rf.me
   // TODO 日志处理
   // 心跳定时器
   rf.pingTimer.Reset(heartbeatInterval)
   go rf.pingLoop() // 开启心跳 loop
   DPrintf("peer: %d become leader\n", rf.me)
}
  • candidate 代表协调者,当切换为 candidate 时,除了将 state 设为 Candidate 之外,还需要增加当前任期,并且给自己投一票
  • follower 代表追随者,当切换为 follower 时,除了将 state 设为 follower 之外,还需设置当前任期为 leader 任期,votedFor 为 -1,没有给任何人投票,并且重置选举定时器。
  • leader 代表领导者,当切换为 leader 时,将 state 设为 follower,重置心跳定时器,并开始心跳 loop,向其它节点发送心跳包

此处逻辑,可以看出,心跳发送是 leader 独有的。

节点初始化

同 Make 函数新建一个 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 // 节点 id
   rf.applyCh = applyCh
   // Your initialization code here (2A, 2B, 2C).
   rf.currentTerm = 0
   rf.votedFor = -1
   rf.leaderId = -1
   rf.state = Follower
   rf.electionTimer = time.NewTimer(getRandElectTimeout())
   rf.pingTimer = time.NewTimer(heartbeatInterval)
   // initialize from state persisted before a crash
   rf.readPersist(persister.ReadRaftState())
   // start ticker goroutine to start elections
   // 开始选举
   DPrintf("peer: %d start ticker\n", rf.me)
   go rf.ticker()
   return rf
}

Make 函数主要对节点状态数据进行初始化,如当前任期(currentTerm)为 0,votedFor 和 leaderId 为 -1,表示空,并且节点的初始状态为 Follower,这点很重要。 随后通过 go 关键字新开 goroutine,运行 ticker 函数,该函数负责 leader 选举,后文再讲。最后返回当前节点实例。

两个 loop

2A 部分有两个 loop,即死循环,一个是 ticker,选举死循环,当一定时间内未收到心跳,则触发新的选举;另一个是 pingLoop,leader 独有,心跳死循环,当节点成为 leader 后,不断的向其它节点发送心跳 RPC。

ticker 每个节点都有,但是当节点成为 leader 后,ticker 函数其实是不工作的,如下:

func (rf *Raft) ticker() {
   for rf.killed() == false {
      <-rf.electionTimer.C
      // 选举超时,重置定时器
      rf.electionTimer.Reset(getRandElectTimeout())
      rf.mu.Lock()
      if rf.state == Leader {
         rf.mu.Unlock()
         continue
      }
      rf.becomeCandidate()
      var votes int32 = 1 // 当前 1 票,自己投自己
      me := rf.me
      for peerId := range rf.peers {
         if peerId == me { // 注意:此处比较仍然需要加锁
            continue
         }
         // 新建 goroutine 发送请求
         go func(peerId int) {
            rf.mu.Lock()
            currentTerm := rf.currentTerm
            args := RequestVoteArgs{
               Term:        currentTerm,
               CandidateId: rf.me,
            }
            reply := RequestVoteReply{}
            DPrintf("peer: %d sendRequestVote to peer: %d\n", rf.me, peerId)
            rf.mu.Unlock() // 解锁,发送 RPC 请求不能加锁
            ok := rf.sendRequestVote(peerId, &args, &reply)
            if !ok { // 请求失败,退出
               DPrintf("peer: %d sendRequestVote fail\n", peerId)
               return
            }
            // 注意:此处分段加锁,减少 RPC 请求等待的影响
            rf.mu.Lock()
            // 如果当前节点已经不是 candidate 了,则直接退出
            if rf.state != Candidate {
               rf.mu.Unlock() // 退出前,记得解锁
               return
            }
            // 如果 reply 中的任期大于当前任期,则当前节点成为 follower
            if reply.Term > currentTerm {
               rf.becomeFollower(reply.Term)
            }
            if reply.VoteGranted { // 如果获得了选票
               // 增加当前选票,并判断是否超过了半数
               atomic.AddInt32(&votes, 1)
               if int(votes) >= (len(rf.peers)+1)/2 {
                  DPrintf("peer: %d received %d votes, become leader\n", rf.me, votes)
                  rf.becomeLeader()
               }
            }
            rf.mu.Unlock()
         }(peerId)
      }
      rf.mu.Unlock() // 切记,必须成对出现
   }
}

在代码第 7 行,如果当前节点状态变更为 leader 后,那么本次循环将会跳过,因为 leader 已经产生,则无需再次选举。 代码 12 行,如果一个节点成为 candidate 后,会给自己投上一票,因此初始票数是 1。

代码 19 行,新建 goroutine 来处理 RPC,避免锁长时间等待。

代码 37 行,这里很重要,如果当前节点已经获得了大多数投票,则已经成为了 leader,无需再处理后面的投票。

pingLoop 在节点成为 leader 后,才开始工作,如下:

func (rf *Raft) pingLoop() {
   for rf.killed() == false {
      // 成为 leader 后,必须立马发送一次心跳,而不是等超时后
      rf.mu.Lock()
      if rf.state != Leader { // 非 leader,则没有资格发送心跳,直接退出
         rf.mu.Unlock()
         return
      }
      // 向其它每个节点发送 AppendEntries RPC
      leaderId := rf.leaderId
      term := rf.currentTerm
      for peerId := range rf.peers {
         if peerId == rf.me {
            continue
         }
         go func(peerId int) {
            args := AppendEntriesArgs{
               Term:     term,
               LeaderId: leaderId,
            }
            reply := AppendEntriesReply{}
            ok := rf.sendAppendEntries(peerId, &args, &reply)
            if !ok {
               return
            }
            rf.mu.Lock()
            // 别人的任期大
            if reply.Term > term {
               rf.becomeFollower(reply.Term)
            }
            // TODO
            if reply.Success { // 证明日志同步成功
            } else { // 日志同步失败,重新同步
            }
            rf.mu.Unlock() // 解锁
         }(peerId)
      }
      rf.mu.Unlock()
      <-rf.pingTimer.C
      // 心跳超时,重置定时器
      rf.pingTimer.Reset(heartbeatInterval)
   }
}

pingLoop 的处理与 ticker 稍有不同,ticker 是定时器到点后才开始处理,而 pingLoop 是直接发出心跳,发完后才进行计时,这是因为成为 leader 后需要立马向其它节点发送心跳。 代码第 5 行,当节点状态不是 leader 时,不能再发送心跳,直接解锁退出。

代码第 29 行,发现别人的任期大时,直接成为 follower。

RPC

前面也提到了,2A 涉及两个 RPC 请求,即 RequestVote 和 AppendEntries,二者都是 RCP 处理器,用于处理其它节点发送的 RPC 请求。

 func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
   rf.mu.Lock()
   defer rf.mu.Unlock()
   reply.Term = rf.currentTerm
   // 如果请求者的任期小于当前任期,那么直接返回 false
   if args.Term < rf.currentTerm {
      reply.VoteGranted = false
      return
   }
   // 一旦发现了任期更大的请求者,则直接变成 follower
   if args.Term > rf.currentTerm {
      rf.becomeFollower(args.Term)
   }
   // 如果 votedFor 是空,或者是请求者 id,注意日志条件是 and,但是 2A 不需要
   if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
      reply.VoteGranted = true
      return
   }
   DPrintf("RequestVote, args: %v, reply: %v\n", args, reply)
}

RequestVote 用于处理其它节点发送的投票请求,给出回复(reply)。 代码第 6 行,如果请求者任期小于当前任期,那么拒绝投票,直接返回。

代码第 11 行,如果请求者任期大于当前任期,那么成为 follower,并进行后面的操作决定是否投票。

代码第 15 行,如果当前节点 votedFor 为 null 或者是 candidateId,那么投票。

注意:为什么 votedFor 为 null 或者是 candidateId 就投票了?原因在于如果 candidateId 是 null,那么代表当前节点未投票,状态为 follower,那么可直接投票;如果是 candidateId,那么代表已经给请求者投过票了,那么仍可以再投。 当然这只是暂时的,在后面的实验中会检查日志完整性再来决定是否投票。

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
      return
   }
   // 一旦发现了任期更大的请求者,则直接变成 follower
   if args.Term > rf.currentTerm {
      rf.becomeFollower(args.Term)
   } else { // 任期相等,则刷新选举时间
      rf.electionTimer.Reset(getRandElectTimeout())
   }
   rf.leaderId = args.LeaderId
   // TODO 日志
   reply.Success = true
   DPrintf("AppendEntries, args: %v, reply: %v\n", args, reply)
}

AppendEntries 在 2A 部分,只负责 leader 向其它节点发送心跳,因此没有日志同步这些复杂逻辑,所以比较简单。 代码第 5 行,如果请求者的任期小于当前任期,那么心跳失败,直接返回,

代码第 10 行,如果发现请求者任期大于当前任期,那么成为 follower,否则任期相同,则重置选举时间。

代码第 15 行,设置 leaderId 并设置 reply 为心跳成功,注意此处暂时未涉及到日志同步问题,后面实验待补充。

小节

实验 2A 部分,是整个实验 2 的地基,工作主要如下:

  • 三个状态,节点状态之间的转换函数。
  • 两个 loop,ticker 和 pingLoop,pingLoop 是 leader 独有,ticker 在 leader 节点中也会跳过。
  • 两个 RPC 请求和处理,分别用于投票和心跳。

运行测试:

go test -run 2A -race
......
Test (2A): initial election ...
  ... Passed --   3.1  3  116   14386    0
Test (2A): election after network failure ...
  ... Passed --   4.4  3  232   18654    0
Test (2A): multiple elections ...
  ... Passed --   5.7  7 1236  103166    0
PASS
ok      6.824/raft      14.221s

完整代码见62fe2b6/src/raft/raft.go