实现Raft协议:Part 1 - 选主

翻译自Eli Bendersky系列博客,已得到原做者受权。git

本文是系列文章中的第一部分,本系列文章旨在介绍Raft分布式一致性协议及其Go语言实现。文章的完整列表以下:github

在这一部分,我会介绍咱们的Raft实现代码的结构,并重点介绍算法的选主部分。本文的代码包括一个全功能的测试工具和一些您能够用来测试系统的案例。可是它不会响应客户端的请求,也很差维护日志,这些功能会在第2部分添加。算法

代码结构

简单介绍一下Raft实现的代码结构,本系列的全部部分都是通用的。安全

一般来讲,Raft都被实现为一个可嵌入某些服务的对象。由于咱们不会真正地开发一个服务,只是研究Raft协议自己,因此建立了一个简单的Server类型,其中包装了一个ConsensusModule类型,以期尽量隔离出代码中最有趣的部分:服务器

Architecture of a consensus module embedded into a server

一致性模块(CM)实现了Raft算法的核心,在raft.go文件中。该模块从网络细节以及与集群中其它副本的链接中完成抽象出来,ConsensusModule中与网络相关的惟一字段就是:网络

// id 是一致性模块中的服务器ID
id int

// peerIds 是集群中全部同伴的ID列表
peerIds []int

// server 是包含该CM的服务器. 该字段用于向其它同伴发起RPC调用
server *Server
复制代码

在实现过程当中,每一个Raft副本将集群中的其它副本称为“同伴”。集群中的每一个同伴都有一个惟一的数值ID,以及记录其同伴ID的列表。server字段是指向模块所在Server*(在server.go中实现)的指针,后者能够容许ConsensusModule将消息发送给同伴。稍后咱们将看到这是如何完成的。数据结构

这样设计的目的就是要将全部的网络细节排除在外,从而专一于Raft算法自己。总之,要将Raft论文对照到本实现的话。你只须要ConsensusModule类及其方法。Server代码是一个很是简单的Go语言网络框架,有一些细微的复杂之处来应对严格的测试。本系列文章中,我不会花时间讨论它。可是若是有什么不清楚的地方,请随意提问。并发

Raft服务器状态

总的来讲,Raft CM就是一个具备3个状态的状态机[1]框架

Raft high level state machine

由于在序言中 花费了不少篇幅解释Raft如何帮助实现状态机,因此对这里可能会有一点困惑,可是必须说明一下,这里的术语*状态含义是不一样的。Raft是一个实现任意复制状态机的算法,可是Raft内部也包含一个小的状态机。后面的章节中,某个地方的状态*是什么含义均可以结合上下弄清楚——若是不能的话,我确定是指出来的。分布式

在一个典型的稳态场景中,集群中有一个服务器是领导者,而其它副本都是追随者。尽管咱们很但愿系统能够一直这样运行下去,可是Raft协议的目标就是容错。所以,咱们会花费大部分时间来讨论一下非典型的故障场景,如某些服务器崩溃,其它服务器断开链接,等等。

以前提到过,Raft使用的是强领导模型。领导者响应客户端请求,向日志中添加新条目并将其复制给其它追随者。每一个追随者随时准备接管领导权,以防领导者故障或中止通讯。这也就是上图中从追随者候选人(Candidate)的转变(“等待超时,开始选举”)。

任期(Terms)

就是正常的选举同样,Raft中也有任期。任期指的就是某一服务器做为领导者的一段时间。新的选举会触发新的任期,并且Raft算法保证在给定的任期中只有一个领导者。

可是这个比喻就到此为止吧,由于Raft中的选主跟真正的选举区别仍是很大的。在Raft中,选举是更具协同性的,候选者的目标不是要不惜一切代价赢得选举——全部候选人有一个共同的目标,那就是在任意给定的任期都有合适的服务器赢得选举。什么稍后会详细讨论”合适“的含义。

选举定时器

Raft算法中的一个关键组成部分就是选举定时器。这是每一个追随者都会持续运行的定时器,每次接收到当前领导者的消息时就从新启动它。领导者会发送周期性的心跳,所以当追随者接收不到这些心跳信号时,他会认为当前领导者出现故障或者断开链接,并开始新一轮选举(切换为候选者状态)。

:全部的追随者不会同时变成候选人吗?

:选举定时器是随机的,这也是Raft协议保持简单性的关键之一。Raft经过这种随机化来下降多个追随者同时进行选举的可能性。可是即使它们在同一时刻变成候选人,在任何一个任期内只有一个服务器会被选为领导者。在极少数状况下,会出现投票分裂致使没有候选人获胜,此时将进行新一轮的选举(使用新的任期)。虽然在理论上有可能会永远在从新选举,可是每多一轮选举,发生这种状况的几率会大大下降。

:若是追随者从集群中断开链接(分区)怎么办?它不会由于没有收到领导者的消息而开始选举吗?

:这就是网络分区问题的隐蔽性,由于追随者没法区分谁被分割了。确实,这个追随者会开启新一轮选举。可是,若是这个追随者被断开链接,那么此次选举也会无果而终——由于它没法联系到其它同伴,也就不会得到任何选票。它可能会在候选者状态一直自旋(每隔一段时间就开启新一轮的选举)直到从新接入集群中。稍后咱们会详细讨论这种状况。

同伴间RPC

Raft协议中,同伴间会发送两类RPC请求。详细的参数和规则能够参考论文中的Figure 2,或者本文的附录。这里简单说明一下两种请求:

  • RequestVote(RV):只有候选人状态下会使用。在一轮选举中,候选人经过该接口向同伴请求选票。返回值中包含是否赞成投票的标志。
  • AppendEntries(AE):只有领导者状态下使用。领导者经过该RPC将日志条目复制给追随者,也用来发送心跳。即便没有要复制的日志条目,也会按期向追随者发送该RPC请求。

明眼人可能看出来追随者不会发送任何的RPC请求。这是对的,追随者不会向同伴发起RPC请求,可是它们在后台会运行一个选举定时器。若是定时器结束以前没有接收到当前领导者的信息,追随者就变成候选人并开始发送RV请求。

实现选举定时器

是时候开始研究代码了。除非特别说明,不然下面展现的全部代码示例都出自这个文件。我不会把ConsensusModule结构体的全部字段——你能够在代码文件中去查看。

咱们的CM模块经过在goroutime中执行如下函数来实现选举定时器:

func (cm *ConsensusModule) runElectionTimer() {
    timeoutDuration := cm.electionTimeout()
    cm.mu.Lock()
    termStarted := cm.currentTerm
	cm.mu.Unlock()
	cm.dlog("election timer started (%v), term=%d", timeoutDuration, termStarted)

    /* 循环会在如下条件结束: 1 - 发现再也不须要选举定时器 2 - 选举定时器超时,CM变为候选人 对于追随者而言,定时器一般会在CM的整个生命周期中一直在后台运行。 */
    ticker := time.NewTicker(10 * time.Millisecond)
	defer ticker.Stop()
	for {
		<-ticker.C

		cm.mu.Lock()
        // CM再也不须要定时器
		if cm.state != Candidate && cm.state != Follower {
			cm.dlog("in election timer state=%s, bailing out", cm.state)
			cm.mu.Unlock()
			return
		}
		
        // 任期变化
		if termStarted != cm.currentTerm {
			cm.dlog("in election timer term changed from %d to %d, bailing out", termStarted, cm.currentTerm)
			cm.mu.Unlock()
			return
		}

		// 若是在超时以前没有收到领导者的信息或者给其它候选人投票,就开始新一轮选举
		if elapsed := time.Since(cm.electionResetEvent); elapsed >= timeoutDuration {
			cm.startElection()
			cm.mu.Unlock()
			return
		}
		cm.mu.Unlock()
	}
}
复制代码

首先经过调用cm.electionTimeout()选择一个(伪)随机的选举超时时间,咱们这里根据论文的建议将范围设置为150ms到300ms。像ConsensusModule中的大多数方法同样,runElectionTimer在访问属性时会先锁定结构体对象。这一步是必不可少的,由于咱们要尽量地支持并发,而这也是Go的优点之一。这也意味着代码须要顺序执行,而不能分散到多个事件处理程序中。不过,RPC请求同时也在发生,因此咱们必须保护共享数据结构。咱们后面会介绍RPC处理器。

主循环中运行了一个周期为10ms的ticker。还有更有效的方法能够实现等待事件,可是使用这种写法的代码是最简单的。每过10ms都会执行一次循环,理论上说定时器能够在整个等待过程当中sleep,可是这样会致使服务响应速度降低,并且日志中的调试/跟踪操做会更困难。咱们会检查状态是否跟预期一致[2],以及任期有没有改变,若是有任何问题,咱们就中止选举定时器。

若是距离上一次”选举重置事件“时间过长,服务器会开始新一轮选举并变成候选人。什么是选举重置事件?能够是任何可以终止选举的事件——好比,收到了有效的心跳信息,为其它候选人投票。咱们很快会看到这部分代码。

成为候选者

前面提到,若是追随者在一段时间内没有收到领导者或其它候选人的信息,它就会开始新一轮的选举。在查看代码以前,咱们先思考一下进行选举须要作哪些事情:

  1. 将状态切换为候选人并增长任期,由于这是算法对每次选举的要求。
  2. 发送RV请求给其它同伴,请他们在本轮选举中为本身投票。
  3. 等待RPC请求的返回值,并统计咱们是否得到足够多的票数成为领导者。

在Go语言中,这个逻辑能够在一个函数中完成:

func (cm *ConsensusModule) startElection() {
  cm.state = Candidate
  cm.currentTerm += 1
  savedCurrentTerm := cm.currentTerm
  cm.electionResetEvent = time.Now()
  cm.votedFor = cm.id
  cm.dlog("becomes Candidate (currentTerm=%d); log=%v", savedCurrentTerm, cm.log)

  var votesReceived int32 = 1

  // 向其它全部服务器发送RV请求
  for _, peerId := range cm.peerIds {
    go func(peerId int) {
      args := RequestVoteArgs{
        Term:        savedCurrentTerm,
        CandidateId: cm.id,
      }
      var reply RequestVoteReply

      cm.dlog("sending RequestVote to %d: %+v", peerId, args)
      if err := cm.server.Call(peerId, "ConsensusModule.RequestVote", args, &reply); err == nil {
        cm.mu.Lock()
        defer cm.mu.Unlock()
        cm.dlog("received RequestVoteReply %+v", reply)

        // 状态不是候选人,退出选举(可能退化为追随者,也可能已经胜选成为领导者)
        if cm.state != Candidate {
          cm.dlog("while waiting for reply, state = %v", cm.state)
          return
        }

        // 存在更高任期(新领导者),转换为追随者
        if reply.Term > savedCurrentTerm {
          cm.dlog("term out of date in RequestVoteReply")
          cm.becomeFollower(reply.Term)
          return
        } else if reply.Term == savedCurrentTerm {
          if reply.VoteGranted {
            votes := int(atomic.AddInt32(&votesReceived, 1))
            if votes*2 > len(cm.peerIds)+1 {
              // 得到票数超过一半,选举获胜,成为最新的领导者
              cm.dlog("wins election with %d votes", votes)
              cm.startLeader()
              return
            }
          }
        }
      }
    }(peerId)
  }

  // 另行启动一个选举定时器,以防本次选举不成功
  go cm.runElectionTimer()
}
复制代码

候选人首先给本身投票——将votesReceived初始化为1,并赋值cm.votedFor = cm.id

而后并行地向全部的同伴发送RPC请求。每一个RPC都是在各自的goroutine中完成的,由于咱们的RPC调用的同步的——程序会阻塞至收到响应为止,这可能须要一段时间。

这里正好能够演示一下RPC是如何实现的:

cm.server.Call(peerId, "ConsensusModule.RequestVote", args, &reply);
复制代码

咱们使用ConsensusModule.server中保存的Server指针来发起远程调用,并指定ConsensusModule.RequestVotes做为请求的方法名,最终会调用第一个参数指定的同伴服务器中的RequestVote方法。

若是RPC调用成功,由于已通过去了一段时间,咱们必须检查服务器状态来决定下一步操做。若是咱们的状态不是候选人,退出。何时会出现这种状况呢?举例来讲,咱们可能由于其它RPC请求返回了足够多的票数而胜选成为领导者,或者某个RPC请求从其它服务器收到了更高的任期,所以咱们退化为跟随者。必定要要记住,在网络不稳定的状况下,RPC请求可能须要很长时间才能到达——当咱们收到回复时,可能其它代码已经继续执行了,在这种状况下优雅地放弃很是重要。

若是收到回复时咱们仍是候选人状态,先检查回复信息中的任期并与咱们发送请求时的任期进行比较。若是返回信息中的任期更高,咱们就恢复到追随者状态。例如,咱们在收集选票时其它服务器胜选就会出现该状况。

若是返回的任期与咱们发送时一致,检查是否同意投票。咱们使用原子变量votes从多个goroutine中安全地收集选票,若是服务器收到了大多数的同意票(包括本身的同意票),就变成领导者。

注意这里的startElection方法是非阻塞的。方法中会更新一些状态,启动一批goroutine并返回。所以,还应该在goroutine中启动新的选举定时器——也就是最后一行代码所作的事。这样能够保证,若是本轮选举没有任何结果,在定时结束后会开始新一轮的选举。这也解释了runElectionTimer中的状态检查:若是本轮选举确实将该服务器变成了领导者,那么并发运行的runElectionTimer在观察到服务器状态与指望值不一样时会直接返回。

成为领导者

咱们已经看到,当投票结果显示当前服务器胜选时,startElection中会调用startLeader方法,其代码以下:

func (cm *ConsensusModule) startLeader() {
  cm.state = Leader
  cm.dlog("becomes Leader; term=%d, log=%v", cm.currentTerm, cm.log)

  go func() {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()

    // 只要当前服务器是领导者,就要周期性发送心跳
    for {
      cm.leaderSendHeartbeats()
      <-ticker.C

      cm.mu.Lock()
      if cm.state != Leader {
        cm.mu.Unlock()
        return
      }
      cm.mu.Unlock()
    }
  }()
}
复制代码

这其实是一个至关简单的方法:全部的内容就是心跳定时器——只要当前的CM是领导者,这个goroutine就会每隔50ms调用一次leaderSendHeartbeats。下面是leaderSendHeartbeats对应的代码:

func (cm *ConsensusModule) leaderSendHeartbeats() {
  cm.mu.Lock()
  savedCurrentTerm := cm.currentTerm
  cm.mu.Unlock()

  // 向全部追随者发送AE请求
  for _, peerId := range cm.peerIds {
    args := AppendEntriesArgs{
      Term:     savedCurrentTerm,
      LeaderId: cm.id,
    }
    go func(peerId int) {
      cm.dlog("sending AppendEntries to %v: ni=%d, args=%+v", peerId, 0, args)
      var reply AppendEntriesReply
      if err := cm.server.Call(peerId, "ConsensusModule.AppendEntries", args, &reply); err == nil {
        cm.mu.Lock()
        defer cm.mu.Unlock()
        // 若是响应消息中的任期大于当前任期,则代表集群有新的领导者,转换为追随者
        if reply.Term > savedCurrentTerm {
          cm.dlog("term out of date in heartbeat reply")
          cm.becomeFollower(reply.Term)
          return
        }
      }
    }(peerId)
  }
}
复制代码

这里的逻辑有点相似于startElection,为每一个同伴启动一个goroutine来发送RPC请求。这里的RPC请求是没有日志内容的AppendEntries(AE),在Raft中扮演心跳的角色。

与处理RV的响应时相同,若是RPC返回的任期高于咱们本身的任期值,则当前服务器变为追随者。这里正好查看一下becomeFollower方法:

func (cm *ConsensusModule) becomeFollower(term int) {
  cm.dlog("becomes Follower with term=%d; log=%v", term, cm.log)
  cm.state = Follower
  cm.currentTerm = term
  cm.votedFor = -1
  cm.electionResetEvent = time.Now()

  // 启动选举定时器
  go cm.runElectionTimer()
}
复制代码

该方法中首先将CM的状态变为追随者,并重置其任期和其它重要的状态属性。这里还启动了一个新的选举定时器,由于这是每一个追随者都要在后台运行的任务。

应答RPC请求

到目前为止,咱们已经看到了实现代码中的主动部分——启动RPC、计时器以及状态转换的部分。可是在咱们看到服务器方法(其它同伴远程调用的过程)以前,演示的代码都是不完整的。咱们先从RequestVote开始:

func (cm *ConsensusModule) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) error {
  cm.mu.Lock()
  defer cm.mu.Unlock()
  if cm.state == Dead {
    return nil
  }
  cm.dlog("RequestVote: %+v [currentTerm=%d, votedFor=%d]", args, cm.currentTerm, cm.votedFor)

  // 请求中的任期大于本地任期,转换为追随者状态
  if args.Term > cm.currentTerm {
    cm.dlog("... term out of date in RequestVote")
    cm.becomeFollower(args.Term)
  }

  // 任期相同,且未投票或已投票给当前请求同伴,则返回同意投票;不然,返回反对投票。
  if cm.currentTerm == args.Term &&
    (cm.votedFor == -1 || cm.votedFor == args.CandidateId) {
    reply.VoteGranted = true
    cm.votedFor = args.CandidateId
    cm.electionResetEvent = time.Now()
  } else {
    reply.VoteGranted = false
  }
  reply.Term = cm.currentTerm
  cm.dlog("... RequestVote reply: %+v", reply)
  return nil
}
复制代码

注意这里检查了“dead”状态,稍后会讨论这一点。

首先是一段熟悉的逻辑,检查任期是否过期并转换为追随者。若是它已是一个追随者,状态不会改变可是其它状态属性会重置。

不然,若是调用者的任期与咱们一致,并且咱们还没有给其它候选人投票,那咱们就同意该选票。咱们决不会向旧任期发起的RPC请求投票。

下面是AppendEntries的代码:

func (cm *ConsensusModule) AppendEntries(args AppendEntriesArgs, reply *AppendEntriesReply) error {
  cm.mu.Lock()
  defer cm.mu.Unlock()
  if cm.state == Dead {
    return nil
  }
  cm.dlog("AppendEntries: %+v", args)

  // 请求中的任期大于本地任期,转换为追随者状态
  if args.Term > cm.currentTerm {
    cm.dlog("... term out of date in AppendEntries")
    cm.becomeFollower(args.Term)
  }

  reply.Success = false
  if args.Term == cm.currentTerm {
    // 若是当前状态不是追随者,则变为追随者
    if cm.state != Follower {
      cm.becomeFollower(args.Term)
    }
    cm.electionResetEvent = time.Now()
    reply.Success = true
  }

  reply.Term = cm.currentTerm
  cm.dlog("AppendEntries reply: %+v", *reply)
  return nil
}
复制代码

这里的逻辑也跟论文的图2中的选主部分一致,须要理解的一个复杂点在于:

if cm.state != Follower {
  cm.becomeFollower(args.Term)
}
复制代码

Q:若是服务器是领导者呢——为何要变成其它领导者的追随者?

A:Raft协议保证了在任一给定的任期都只有惟一的领导者。若是你本身研究RequestVote的逻辑,以及startElection中发送RV请求的代码,你会发如今集群中不会有两个使用相同任期的领导者存在。这个条件对于那些发现其它同伴赢得本轮选举的候选人很重要。

状态和goroutine

咱们有必要回顾一下CM中可能存在的全部状态,以及其对应运行的不一样goroutine:

追随者:当CM被初始化为追随者,或者每次执行becomeFollower方法时,都会启动新的goroutine运行runElectionTimer,这是追随者的附属操做。请注意,在短期内可能同时运行多个选举定时器。假设一个追随者收到了领导者发出的带有更高任期的RV请求,这将触发一次becomeFollower调用并启动一个新的定时器goroutine。可是旧的goroutine只要注意到任期的变化就会天然退出。

候选人:候选人也有一个并行运行的选举定时器goroutine,可是除此以外,它还有一些发送RPC请求的goroutine。它与追随者具备相同的保护措施,能够在新选举开始时中止”旧“的选举goroutine。必定要记住,RPC goroutine可能须要很长时间才能完成,所以,若是RPC调用返回时,它们发现自身的任期已通过时,那么它们必须安静地退出。

领导者:领导者没有选举定时goroutine,可是它确定有一个每隔50ms执行一次的心跳goroutine。

代码中还有一个附加的状态——Dead状态。这纯粹是为了有序关闭CM。调用”Stop“方法会将状态置为Dead,全部的goroutine在观察到该状态后会当即退出。

这些goroutine的运行可能会让人担心——若是其中一些goroutine滞留在后台,该怎么办?或者出现更糟的状况,这些goroutine不断泄漏并且数量无限制地增加,怎么办?这也就是泄漏检查的目的,并且一些测试案例中也启用了泄漏检查。这些测试中会执行很是规的一系列Raft选举操做,并保证在测试结束后没有任何游离的goroutine在运行(在调用stop方法以后,给这些goroutine一些时间去退出)。

服务器失控和增长任期

做为这一部分的总结,咱们来研究一个可能出现的复杂场景以及Raft如何应对。我以为这个例子颇有趣,也颇有启发性。这里我试图以故事的方式来呈现,可是你最好有一张纸来记录各服务器的状态。若是你无法理解这个示例——请发邮件告知我,我很乐意将它改得更清楚一些。

想象一个有三台服务器A,B和C的集群。假设A是领导者,起始任期是1,而且集群正在完美运行着。A每隔50ms都想B、C发一次心跳AE请求,并在几毫秒内获得及时响应。每一次的AE请求都会重置B、C中的electionResetEvent属性,所以它们也都很愿意继续作追随者。

在某个时刻,因为网络路由器的临时故障,服务器B与A、C之间出现了网络分区。A仍然每隔50ms发一次AE请求,可是这些AE要求要么当即失败,或者是因为底层RPC引擎的超时致使失败。A对此无能为力,可是问题也不大。咱们目前尚未涉及到日志复制,可是由于3台服务器中的2台都是正常的,集群仍然能够提交客户端指令。

那么B呢?假设在断开链接的时候,它的选举超时设置为了200ms。在断开链接大约200ms后,B的runElectionTimer会意识到在选举等待时间内没有收到领导者的信息,B没法区分是谁出了错,因此它就变为了候选者并开启一轮选举。

所以B的任期将变为2(而A和C的任期仍然是1)。B会向A和C发送RV请求,要求他们为本身投票;固然,这些请求会丢失在网络中。不要惊慌!B中的startElection方法也启动了另外一个goroutine执行runElectionTimer任务,假设这个goroutine会等待250ms(要记住,咱们的超时时间是在150ms-300ms之间随机选择的),以查看上一轮选举是否出现实质性的结果。由于B仍然被彻底隔离,也就不会发生什么,所以runElectionTimer会发起另外一轮选举,并将任期增长到3。

如此这般,B的服务器在几秒钟以后自我重置并从新上线,与此同时,B因为每隔一段时间都发起选举,它的任期已经变为8。

这时网络分区问题已经修复,B从新链接到了A和C。

不久以后,A发送的AE请求到了。回想一下,A每隔50ms都会发送心跳信息,即便B一直没有回复。

B的AppendEntries被调用,而且回复信息中携带的任务为8.

A在leaderSendHeartbeats方法中收到此回复,检查回复信息中的任期后发起比本身的任期更高。A将自身的任期改成8并变成追随者。集群暂时失去了领导者。

接下来根据定时的不一样,可能会出现多种状况。B是候选者,可是它可能在网络恢复以前已经发送了RV请求;C是追随者,可是因为在选举超时内没有收到A的AE请求,也会变成候选人;A变成了追随者,也可能由于选举超时变成候选人。

因此其中任何一个服务器均可能在下一轮的选举中胜选,注意,这只是由于咱们在这里并无复制任何日志。咱们将在下一部分看到,实际状况下,A和C可能会在B离线的时候添加了一些写的客户端指令,所以它们的日志是最新的。所以,B不会变成新的领导者——会出现新的一轮选举,并且A或C会胜选。在下一部分咱们会再次讨论这个场景。

假如在B断开链接以后没有新增任何指令,则从新链接以后更换领导者也是彻底能够的。

看起来可能有些效率低下——确实如此。这里更换领导者是没必要要的,由于A在整个场景中都是很是健康的。可是,以牺牲特殊状况下的效率为代价,保证算法逻辑的简单性,这也是Raft作出的选择之一。算法在通常情形(没有任何异常)下的效率更重要,由于集群99.9%的时间都处于该状态。

下一步

为了确保你对实现的理解不只仅局限在理论,我强烈建议你运行一下代码

代码库中的README文件对于代码交互、运行测试用例、观察结果提供了详细的说明。代码中附带了不少针对特定场景的测试(包括前面章节中提到的场景),运行一个测试用例并查看Raft日志对于学习颇有意义。注意到代码中调用的cm.dlog(...)了吗?仓库中提供了一个工具能够将这些日志在HTML文件中进行可视化——能够在README文件中查看说明。运行代码,查看日志,也能够在代码中随意添加本身的dlog,以便更好地理解代码中的不一样部分是在什么时候运行的。

本系统的第2部分会描述更完整的Raft实现,其中处理了客户端的指令,并在集群中复制这些日志。敬请关注!

附:

Raft论文中的图2以下所示,这里对其作简要的翻译及说明。其中有部分关于日志复制和提交的,能够在看完下一篇以后从新对照理解。

Raft-RPC参数

状态 State

服务器中的状态字段有三类,分别进行介绍。

全部服务器中都须要持久化保存的状态(在响应RPC请求以前须要更新到稳定的存储介质中)

字段 说明
currentTerm 服务器接收到的最新任期(启动时初始化为0,单调递增)
votedFor 在当前任期内收到赞同票的候选人ID(若是没有就是null)
log[] 日志条目;每一个条目中 包含输入状态机的指令,以及领导者接收条目时的任期(第一个索引是1)

全部服务器中常常修改的状态字段:

字段 说明
commitIndex 确认已提交的日志条目的最大索引值(初始化为0,单调递增)
lastApplied 应用于状态机的日志条目的最大索引值(初始化为0,单调递增)

领导者服务器中常常修改的状态字段(选举以后从新初始化):

字段 说明
nextIndex[] 对于每一个服务器,存储要发送给该服务器的下一条日志条目的索引(初始化为领导者的最新日志索引+1)
matchIndex[] 对于每一个服务器,存储确认复制到该服务器的日志条目的最大索引值(初始化为0,单调递增)

AE请求

AE请求即AppendRntries请求,由领导者发起,用于向追随者复制客户端指令,也用于维护心跳。

请求参数
参数 说明
term 领导者的任期
leaderId 领导者ID,追随者就能够对客户端进行重定向
prevLogIndex 紧接在新日志条目以前的条目的索引
prevLogTerm prevLogIndex对应条目的任期
entries[] 须要报错的日志条目(为空时是心跳请求;为了高效可能会发送多条日志)
leaderCommit 领导者的commitIndex
返回值
参数 说明
term currentTerm,当前任期,回复给领导者。领导者用于自我更新
success 若是追随者保存了prevLogIndexprevLogTerm相匹配的日志条目,则返回true
接收方实现:
  1. 若是term < currentTerm,返回false
  2. 若是日志中prevLogIndex对应条目的任期与prevLogTerm不匹配,返回false
  3. 若是本地已存在的日志条目与新的日志冲突(索引相同,可是任期不一样),删除本地已存在的条目及其后全部的条目;
  4. 追加全部log中未保存的新条目;
  5. 若是leaderCommit > commitIndex,就将commitIndex设置为leaderCommit和最新条目的索引中的较小值。

RV请求

候选人执行,用于在发起选举时收集选票。

请求参数
字段 说明
term 候选人的任期
candidateId 请求选票的候选人ID
lastLogIndex 候选人的最新日志条目对应索引
lastLogTerm 候选人的最新日志条目对应任期
返回值
字段 说明
term currentTerm,当前任期,回复给候选人。候选人用于自我更新
voteGranted true表示候选人得到了同意票
接收方实现:
  1. 若是term < currentTerm返回false
  2. 若是votedFor为空或等于candidateId,而且候选人的日志至少与接收方的日志同样新,投出同意票。

服务器响应规则

按照服务器当下的状态(所处的角色),分别进行介绍:

全部服务器:
  • 若是commitIndex > lastApplied:增长lastApplied,将log[lastApplied]应用到状态机;
  • 若是RPC请求或者响应中携带的任期T知足T > currentTerm:设置currentTerm = T,转换为追随者。
追随者:
  • 响应候选人和领导者的RPC请求;
  • 若是超时等待时间内没有收到当前领导者的AE请求或者给候选人投出选票:转换为候选人。
候选人:
  • 刚转换为候选人时,启动选举:
    • 增长当前任期,currentTerm
    • 给本身投票
    • 重置选举定时器
    • 向其它全部服务器发送RV请求
  • 若是接收到多数服务器的同意票:变成领导者
  • 若是接收到新领导者发出的AE请求:转换为追随者
  • 若是选举等待超时:开始新一轮选举
领导者:
  • 当选时:向每一个服务器发送初始化的空AE请求(心跳);在空闲时间也重复发送AE请求,防止追随者出现等待超时;
  • 若是从客户端接收到指令:向本地日志中追加条目,在新指令被应用到状态机以后响应客户端;
  • 若是最新的日志索引index与追随者下一条日志索引nextIndex 知足 index ≥ nextIndex:向追随者发送AE请求,携带从nextIndex开始的全部日志条目:
    • 若是成功:更新追随者对应的nextIndexmatchIndex
    • 若是AE因为日志不一致而失败:减少nextIndex并重试;
  • 若是存在N,知足N > commitIndex,多数的matchIndex[i] ≥ N,而且log[N].term == currentTerm:设置commitIndex = N

  1. 这张示意图与Raft论文中的图4是相同的。正好也能够提醒一下,在本系列的文章中。我都假设您已经读过这篇论文了。 ↩︎

  2. 检查状态是否追随者和候选人可能看起来有点奇怪。难道服务器能够不经过runElectionTimer发起的选举而忽然成为领导者吗? 继续日后阅读了解候选人是如何重启选举计数器的。 ↩︎

相关文章
相关标签/搜索