Project2A对应824的Lab2,本文主要讲解这一个project大体框架,主要流程,如果之前没有做过824,可以先大概过一眼我之前的6.824 lab2的文章。
文档翻译
这文档感觉什么都说了,又什么都没说,大概看看就行,不用纠结细节。
Raft被设计为易理解的共识算法,你可以通过raft.github.io或the extended Raft paper来了解更多关于Raft的知识。
本项目中,你将基于Raft实现一个高可用KV服务,具体包括利用badger管理Raft的持久化状态、为快照消息添加流控制等。
本项目有3个部分需要去做。
- 实现基本的Raft算法。
- 基于Raft实现容错的KV服务。
- 支持raftlog回收、快照。
Part A
Part A的代码在raft/下,你需要实现的所有函数都已经在代码中列出了,此外,代码中使用逻辑时钟(tick)判定选举和心跳的超时而非物理时钟,并且消息的收发应该是异步的。
动手前,先看看Part A的提示,并粗略的看下proto/proto/eraftpb.proto,里面定义了一些收发消息的相关结构。需要注意的是,不同于Paper中的实现,这里Heartbeat和AppendEntries分成了两部分,逻辑更清晰。
Part A可以分成3步。
- 领导者选举。
- 日志复制。
- 与上层应用交互的接口。
raft/raft.go提供了Raft算法的核心,你可以通过raft/doc.go获得更多的指引。
领导者选举
从Raft.tick()开始,驱动选举或心跳超时,现在不需要关心消息的收发逻辑,如果需要发送消息,只需要追加到Raft.msgs中,Raft.Step()是处理消息的入口,还需要实现Raft.becomeXXX()用于切换身份时更新Raft状态。
可以使用make project2aa测试这一部分。
日志复制
从处理MsgAppend消息开始,相关结构为raft.RaftLog,raft.Storage用于上层应用获取Raft的持久化状态,例如日志条目和快照。
可以使用make project2ab测试这一部分。
实现与上层应用交互的接口
接口由raft.RawNode给出,包含Raft结构,并封装了Raft.tick()和Raft.Step()两个函数,为RawNode.Tick()和RawNode.Step()。RawNode.Propose()用于上层应用追加新的日志。
Ready结构体记录了Raft的状态、日志条目、已提交的日志条目、快照、将要发送的消息等字段,上层应用通过调用RawNode.Ready()获取这些信息。
可以使用make project2ac测试这一部分。
一些提示
- 添加你需要的状态到Raft、RaftLog、RawNode结构中,添加你需要的message到eraftpb.proto中。
- 假定term从0开始。
- 假定新的leader要在其term内追加一个noop日志项。
- 假定当leader更改commit index,会通过MessageType_MsgAppend广播。
- 测试程序不会为MessageType_MsgHup,MessageType_MsgBeat,MessageType_MsgPropose修改term。
- leader和非leader在追加日志项上由很大不同。
- 不要忘记不同节点的election timeout应该不同。
- rawnode.go中的一些套壳函数,可以用raft.Step(local message)实现。
- 启动一个新的Raft时,从Storage获取最新的状态用于初始化Raft和RaftLog。
2aa & 2ab
这两部分就一起看了,2aa test里也要求了AppendEnteis,所以分的不是很开。
主体框架
我们需要实现的函数大致分三类。
- sendXXX
- handleXXX
- becomeXXX
还有驱动这三类函数的两个核心函数。
- tick
- Step
它们之间的关系如下图所示。
以选举的流程为例。
-
候选人流程
- tick发起选举,Step(MsgHup)
- Step根据消息调用handleHup
- handleHup调用sendRequestVote
- sendRequestVote发送MsgRequestVote消息
-
其余节点流程
- 收到MsgRequestVote,Step(MsgRequestVote)
- Step根据消息调用handleRequestVote
- handleRequestVote调用sendRequestVoteResponse
- 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下标的计算如下图所示。
当len(r.RaftLog.entries) == 0时,len(ms.ents) == 1,first为1,stabled和last为0。
Storage保存了如下信息。
-
HardState
- Term:即Raft.Term
- Vote:即Raft.Vote
- Commit:即Raft.RaftLog.committed
-
Snapshot
-
Data:即Snapshot中所有的Entry
-
MetaData
-
Index:Snapshot最后一个Entry的Index
-
Term:Snapshot最后一个Entry的Term
-
ConfState
- Nodes:即Peers
-
-
-
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的意思来写才行。