缘起
Part A Leader选举可以看这篇文章:zhuanlan.zhihu.com/p/681385987
我想学习下分布式系统的经典入门课程 MIT 6.824 ,正好看到木鸟(www.zhihu.com/people/qtmu...)的课程 ------ 基于 MIT 6.824 的课程,从零实现分布式 KV。门课会,手把手带你看论文写代码。所以这篇文章的主要内容是我的课程心得,介绍什么是Raft,并实现一个基本的框架。
希望这篇文章能带你进入分布式系统的大门,作为一个新手,我深知学习分布式系统是一个挑战,但我也坚信通过不断地学习和实践,我们可以逐步掌握其中的要领不断进步。期待与大家一起探索分布式系统的奥秘,共同成长。这篇文章只讨论怎么实现日志应用部分,代码在这:github.com/Maricaya/ra...
如果对你有帮助,请给我点赞 评论,这是我继续更新的最大动力~
在Part A 我们实现了选举和心跳逻辑,在partB我们要继续补全日志同步的逻辑。
在Raft算法的Part B中,日志应用的逻辑是确保所有的节点在接收到Leader的日志复制请求后,将这些日志条目应用到各自的状态机上,从而保持各个节点的状态机数据一致性。
Raft 总览
具体来说,Raft 属于状态复制机 (Replicated State Machine )模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。
状态复制机的工作原理如下:
-
用户输入一些指令,比如设置变量 y 的值为 1,然后将 y 的值改为 9。
-
一旦有用户输入了指令,集群中的每台服务器都会收到。然后,这些服务器会将这个指令记录在它们的日志文件 Log 中,就好像写日记一样。这样,每个服务器都有了一份完整的操作记录。
-
可以通过 Log 来推理出每个变量的最新值,比如 y 为 9,发送给当前服务器的状态机。
-
最后用户从任何一台服务器得到最新的变量状态,比如 y 为 9,因为每个机器的状态机都是一样的。
所以,我们必须要保证 log 一致性,也就是所有的 log 都必须保持一致。part A 说了,Raft 时强 Leader 政策,所以此处就很好理解了,所有的 log 都先交给 leader处理,由leader复制给其他的 follower。当这条 log 被集群过半的节点接受后,会被提交到状态机上。
而这就是我们要实现的 part B 日志同步的过程。
日志同步
我们前面说了,所以到 log 都先交给 Leader 处理,由 Leader 复制给其他的 Follower,这一步可以放在周期性的广播 part A 的心跳逻辑里。
那怎么传递 log 呢?
最简单直接的方式是让 Leader 把自己所有的日志都发给 Follower,Follower 收到后直接用这些日志替换自己的日志。
但如果 Leader 的日志很多,这样通信的成本会很高。那么有没有更好的方法呢?
因此 Leader 采用一种"乐观+回撤"的方式进行同步:
- 乐观:一开始,Leader 发送心跳时不附带任何日志,只携带一些"暗号"过去。如果 Follower 发现自己的日志和 Leader 完全一样,就直接回复"一致",以后的心跳就不用再附带日志了。
- 回撤:如果 Follower 发现自己的日志和 Leader 不一致,就会告诉 Leader:"下次请附带日志"。那么 Leader 就会附加一些最新的日志,如果还是不一致,就继续往前附加一些日志,并更新"暗号",直到收到 Follower 的确认回复,然后就可以继续发送不带日志的心跳了。
这个"暗号",就是 Leader 所附带日志的的前一条日志信息的二元组:
<index,term>
。如果心跳没有附加任何日志,则暗号就是 Leader 最后一条日志的相关信息。
为什么要加上term?
仅仅用index不行吗?Term 可以用来检查不同节点间日志是否存在不一致的情况。也就是说只要 term 和index都一致,那么那么它们一定存储了相同的指令。
为什么一定存储了相同的指令?
因为 Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不修改。而且只要不同节点之间的日志有相同的(term,index),那么在这之前的日志全部相同。
根据数学归纳法,每次附加新日志,都要对齐前序日志。最终,所有的日志都会收敛为leader日志。
没有日志的时候怎么判断?
为了保证 Leader 附带日志总有前一条日志,我们在对日志进行初始化的时候,会在开头放一条"空日志",从而避免一些边界判断(这个做法类似带头结点的链表)。
什么时候可以复制到状态机?
当 leader 得知日志被过半的 follower 复制成功的时候,就可以被 apply 到状态机了。
图解
图解的过程转载自Q的博客,写的非常好,我就直接搬运一下
可以通过看 raft.github.io/ Raft 的官方动画来模拟日志复制的过程:
S5 当选 leader,此时还没有任何日志。我们模拟客户端向 S5 发起一个请求。
S5 收到客户端请求后新增了一条日志 (term2, index1),然后并行地向其它节点发起 AppendEntries RPC。
S2、S4 率先收到了请求,各自附加了该日志,并向 S5 回应响应。
所有节点都附加了该日志,但由于 leader 尚未收到任何响应,因此暂时还不清楚该日志到底是否被成功复制。
当 S5 收到2个节点 的响应时,该日志条目的边框就已经变为实线,表示该日志已经安全的复制 ,因为在5节点集群中,2个 follower 节点加上 leader 节点自身,副本数已经确保过半,此时 S5 将响应客户端的请求。
leader 后续会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为 committed)的日志索引,此处为 (term2, index1)。
所有 follower 都通过心跳包得知 (term2, index1) 的 log 已经成功复制 (committed),因此所有节点中该日志条目的边框均变为实线。
同步失败
要想知道怎么处理失败的情况,需要先知道怎么哪些情况下会失败。这点,在Raft论文中有详细的讲解,先来看这张图:
上图描述了一些Followers可能和新的Leader日志不同的情况。一个Follower可能会丢失掉Leader上的一些条目(b),也有可能包含一些Leader没有的条目(d),也有可能两者都会发生(e)。丢失的或者多出来的条目可能会持续多个任期。
Leader 通过强制 Followers 复制它的日志来处理日志的不一致,Followers上的不一致的日志会被Leader的日志覆盖。
Leader为了使Followers的日志同自己的一致,Leader需要先找到Followers同它的日志一致的地方,然后覆盖Followers在该位置之后的条目。
那 Leader 怎么找到 Follower 和他日志一致的地方呢?
从后往前试,每次 AppendEntries 失败后尝试前一个日志条目,直到成功找到每个 Follower 的日志一致位点,然后向后逐条覆盖 Followers 在该位置之后的条目。
开始写代码
好的,在了解日志应用的逻辑后,可以来愉快的写代码啦!
我们来拆分日志应用(Log Application)部分的逻辑:
日志复制到大多数节点
Leader 节点定期检查自己的提交日志,将其复制到 Followers 上。
这一步可以在 Raft 算法的 "AppendEntries" RPC 中(也就是 part A 的心跳消息),用于将 Leader 节点的日志复制到 Followers 节点上。
具体来说,"附加日志" 步骤的流程如下:
Leader 维护日志
Leader 维护一个日志(rf.log
),其中包含所有的日志条目。每个 Follower 根据收到的信息,将新的日志条目追加到自己的日志中。
根据 Raft 论文图写出 AppendEntries RPC 的数据结构:
Leader 使用 nextIndex 和 matchIndex 来维护与每个 Follower 的日志复制进度。nextIndex 表示 Follower 需要复制的下一个日志的index,matchIndex 表示 Follower 已经匹配好的最高日志 index。
c
type Raft struct {
//...
// log in Peer's local
log []LogEntry
// only used when it is Leader,
// log view for each peer
nextIndex [] int
matchIndex [] int
}
PrevLogIndex 和 PrevLogTerm 用于匹配日志前缀,也就是我们前面所说的"暗号", Leader 附带日志的的前一条日志信息的二元组:<index,term>
。
go
type AppendEntriesArgs struct {
Term int
LeaderId int
PrevLogIndex int // 上一个日志的index
PrevLogTerm int // 上一个日志的Term
Entries []LogEntry
}
最后补充下 LogEntry,Raft的日志条目 , 根据 ApplyMsg 的字段写出:
go
type LogEntry struct {
Term int // 用于区分不同的Leader任期
CommandValid bool // 当前指令是否有效。如果无效,follower 可以拒绝复制
Command interface {} // 表示可以存储任意类型的指令。
}
初始化
go
// 初始化
func Make(peers []*labrpc.ClientEnd, me int,
persister *Persister, applyCh chan ApplyMsg) *Raft {
// ......
rf.log = append (rf.log, LogEntry{})
rf.matchIndex = make ([] int , len (rf.peers))
rf.nextIndex = make ([] int , len (rf.peers))
// ......
}
scss
// 初始化
func (rf *Raft) becomeLeaderLocked() {
if rf.role != Candidate {
LOG(rf.me, rf.currentTerm, DError, "Only Candidate can become Leader")
return
}
LOG(rf.me, rf.currentTerm, DLeader, "Become Leader in T%d", rf.currentTerm)
rf.role = Leader
for peer := 0 ; peer < len (rf.peers); peer++ {
rf.nextIndex[peer] = len (rf.log)
rf.matchIndex[peer] = 0
}
}
Leader 发送附加日志的 RPC
Leader 定期向 Followers 发送 "AppendEntries" RPC,这个 RPC 包含了 Leader 的日志条目等信息。
go
func (rf *Raft) startReplication(term int) bool {
// ...
for peer := 0; peer < len(rf.peers); peer++ {
if peer == rf.me {
rf.matchIndex[peer] = len (rf.log) - 1
rf.nextIndex[peer] = len (rf.log)
continue
}
prevIdx := rf.nextIndex[peer] - 1
args := &AppendEntriesArgs{
Term: rf.currentTerm,
LeaderId: rf.me,
PrevLogIndex: prevIdx,
PrevLogTerm: rf.log[prevIdx].Term,
Entries: rf.log[prevIdx+ 1 :],
}
LOG(rf.me, rf.currentTerm, DDebug, "-> S%d, Send log, Prev=[%d]T%d, Len()=%d" , peer, args.PrevLogIndex, args.PrevLogTerm, len (args.Entries))
go replicateToPeer(peer, args)
}
return true
}
### Followers 处理 AppendEntries 的 RPC
Followers 接收到 Leader 发送的 "AppendEntries" RPC,开始处理附加日志。
在这之前先进行「试探点」的检查,PrevLogIndex 和 PrevLogTerm 是否能匹配 Followers 上的日志。
如果匹配,则表示 Leader 和 Follower 的日志在 PrevLogIndex
位置之前是一致的。
perl
if args.PrevLogIndex >= len (rf.log) {
LOG(rf.me, rf.currentTerm, DLog2, "<- S%d, Reject log, Follower log too short, Len:%d <= Prev:%d" , args.LeaderId, len (rf.log), args.PrevLogIndex)
return
}
if rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
LOG(rf.me, rf.currentTerm, DLog2, "<- S%d, Reject log, Prev log not match, [%d]: T%d != T%d" , args.LeaderId, args.PrevLogIndex, rf.log[args.PrevLogIndex].Term, args.PrevLogTerm)
return
}
- 复制日志条目:如果
PrevLogIndex
和PrevLogTerm
匹配,那么 Followers 将 Leader 发送的新的日志条目追加到自己的日志中。这样,Leader 的日志就被复制到 Followers 上。
ini
rf.log = rf.log[:args.PrevLogIndex+ 1 ]
rf.log = append (rf.log, args.Entries...)
LOG(rf.me, rf.currentTerm, DLog2, "<- S%d, Follower append logs: (%d, %d]" , args.PrevLogIndex, args.PrevLogIndex+ len (args.Entries))
reply.Success = true
Leader 收到 Followers的附加日志RPC回复后
我们在「同步失败」部分说了,失败之后,需要找到前一个任期日志,重新发送 RPC 请求。
那怎么找到前一个日志条目呢?从当前尝试要发送的日志开始,向前遍历,直到找到不属于同一个任期的日志。
ini
if !reply.Success {
idx := rf.nextIndex[peer] - 1
term := rf.log[idx].Term
for idx > 0 && rf.log[idx].Term == term {
idx--
}
rf.nextIndex[peer] = idx+ 1
}
-
如果当前follower上日志附加成功, Leader 要更新相应 Follower 的状态信息,也就是 matchIndex 和 nextIndex
-
当前 follower的 matchIndex 是多少?
- Leader 上一条已经成功复制到 Follower 的日志条目的 index ------ args.PrevLogIndex, 加上新追加的日志条目的数量 len(args.Entries), 就是 matchIndex。
-
下一次要发送日志的索引 nextIndex 是多少?
- 在raft中,日志是逐个发送的,所以直接 +1,rf.matchIndex[peer] + 1
scssrf.matchIndex[peer] = args.PrevLogIndex + len (args.Entries) rf.nextIndex[peer] = rf.matchIndex[peer]+ 1
-
这样,通过定期的 "AppendEntries" RPC,Leader 将自己的日志复制到 Followers,保持整个集群中的日志一致性。
选举时,进行日志比较
日志复制到大多数节点之后,我们来完善一下 part A 选举 Leader 的逻辑,在选举 Leader 时进行日志比较。
为什么要比较?
因为 Leader 一定要包含所有已经提交的日志。
所以,在有了日志之后,在投票的时候,需要进行节点之间的日志比较。如果当前 Peer 比 Candidate 更新,则拒绝投票给 Candidate。
go
func (rf *Raft) startElection(term int) {
// ...
l := len (rf.log)
for peer := 0; peer < len(rf.peers); peer++ {
if peer == rf.me {
votes++
continue
}
args := &RequestVoteArgs{
Term: rf.currentTerm,
CandidateId: rf.me,
LastLogIndex: l -1 ,
LastLogTerm: rf.log[l -1 ].Term,
}
go askVoteFromPeer(peer, args)
}
}
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
// ...
// check log, only grante vote when the candidates have more up-to-date log
if rf.isMoreUpToDateLocked(args.LastLogIndex,args.LastLogTerm) {
LOG(rf.me, rf.currentTerm, DVote, "-> S%d, Reject Vote, S%d's log less up-to-date" , args.CandidateId)
return
}
// ...
}
怎么比较?
论文原文里写的非常详细:
Raft determines which of two logs is more up-to-date by comparing the index and term of the last entries in the logs.
- If the logs have last entries with different terms, then the log with the later term is more up-to-date.
- If the logs end with the same term, then whichever log is longer is more up-to-date
比较 logs 中最后一条的 index 和 term。如果 term 不同,term 越大越新;如果 term 相同,log 越长越新。
go
func (rf *Raft) isMoreUpToDateLocked(candidateIndex, candidateTerm int ) bool {
l := len (rf.log)
lastTerm, lastIndex := rf.log[l -1 ].Term, l -1
LOG(rf.me, rf.currentTerm, DVote, "Compare last log, Me: [%d]T%d, Candidate: [%d]T%d" , lastIndex, lastTerm, candidateIndex, candidateTerm)
if lastTerm != candidateTerm {
return lastTerm > candidateTerm
}
return lastIndex > candidateIndex
}
日志应用
Leader 提交日志到状态机
- Leader 给其他 Follower 追加日志成功之后,还缺少了一步,就是将 log 发送给状态机进行应用
- 我们要做的就是找到当前节点的 commitIndex,被超过一半的节点复制成功的 log。
go
replicateToPeer := func(peer int, args *AppendEntriesArgs) {
// ......
// update the commmit index if log appended successfully
rf.matchIndex[peer] = args.PrevLogIndex + len(args.Entries)
rf.nextIndex[peer] = rf.matchIndex[peer] + 1 // important: must update
majorityMatched := rf.getMajorityIndexLocked()
if majorityMatched > rf.commitIndex {
LOG(rf.me, rf.currentTerm, DApply, "Leader update the commit index %d->%d", rf.commitIndex, majorityMatched)
rf.commitIndex = majorityMatched
rf.applyCond.Signal()
}
}
func (rf *Raft) getMajorityIndexLocked() int {
tmpIndexes := make ([] int , len (rf.matchIndex))
copy (tmpIndexes, rf.matchIndex)
sort.Ints(sort.IntSlice(tmpIndexes))
majorityIdx := ( len (tmpIndexes) - 1 ) / 2
LOG(rf.me, rf.currentTerm, DDebug, "Match index after sort: %v, majority[%d]=%d" , tmpIndexes, majorityIdx, tmpIndexes[majorityIdx])
return tmpIndexes[majorityIdx] // min -> max
}
Follower 提交日志到状态机
- 在 Leader CommitIndex 更新后,会通过
AppendEntries
发送给 Follower。Follower 根据 Leader Commit,来更新自己的 commitIndex
go
type AppendEntriesArgs struct {
Term int
LeaderId int
PrevLogIndex int
PrevLogTerm int
Entries []LogEntry
LeaderCommit int
}
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
// ...
// update the commit index if needed and indicate the apply loop to apply
if args.LeaderCommit > rf.commitIndex {
LOG(rf.me, rf.currentTerm, DApply, "Follower update the commit index %d->%d" , rf.commitIndex, args.LeaderCommit)
// 注意边界条件
if rf.commitIndex >= len (rf.log) {
rf.commitIndex = len (rf.log) - 1
} else {
rf.commitIndex = args.LeaderCommit
}
rf.applyCond.Signal()
}
// reset the election timer, promising not start election in some interval
rf.resetElectionTimerLocked()
}
Apply 工作流
将已提交但尚未应用到状态机的日志条目应用到状态机上。
在这个阶段,最重要的是给 applyCh 发送信息的时候不能加锁,因为我们不知道这个操作需要耗时多久。
所以,我们把 apply 需要加锁才能得到的数据**rf.log
**提前拿出来,存在 entries
里。
再进行 apply 操作,最后别忘了更新 lastApplied
。
go
func (rf *Raft) applyTicker() {
for !rf.killed() {
rf.mu.Lock()
rf.applyCond.Wait() // 阻塞 go routine 执行
entries := make ([]LogEntry, 0 )
// should start from rf.lastApplied+1 instead of rf.lastApplied
for i := rf.lastApplied + 1 ; i <= rf.commitIndex; i++ {
entries = append (entries, rf.log[i])
}
rf.mu.Unlock()
// 将一个 log 发送到 rf.applyCh 中
for i, entry := range entries {
rf.applyCh <- ApplyMsg{
CommandValid: entry.CommandValid,
Command: entry.Command,
CommandIndex: rf.lastApplied + 1 + i,
}
}
rf.mu.Lock()
LOG(rf.me, rf.currentTerm, DApply, "Apply log for [%d, %d]" , rf.lastApplied+ 1 , rf.lastApplied+ len (entries))
rf.lastApplied += len (entries)
rf.mu.Unlock()
}
}
初始化
最后,补充一些初始化的操作
go
// A Go object implementing a single Raft peer.
type Raft struct {
// ......
// commit index and last applied
commitIndex int
lastApplied int
applyCond *sync.Cond
applyCh chan ApplyMsg
// ......
}
func Make(peers []*labrpc.ClientEnd, me int,
persister *Persister, applyCh chan ApplyMsg) *Raft {
// ......
rf.applyCh = applyCh
rf.commitIndex = 0
rf.lastApplied = 0
rf.applyCond = sync.NewCond(&rf.mu)
// initialize from state persisted before a crash
rf.readPersist(persister.ReadRaftState())
// start ticker goroutine to start elections
go rf.electionTicker()
go rf.applyTicker()
return rf
}
通过实现这些小部分,可以逐步完成日志应用的逻辑。每个部分的实现都需要考虑并遵循 Raft 协议的规范,确保日志的正确提交和应用。