前言
系列文章
相较于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
-
Arguments
- entries[]:数组,leader发送给follower的日志
- leaderCommit:leader最新日志提交的索引,通过该值follower可以更新本地提交索引
-
消息接收者细节
- 当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
- leader在接受到客户端写请求时,需要先本地写日志,等待日志成功被应用到状态机后再响应
- 当nextIndex的值小于本地最新日志时,需要从nextIndex开始给follower发送日志
- 如果发送成功,更新本地matchIndex和nextIndex
- 如果发送失败,出现冲突,则回退nextIndex重试
- 如果存在一个值为N,N大于leader的commitIdex,并且绝大多数的matchIndex大于N,立马将leader的commitIndex设置为N
- leader无条件覆盖follower的日志
-
复制案例
在看了上述的规则,其实还是会云里雾里,人们往往很好接受具体的存在的事物,对于抽象的总是会难以理解,最快熟悉的方法就是实践。下面以几个例子,来协助大家理解raft算法的日志复制过程,以及论证日志复制的安全性。
以下的案例都来自于raft官方提供的动画演示
案例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
- go rf.replicator(i) 为每个节点启动了一个goroutine,用于执行leader到该节点的日志复制
- 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请求,大体逻辑如下:
- 查看请求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)
}
}
}
对于请求失败有两种情况:
- 当前term小于响应节点term,说明自己是个过时的leader,将自己置为follower
- 发生日志冲突,修正对应节点的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协程
这一块也是比较简单
- 通过判断lastApplied和commitIndex的大小来决定该协程是否等待
- 将需要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%" />