手把手教你写raft--日志复制(3)

113 阅读12分钟

前言

系列文章

手把手教你写raft--选举(1)

手把手教你写raft--工具(2)

相较于raft选举,raft日志复杂要复杂一些,本文也更需要你细心地阅读,以便理解raft日志复制-提交-应用到状态机的过程。

大多数有了解过raft算法的同学都应该知道半数提交原则,即leader提交日志需要集群中超过半数的节点同意,该日志才能够被提交。但detail is devil,大部分应该都不了解日志复制的细节,譬如raft是如何发现follower落后leader,又是如何如何被leader节点修正。

复制规则

又是这张熟悉的图,同样地我们需要通过这样图来看下与raft日志复制有关的部分

  • State

    • commitIndex:记录节点上最后一条被提交日志的索引
    • lastApplied:记录节点上最后一条被应用到状态机的索引
    • nextIndex[]:leader特有,数组,长度和节点数目一致,记录每个节点下一条日志的索引,用于确认后续应该发给follower的日志
    • matchIndex[]:leader特有, 数组,长度和节点数目一致,记录follow节点最后一条与leader匹配的日志,用于协助leader确认日志是否可以被提交。一个朴素的算法,我们可以对matchIndex进行降序排序,第(n/2)个元素的值就是已经被大部分节点提交的日志索引。
  • AppendEntries RPC

  1. Arguments

    • entries[]:数组,leader发送给follower的日志
    • leaderCommit:leader最新日志提交的索引,通过该值follower可以更新本地提交索引
  2. 消息接收者细节

    • 当leader持有的当前节点的日志信息不正确(preLogIndex和preLogTerm不在节点本地日志内),返回false
    • 当日志与leader发生冲突,需要删除follower发生冲突的日志。比如下图中,第一行为term8 leader的日志,第二行为同term follower的。则follower需要删除最后两条term7的日志
    • follower的commitIndex = Min(LeaderCommitIndex, lastLog.Index),lastLog指的是本地最后一条(最新)的日志

实际上在响应参数这边有一个优化点,在原始的raft的论文中响应只返回了follow所处term和是否成功添加的bool值,一个优化的版本增加了发生冲突的日志索引和Term。好处是leader通过一次失败的AppendEntries就能够快速定位到follower发生冲突的日志,通过修正nextIndex的值,就能在下一次日志复制时候携带正确的日志。

  • Rules For Server

    • leaders

      1. leader在接受到客户端写请求时,需要先本地写日志,等待日志成功被应用到状态机后再响应
      2. 当nextIndex的值小于本地最新日志时,需要从nextIndex开始给follower发送日志
        • 如果发送成功,更新本地matchIndex和nextIndex
        • 如果发送失败,出现冲突,则回退nextIndex重试
      3. 如果存在一个值为N,N大于leader的commitIdex,并且绝大多数的matchIndex大于N,立马将leader的commitIndex设置为N
      4. leader无条件覆盖follower的日志

复制案例

在看了上述的规则,其实还是会云里雾里,人们往往很好接受具体的存在的事物,对于抽象的总是会难以理解,最快熟悉的方法就是实践。下面以几个例子,来协助大家理解raft算法的日志复制过程,以及论证日志复制的安全性。

以下的案例都来自于raft官方提供的动画演示

raft.github.io/

案例1

有如上场景,S3当选了Term5的leader,在此之前S3是Follow,S3的初始状态如下。你可能会奇怪,为什么nextIndex都会是3?因为对于S3他的最新日志的索引是2,初始化默认所有的follower的nextIndex都是3,这时候是错误的没关系啊,会通过心跳修正各个节点的nextIndex。

PS:回顾一下心跳请求,实际就是一个不携带日志的AppenEntriesRPC,有理由将其的处理行为认为就是个普通的日志添加请求

S3会对每个节点发起如下图的心跳,心跳中携带

prevIndex == nextIndex - 1

prevTerm == log[nextIndex - 1].Term

commitIndex

S1在收到心跳后,返回如下的response,其中success标志着这次心跳响应失败,需要leader s3特殊处理,matchIndex等于0表示该节点日志与leader不一致。

与S1节点不同,S2和S4节点会返回success==true,matchIndex==2,表示节点日志与leader一致。

经过一轮心跳,leader知道了各个节点的日志缺失情况,在随后补齐节点缺失的日志

案例2

还记得在复制规则中leader的一条规则吗?leader无条件覆盖follower节点日志,带着这个规则来看下面的案例。

  • 在(a)时刻,S1或者S2是集群的leader,term为2。
  • 在(b)时刻,S5被当选为leader,并添加term3的日志。
  • 在(c)时刻,S1倍当选为leader,添加term4的日志,由于其他节点不存在term2的日志,重新提交term2的日志,复制到S3节点。
  • 在(d)时刻,term2的日志还未提交,S5重新被选为leader(完全有可能,回顾下选举,S5的term比其他节点都大,且日志比S2、S3、S4节点都新),执行日志覆盖逻辑,覆盖其他节点日志。此时奇怪的一点就来了,term为2的日志被大部分节点(过半数)所持有,但是却被term3的日志覆盖了。
  • 在(e)时刻,与(d)时刻刚好形成对比,S1在term4完成了term4日志的提交,此时S5再也无法当选leader。

为了解决(d)时刻的问题,raft制定了如下规则

leader只允许提交同term的日志

你可能会有点疑问,啥叫只允许提交同term的日志啊?term2的日志不是S1提交的吗?不得复制给其他节点吗?诶!你先别急,听我一一道来。

在raft中leader默认自己所有的日志都是已经提交的,在进行日志复制时候,只允许通过半数提交规则来提交当前term的日志,对于之前term的日志都是顺带提交。

打个比方,还是在上图中,S1在term4时期写入了term4日志,在复制给S4时,发现nextIndex对不上,于是修正了S4的nextIndex和matchIndex后,把缺失的日志到term4的日志一并发给了S4,此时S1只提交了term4的日志,而之前顺带着就被“提交了”。

通过这种默认的日志对齐机制,(d)时刻就再也无法发生了,如果term4的日志提交了,S5永远无法当选leader。反之,如果term4的日志没有提交成功,那么S5当选leader,发生日志覆盖也就是理所当然的了。

代码

结构体&构造方法

结构体

在结构体中新增了几个成员变量

  • commitIndex:当前节点提交的最后一条日志的索引
  • lastApplied:当前节点被apply到状态机的最后一条日志的索引
  • applyCh:用于发送日志操作到状态机的通道
  • applyCond:用于在提交日志后唤醒apply到状态机goroutine的条件
  • replicatorCond:用于在写入日志后唤醒各个节点复制goroutine的条件

leader独有的

  • nextIndex:记录着与各个节点下一条写入日志的索引的数组
  • matchIndex:记录着与各个节点最后一条相同日志的索引的数组
type Raft struct {
	mu        sync.Mutex        // Lock to protect shared access to this peer's state
	peers     []*labrpc.ClientEnd // RPC end points of all peers
	persister *Persister          // Object to hold this peer's persisted state
	me        int                 // this peer's Index into peers[]
	dead      int32               // set by Kill()

	// for 2A
	currentTerm      int
	votedFor         int
	roleState        NodeState
	logs             []Entry
	electionTimeout  *time.Timer
	heartbeatTimeout *time.Timer

	// for 2B
	commitIndex    int
	lastApplied    int
	nextIndex      []int
	matchIndex     []int
	applyCh        chan ApplyMsg
	applyCond      *sync.Cond   // used to wakeup applier goroutine after committing new entries
	replicatorCond []*sync.Cond // used to signal replicator goroutine to batch replicating entries
}

构造方法

相较于选举部分代码,最大的更改是多了几个goroutine

  1. go rf.replicator(i) 为每个节点启动了一个goroutine,用于执行leader到该节点的日志复制
  2. go rf.applier() 启动了一个用于apply日志的goroutine
func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {
	rf := &Raft{
		// 2A
		peers:            peers,
		persister:        persister,
		me:               me,
		dead:             0,
		currentTerm:      0,
		votedFor:         -1,
		roleState:        StateCandidate,
		logs:             make([]Entry, 1),
		heartbeatTimeout: time.NewTimer(StableHeartbeatTimeout()),
		electionTimeout:  time.NewTimer(RandomizedElectionTimeout()),
		// 2B
		nextIndex:      make([]int, len(peers)),
		matchIndex:     make([]int, len(peers)),
		applyCh:        applyCh,
		replicatorCond: make([]*sync.Cond, len(peers)),
	}

	// start ticker goroutine to start elections
	// for 2A
	go rf.ticker()

	// for 2B
	rf.applyCond = sync.NewCond(&rf.mu)
	lastLog := rf.getLastLog()
	for i := 0; i < len(peers); i++ {
		rf.matchIndex[i], rf.nextIndex[i] = 0, lastLog.Index+1
		if i != rf.me {
			rf.replicatorCond[i] = sync.NewCond(&sync.Mutex{})
			go rf.replicator(i)
		}
	}
	go rf.applier()

	return rf
}

AppendEntries-核心代码

Leader写日志

先说一下大概的实现思路,客户端发起写入操作,leader接受到请求后,先将日志写入本地log,再遍历数组replicatorCond,唤醒各个节点的复制协程。通过响应确认日志被提交(超半数持有),提交日志,并通过applyCond唤醒应用状态机协程,将提交日志携带的操作输送到管道以便状态机应用。

在Mit6.824的实验中,制定了Start方法作为leader写日志的入口,并且要求该接口立即返回,不用等待提交。

func (rf *Raft) Start(command interface{}) (int, int, bool) {
	// Your code here (2B).
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if rf.roleState != StateLeader {
		return -1, -1, false
	}
	newLog := rf.appendNewEntry(command)
	Debug(dLog, "S%d receives a new command[%v] to replicate in term %v", rf.me, newLog, rf.currentTerm)
	rf.BroadcastHeartbeat(false)
	return newLog.Index, newLog.Term, true
}

appendNewEntry方法较为简单,就是将日志添加到本地日志的末尾,更新当前节点的matchIndex和nextIndex

func (rf *Raft) appendNewEntry(command interface{}) Entry {
	lastLog := rf.getLastLog()
	newLog := Entry{lastLog.Index + 1, rf.currentTerm, command}
	rf.logs = append(rf.logs, newLog)
	rf.matchIndex[rf.me], rf.nextIndex[rf.me] = newLog.Index, newLog.Index+1
	return newLog
}

在新的BroadcastHeartbeat中我们传入了false,使用replicatorCond唤醒了所有节点的复制协程。needReplicating方法用于判断是否需要复制。

func (rf *Raft) BroadcastHeartbeat(isHeartBeat bool) {
	Debug(dTimer, "S%d as leader send heartbeat in term %d", rf.me, rf.currentTerm)
	for peer := range rf.peers {
		if peer == rf.me {
			continue
		}
		if isHeartBeat {
			// need sending at once to maintain leadership
			go rf.replicateOneRound(peer)
		} else {
			// just signal replicator goroutine to send entries in batch
			rf.replicatorCond[peer].Signal()
		}
	}
}

func (rf *Raft) replicator(peer int) {
	rf.replicatorCond[peer].L.Lock()
	defer rf.replicatorCond[peer].L.Unlock()
	for rf.killed() == false {
		for !rf.needReplicating(peer) {
			rf.replicatorCond[peer].Wait()
		}
		rf.replicateOneRound(peer)
	}
}

func (rf *Raft) needReplicating(peer int) bool {
	rf.mu.Lock()
	defer rf.mu.Unlock()
    // 当前节点是leader,切当前节点与目标节点的matchIndex不一致
	return rf.roleState == StateLeader && rf.matchIndex[peer] < rf.getLastLog().Index
}

最终replicateOneRound来实现日志复制,注意这里锁的使用,避开了将rpc调用放入同步块中。此外,在第一个上锁到释放锁,实际可以被替换为读锁,以获得更好的并发性能,毕竟这里是只读操作。

func (rf *Raft) replicateOneRound(peer int) {
	rf.mu.Lock()
	if rf.roleState != StateLeader {
		rf.mu.Unlock()
		return
	}

	preLogIndex := rf.nextIndex[peer] - 1
	request := rf.genAppendEntriesRequest(preLogIndex)
	response := new(AppendEntryResponse)
	rf.mu.Unlock()
	
	if rf.sendAppendEntries(peer, request, response) {
		// just do nothing
		rf.mu.Lock()
		defer rf.mu.Unlock()
		rf.handleAppendEntriesResponse(peer, request, response)
	}
}

genAppendEntriesRequest负责生成AppendEntriesRPC请求,可以看到这里我们根据nextIndex,将从nextIndex到最新日志一股脑都放入了请求中,也就是采用了案例2的方案。

func (rf *Raft) genAppendEntriesRequest(preLogIndex int) *AppendEntryRequest {
	firstIndex := rf.getFirstLog().Index
	entries := make([]Entry, len(rf.logs[preLogIndex+1-firstIndex:]))
	copy(entries, rf.logs[preLogIndex+1-firstIndex:])
	return &AppendEntryRequest{
		Term:         rf.currentTerm,
		LeaderId:     rf.me,
		PrevLogIndex: preLogIndex,
		PrevLogTerm:  rf.logs[preLogIndex-firstIndex].Term,
		Entries:      entries,
		LeaderCommit: rf.commitIndex,
	}
}

Follower请求处理

接着来看下follower是如何处理leader的AppendEntriesRPC请求,大体逻辑如下:

  1. 查看请求term是否合法,如果本地term小于等于请求term,更改本地term,并更改自身状态为follower,重置超时时间。否则拒绝这次请求。
func (rf *Raft) AppendEntries(request *AppendEntryRequest, response *AppendEntryResponse) {
	rf.mu.Lock()
	defer rf.mu.Unlock()
    // 1. term验证
	if request.Term < rf.currentTerm {
		response.Term, response.Success = rf.currentTerm, false
		return
	}
	if request.Term > rf.currentTerm {
		rf.currentTerm, rf.votedFor = request.Term, -1
	}
	Debug(dTimer, "S%d receive heartbeat from S%d in term %d", rf.me, request.LeaderId, rf.currentTerm)
	rf.ChangeState(StateFollower)
	rf.electionTimeout.Reset(RandomizedElectionTimeout())

    // 2. 日志对齐校验
	if request.PrevLogIndex < rf.getFirstLog().Index {
		response.Term, response.Success = 0, false
		Debug(dError, "S%d receive unexpected AppendEntriesRequest[%v] from S%d, because prevLogIndex %v < firstLogIndex %v", rf.me, request, request.LeaderId,
			request.PrevLogIndex, rf.getFirstLog().Index)
		return
	}
	if !rf.matchLog(request.PrevLogTerm, request.PrevLogIndex) {
		response.Term, response.Success = rf.currentTerm, false
		lastIndex := rf.getLastLog().Index
		if lastIndex < request.PrevLogIndex {
			response.ConflictTerm, response.ConflictIndex = -1, lastIndex+1
		} else {
			firstIndex := rf.getFirstLog().Index
			response.ConflictTerm = rf.logs[request.PrevLogIndex-firstIndex].Term
			index := request.PrevLogIndex - 1
			for index >= firstIndex && rf.logs[index-firstIndex].Term == response.ConflictTerm {
				index--
			}
			response.ConflictIndex = index
		}
		return
	}
    
    // 3. 写日志
	firstIndex := rf.getFirstLog().Index
	for index, entry := range request.Entries {
		if entry.Index-firstIndex >= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term {
			rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], request.Entries[index:]...))
			break
		}
	}
	rf.advanceCommitIndexForFollower(request.LeaderCommit)
	response.Term, response.Success = rf.currentTerm, true
}

2. 查看request携带的prevLogIndex和prevLogTerm是否合法,如果不合法计算出冲突索引起始位置,通过响应返回给leader。

判断日志是否对齐有两个依据

  • 如果leader记录的nextIndex大于当前节点的最后一条日志的索引,则说明follower日志出现缺失,leader需要修正nextIndex。
  • 相同索引位置的term是否相同,如果不同则说明follower的日志和leader不一致,需要从冲突位置重新覆盖,leader也会修正nextIndex
func (rf *Raft) matchLog(term, index int) bool {
	return index <= rf.getLastLog().Index && rf.logs[index-rf.getFirstLog().Index].Term == term
}

3. 日志没有问题,写本地日志返回成功响应。

follow的提交索引取决于leader提交索引位置和本地日志的最大索引的最小值

func (rf *Raft) advanceCommitIndexForFollower(leaderCommit int) {
	newCommitIndex := Min(leaderCommit, rf.getLastLog().Index)
	if newCommitIndex > rf.commitIndex {
		DPrintf("{Node %d} advance commitIndex from %d to %d with leaderCommit %d in term %d", rf.me, rf.commitIndex, newCommitIndex, leaderCommit, rf.currentTerm)
		rf.commitIndex = newCommitIndex
		rf.applyCond.Signal()
	}
}

如果产生了新的提交,则唤醒状态机应用协程。

Leader处理响应

func (rf *Raft) handleAppendEntriesResponse(peer int, request *AppendEntryRequest, response *AppendEntryResponse) {
	if rf.roleState == StateLeader && rf.currentTerm == request.Term {
		if response.Success {
			rf.matchIndex[peer] = request.PrevLogIndex + len(request.Entries)
			rf.nextIndex[peer] = rf.matchIndex[peer] + 1
			rf.advanceCommitIndexForLeader()
			// do nothing
		} else {
			if response.Term > rf.currentTerm {
				Debug(dTimer, "S%d know self is old leader, and back to follower", rf.me)
				rf.ChangeState(StateFollower)
				rf.currentTerm, rf.votedFor = response.Term, -1
			} else if response.Term == rf.currentTerm {
				rf.nextIndex[peer] = response.ConflictIndex
				if response.ConflictTerm != -1 {
					firstIndex := rf.getFirstLog().Index
					for i := request.PrevLogIndex; i >= firstIndex; i-- {
						if rf.logs[i-firstIndex].Term == response.ConflictTerm {
							rf.nextIndex[peer] = i + 1
							break
						}
					}
				}
			}
		}
	}
}

先看请求成功部分代码,更新对应节点的matchIndex和nextindex,并尝试提交日志

if response.Success {
    rf.matchIndex[peer] = request.PrevLogIndex + len(request.Entries)
    rf.nextIndex[peer] = rf.matchIndex[peer] + 1
    rf.advanceCommitIndexForLeader()
    // do nothing
}

为什么说是尝试呢,实际上在实现raft半数提交时,我们并没有为每个日志的复制进行计数计算,而是通过matchIndex计算出那些日志已经被提交了,具体逻辑如下:

func (rf *Raft) advanceCommitIndexForLeader() {
	n := len(rf.matchIndex)
	srt := make([]int, n)
	copy(srt, rf.matchIndex)
    // 排序
	insertionSort(srt)
    // 取第n/2大的值,作为提交索引 
	newCommitIndex := srt[n-(n/2+1)]
	if newCommitIndex > rf.commitIndex {
		if rf.matchLog(rf.currentTerm, newCommitIndex) {
			Debug(dLog, "S%d advance commitIndex from %d to %d with matchIndex %v in term %d", rf.me, rf.commitIndex, newCommitIndex, rf.matchIndex, rf.currentTerm)
			rf.commitIndex = newCommitIndex
            // 唤醒状态机应用协程
			rf.applyCond.Signal()
		} else {
			Debug(dLog, "S%d can not advance commitIndex from %d because the term of newCommitIndex %d is not equal to currentTerm %d", rf.me, rf.commitIndex, newCommitIndex, rf.currentTerm)
		}
	}
}

对于请求失败有两种情况:

  1. 当前term小于响应节点term,说明自己是个过时的leader,将自己置为follower
  2. 发生日志冲突,修正对应节点的nextIndex,等待下一次replicate协程执行复制,就可以正确复制日志了。
else {
        if response.Term > rf.currentTerm {
            Debug(dTimer, "S%d know self is old leader, and back to follower", rf.me)
            rf.ChangeState(StateFollower)
            rf.currentTerm, rf.votedFor = response.Term, -1
        } else if response.Term == rf.currentTerm {
            rf.nextIndex[peer] = response.ConflictIndex
            if response.ConflictTerm != -1 {
                firstIndex := rf.getFirstLog().Index
                for i := request.PrevLogIndex; i >= firstIndex; i-- {
                    if rf.logs[i-firstIndex].Term == response.ConflictTerm {
                        rf.nextIndex[peer] = i + 1
                        break
                    }
                }
            }
        }
    }

Apply协程

这一块也是比较简单

  1. 通过判断lastApplied和commitIndex的大小来决定该协程是否等待
  2. 将需要apply的日志包装成ApplyMsg发送到applyCh channel即可

需要注意的是在读取数据,写数据时候上锁,但是别再使用channel 使用锁(与rpc调用不持有锁类似),避免channel没有消费,锁无法释放,也算是个并发编程锁机制的很好的实践了。

func (rf *Raft) applier() {
	for rf.killed() == false {
		rf.mu.Lock()
		for rf.lastApplied >= rf.commitIndex {
			rf.applyCond.Wait()
		}
		firstIndex, commitIndex, lastApplied := rf.getFirstLog().Index, rf.commitIndex, rf.lastApplied
		entries := make([]Entry, commitIndex-lastApplied)
		copy(entries, rf.logs[lastApplied+1-firstIndex:commitIndex+1-firstIndex])
		rf.mu.Unlock()
		for _, entry := range entries {
			rf.applyCh <- ApplyMsg{
				CommandValid: true,
				Command:      entry.Command,
				CommandIndex: entry.Index,
				CommandTerm:  entry.Term,
			}
		}
		rf.mu.Lock()
		rf.lastApplied = Max(rf.lastApplied, commitIndex)
		rf.mu.Unlock()
	}
}
<img src="```" alt="" width="30%" />