最近我想学习下分布式系统的经典入门课程 MIT 6.824 ,正好看到木鸟(www.zhihu.com/people/qtmu...%25E7%259A%2584%25E8%25AF%25BE%25E7%25A8%258B "http://www.zhihu.com/people/qtmu…)%E7%9A%84%E8%AF%BE%E7%A8%8B") ------ 基于 MIT 6.824 的课程,从零实现分布式 KV。门课会,手把手带你看论文写代码。所以这篇文章的主要内容是我的课程心得,介绍什么是Raft,并实现一个基本的框架。
希望这篇文章能带你进入分布式系统的大门,作为一个新手,我深知学习分布式系统是一个挑战,但我也坚信通过不断地学习和实践,我们可以逐步掌握其中的要领不断进步。期待与大家一起探索分布式系统的奥秘,共同成长。这篇文章只讨论怎么实现持久化部分,代码在这:github.com/Maricaya/ra...
如果对你有帮助,请给我点赞 评论,这是我继续更新的最大动力~
Raft 系统其他文章:
Part A Leader选举:zhuanlan.zhihu.com/p/681385987
Part B 日志应用 :zhuanlan.zhihu.com/p/682721071
这一 part 的功能------持久化,相对A和B来说简单很多。但是因为测试用例中加入了更严格的条件(服务器宕机和大量日志),我们会发现之前代码中的一些bug,定位和修改这些bug花了不少时间。
持久化
为什么要持久化?
Peer 宕机的情况下,重启后数据还在
- 所以要把需要的信息存在 disk 上
- 但是没有 disk,我们用测试框架中的
persistor.go
中的Persister
对数据进行"持久化"
-
存、读
-
存这三个字段 currentTerm,votedFor,log
- currentTerm、votedFor 选leader 用
- log日志复制用
-
go
func (rf *Raft) persistLocked() {
w := new (bytes.Buffer)
e := labgob.NewEncoder(w)
e.Encode(rf.currentTerm)
e.Encode(rf.votedFor)
e.Encode(rf.log)
raftstate := w.Bytes()
// leave the second parameter nil, will use it in PartD
rf.persister.Save(raftstate, nil )
}
// restore previously persisted state.
func (rf *Raft) readPersist(data []byte) {
if data == nil || len(data) < 1 {
return
}
var currentTerm int
var votedFor int
var log []LogEntry
r := bytes.NewBuffer(data)
d := labgob.NewDecoder(r)
if err := d.Decode(¤tTerm); err != nil {
LOG(rf.me, rf.currentTerm, DPersist, "Read currentTerm error: %v" , err)
return
}
rf.currentTerm = currentTerm
if err := d.Decode(&votedFor); err != nil {
LOG(rf.me, rf.currentTerm, DPersist, "Read votedFor error: %v" , err)
return
}
rf.votedFor = votedFor
if err := d.Decode(&log); err != nil {
LOG(rf.me, rf.currentTerm, DPersist, "Read log error: %v" , err)
return
}
rf.log = log
LOG(rf.me, rf.currentTerm, DPersist, "Read Persist %v" , rf.stateString())
}
nextIndex[] 回退
我们先来复习一下,Follower 和 Leader 日志不一样的情况:
如果日志不一致,完全和leader保持一致。一个Follower可能会丢失掉Leader上的一些条目(b),也有可能包含一些Leader没有的条目(d),也有可能两者都会发生(e)。丢失的或者多出来的条目可能会持续多个任期。
优化
在 partB 中,我们采取乐观+回撤的方法找正确的 nextIndex。(详见partB:zhuanlan.zhihu.com/p/682721071...
所以,我们需要一个解决办法,让日志回撤的时候快速定位到有问题的日志。
很简单,让 Follower 给点信息------告诉 Leader 自己日志大致到哪里就好了!
那Follower需要返回哪些信息呢?
主要是两个 ConflictTerm
和**ConflictIndex
** ,发生冲突的 Term 和 Index。
当Follower因为探针 prevLog 冲突而拒绝 Leader 的时候,可以获得这些信息:
makefile
XTerm: 空,或者 Follower 与 Leader PrevLog 冲突 entry 所存的 term
XIndex: 空,或者 XTerm 的第一个 entry 的 index
XLen: Follower 日志长度
那么 Leader 的逻辑可以是这样的:
ini
Case 1: Follower 的 Log 太短了:(迅速退到和 Follower 同长度)
nextIndex = XLen
Case 2 : Leader 没有 XTerm:(以 Follower 为准迅速回退跳过该 term 所有日志)
nextIndex = XIndex
Case 3: Leader 存在 XTerm: (以 Leader 为准,迅速回退到该 term 的最后一个日志)
nextIndex = Leader 在 XTerm 最后的 log 的 index
代码
优化点1
如果follower.log
不存在prevLog
,让Leader下一次从follower.log
的末尾开始同步日志。
优化点2
如果是因为prevLog.Term
不匹配,记follower.prevLog.Term
为conflictTerm
。
- 如果
leader.log
找不到Term为conflictTerm
的日志,则下一次从follower.log
中conflictTerm
的第一个log的位置开始同步日志。 - 如果
leader.log
找到了Term为conflictTerm
的日志,则下一次从leader.log
中conflictTerm
的最后一个log的下一个位置开始同步日志。
nextIndex
的正确位置可能依旧需要多次RPC才能找到,改进的流程只是加快了找到正确nextIndex
的速度。
-
如果 follower 的 log 中不存在
prevLogIndex
,它应该返回conflictIndex = len(log)
和conflictTerm = None
.luaif args.PrevLogIndex >= len(rf.log) { reply.ConflictIndex = len (rf.log) reply.ConflictTerm = InvalidTerm return }
-
如果 follower 的日志中确实有 prevLogIndex,但 term 不匹配,那么应该返回
conflictTerm = log[prevLogIndex].Term
,ConflictIndex 是conflictTerm
的 log 中第一个 index 。
ini
if rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
reply.ConflictTerm = rf.log[args.PrevLogIndex].Term
reply.ConflictIndex = rf.firstLogFor(reply.ConflictTerm)
return
}
- 收到有 conflict 的回复后,leader 应该根据
conflictTerm
搜索自己的 log。如果找到了 log,nextIndex
应该设置为该 term 中最后一个条目索引之后的索引。 - 如果找不到具有该 term 的 log,应设置
nextIndex = conflictIndex
.
go
if reply.ConflictTerm == InvalidTerm {
rf.nextIndex[peer] = reply.ConflictIndex // 如果没有 ConflictTerm,从这开始,以 Follower 为准
} else {
firstTermIndex := rf.lastLogFor(reply.ConflictTerm)
// 不存在
if firstTermIndex == InvalidIndex {
rf.nextIndex[peer] = reply.ConflictIndex // 如果没有,从这开始,以 Follower 为准
} else {
rf.nextIndex[peer] = firstTermIndex + 1 // 以 Leader 为准,firstTermIndex + 1
}
}
例子 🌰
这么说可能有点抽象,来看个例子你就明白了:
在这个集群中,一开始 S0 担任领导者角色,接收到日志条目 [1],任期号为 3,S0 将这个日志条目传递给 S1 和 S2。
随后,S0 接收到连续的日志条目 [2][3][4][5],任期号均为 3。然后,S0 的通信中断了。
接着,S1 成为新的领导者,接收到日志条目 [2],任期号为 4,并将其传递给 S2。
此时,S0 恢复通信,S1 接收到新的日志条目 [5],任期号为 5,需要将其传递给 S0 和 S2。
用图表示:
S1 通过"探针"方式向 S0 和 S2 传递了日志条目 [3],任期号为 5。在进行传递之前,S1 使用探针查看之前的日志是否能够匹配上:
-
对于 S0,上一个日志条目为 [2],任期号为 4。
-
对于 S2,上一个日志条目为 [2],任期号为 4。
为什么 S0的探针也是[2]T4呢?
因为S1变成leader后会对nextIndex进行初始化,将其设置为leader自身的信息,而探针是根据nextIndex得到的。
这样讲可能有点抽象,直接看代码
becomeLeaderLocked
就明白啦
scss
for peer := 0; peer < len(rf.peers); peer++ {
// 记录每个Follower节点需要发送的下一个日志条目的索引
rf.nextIndex[peer] = len(rf.log)
// 记录每个Follower节点已经复制到的最高日志条目的索引
rf.matchIndex[peer] = 0
}
这个时候,S0接收到S1传递的[2]T4日志时,发现前一个日志无法匹配。
具体来说,S1的[2]T4日志与S0本地日志的[2]T3不匹配。
因此,我们记录下发生冲突的位置:
S0->S1:冲突Term:3(当前发生冲突的Term),冲突Index:1 (冲突Term的第一个log,索引为 1)
S1 收到回复后,开始根据 ConflictTerm、ConflictIndex找合适的nextIndex。
先根据ConflictTerm 3,找到当前节点的最后一个 log index,也就是 1。nextIndex 为 1+1=2。
这样,就找到了S0相对S1正确的 nextIndex ------ 2。
代码总览
go
type AppendEntriesReply struct {
Term int
Success bool
ConflictIndex int
ConflictTerm int
}
// --- rf.AppendEntries in raft_replication.go
// ConflictTerm - previous
leader args.PrevLogIndex = rf.nextIndex[peer] - 1 = 3
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
//...
// return failure if prevLog not matched
if args.PrevLogIndex >= len(rf.log) {
reply.ConflictIndex = len (rf.log)
reply.ConflictTerm = InvalidTerm
return
}
if rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
reply.ConflictTerm = rf.log[args.PrevLogIndex].Term
reply.ConflictIndex = rf. firstLogFor (reply.ConflictTerm)
return
}
//...
}
// --- rf.startReplication.replicateToPeer in raft_replication.go
if !reply.Success {
prevNext := rf.nextIndex[peer]
if reply.ConflictTerm == InvalidTerm {
rf.nextIndex[peer] = reply.ConflictIndex // 如果没有,从这开始,以 Follower 为准
} else {
firstTermIndex := rf.firstLogFor (reply.ConflictTerm) //
// 不存在
if firstTermIndex == InvalidIndex {
rf.nextIndex[peer] = reply.ConflictIndex // 如果没有,从这开始,以 Follower 为准
} else {
rf.nextIndex[peer] = firstTermIndex + 1 // 以 Leader 为准,firstTermIndex + 1
}
}
// avoid the late reply move the nextIndex forward again
/* 当匹配探测期(AppendEntries RPC)的时间比较长的时候,
会有多个探测的 RPC 在同一时刻发送给 Followers,
如果 RPC 结果乱序回来,就会导致问题:
一个先发出去的探测 RPC 后回来了,
其中所携带的 ConflictTerm 和 ConflictIndex 就有可能造成 rf.next 的"反复横跳"。
为了解决这个问题,我们可以强制要求 Leader 中的 nextIndex 必须单调递减,
也就是说,每次调整 nextIndex 的时候,都要比之前更小,从而避免问题。
*/
rf.nextIndex[peer] = min(prevNext, rf.nextIndex[peer])
return
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// --- in raft.go
const (
InvalidIndex int = 0
InvalidTerm int = 0
)
// --- in raft.go
func (rf *Raft) firstLogFor(term int) int {
for i, entry := range rf.log {
if entry.Term == term {
return i
} else if entry.Term > term {
break
}
}
return InvalidIndex
}
重置时钟
最后还有个小bug,在收到 AppendEntries RPC 时,无论 Follower 接受还是拒绝日志,只要认可对方是 Leader 就要重置时钟。
但在我们之前的实现,只有接受日志才会重置时钟。
这是不对的,如果 Leader 和 Follower 匹配日志所花时间特别长,Follower 一直不重置选举时钟,就有可能错误的选举超时触发选举。
这里我们可以用一个 defer 函数来在合适位置之后来无论如何都要重置时钟:
go
defer rf.resetElectionTimerLocked()
Figure 8
上面的完成之后,我们测试一下,发现 figure8 这个测试用例没通过,去论文里看看是什么名堂。
以下内容来自 Q的博客,juejin.cn/post/690715... Figure 8 讲解对非常详细,我也没什么好补充的,直接搬运一下 ^_^
上图从左到右按时间顺序模拟了问题场景。
阶段a:S1 是 leader,收到请求后将 (term2, index2) 复制给了 S2,尚未复制给 S3 ~ S5。
阶段b:S1 宕机,S5 当选 term3 的 leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。
阶段c:S5 宕机,S1 恢复,S1 重新当选 term4 的 leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。
阶段d:S1 又宕机,S5 恢复,S5 重新当选 leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。
为了避免这种错误,我们需要添加一个额外的限制:
Leader 只允许 commit 包含当前 term 的日志。
针对上述场景,问题发生在阶段c,即使作为 term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其 commit,而是必须等待 term4 的日志到来并成功复制后,一并进行 commit。
阶段e:在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样 S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。
ini
majorityMatched := rf.getMajorityIndexLocked()
if majorityMatched > rf.commitIndex && rf.log[majorityMatched].Term == rf.currentTerm {
LOG(rf.me, rf.currentTerm, DApply, "Leader update the commit index %d->%d", rf.commitIndex, majorityMatched)
rf.commitIndex = majorityMatched
rf.applyCond.Signal()
}
以上便是对算法增加的一个小限制,它们对确保状态机的安全性起到了至关重要的作用。