从0到1实现 Raft — 日志应用 (MIT 6.5840 Lab3 PartB)

缘起

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

状态复制机的工作原理如下:

  1. 用户输入一些指令,比如设置变量 y 的值为 1,然后将 y 的值改为 9。

  2. 一旦有用户输入了指令,集群中的每台服务器都会收到。然后,这些服务器会将这个指令记录在它们的日志文件 Log 中,就好像写日记一样。这样,每个服务器都有了一份完整的操作记录。

  3. 可以通过 Log 来推理出每个变量的最新值,比如 y 为 9,发送给当前服务器的状态机。

  4. 最后用户从任何一台服务器得到最新的变量状态,比如 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 采用一种"乐观+回撤"的方式进行同步:

  1. 乐观:一开始,Leader 发送心跳时不附带任何日志,只携带一些"暗号"过去。如果 Follower 发现自己的日志和 Leader 完全一样,就直接回复"一致",以后的心跳就不用再附带日志了。
  2. 回撤:如果 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 到状态机了。

图解

juejin.cn/post/689946...

图解的过程转载自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
} 
  • 复制日志条目:如果 PrevLogIndexPrevLogTerm 匹配,那么 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
    scss 复制代码
    rf.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.

  1. If the logs have last entries with different terms, then the log with the later term is more up-to-date.
  2. 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 协议的规范,确保日志的正确提交和应用。

相关推荐
stevewongbuaa1 小时前
一些烦人的go设置 goland
开发语言·后端·golang
花心蝴蝶.4 小时前
Spring MVC 综合案例
java·后端·spring
落霞的思绪4 小时前
Redis实战(黑马点评)——关于缓存(缓存更新策略、缓存穿透、缓存雪崩、缓存击穿、Redis工具)
数据库·spring boot·redis·后端·缓存
m0_748255655 小时前
环境安装与配置:全面了解 Go 语言的安装与设置
开发语言·后端·golang
SomeB1oody10 小时前
【Rust自学】14.6. 安装二进制crate
开发语言·后端·rust
患得患失94912 小时前
【Django DRF Apps】【文件上传】【断点上传】从零搭建一个普通文件上传,断点续传的App应用
数据库·后端·django·sqlite·大文件上传·断点上传
customer0812 小时前
【开源免费】基于SpringBoot+Vue.JS校园失物招领系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
中國移动丶移不动13 小时前
Java 反射与动态代理:实践中的应用与陷阱
java·spring boot·后端·spring·mybatis·hibernate
uzong15 小时前
Mybatis-plus 更新 Null 的策略踩坑记
java·后端
uzong15 小时前
mapStruct 使用踩坑指南
java·后端