从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()
}

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

相关推荐
LucianaiB17 分钟前
参加高德 AI 发布会的一点感受:地图,正在变成 AI 的行动入口
后端
属于自己的天空17 分钟前
一个文件让 Claude Code 理解你的项目:CLAUDE.md 从入门到精通
后端
jiangbo_dev23 分钟前
还在手搓分布式事务?我把 Saga + Outbox 模板化后,新服务接入从 5 天压到 1 天
后端
BING_Algorithm26 分钟前
深入理解JVM垃圾回收
jvm·后端·面试
RainCity1 小时前
Java Swing 自定义组件库分享(六)
java·笔记·后端
techdashen1 小时前
深入 Rust enum 的内存世界
开发语言·后端·rust
龙码精神1 小时前
TimescaleDB 物联网设备属性历史数据表设计及常用SQL文档
后端
小小小小宇1 小时前
Go 后端锁机制详解
后端
挖坑的张师傅1 小时前
你的仓库 Agent Ready 了吗?
后端
客场消音器2 小时前
如何使用codex进行UI重构,让AI开发的前端页面不再千篇一律
前端·后端·微信小程序