MIT6.824 分布式系统课程实验笔记 Lab 2

Lab 2、3、4 是一个系列,最终需要实现一个可容错、可分片的分布式 K-V 数据库,该数据库的底层基于分布式共识算法 Raft。Lab 2 的目标就是实现 Raft 底层协议,以支持数据库的分布式、多副本、一致性的需求。

Raft 算法

Raft 算法的提出就是为了解决之前的分布式共识算法 Paxos 可理解性较差、不易实现的问题。但即便如此,Raft 算法理解、实现起来也并不简单,并且有很多需要注意的细节问题。Raft 算法设计的目的是什么?本节先从分布式副本集群、主备同步、分布式共识等几个方面进行简单的梳理。

多副本集群

多副本集群是分布式系统能够正常运行的基础。我们构建分布式系统可能有多个原因,比较主要的两个原因就是:并行计算容错。这两者之间是有一定因果关系的,如下面的逻辑推演:

  1. 单体应用想要提高性能 ——> 并行计算
  2. 并行计算需要将数据划分后同时进行 ——> 构建服务器集群
  3. 构建集群后不可避免地会出现单点故障 ——> 使集群能够自动容错
  4. 容错要求出现故障后数据不丢失 ——> 每个数据复制多个副本
  5. 数据的多个副本必须保持一致性 --> 每个副本之间需要进行同步
  6. 副本同步带来额外的开销 --> 降低了整体性能

可以看到,最终的结果可能和最初的设想有些矛盾,也就是说为了保证多副本之间的一致性,就必须牺牲掉一定的性能。分布式系统的设计需要在它们之间进行权衡,在能够达到可接受的容错性的同时减少性能的损耗。

主备同步

关于如何设计多副本系统,有很多不同的实现,但是基本都离不开主备同步

也就是有一份主数据(Primary)和多份备份数据(Backup),这里的“数据”是抽象层面的,可以是任何需要多副本的对象,也可代表承载对象的服务器节点。平时我们处理和更新 Primary,同时让 Backup 与 Primary 保持同步,当 Primary 所在的节点故障后可以立刻启用 Backup,而不用等待故障恢复。(需要注意的是,外部调用者不应当感知到数据有多个副本

关于主备之间的同步,有两种方案:

  1. State transfer,状态转移。Primary 将自己完整状态,比如说内存中的内容,拷贝并发送给 Backup。Backup 会保存收到的最近一次状态。
  2. Replicated state machine,复制状态机。使服务器状态发生改变的往往是外部事件,这个方案不会在不同的副本之间发送完整状态,只会从 Primary 将这些外部事件,例如外部的输入、增删改查,发送给 Backup,从而达到状态同步。

上面两种方案人们更亲向于后者,因为需要传递的数据更少,但是它实现起来更复杂一些,也会受并行计算的影响(比如在多核处理器上,同样的外部命令可能得到不同的结果,如随机数)。

不论哪种主备同步,都需要进行良好的输出控制(Output Rule),防止出现外部更新了 Primary,副本因为网络问题没有及时同步数据,而外部已经收到了更新结果的情况。所以大部分的分布式副本系统都涉及到 Primary 停下来等待 Backup 的问题。Raft 算法也不例外。

分布式共识

在多副本集群中,Primary 应当只有一个,否则会出现脑裂。但是多个节点究竟让谁作 Primary 呢?不同的设计思想可能有不同的实现,但是不管怎样只要是通过单服务去决定谁是 Primary 就是有问题的,因为它又会引出单点故障,是不可靠的,哪怕这个服务本身也是多副本。比如下面的设计思想:

多个副本通过Test-And-Set服务(TAS 服务本身也是多副本的)去请求当 Primary(类似请求获取分布式锁),最终应该只有一个请求成功。但是在分布式环境下网络是不可靠的,可能会出现下面的情况:

如果有两个客户端 C1,C2 请求成为主副本,有两个 TAS 服务器 S1,S2。当一个客户端能和一个服务器通信而不能和另外一个通信的时候,就可能会发生脑裂问题。此时客户端有两种选择:

  1. 依次询问所有服务器 S1、S2,等待它们的响应。但这样就失去了容错的意义,因为只要有一台服务器坏掉,系统就无法正常运行;而且,一个好的多副本服务是不应该让客户端意识到有多个副本的。
  2. 认为自己无法与其通信的服务器 S2 已经宕机了,所以其他服务器也一定发现了这个问题,所以可以只去 S1 服务器请求。但是这个想法是错误的,因为 S2 很可能没有宕机,而是由于网络问题, C1 只能通信 S1,C2 只能通信 S2,这样很容易就形成脑裂了。

虽然上面的设计思想在现实生活中可以较好地运行,只要保证集群网络正常就行,但是毕竟是不完美的。

因此,像 Paxos、Raft、ZAB 这类靠“ 过半票决 ”思想来保证主备一致性的分布式共识算法就被提了出来。

Raft 算法概述

Raft 算法本身是一个复制状态机架构,其中的外部事件它称为日志(log),日志是有时间顺序的,不同的节点之间只要日志是一致的,那么状态就是一致的。所以 Raft 算法最重要的部分就是日志的正确同步
image.png
图中的State Machine部分其实就是需要保证分布式一致性的数据,在不同的应用中指代的对象可能是不同的。比如在分布式 KV 数据库中,它可能存储的就是键和值;在分布式消息队列中,它可能存储的就是消息的状态。Raft 算法并不知道每一条 Log 代表的含义,也不知道状态机目前的状态,它只保证不同节点之间的状态机是一致的。所以 Raft 算法处于一个所有应用共性的位置,也就是上图②和③所代表的流程。

Raft 算法解决了三个子问题:领导者选举、日志复制、安全性。

领导者选举

每个节点可能的状态:follower, candidate, leader

leader 的作用是协调所有的 follower 进行正确的日志同步,并响应上层的事件输入。它通过心跳来在集群中维护自己的“统治地位”。但是如何避免上文所述的单点故障呢?如果 leader 断连或者宕机了,那么需要在剩下的 follower 中选择一个新的 leader,选举的规则就是某一个 follower 转变为 candidate,然后让其他节点为自己投票,如果所获同意的票数超过节点总数(包括宕机和断连的)的一半(大多数),那么该节点就成为了新的 leader。为了区分新旧 leader ,Raft 采用了任期(Term)的概念,所有旧 Term 的消息都应当被舍弃。在同一个 Term 中,一个 follower 只能给一个 candidate 投票。

为了尽量避免选举分裂的情况,Raft 规定每个 follower 在收不到 leader 心跳的随机一段时间后发起新一轮选举。节点的状态转换如下图:
image.png
在 Raft 中,只要 Term 合法,follower 对于 leader 必须无条件服从,所以对 candidate 的投票必须谨慎,因为投票规则决定了数据的安全性。

日志复制

日志复制是 Raft 算法中最重要的部分,需要保证无论是 follower 还是 leader 在宕机重启后经过一定的机制使得日志重新一致。日志的产生来自于客户端(外部)的命令,客户端与 leader 进行交互,它向 leader 发出一个命令,leader 产生一个日志,然后 leader 向 follower 进行同步,当集群中超过半数的 follower 都收到日志后,leader 向客户端通知操作成功。

这有点像两阶段提交(2 PC) 但并不完全是,这里的提交指的是集群的一种状态,也就是某个日志如果达成了群体一致性,那么集群就可以将这个日志的状态设置为已提交,leader 向状态机 apply 某个操作进而通知客户端操作成功的前提是该日志已经提交。

如果 leader 和 follower 都不出错,并且网络正常,那么底层 Raft 的流程就是下面的步骤:

  1. leader 接收客户端操作命令
  2. leader 将命令转化为一条日志追加到本地
  3. leader 向所有的 follower 发起日志追加请求
  4. follower 收到请求后追加新日志到本地,返回成功与否
  5. leader 在收到过半的 follower 追加成功的答复后,将本地该日志设置为已提交,并向上层状态机 apply
  6. leader 状态机返回客户端此次操作的结果
  7. leader 通过心跳向 follower 广播该日志的提交状态
  8. follower 收到心跳同样将本地的该日志设置为已提交,并向上层状态机 apply

这样一来,所有节点的本地日志、状态机状态都是一致的。但是,分布式环境下 leader、follower、网络状态都可能频繁出错,所以实际的日志复制情况并没有这么简单,需要考虑数据的恢复,并保证安全性和一致性。

安全性

Raft 需要一定的规则来保证数据的安全性和一致性。

安全性主要指客户端已经收到处理成功的操作不能因为集群的某些节点宕机而丢失或被覆盖。具体来说的话可以分为:投票安全性复制安全性提交安全性

投票安全性:

  • follower 在每个 Term 只能对一个 candidate 投票
  • 不能选日志的 Term 和 Index 落后自己的节点作为 leader

复制安全性:

  • follower 在收到 leader 的日志追加请求时如果位置冲突则需要通知 leader 进行快速恢复(从不冲突的位置进行覆盖),这样能保证在新的 leader 上任后,所有的 follower 和 leader 的日志一致,并且没有空隙

提交安全性:

  • 因为一个节点一旦提交、apply 后就不能反悔了,所以提交也需要慎重
  • leader 需要在合适的时机确定所有的 follower 都已经完成同步的日志号,并且该日志的 Term 需要等于当前 Term,然后将该位置的日志提交
  • follower 只有在收到 leader 的提交位置后才能更新自己的提交位置

一致性主要指集群中所有的节点数据需要能够容错,在恢复后仍然能保持一致。从整体来看,Raft 算法是强一致性模型,也就是系统保证不论在哪个时间点,外部的每个读操作都将返回最近的写操作的结果。保证强一致的机制就是 Raft 的集群提交状态,只有 leader 收到过半的 follower 的同步成功后,才向上层返回结果,此时哪怕 leader 宕机了,上面的投票规则也能保证选举出来的新 leader 具有最新的日志。

Lab 要求

Lab 2 有四个子 Lab:

  • Lab 2A:实现 leader 选举
  • Lab 2B:实现日志复制/同步
  • Lab 2C:实现持久化
  • Lab 2D:实现日志压缩(快照)

每一个子 Lab 都有一系列测试。2A 和 2B 是最主要的实现,2C 和 2D 以这两个为基础。但是 2C 和 2D 的测试极其严苛,能够测出很多前两个 Lab 中存在的 BUG,所以这四个 Lab 基本上是难度递增。

实验提供了 labrpc.go 用来模拟现实网络环境下的 RPC 调用,同时也可以产生很严苛的测试场景。我们的所有代码写在 raft.go 中,这个文件仅规定了 Raft 与外界交互的接口,其他的内部实现全部需要自己完成。

实验层会调用该文件中 Make(peers, me, ...) 函数来生成一个 Raft 对象,通过这个函数的参数我们可以知道集群中总共有多少个节点 (peers),自己是哪一个节点(me),还有与外界传递信息的 apply 通道(applyCh)等。

实验层会调用 GetState() 方法来获取当前节点所处的状态(leader/follower/candidate),通常情况下,客户端只会与 leader 进行交互。

关键问题

多 Leader 错误

一般来说,只要不出现网络分区情况,集群中只能有一个 Leader,否则就是脑裂了。这就要求节点的身份及时、正确地改变。任何节点在收到 AE 和 Vote 请求时都要根据 Term 去判断自己是否要改变身份,这里身份的改变和其他属性(如当前 Term)的改变要保持原子性,否则容易出现多 Leader 错误。

计时功能设计

Lab 官方推荐在需要进行周期性动作的地方使用 time.Sleep() 函数实现(因为简单),但是这个方案对于 Raft 的超时选举算法并不优雅,因为 Raft 要求 follower 在每次收到 leader 的心跳后重置选举计时器。因此我采用了 time.Timer 来实现 follower 的选举计时和 leader 的心跳计时,虽然 debug 难度增加了,但是更加接近 Raft 设计者的本意。

Log 存储问题

从逻辑上看,每个节点的本地日志就是一个 log 数组,一开始我也仅仅用一个数组存储,但是 log Index 的下标是从 1 开始的(论文中的考量),这就需要判断好各种边界问题。此外,做到 Lab 2D 的时候要求对日志进行裁剪,这个时候逻辑 Index 和实际 Index 已经完全不同了,如果每次都额外判断、处理的话工作量太大。所以我采用了面向对象的思想,引入了 LogManager 对象,用于统一向外界提供从 1 开始的索引映射和增删改查功能,大大的方便了后续开发。

一致性检查的问题

Leader 始终要保证自己的 follower 和自己的 log 内容是一模一样的,同步就是通过 AE 请求。如果 Leader 发现了不一致的现象就要及时纠正。但是,检查的时机如何确定,如果次数太少,会导致群体一致性达成过慢;如果太过频繁,会加大并发问题的概率,同时也会造成网络拥堵。一般来说,每次心跳都是一次同步,但是远远不够,因为客户端可能会在两次心跳之间多次操作,请求达成一致。因此需要一种机制,既能够及时响应 Command,又能够尽量减少并发 RPC 冲突。

我采用的是清零心跳计时器的方法。计时器清零后,如果上一次心跳已经结束,那么下一次会马上开始;如果上一次还没结束,那么就会等待心跳全部发送出去。这样不会再短时间的高并发情况下导致过多的心跳 RPC 出现,同时也能够达到一种批处理的效果。

如何判断提交状态

一开始,我是在每次心跳完成后,根据 follower 的回复判断是否能够更新提交,后来发现太天真了。论文中设置 matchIndex 是非常有必要的,需要另起一个协程,通过判断 matchIndex 和 Leader 的 log index 来判断当前的提交位置。并且有一个条件非常关键:Raft 永远不会通过计算副本数目的方式来提交之前任期内的日志条目,否则在宕机重启后会出现数据不一致。

持久化需要注意什么

在分布式开发中,任何时刻都要确保系统在某一时刻的状态是一致的,因此在状态更改的时候为了防止宕机丢失数据,一定要及时进行持久化。
还要搞清两个问题:

  1. 为什么要持久化 currentTerm?
    1. 因为避免出现两个意义不同的但数字相同的 Term
  2. 为什么要持久化 votedFor?
    1. 因为要避免一个 server 同时给多个 Candidate 投票

快照需要注意什么

在论文中有两种方法进行快照,一种是每个服务器独立拍摄快照的;另一种是由 leader 领导拍摄。Lab 是第一种,因此不用由 leader 主动同步,而是发现 follower 过于落后时同步。每个服务器平时也不需要 apply 快照,而是在过于落后或重启时才 apply。

因为快照请求有可能在任何时间到达,它和 apply 操作是互斥的,因此一定要保证先后顺序,不让多余的 log 向上层 apply。

分布式应用开发的难点

最难的部分就是不确定性,状态、请求顺序、机器可靠等问题都有极大地不确定性,需要很多看起来冗余实际上非常有必要的操作去避免不确定性带来的数据不一致问题,比如代码的先后顺序、原子性保证等。

分布式应用的调试会非常折磨,因为有些 BUG 无法快速复现,因此在开发是需要养成随时打印日志的习惯,以便后期排查。

具体实现

Raft 对象结构

Raft 从实现层面来说是一个底层库,向上层(Server)提供一致性保障的方法,因此需要明了 Raft 向外提供什么接口:

  • Make(peers, me, persister, applyCh),在当前 Server 节点构建 Raft 对象并返回,peers 是集群节点信息,me 是当前节点编号,persister 是持久化工具类,applyCh 是 Server 接收某个命令达成一致性结果的消息通道;
  • Start(command interface{}),向当前节点发起一条命令一致性过程,该方法只有处于 Leader 状态的节点才可被调用;
  • GetState(),获取 Server 当前的 Raft 状态;
  • Kill(),结束 Raft 服务;
  • Snapshot(index int, snapshot []byte),Server 完成快照,向 Raft 同步(日志裁剪)

我设计的 Raft 对象关键字段:

	// 持久属性
	currentTerm int         // 当前任期
	votedFor    int         // 当前任期投票给了谁
	lm          *LogManager // 管理日志存储
	snapshot    []byte      // 上一次保存的快照

	// 易失属性
	state         string        // 当前角色状态
	commitIndex   int           // 已提交的最大下标
	lastApplied   int           // 以应用到状态机的最大下标
	applyCh       chan ApplyMsg // apply通道
	applyCond     *sync.Cond    // apply协程唤醒条件
	installSnapCh chan int      // install snapshot的信号通道,传入trim index
	backupApplied bool          // 从磁盘恢复的snapshot已经apply
	notTicking    chan bool     // 没有进行选举计时
	electionTimer *time.Timer   // 选举计时器

	// Leader特有字段
	nextIndexes    []int       // 对于每个follower,leader要发送的下一个复制日志下标
	matchIndexes   []int       // 已知每个follower和自己一致的最大日志下标
	heartbeatTimer *time.Timer // 心跳计时器

易失属性是指断电重启后就丢失内容的属性,丢失后不影响状态的正确性;持久属性是必须在适当的时间进行持久化的属性,否则断电重启后集群会发生异常。

超时选举实现

在集群刚启动的时候,所有的节点都是 follower 状态,需要节点自觉地发起选举。为了避免同一个时刻有多个节点发起选举造成选举分裂,采用随机超时策略,当一个节点在某个随机的时间内都没有收到心跳的话,他便发起新一轮选举。
选举流程:

  1. 当前 Term+1
  2. 转换状态为 candidate
  3. 投自己一票
  4. 向其他人广播选举请求
  5. 当收到超过半数投票后,转换为 leader,开启心跳

投票通过 RequestVoteRPC 进行通知

func (rf *Raft) kickOffElection() {
	rf.currentTerm++     // 更新任期
	rf.state = CANDIDATE // 变为候选人
	rf.votedFor = rf.me  // 先投自己一票
	rf.persist()
	votes := 1
	total := len(rf.peers)
	args := rf.genRequestVoteArgs()
	mu := sync.Mutex{}
	for i := 0; i < total; i++ {
		if i == rf.me {
			continue
		}
		go func(server int) {
			reply := RequestVoteReply{}
			if !rf.sendRequestVote(server, &args, &reply) {
				return
			}
			if args.Term == rf.currentTerm && rf.state == CANDIDATE { // 防止等的时间太长,已经开始了下一轮,或者身份已经变化
				if reply.Term < rf.currentTerm { // 投票者的任期小于自己的
					// 丢弃
					return
				}
				if reply.VoteGranted {
					mu.Lock()
					votes++
					if rf.state != LEADER && votes > total/2 { // 得票超半数
						rf.initLeader()
						rf.state = LEADER
						// 立即开始心跳
						go rf.heartbeat()
					}
					mu.Unlock()
				}
			}
		}(i)
	}
}

需要注意选举过程中身份、任期的判断和互斥锁的使用。

投票流程:
任何节点在接收到某个非旧任期 candidate 的投票请求后都要有所响应,要么同意要么否决。需要根据安全性规则投票。

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
	if args.Term >= rf.currentTerm { // 候选人要比自己所在任期领先或持平
		if args.Term > rf.currentTerm { // 新任期重置投票记录,改变身份
			rf.enterNewTerm(args.Term)
		}
		if rf.state != CANDIDATE { // 如果不是候选人就重置选举计时
			rf.resetElectionTimer()
		}
		rf.mu.Lock()
		reply.Term = rf.currentTerm
		reply.VoteGranted = false
		if rf.votedFor == -1 || rf.votedFor == args.CandidateId { // 每个任期只能投一次票
			// 安全性检查,即不能选日志的term和index落后自己的节点作为leader,term优先级大于index
			len := rf.lm.len()
			var lastLogIndex, lastLogTerm int // 自身的最后index和term
			if len == rf.lm.lastTrimmedIndex {
				lastLogIndex = rf.lm.lastTrimmedIndex
				lastLogTerm = rf.lm.lastTrimmedTerm
			} else {
				lastLogIndex = len
				lastLogTerm = rf.lm.get(len).Term
			}
			if lastLogTerm == 0 || lastLogTerm < args.LastLogTerm { // 比较log term
				reply.VoteGranted = true
				rf.votedFor = args.CandidateId
			} else if lastLogTerm == args.LastLogTerm && lastLogIndex <= args.LastLogIndex { // log term一样,比较index
				reply.VoteGranted = true
				rf.votedFor = args.CandidateId
			}
		}
		rf.mu.Unlock()
	}
	go rf.persist() // 返回前持久化
}

因为这个部分涉及到了之后的日志剪裁,所以代码稍复杂。

Leader 心跳实现

当选成功的 Leader 需要立即发起心跳,心跳的目的有两个:

  1. 维护自己的统治地位
  2. 对 follower 进行日志同步

均通过 AppendEntriesRPC 进行通知

  • 当 Entry 为空时,是心跳
  • 当 Entry 不为空时,是日志同步
func (rf *Raft) sendHeartbeats() {
	total := len(rf.peers)
	args := AppendEntriesArgs{}
	args.Term = rf.currentTerm
	args.LeaderId = rf.me
	args.LeaderCommit = rf.commitIndex
	// follower在收到日志后首先要检查这两个参数,与自己的不一致的话就要返回false
	// 这里是将leader目前最高index发送给follower去比对一致性,注意边界判断
	rf.mu.Lock()
	if rf.lm.len() >= 1 {
		args.PrevLogIndex = rf.lm.len()
		if args.PrevLogIndex > rf.lm.lastTrimmedIndex {
			args.PrevLogTerm = rf.lm.get(args.PrevLogIndex).Term
		} else {
			args.PrevLogIndex = rf.lm.lastTrimmedIndex
			args.PrevLogTerm = rf.lm.lastTrimmedTerm
		}
	}
	rf.mu.Unlock()
	for i := 0; !rf.killed() && i < total && rf.state == LEADER; i++ {
		if i != rf.me {
			go func(server int) {
				reply := AppendEntriesReply{}
				if rf.state == LEADER && rf.sendRequestAppendEntries(server, &args, &reply) {
					if reply.Term > rf.currentTerm { // 其他节点的Term比自己高了,转变为follower
						rf.enterNewTerm(reply.Term)
						rf.resetElectionTimer()
						return
					}
					if !reply.Success { // 说明出现了日志不一致
						// 主发生变化时有可能Index异常,因此先修正一次
						rf.fastBackup(server, reply)
						rf.agreement(server, rf.lm.len(), false) // 从最新位置检查一致性
					} else {
						// 更新一致的位置
						rf.mu.Lock()
						rf.nextIndexes[server] = args.PrevLogIndex + 1
						rf.matchIndexes[server] = args.PrevLogIndex
						rf.mu.Unlock()
						rf.tryCommit() // 每次心跳成功后检查能否进行提交
					}
				}
			}(i)
		}
	}
}

该方法通过定时器,定时执行。
Leader 会拿自己当前最新的 log 的位置去询问所有的 follower 该位置是否冲突,如果不冲突,那么在 Leader 记录该 follower 与自己的 log 最大匹配的下标和下一个接收日志的下标;如果冲突,那么就需要进入快速恢复和同步的流程。

快速恢复和同步实现

在 Leader 察觉到有 follower 与自己的 log 有冲突时,需要进行同步。因为 Leader 是从自己当前的最高 log 位询问 follower 的,所以 Leader 需要一步一步把 log 位置向前移再次询问,直到遇到一个 follower 不冲突的位置,那么从这个位置往后都要被 Leader 的日志所覆盖。这也是强一致性的体现。

但是,一个 follower 有可能刚刚宕机重启,它的日志可能落后于 Leader 很多条,这时候一步一步的试探效率十分低下。因此我们采用快速恢复策略,即 Leader 每次以 Term 为单位退回而不是 Index。

具体的做法就是 follower 在拒绝 Leader 的 AE 请求时在回应中添加一个额外的字段,让 Leader 能够判断如何快速回退:

  • XTerm:冲突的 log 任期,如果不存在 log,返回-1
  • XIndex:任期号为 XTerm 的第一条 Log 的 Index
  • XLen:follower 自己的 log 长度

Leader 端的快速恢复算法:

func (rf *Raft) fastBackup(server int, reply AppendEntriesReply) {
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if reply.XTerm == -1 { // follower对应位置没有log
		rf.nextIndexes[server] = reply.XLen + 1
	} else if reply.XTerm == 0 { // 某个地方出问题了,执行到这里不应该是0
		rf.nextIndexes[server] = 1 // 重置为1总没错
	} else {
		lastOfTerm := rf.findLastLogIndexOfTerm(reply.XTerm)
		if lastOfTerm == -1 { // leader没有follower的Term
			rf.nextIndexes[server] = reply.XIndex
		} else {
			rf.nextIndexes[server] = lastOfTerm + 1
		}
	}
}

Leader 提交判断

一开始,我们总会容易认为以 cmd 为单位进行判断,因为 Raft 的逻辑模型很明显:Leader 收到 cmd -> 生成日志 -> 集群复制该日志 -> 超过半数成功 -> 提交该日志 -> apply 该 cmd

但其实,如果出现宕机重启、重新选举等问题,没有外部命令的时候我们同样需要不断更新提交状态,所以需要再每次同步之后,通过 Leader 记录的 matchIndex 来进行判断。需要注意:Raft 中的提交并不是某个操作,而是一种集群状态,如果 index 位置设置为提交位置,那么说明 index 之前的所有日志都已经提交。

// leader统计matchIndex,尝试提交
func (rf *Raft) tryCommit() {
	total := len(rf.peers)
	// 找到一个最大的N>commitIndex,使得超过半数的follower的matchIndex大于等于N,
	// 且leader自己N位置的log的Term等于当前Term(这一点很重要,安全性问题),那么N的位置就可以提交
	len := rf.lm.len()
	for N := rf.commitIndex + 1; N <= len; N++ {
		majorityCnt := 1
		for _, matchIndex := range rf.matchIndexes {
			if matchIndex >= N && rf.lm.get(N).Term == rf.currentTerm {
				majorityCnt++
			}
		}
		if majorityCnt > total/2 {
			rf.commitIndex = N // 更新提交Index
		}
	}
	rf.mu.Lock()
	rf.applyCond.Signal() // 唤醒异步apply
	rf.mu.Unlock()
}

Leader 的 commitIndex 会随着心跳和同步请求到达所有的 follower,使 follower 的 commitIndex 也随之更新。当任何节点的 commitIndex 领先于上一次 apply 的 Index 的时候,就可以将这部分 log 向上层进行 apply。

Follower 处理日志同步

follower 对 Leader 发来的日志同步请求进行处理是整个 Raft 算法最核心的地方,因为他决定着 Raft 的同步机制是否能够保持数据一致性。在实现的时候需要遵循安全性规则并且要注意不确定性情况的发生。

func (rf *Raft) RequestAppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
	reply.Term = rf.currentTerm
	if args.Term < rf.currentTerm { // 说明这个leader已经过期
		reply.Success = false
		return
	}
	// 进入新的一个Term,更新
	if args.Term > rf.currentTerm {
		rf.enterNewTerm(args.Term) // 改变votedFor
	} else {
		rf.state = FOLLOWER // 不改变votedFor
	}
	rf.resetElectionTimer() // 刷新选举计时
	rf.mu.Lock()
	defer rf.mu.Unlock()
	if args.PrevLogIndex < rf.commitIndex || rf.findLastLogIndexOfTerm(args.Term) > args.PrevLogIndex { // 说明这个请求滞后了
		reply.XLen = rf.lm.len()
		reply.XTerm = -1
		reply.Success = false
		return
	}
	// 一致性检查
	if args.PrevLogIndex > rf.lm.len() {
		reply.XLen = rf.lm.len()
		reply.XTerm = -1
		reply.Success = false
		return
	}
	if args.PrevLogIndex >= 1 && args.PrevLogIndex > rf.lm.lastTrimmedIndex { // 如果prevIndex已经被裁剪了,那一定不冲突
		if rf.lm.get(args.PrevLogIndex).Term != args.PrevLogTerm {
			// 有冲突了
			reply.XTerm = rf.lm.get(args.PrevLogIndex).Term
			reply.XIndex = rf.findFirstLogIndexOfTerm(reply.XTerm)
			reply.Success = false
			return
		}
	}
	// 这里有可能leader传来的一部分log已经裁掉了,需要过滤一下
	from := max(args.PrevLogIndex+1, rf.lm.lastTrimmedIndex+1)
	filter := min(from-args.PrevLogIndex-1, len(args.Entries)) // 防止越界
	args.Entries = args.Entries[filter:]
	rf.lm.appendFrom(from, args.Entries) // 强制追加(覆盖)日志

	// 提交log
	if args.LeaderCommit > rf.commitIndex {
		rf.commitIndex = min(args.LeaderCommit, rf.lm.len())
	}
	rf.applyCond.Signal() // 唤醒异步apply
	rf.persist()
	reply.Success = true
}

需要考虑选举计时刷新、滞后请求忽略、支持快速恢复、日志一致性检查、日志剪裁边界处理、更新提交 Index 几个方面。

异步 apply 实现

因为 commitIndex 是随时动态变化的,所以 apply 也要跟着随时执行。但是在一个 Raft 内部同一时刻只能有一个 apply 线程,否则会发生冲突。我采用的是异步唤醒机制而不是方法调用去进行 apply。在开启 Raft 之后会新建一个 apply 协程,他会不断地比较 lastApplied 和 commitIndex 的大小,如果前者大于等于后者,说明当前没有需要 apply 的 cmd,协程阻塞;一旦 commitIndex 领先了,更新 commitIndex 的线程会唤醒该协程让他去 apply。这样就算同时有多个线程唤醒 apply,也能够保证幂等性,不会出现重复 apply 的现象。

func (rf *Raft) apply() {
	// 先apply快照
	if rf.lm.lastTrimmedIndex != 0 {
		rf.applySnapshot()
	}
	rf.backupApplied = true
	// 然后再apply剩余log
	for !rf.killed() {
		for rf.lastApplied >= rf.commitIndex {
			// 每次休眠前先看有无快照可apply
			select {
			case index := <-rf.installSnapCh:
				// 这两个操作要保证原子性
				rf.trim(index)
				rf.applySnapshot()
			default:
			}
			rf.mu.Lock()
			rf.applyCond.Wait() // 等待别处唤醒去apply,避免了并发冲突
			rf.mu.Unlock()
		}
		rf.mu.Lock()
		// commitIndex领先了
		applyIndex := rf.lastApplied + 1
		commitIndex := rf.commitIndex
		entries := rf.lm.split(applyIndex, commitIndex+1) // 本轮要apply的所有log
		rf.mu.Unlock()
		for index, log := range entries {
			if applyIndex <= rf.lm.lastTrimmedIndex { // applyIndex落后快照了
				break
			}
			msg := ApplyMsg{
				CommandValid: true,
				Command:      log.Command,
				CommandIndex: applyIndex,
				CommandTerm:  log.Term, // 为了Lab3加的
			}
			rf.applyCh <- msg
			rf.mu.Lock()
			if rf.lastApplied > applyIndex { // 说明snapshot抢先一步了
				rf.mu.Unlock()
				break
			}
			rf.lastApplied = applyIndex
			applyIndex++
			rf.mu.Unlock()
		}
	}
}

在 apply 的过程中需要注意快照的问题,因为快照随时可能到达,快照在 apply 之后不能够在 apply 快照之前的 cmd。此外,宕机重启后如果有已经备份快照也需要先 apply,因此我用了一个 backupApplied 去进行标识。


全部测试通过:
image.png

  • 13
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值