概述
Raft算法需要通过rpc框架在leader与follower间通信,并保持节点间日志的一致性。本文试图在仿制etcd的项目tinykv中讲解Raft算法的日志复制机制。
RaftLog
tinykv中对RaftLog的解释
// RaftLog manage the log entries, its struct look like:
//
// snapshot/first.....applied....committed....stabled.....last
// --------|------------------------------------------------|
// log entries
//
// for simplify the RaftLog implement should manage all log entries
// that not truncated
RaftLog包含了已纳入快照的日志snapshot及没有纳入快照的日志log entries,其中,又以applied,committed,stabled三个索引来区分日志的提交状态。
其中first表示第一个不是快照的日志,applied:应用到状态机的日志,commited表示已是提交状态的日志,stabled表示已经持久化的日志,last表示最后一个日志。
No-op Entry
no-op entry和普通的heartbeat不一样,no-op是一个log entry,是一条需要落盘的log,只不过其只有term、index,没有额外的value信息。
在leader刚选举成功的时候,leader首先发送一个no-op log entry。从而保证之前term的log entry提交成功。并且通过no-op,新当选的leader可快速确认自己的CommitIndex,来保证系统迅速进入可读状态。
在tinykv的文档中也指出
The tests assume that the newly elected leader should append a noop entry on its term。
关于No-op Entry在Raft算法一致性中的作用,可见这篇文章杰特JET:raft引入no-op解决了什么问题
日志广播
在etcd中,raft节点在成功竞选Leader之后,首先会调用bcastAppend,像所有其他节点发送追加日志的请求,bcastAppend实现主要是遍历所有的raft节点,结合该节点的日志同步进度Progress,构造同步日志的消息。
// bcastAppend sends RPC, with entries to all peers that are not up-to-date
// according to the progress recorded in r.prs.
func (r *raft) bcastAppend() {
r.forEachProgress(func(id uint64, _ *Progress) {
if id == r.id {
return
}
r.sendAppend(id)
})
}
// maybeSendAppend sends an append RPC with new entries to the given peer,
// if necessary. Returns true if a message was sent. The sendIfEmpty
// argument controls whether messages with no entries will be sent
// ("empty" messages are useful to convey updated Commit indexes, but
// are undesirable when we're sending multiple messages in a batch).
func (r *raft) maybeSendAppend(to uint64, sendIfEmpty bool) bool {
pr := r.getProgress(to)
if pr.IsPaused() {
return false
}
m := pb.Message{}
m.To = to
term, errt := r.raftLog.term(pr.Next - 1)
ents, erre := r.raftLog.entries(pr.Next, r.maxMsgSize)
if len(ents) == 0 && !sendIfEmpty {
return false
}
if errt != nil || erre != nil { // send snapshot if we failed to get term or entries
// 先忽略同步snapshot的情况,后面有记录
} else {
m.Type = pb.MsgApp
m.Index = pr.Next - 1
m.LogTerm = term
m.Entries = ents
m.Commit = r.raftLog.committed
if n := len(m.Entries); n != 0 {
switch pr.State {
// optimistically increase the next when in ProgressStateReplicate
case ProgressStateReplicate:
last := m.Entries[n-1].Index
pr.optimisticUpdate(last)
pr.ins.add(last)
case ProgressStateProbe:
pr.pause()
default:
r.logger.Panicf("%x is sending append in unhandled state %s", r.id, pr.State)
}
}
}
r.send(m)
return true
}
日志广播在以下三种情况下会发送:
- 一个节点刚成为leader时,向其它节点发送no-op entry。
- leader节点的日志收到多数(半数以上)节点的认可,将日志commit,并催促其它节点commit日志。
- leader节点在收到MsgPropose后,自身添加日志,并催促其它节点添加日志。
MsgPropose
MsgPropose消息用于上层状态机向Raft节点写入新的日志,值得注意的是,follower节点与leader节点对于该消息的处理并不相同,在Raft算法中,为保证一致性,只有leader节点具有写入日志的可能性,follower节点在接受到MsgPropose消息时,应将其转发到自身的leader节点。
func (r *Raft) handleMsgPropose(m pb.Message) {
r.lg.Debugf("handleMsgPropose")//debug
if r.State != StateLeader {
msg := pb.Message{MsgType: m.GetMsgType(), To: r.Lead, From: r.id, Entries: m.GetEntries()}
r.msgs = append(r.msgs, msg)
return
}
//other operation for StateLeader
}
(上述示例代码为我个人实现,更加优雅而严谨的代码可以参考etcd)
而leader在接受到这条消息时,应当先将MsgPropose中携带的日志添加到自身日志中,然后向所有节点发送日志广播。
Leader和Follower在什么时候提交日志
leader在大多数节点将相同的日志写入后,可以将这些日志标记为committed(即提交日志),并发送新的日志广播标记这身提交了日志,follower在收到这条广播后,将自身提交日志与leader同步。
值得注意的是,leader只能提交其自身任期的日志。
单个节点提交日志的场景
Raft算法用于分布式场景,讨论只有唯一节点的选举情况在通常情况下没有意义,但tinykv似是出于严谨性的考虑,给出了只有一个节点的测试样例。显然,在单个节点的情况下,leader节点在写入日志后应立即进行日志提交。(应没有其它节点,不会发送sendAppend,亦不会收到MsgSendAppendResponse)
SendAppend
tinykv使用了progress使leader节点获知其它节点的日志同步情况和下次应该append entry的起始位置。
type Progress struct {
Match, Next uint64
}
在leader进行sendAppend请求时,应当将从follower节点的next所代表的index开始,至leader自身lastIndex为止的全部日志向该follower节点发送。
同时,应当在MsgAppendEntries消息中标记leader的LogTerm与Committed。
var logTerm uint64
if len(ents) > 0 {
logTerm = ents[len(ents)-1].GetTerm()
} else {
logTerm = 0
}
r.msgs = append(r.msgs, pb.Message{MsgType: pb.MessageType_MsgAppend, Term: r.Term,
LogTerm: logTerm, From: r.id, To: to, Entries: ents, Index: preIndex,
Commit: r.RaftLog.committed})
上述示例代码标记Msg的LogTerm为leader自身entries中最新的term。
Follower在什么时候应拒绝拒绝添加日志
- leader的term小于follower的term时。
- leader发出的append请求中的日志,与follower自身的日志没有交集。(可能是follower自身的日志太旧,表现为m.GetIndex() > r.RaftLog.LastIndex())
- leader的日志比follower的日志旧。(表现为r.RaftLog.entries[preIndex].GetTerm() > m.GetLogTerm())
- 如果发生更新,会导致follower日志在index递增时,出现term减小的错误情况,此时,应拒绝添加日志。(表现为,leader不能更新到follower的最后一条日志,却意图在未能更新的日志前,插入term较未更新的日志的term更大的日志。
日志替换与SendAppendResponse
follower收到MsgAppendEntries后,无论是拒绝还是接受,都应发送MsgAppendEntriesResponse,便于leader进行日志的提交或者重新发送符合follower要求的日志。
对于follower的日志与leader没有交集的情况,应当附上自身的lastIndex方便leader进行日志的重发。
如果接受leader的日志,应当逐个将leader的日志与自身相同index的日志进行替换,并将自身日志的stabled修正。如果leader的commit数量多于follower,还应将follower的提交状态与leader同步。
注意上述行为应当具有幂等性。
HandleAppendResponse
leader在接受到MsgAppendEntriesResponse,应当先对(收到reject)需要重发的的请求进行重发。对于没有收到reject的请求,先更新自身Progress的match与next同步状态。如果这条Msg的任期与自身任期已不相同,说明这条Msg已经过时(因为leader只能提交自身任期的日志,所以如果任期已不相同,说明期间已发生过leader的更迭,并且该leader节点恰巧先后两次成为leader),应当进行忽略。否则,则将其commit数量进行记录,并判断是否进行新的commit提交,如果进行提交,则通过日志广播向全部其它节点发送日志提交情况。
func (r *Raft) handleAppendResp(m pb.Message) {
if m.Reject {
if m.Index == None {
return
}
r.Prs[m.From].Next = m.GetIndex()
r.sendAppend(m.From)
return
}
if r.Prs[m.GetFrom()].Match < m.GetIndex() {
r.Prs[m.GetFrom()].Match = m.GetIndex()
}
r.Prs[m.GetFrom()].Next = r.Prs[m.GetFrom()].Match + 1
preIndex := m.GetIndex() - 1
if preIndex >= 0 && r.RaftLog.entries[preIndex].GetTerm() != r.Term {
return
}
//判断是否进行新的提交
if cap(r.matchBuf) < len(r.peerArray) {
r.matchBuf = make(uint64Slice, len(r.peerArray))
}
r.matchBuf = r.matchBuf[:len(r.peerArray)]
idx := 0
for _, p := range r.peerArray {
r.lg.Debugf("%d peer matches index %d", p, r.Prs[p].Match)
r.matchBuf[idx] = r.Prs[p].Match
idx++
}
sort.Sort(&r.matchBuf)
mci := r.matchBuf[len(r.matchBuf)-r.quorum()-1]
if r.RaftLog.maybeCommit(mci, r.Term) {
//如果有新的提交,则进行日志广播
for _, i := range r.peerArray {
if i == r.id {
continue
}
r.sendAppend(i)
}
}
}
(上述示例代码为我个人实现,更加优雅而严谨的代码可以参考etcd)
对于投票环节的修正
相较前文讨论过的Raft选举投票环节,在引入日志替换机制之后,需要进行一些修正。
在candidate选举发送MsgRequestVote消息时,应当在消息中携带自身最新的LogTerm与index。
在投票环节中,如果voter的日志比candidate更加新(表现为LogTerm更高或者在LogTerm相同时拥有更高的Index),则voter拒绝为candidate投票(即使voter的term更低,此时应当将voter变为没有领导的follower,voter.becomeFollower(m.GetTerm(),None))。
参考文章
etcd 中的raft是如何处理Leader和Follower日志冲突的
talent-plan/tinykv: A course to build distributed key-value service based on TiKV model (github.com)