从0到1实现 Raft — 持久化 (MIT 6.5840 Lab3C)

最近我想学习下分布式系统的经典入门课程 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(&currentTerm); 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.TermconflictTerm

  1. 如果leader.log找不到Term为conflictTerm的日志,则下一次从follower.logconflictTerm的第一个log的位置开始同步日志。
  2. 如果leader.log找到了Term为conflictTerm的日志,则下一次从leader.logconflictTerm的最后一个log的下一个位置开始同步日志。

nextIndex的正确位置可能依旧需要多次RPC才能找到,改进的流程只是加快了找到正确nextIndex的速度。

  • 如果 follower 的 log 中不存在prevLogIndex ,它应该返回 conflictIndex = len(log)conflictTerm = None.

    lua 复制代码
    if args.PrevLogIndex >= len(rf.log) { 
            reply.ConflictIndex = len (rf.log)
        reply.ConflictTerm = InvalidTerm
            return
    }
  • 如果 follower 的日志中确实有 prevLogIndex,但 term 不匹配,那么应该返回conflictTerm = log[prevLogIndex].TermConflictIndex 是 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()
}

以上便是对算法增加的一个小限制,它们对确保状态机的安全性起到了至关重要的作用。

相关推荐
姜学迁1 小时前
Rust-枚举
开发语言·后端·rust
爱学习的小健2 小时前
MQTT--Java整合EMQX
后端
北极小狐2 小时前
Java vs JavaScript:类型系统的艺术 - 从 Object 到 any,从静态到动态
后端
【D'accumulation】2 小时前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
2401_854391082 小时前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss2 小时前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
Cikiss2 小时前
微服务实战——平台属性
java·数据库·后端·微服务
OEC小胖胖3 小时前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617623 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
计算机学姐3 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis