Raft算法笔记——日志复制机制

329 阅读7分钟

前文Raft算法笔记——投票选主机制

概述

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))。

参考文章

杰特JET:raft引入no-op解决了什么问题

raft (二) 日志(Entry)复制

etcd 中的raft是如何处理Leader和Follower日志冲突的

Pingcap TinyKV 2022 lab2思路及文档

talent-plan/tinykv: A course to build distributed key-value service based on TiKV model (github.com)

etcd-io/etcd: Distributed reliable key-value store for the most critical data of a distributed system (github.com)