Talent Plan TinyKV Project2A RaftKV

1,685 阅读7分钟

Project2A对应824的Lab2,本文主要讲解这一个project大体框架,主要流程,如果之前没有做过824,可以先大概过一眼我之前的6.824 lab2的文章

文档翻译

这文档感觉什么都说了,又什么都没说,大概看看就行,不用纠结细节。

Raft被设计为易理解的共识算法,你可以通过raft.github.iothe extended Raft paper来了解更多关于Raft的知识。

本项目中,你将基于Raft实现一个高可用KV服务,具体包括利用badger管理Raft的持久化状态、为快照消息添加流控制等。

本项目有3个部分需要去做。

  1. 实现基本的Raft算法。
  2. 基于Raft实现容错的KV服务。
  3. 支持raftlog回收、快照。

Part A

Part A的代码在raft/下,你需要实现的所有函数都已经在代码中列出了,此外,代码中使用逻辑时钟(tick)判定选举和心跳的超时而非物理时钟,并且消息的收发应该是异步的。

动手前,先看看Part A的提示,并粗略的看下proto/proto/eraftpb.proto,里面定义了一些收发消息的相关结构。需要注意的是,不同于Paper中的实现,这里Heartbeat和AppendEntries分成了两部分,逻辑更清晰。

Part A可以分成3步。

  1. 领导者选举。
  2. 日志复制。
  3. 与上层应用交互的接口。

raft/raft.go提供了Raft算法的核心,你可以通过raft/doc.go获得更多的指引。

领导者选举

Raft.tick()开始,驱动选举或心跳超时,现在不需要关心消息的收发逻辑,如果需要发送消息,只需要追加到Raft.msgs中,Raft.Step()是处理消息的入口,还需要实现Raft.becomeXXX()用于切换身份时更新Raft状态。

可以使用make project2aa测试这一部分。

日志复制

从处理MsgAppend消息开始,相关结构为raft.RaftLograft.Storage用于上层应用获取Raft的持久化状态,例如日志条目和快照。

可以使用make project2ab测试这一部分。

实现与上层应用交互的接口

接口由raft.RawNode给出,包含Raft结构,并封装了Raft.tick()Raft.Step()两个函数,为RawNode.Tick()RawNode.Step()RawNode.Propose()用于上层应用追加新的日志。

Ready结构体记录了Raft的状态、日志条目、已提交的日志条目、快照、将要发送的消息等字段,上层应用通过调用RawNode.Ready()获取这些信息。

可以使用make project2ac测试这一部分。

一些提示

  1. 添加你需要的状态到Raft、RaftLog、RawNode结构中,添加你需要的message到eraftpb.proto中。
  2. 假定term从0开始。
  3. 假定新的leader要在其term内追加一个noop日志项。
  4. 假定当leader更改commit index,会通过MessageType_MsgAppend广播。
  5. 测试程序不会为MessageType_MsgHup,MessageType_MsgBeat,MessageType_MsgPropose修改term。
  6. leader和非leader在追加日志项上由很大不同。
  7. 不要忘记不同节点的election timeout应该不同。
  8. rawnode.go中的一些套壳函数,可以用raft.Step(local message)实现。
  9. 启动一个新的Raft时,从Storage获取最新的状态用于初始化Raft和RaftLog。

2aa & 2ab

这两部分就一起看了,2aa test里也要求了AppendEnteis,所以分的不是很开。

主体框架

我们需要实现的函数大致分三类。

  1. sendXXX
  2. handleXXX
  3. becomeXXX

还有驱动这三类函数的两个核心函数。

  1. tick
  2. Step

它们之间的关系如下图所示。

image.png

以选举的流程为例。

  • 候选人流程

    1. tick发起选举,Step(MsgHup)
    2. Step根据消息调用handleHup
    3. handleHup调用sendRequestVote
    4. sendRequestVote发送MsgRequestVote消息
  • 其余节点流程

    1. 收到MsgRequestVote,Step(MsgRequestVote)
    2. Step根据消息调用handleRequestVote
    3. handleRequestVote调用sendRequestVoteResponse
    4. sendRequestVoteResponse回复MsgRequestVoteResponse消息

现在你应该知道,sendXXX和handleXXX中的XXX指的就是消息类型。

Step

Step根据消息的类型,调用对应的handleXXX处理。下面代码中给出的是这一部分我们要处理的消息类型。

func (r *Raft) Step(m pb.Message) error {
   switch m.MsgType {
      case pb.MessageType_MsgHup:
         r.handleHup(m)
      case pb.MessageType_MsgBeat:
         r.handleBeat(m)
      case pb.MessageType_MsgPropose:
         r.handlePropose(m)
      case pb.MessageType_MsgAppend:
         r.handleAppendEntries(m)
      case pb.MessageType_MsgAppendResponse:
         r.handleAppendEntriesResponse(m)
      case pb.MessageType_MsgRequestVote:
         r.handleRequestVote(m)
      case pb.MessageType_MsgRequestVoteResponse:
         r.handleRequestVoteResponse(m)
      case pb.MessageType_MsgHeartbeat:
         r.handleHeartbeat(m)
      case pb.MessageType_MsgHeartbeatResponse:
         r.handleHeartbeatResponse(m)
   }
   return nil
}

sendXXX

handleXXX有哪些在Step中已经给出了,sendXXX少一些,分别是sendAppend/sendAppendResponse、sendRequestVote/sendRequestVoteResponse、sendHeartbeat/sendHeartbeatResponse。

tinykv中把Heartbeat和AppendEntries分开了,不要自作主张把它们合并到一起,因为测试程序不允许。

具体实现参考paper,如果你做过824应该是不太难,代码这里就不给出了。

handleXXX

handleXXX大部分都好理解,唯独一个handlePropose可能理解起来有点费劲,这里大概说一下。

Propose的语义就是追加指定的entries,即Message.Entries,然后再同步给其他节点,类似824中的Start。这里不要忘记更新Next和Match。

func (r *Raft) handlePropose(m pb.Message) {
   if r.State != StateLeader {return}
   for i, e := range m.Entries {
      e.Index = uint64(i) + r.RaftLog.LastIndex() + 1
      e.Term = r.Term
      r.RaftLog.entries = append(r.RaftLog.entries, *e)
   }
   r.Prs[r.id] = &Progress{
      Next: r.RaftLog.LastIndex() + 1,
      Match: r.RaftLog.LastIndex(),
   }
   r.bcastAppend()
}

这里给出handlePropose的代码,其中bcastAppend就是同步其他节点,和Step(Beat)、Step(Hup)类似。

becomeXXX

也就是becomeLeader、becomeCandidate、becomeFollower,函数中会更新相应的Raft状态,唯一要注意的是becomeLeader。

tinykv对becomeLeader有这样的要求:// NOTE: Leader should propose a noop entry on its term

也就是需要Step(MsgPropose),Message.Entries包含一个空的entry,也就是noop entry。

func (r *Raft) becomeLeader() {
   r.State = StateLeader
   r.Lead = r.id
   r.heartbeatElapsed = 0

   for id := range r.Prs {
      r.Prs[id] = &Progress{
         Next: r.RaftLog.LastIndex() + 1,
         Match: 0,
      }
   }

   r.Step(pb.Message{MsgType: pb.MessageType_MsgPropose, Entries: []*pb.Entry{{}}})
}

tick

tinykv中,使用逻辑时钟来判断是否心跳和选举,elapsed表示距离上一次心跳或选举过去的时间单位,timeout表示每多少个时间单位进行一次心跳或选举。

elapsed和timeout都是整型值,测试程序每调用一次tick,过去一个时间单位,对应elapsed加一,达到timeout后,进行心跳或选举,并将elapsed归零。

func (r *Raft) tick() {
   switch r.State {
   case StateLeader:
      r.heartbeatElapsed++
      if r.heartbeatElapsed >= r.heartbeatTimeout {
         r.Step(pb.Message{MsgType: pb.MessageType_MsgBeat})
      }
   default:
      r.electionElapsed++
      if r.electionElapsed >= r.electionTimeout {
         r.Step(pb.Message{MsgType: pb.MessageType_MsgHup})
      }
   }
}

RaftLog

RaftLog中的committed、applied、entries等都好理解,但storage、stabled是什么鬼,entries的下标到底是从0开始还是从1开始。

Storage为一个抽象类,这里的具体实现为MemoryStorage,作为RaftLog持久化用,在Raft重启时,从Storage中恢复相应的状态。

MemoryStorage(ms)和RaftLog中都存有entries,它们之间的关系,以及entries下标的计算如下图所示。

image.png

len(r.RaftLog.entries) == 0时,len(ms.ents) == 1,first为1,stabled和last为0。

image.png

Storage保存了如下信息。

  1. HardState

    • Term:即Raft.Term
    • Vote:即Raft.Vote
    • Commit:即Raft.RaftLog.committed
  2. Snapshot

    • Data:即Snapshot中所有的Entry

    • MetaData

      • Index:Snapshot最后一个Entry的Index

      • Term:Snapshot最后一个Entry的Term

      • ConfState

        • Nodes:即Peers
  3. Stabled Entries:即ms.ents

创建Raft、RaftLog时,需要从Storage恢复相应的状态。

func newLog(storage Storage) *RaftLog {
   lo, _ := storage.FirstIndex()
   hi, _ := storage.LastIndex()
   hardState, _, _ := storage.InitialState()
   entries, _ := storage.Entries(lo, hi+1)

   return &RaftLog{
      storage:         storage,
      committed:       hardState.Commit,
      applied:         lo - 1,
      stabled:         hi,
      entries:         entries,
   }
}

func newRaft(c *Config) *Raft {
   if err := c.validate(); err != nil {
      panic(err.Error())
   }
   hardState, confState, _ := c.Storage.InitialState()
   if c.peers == nil {c.peers = confState.Nodes}
   r := &Raft{
      id: c.ID,
      Term: hardState.Term,
      Vote: hardState.Vote,
      RaftLog: newLog(c.Storage),
      Prs: make(map[uint64]*Progress),
      State: StateFollower,
      votes: make(map[uint64]bool),
      Lead: None,
      heartbeatTimeout: c.HeartbeatTick,
      electionTimeout: c.ElectionTick,
   }
   r.RaftLog.applied = c.Applied
   for _, id := range c.peers {
      r.Prs[id] = &Progress{}
   }
   return r
}

stabled除了在newLog中初始化外,还会在handleAppendEntries时发生变化。

for i, e := range m.Entries {
   j := m.Index + uint64(i) + 1 - r.RaftLog.FirstIndex()
   if j >= uint64(len(r.RaftLog.entries)) {
      r.RaftLog.entries = append(r.RaftLog.entries, *e)
   } else {
      if r.RaftLog.entries[j].Term != e.Term {
         // 这里更新stabled
         r.RaftLog.stabled = min(j+r.RaftLog.FirstIndex()-1, r.RaftLog.stabled)
         r.RaftLog.entries = append(r.RaftLog.entries[:j], *e)
      } else {
         // 这里*e和r.RaftLog.entries[j]是一样的,相同term相同index是相同的entry
         r.RaftLog.entries[j] = *e
      }
   }
}

也就是在appendEntries的过程中,覆盖了stabled entries,就需要更新stabled。

2ac

这里要实现的是rawnode.go中的Ready、HasReady、Advance三个函数。

RawNode就是在Raft上又封装了一层。

Ready

Ready结构如下(Snapshot在Part A不需要实现)。

type Ready struct {
    // Raft.Lead, Raft.State
   *SoftState
   // Raft.Term, Raft.Vote, Raft.RaftLog.committed
   pb.HardState
   // Raft.RaftLog.unstabledEntries()
   Entries []pb.Entry
   // Raft.RaftLog.nextEnts()
   CommittedEntries []pb.Entry
   // Raft.msgs
   Messages []pb.Message
}

每一次调用Ready函数,如果相比于上一次调用Ready函数Raft状态发生了变化,就会把这些状态构造成Ready结构返回;如果没有变化,返回空的Ready。

HasReady

HasReady函数就是用于判断相比于上一次调用Ready函数Raft状态是否发生了变化。

Advance

这一套流程是,先调用HasReady,如果有变化,调用Ready获取现在Raft的状态,接着将Raft中未持久化的entries持久化;已apply未commit的entries都apply;把msgs都发给对应的msg.To节点。

这里我们不用持久化entries,也不用apply entries到状态机,也不用发送msg。只要更改RaftLog的stabled、applied的值,并将Raft的msgs置空就好,也就是假装做了上面这些事情。

总结

做tinykv唯一舒服的就是,Test写的很好懂,而且由Test控制,不需要考虑并发安全,也不用看着满屏的log调试了。但有好处也由坏处,代码不得不顺着Test的意思来写才行。