MIT-6.824 LAB2 RAFT算法实现

466 阅读11分钟

MAPREDUCE、GFS、VMVARE FT的不足

三个系统在选主的过程都依赖于一个单主节点,优点是它不可能否认自己,因此我们可以避免脑裂问题,但是缺点是单主节点本身又是一个单点故障问题。无法完全实现多节点故障容错。当单主节点无法工作时,整个系统都无法工作。

脑裂问题

简单来说就是一个分布式系统出现了两个主节点1和2。假设现在有两个客户端1和2当客户端1发送请求到服务端,客户端1设置变量X=1,客户端2设置变量X=2,客户端1请求的可能是主节点1。主节点1将X设置为1,而客户端2请求的可能是主节点2,主节点2将x设置为2,这时候就会出现数据不一致和冲突问题。

解决脑裂问题

  • 构建一个不可能出现故障的网络。
  • 仲裁机制:当两个节点出现分歧时,由第3方的仲裁者决定听谁的。这个仲裁者,可能是一个锁服务,一个共享盘或者其它什么东西,甚至可以是人工仲裁。

Raft算法如何解决脑裂问题

Raft算法通过过半投票选主,且一个任期只能存在一个leader来保证系统不会出现脑裂问题。

Raft算法相比与其他分布式共识算法的不同一些特性

  • Strong Leader(强领导性):相比于其他算法,Raft使用了更强的领导形式,比如日志条目只能从leader流向follower,follower无条件遵循leader发起的日志复制。
  • Leader Election(领导选举):Raft使用随机计时器来进行领导选举。任何共识算法都需要心跳机制,Raft只需要在这个基础上,添加少量机制,就可以快速简单地解决冲突。
  • Membership changes(成员变更):Raft在更改集群中服务器地机制中使用了一个联合共识地方法,在联合共识下,在集群配置的转换过程中,新旧两种配置大多数是重叠的,这使得集群在配置更改期间可以继续正常继续运行。
  • 通过减少状态的数量简化状态空间,通过随机化方法见识少状态空间,尽管引入了不确定性,但是往往能够提供算法的可理解性和减少状态空间。

复制状态机

复制状态机是用来解决分布式系统的中的各种容错问题。复制状态机通常都是使用日志复制来实现,每个服务器都保存着一份拥有一系列命令的日志,然后服务器上的状态机会按顺序置性日志中的命令,每一份日志中命令相同并且顺序也相同,因此每个状态机可以处理相同的命令序列。所以状态机是可以确定的,每个状态机都置性相同的状态和相同的输出序列。
共识算法的主要工作就是保证复制日志的一致性。每台服务器上的共识模块接收来自客户端的命令,并将这些命令添加到其气质当中。共识模块与其他服务器上的共识模块进行通信,以确保每台服务器上最终以相同的顺序包含相同的命令,即使部分服务器崩溃了,这个条件也可以满足。一旦命令被正确复制,每台服务器上的状态机就会按日志顺序处理它们,并将输出返回给客户端。这样就形成了高可用的复制状态机。

Raft共识算法的三大问题

Leader Election(领导选举)

一个leader倒下之后,一定会有一个新的leader站起来。

Log replication(日志复制)

leader 必须接收来自客户端的日志条目然后复制到集群中的其他节点,并且强制其他节点的日志和自己的保持一致。

Safety(安全性)

Raft 中安全性的关键是图 3 中状态机的安全性:只要有任何服务器节点将一个特定的日志条目应用到它的状态机中,那么其他服务器节点就不能在同一个日志索引位置上存储另外一条不同的指令。

实验过程中的思考:

  • 避免产生死锁,在发送RPC请求或等待通道接收发送前要解锁。
  • 避免使用细粒度的锁
  • 抛弃过期请求的回复:对于过期请求的回复,直接抛弃就行,不要做任何处理,这一点 guidance 里面也有介绍到。
  • 当前的leader不能提交过去任期的日志,只能通过提交当前任期的日志同时,间接提交之前任期的日志。 在处理RequestVote或者AppendEntry的RPC请求时,在RPC开始的时候上锁,在RPC解锁解锁。原因:保证整个处理RPC请求的原子性,避免在过程中Raft状态发生改变。

一个节点选举为leader后怎么通知其他candidate和follower

发送一个AppendEntries空心跳通知其他candidate和follower本轮该节点已称为leader,其他的节点都转变为follower

一个节点怎么判断当前可以开启选举?

心跳时间内无法收到心跳且状态不为leader或者candiate即可开启选举

如何确保不同 Peers 不会在同一时间选举超时

使用随机的选举超时时间,保证不同Peer不会在同一时间开启选举

一个leader节点失去连接重新选举leader后又重新连接怎么解决

一个leader节点可能因为网络原因迟迟没有发送心跳,重新选举新的leader会将版本号+1,如果过了一段时间,又收到了之前leader发送的心跳,由于版本号小于当前的版本号,会认为此次心跳无效,并返回给旧leader新的版本号。

三个节点断开两个节点,一个节点断开连接后开启选举,之后重新连接后另一个节点也在选举等待,同时等待另一个坏节点响应

设置选举超时机制,candidate的选举时间不应该超过3s,当两个节点同时给自己投票时,选举时间超过3s就会默认为选举失败,随机休眠一段时间后重新开始选举。

如何保证节点断开后重连保持与leader的日志一致

通过发送心跳,验证节点日志和leader日志是否一致,如果不一致,leader nextIndex[peer]--.重试发送rpc直到节点日志和leader日志一致。
性能优化:验证节点日志和leader日志不一致,follower 可以将包含冲突条目的任期号ConflictTerm和自己存储的那个任期的ConflictIndex返回给leader。leader直接将nextIndex[peer]移动到ConflictIndex,减少了RPC的发送次数。

LAB2B 日志复制

复制模型: Raft节点为每一个网络上的其他节点创建一个Relicator协程进行日志的复制。当Raft节点为follower时,该协程利用条件变量cond执行wait来避免耗费CPU,并等待变成leader时再被唤醒。对于leader节点,该协程负责最大努力去向对于follower发送日志使其同步,直到该节点不再是 leader 或者该 follower 节点的 matchIndex 大于等于本地的 lastIndex。
好处: 将上层提交新命令与日志复制解耦,对命令提交做批处理优化。当客户端有大量的命令并发提交时,我们不用每一次都去调用日志复制。Relicator协程会一次性将所有已提交的命令通过RPC发送给其他节点。减少传输的数据量和发送RPC的次数。

LAB2C 持久化

需要持久化的数据:

  • CurrentTerm 当前的版本号
  • Log 日志
  • VoteFor 投票给谁
  • LastIncludeIndex 日志压缩的最大下标
  • LastIncludeTerm 日志压缩的任期号 每当这5个数据改变后,我们都会持久化,保证了数据的安全,但是性能较低。当节点宕机后,节点会根据持久化的5个数据以及保存的快照快速恢复节点的状态。 为什么需要持久化VoteFor? 原因: 防止同一任期产生两个Leader

LAB2D日志压缩

  • 生成快照日志压缩的过程:因为leader状态机一定是最新的状态,所以首先由leader节点server生成快照,并传给下层raft进行持久化。leader节点的raft层再将快照通过InstallSnapshot RPC通知其他follower raft层保存快照。follower raft层再通过applier将快照提交到该节点的客户端。客户端收到新的快照后应用新的快照。
  • 日志压缩调用链路: leader节点客户端生成快照-----》leader节点raft层持久化------》follower节点raft层验证快照的合法性并保存快照----》follower节点raft层将快照提交给客户端-------》客户端应用新的快照
  • 日志压缩防止内存泄漏: 我们删除已被压缩的日志不能只仅仅对日志进行截取。因为这样的话,切片的底层数组仍然会对已被压缩的日志引用。那么内存就不会被释放。因此,我们需要将未被压缩的日志转移到另一个新的切片。这样旧切片才会被回收器回收。
func shrinkEntriesArray(logs []Entry) []Entry {
	newLogs := make([]Entry, 0)
	newLogs = append(newLogs, logs...)
	return newLogs
}

leader上任后必须提交一条no-op日志

作用:解决幽灵复现问题和特殊场景下长时间读服务无法提供问题

读服务无法提供问题

考虑这样一个场景:三节点的集群,节点 1 是 leader,其 logs 是 [1,2],commitIndex 和 lastApplied 是 2,节点 2 是 follower,其 logs 是 [1,2],commitIndex 和 lastApplied 是 1,节点 3 是 follower,其 logs 是 [1],commitIndex 和 lastApplied 是 1。即节点 1 将日志 2 发送到了节点 2 之后即可将日志 2 提交。此时对于节点 2,日志 2 的提交信息还没有到达;对于节点 3,日志 2 还没有到达。显然这种场景是可以出现的。此时 A 作为 leader 自然可以处理读请求,即此时的读请求是对于 index 为 2 的状态机做的。接着 leader 节点进行了宕机,两个 follower 随后会触发选举超时,但由于节点 2 日志最新,所以最后一定是节点 2 当选为 leader,节点 2 当选之后,其会首先判断集群中的 matchIndex 并确定 commitIndex,即 commitIndex 为 1,此时如果客户端没有新地写请求过来,这个 commitIndex 就一直会是 1,得不到更新。那么在此期间,如果又有读请求路由到了节点 2,如果其直接执行了读请求,显然是不满足线性一致性的,因为状态机的状态是更旧的。因此,其需要首先判断当前 term 有没有提交过日志,如果没有,则应该等待当前 term 提交过日志之后才能处理读请求。而当前 term 提交日志又是与客户端提交命令绑定的,客户端不提交命令,当前 term 就没有新日志,那么就一直不能处理读请求。因此,leader 上任后应该提交一条空日志来提交之前的日志。

幽灵复现问题

mp.weixin.qq.com/s__biz=MzIz…
幽灵复现问题简单说就是客户端在之前查询不到的日志一会之后又重新出现了。 假设有3个节点A、B、C,leader节点为A,A拥有1-10的日志。但是只有1-5的日志达成一致。B和C拥有1-5的日志。此时A节点宕机了。第二轮B成为了leader。B写入了6和20的日志。而7-19的日志空缺。第三轮A恢复,重新当选leader,于是A向B、C重新发送7-10的日志。这时第二轮中客户端无法查询的7-10的日志又会重新出现。这就是幽灵复现问题。

另一种幽灵复现问题:

image.png

Raft算法如何解决该问题?

首先,在Raft算法中第三轮A不可能会当选leader,因为节点A的日志不是最新的。同时第二轮B 7-19的日志是不可能空缺的。因为Raft算法除了no-op日志外不允许存在空日志。节点A宕机后7-10的日志未能达成一致,日志在第二轮会重新发送给leader节点B。