深入理解etcd(四)--- raft 原理

435 阅读22分钟

本文主要记录 raft 算法的大致实现,包括 leader 选举、日志复制和安全性等三个子问题,并详细探寻了etcd中的raft 是如何实现的

1. 背景

什么是共识算法?

共识算法就是保证一个集群的多台机器协同工作,在遇到请求时,数据能够保持一致。即使遇到机器宕机,整个系统仍然能够对外保持服务的[可用性]。

什么是raft?为什么需要raft?

共识算法的祖师爷是 Paxos, 但是它过于复杂,难于理解,工程实践上也较难落地,导致在工程界落地较慢,所以斯坦福学者花了很多时间理解 Paxos,并研究出来 Raft。

2. 简介

为了达到易于理解的目标,raft做了很多努力,包括问题分解和状态简化。

Raft 将共识问题分解三个子问题:

  1. Leader election 领导选举:有且仅有一个 leader 节点,如果 leader 宕机,通过选举机制选出新的 leader;

  2. Log replication 日志复制:leader 从客户端接收数据更新/删除请求,然后日志复制到 follower 节点,从而保证集群数据的一致性;

  3. Safety 安全性:通过安全性原则来处理一些特殊 case,保证 Raft 算法的完备性;

Raft 算法核心流程可以归纳为:

  • 首先选出 leader,leader 节点负责接收外部的数据更新/删除请求(注意是写请求);
  • 然后日志复制到其他 follower 节点,同时通过安全性的准则来保证整个日志复制的一致性;
  • 如果遇到 leader 故障,followers 会重新发起选举出新的 leader;

3. leader election

3.1. 任期

Raft 算法把时间轴划分为不同任期 Term。每个任期 Term 都有自己的编号 TermId,该编号全局唯一且单调递增。如下图,每个任务的开始都 Leader Election 领导选举。如果选举成功,则进入维持任务 Term 阶段,此时 leader 负责接收客户端请求并,负责复制日志。Leader 和所有 follower 都保持通信,如果 follower 发现通信超时,TermId 递增并发起新的选举。如果选举成功,则进入新的任期。如果选举失败,TermId 递增,然后重新发起选举直到成功。

3.2. 角色

raft集群中每个节点只能处于 Leader、Follower 和 Candidate 三种状态的一种:

  1. follower
  • 节点默认是 follower;
  • 如果刚刚开始 或和 leader 通信超时follower 会发起选举,变成 candidate,然后去竞选 leader;
  • 如果收到其他 candidate 的竞选投票请求,按照先来先得 & 每个任期只能投票一次 的投票原则投票;
  1. candidate
  • follower 发起选举后就变为 candidate,会向其他节点拉选票
  • candidate 的票会投给自己,所以不会向其他节点投票
  • 如果获得超过半数的投票,candidate 变成 leader,然后马上和其他节点通信,表明自己的 leader 的地位;
  • 如果选举超时,重新发起选举;
  • 如果遇到更高任期 Term 的 leader 的通信请求,转化为 follower;
  1. leader
  • 成为 leader 节点后,此时可以接受客户端的数据请求,负责日志同步;
  • 如果遇到更高任期 Term 的 candidate 的通信请求,这说明 candidate 正在竞选 leader,此时之前任期的 leader 转化为 follower,且完成投票;
  • 如果遇到更高任期 Term 的 leader 的通信请求,这说明已经选举成功新的 leader,此时之前任期的 leader 转化为 follower;

状态转换图如下:

而在etcd的raft算法 实现中,增加了一个pre-candidate,在投票前先发起一轮预投票,获得大多数节点认可,再转化为candidate去发起真正的选举。

目的是为了避免因为网络分区的原因出现一轮无用选举。

在节点数能够达到大多数的分区中,选举流程会正常进行,该分区中的所有节点的term最终会稳定为新选举出的leader节点的term。

在节点数无法达到quorum的分区中,如果该分区中没有leader节点,且因为节点总是无法收到数量达到大多数的投票而不会选举出新的leader,所以该分区中的节点在election timeout超时后,会增大term并发起下一轮选举,这导致该分区中的节点的term会不断增大。

如果网络一直没有恢复,这是没有问题的。如果网络分区恢复,此时,达不到quorum的分区中的节点的term值会远大于能够达到quorum的分区中的节点的term,这会导致能够达到quorum的分区的leader退位(step down)并增大自己的term到更大的term,使集群产生一轮不必要的选举。

3.3. 选举

选举的结果有两个:

  1. 选举成功,获得大多数投票
  2. 选举失败,未获得大多数投票

投票原则:

  1. 一个任期只能投一票,投票先来先得
  2. 日志至少和本节点一样新(先比较最后一条日志的任期,再比较比较最后一条日志的索引)
  3. 被投票的节点的任期必须不小于投票的节点
  4. 当一个节点收到一个比自身任期大的消息时,无论是leader还是candidate,都会设置任期并转化为follower
  5. 每个节点都会设置一个超时时间,该超时时间用于检测领导者多久未发送心跳。如果在超时时间内没有收到心跳,并且未选出新的领导者,节点会自增任期(term),并发起新一轮选举。

3.4. 优化

3.4.1. pre-vote

前面写了

3.4.2. check quorum

check quorum: leader 节点定期检查follower节点存活数是否超过大多数

为什么会出现check quorum?

在raft 算法里面,保证数据读取的线性一致性方式有多种:

  • 把读请求当作raft提议去处理,通过与其它日志相同的方式执行
  • 通过read index 去处理,在etcd 架构那篇文章介绍过

在大多数系统中,读多写少是常态,而第一种读取方式性能很差。但是绕过日志,就有可能会读到旧的数据(stale read)。

在网络分区情况下,假设Node 5原本是集群的领导者。网络分区后,Partition 0中的节点选举出新的领导者(如Node 1)。由于网络分区,Node 5无法接收到来自Partition 0的消息,不知道新领导者的存在。虽然Node 5无法完成日志提交,但它仍可能提供读取服务,导致连接至Node 5的客户端读取到陈旧数据。

为了缓解这一问题,Etcd实现了Quorum检查机制。领导者定期验证跟随者的活跃状态,若活跃跟随者数量不足法定人数,则认为自己可能是旧领导者,并主动降级为跟随者,停止提供读取服务,防止陈旧数据的读取。

然而,Quorum检查只能缩短陈旧读取的时间窗口,降低其影响,而不能完全避免。 若要确保严格的线性一致性,还需采用额外机制,例如线性化读取(linearizable reads),以保证所有读操作都反映最新的写入结果。

3.4.3. leader lease

leader lease: follower节点在接受一个心跳后,会在选举超时时间段内,认为leader仍然存活,会给所有投票请求响应拒绝

为什么会出现leader lease?

分布式系统中的网络环境十分复杂,有时可能出现网络不完全分区的情况,即整个整个网络拓补图是一个连通图,但是可能并非任意的两个节点都能互相访问。

在网络分区情况下,假设Node 1是集群的领导者,且Node 1与Node 2之间的通信突然中断。此时,Node 2不再接收到Node 1的心跳,并可能启动选举过程。若在此之前Node 1和Node 3没有新的日志条目,Node 2可能会因自身和Node 3的投票而赢得选举,成为新的领导者。

为了防止这种情况导致多个领导者并存,Raft算法引入了Leader Lease机制。根据这一机制,如果一个节点在选举超时前收到了来自现有领导者的有效心跳消息,它将不会响应其他节点的投票或预投票请求。这意味着,在Leader Lease期间,即使出现网络分区,正常运作的集群成员也不会投票给其他尝试竞选的节点,从而避免了不必要的领导者更替,确保了集群的一致性和稳定性。

3.5. 优化出现的问题

q1: Leader Lease需要依赖Check Quorum机制才能正常工作。

在上图网络分区情况下(node 1 是原先的 leader):

  1. 假设没有启用Leader Lease和Check Quorum机制,Node 3Node 4 会因收不到领导者的心跳而发起选举。由于Node 2Node 3Node 4 之间可以相互通信,且该分区内的节点数达到法定人数(quorum),它们能够成功选举出新的领导者。
  1. 如果启用了Leader Lease但未启用Check QuorumNode 2 仍能接收到原领导者 Node 1 的心跳消息,受Leader Lease约束,它不会为其他节点投票。结果是,尽管存在一个可达法定人数的活跃分区,但由于缺少关键选票,集群可能陷入无法选举出新领导者的状态,影响服务的连续性。
  1. 若同时启用了Leader LeaseCheck Quorum,网络分区发生后,Node 1 在选举超时(election timeout)到期后,因检测到活跃节点数不足法定人数,会主动降级为跟随者。这样,Node 2Node 3Node 4 可以自由地进行选举,确保新领导者被及时选出,维持集群的一致性和高可用性。

q2: 引入上述优化后,一个节点收到了term比自己低的消息时,原本的逻辑是直接忽略该消息,因为term比自己低的消息仅可能是因网络延迟的迟到的旧消息。然而,开启了这些机制后,在如下的场景中会出现问题。

注意:在etcd/raft的实现中,开启Check Quorum会自动开启Leader Lease。在符合Check Quorum 和 leader lease 情况下,如果是投票请求或者预投票请求信息,即使消息的任期更高,也不会做任何处理,不符合的情况下,会自动转化为follower

case m.Term > r.Term:
        // 消息的Term大于节点当前的Term
        if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {
            force := bytes.Equal(m.Context, []byte(campaignTransfer))
            // 如果是leader lease 且选举未超时,返回
            inLease := r.checkQuorum && r.lead != None && r.electionElapsed < r.electionTimeout
            if !force && inLease {
                // If a server receives a RequestVote request within the minimum election timeout
                // of hearing from a current leader, it does not update its term or grant its vote
                last := r.raftLog.lastEntryID()
                // TODO(pav-kv): it should be ok to simply print the %+v of the lastEntryID.
                r.logger.Infof("%x [logterm: %d, index: %d, vote: %x] ignored %s from %x [logterm: %d, index: %d] at term %d: lease is not expired (remaining ticks: %d)",
                                       r.id, last.term, last.index, r.Vote, m.Type, m.From, m.LogTerm, m.Index, r.Term, r.electionTimeout-r.electionElapsed)
                return nil
            }
        }

		switch {
		case m.Type == pb.MsgPreVote:
			// Never change our term in response to a PreVote
		case m.Type == pb.MsgPreVoteResp && !m.Reject:
			// We send pre-vote requests with a term in our future. If the
			// pre-vote is granted, we will increment our term when we get a
			// quorum. If it is not, the term comes from the node that
			// rejected our vote so we should become a follower at the new
			// term.
		default:
			// 任期高会让本节点成为follower
			r.logger.Infof("%x [term: %d] received a %s message with higher term from %x [term: %d]",
				r.id, r.Term, m.Type, m.From, m.Term)
			if m.Type == pb.MsgApp || m.Type == pb.MsgHeartbeat || m.Type == pb.MsgSnap {
				r.becomeFollower(m.Term, m.From)
			} else {
				r.becomeFollower(m.Term, None)
			}
		}

场景一:如下图所示,在开启了Check Quorum / Leader Lease后(假设没有开启Pre-Vote,Pre-Vote的问题在下一场景中讨论),数量达不到quorum的分区中的leader会退位,且该分区中的节点永远都无法选举出leader,因此该分区的节点的term会不断增大。当该分区与整个集群的网络恢复后,由于开启了Check Quorum / Leader Lease,即使该分区中的节点有更大的term,由于原分区的节点工作正常,它们的选举请求会被丢弃。同时,由于该节点的term比原分区的leader节点的term大,因此它会丢弃原分区的leader的请求。这样,该节点永远都无法重新加入集群,也无法当选新leader。

场景2:Pre-Vote机制也有类似的问题。如上图所示,假如发起预投票的节点,在预投票通过后正要发起正式投票的请求时出现网络分区。此时,该节点的term会高于原集群的term。而原集群因没有收到真正的投票请求,不会更新term,继续正常运行。在网络分区恢复后,原集群的term低于分区节点的term,但是日志比分区节点更新。此时,该节点重新发起的预投票请求因没有日志落后会被丢弃,而原集群leader发给该节点的请求会因term比该节点小而被丢弃。同样,该节点永远都无法重新加入集群,也无法当选新leader。

场景3: 在更复杂的情况中,比如,在变更配置时,开启了原本没有开启的Pre-Vote机制。此时可能会出现与上一条类似的情况,即可能因term更高但是log更旧的节点的存在导致整个集群的死锁,所有节点都无法预投票成功。这种情况比上一种情况更危险,上一种情况只有之前分区的节点无法加入集群,在这种情况下,整个集群都会不可用。

为了解决以上问题,节点在收到term比自己低的请求时,需要做特殊的处理。处理逻辑也很简单:

  • 如果收到了term比当前节点term低的leader的消息,且集群开启了Check Quorum / Leader Lease或Pre-Vote,那么发送一条term为当前term的消息,令term低的节点成为follower。(针对场景1、场景2)
  • 对于term比当前节点term低的预投票请求,无论是否开启了Check Quorum / Leader Lease或Pre-Vote,都要通过一条term为当前term的消息,迫使其转为follower并更新term。(针对场景3)

4. Log Replication

当选举出新的领导者后,整个集群即可恢复正常运作并向外提供服务。

  1. 领导者负责接收所有客户端请求,并将这些请求转化为日志条目进行复制。每个日志复制请求包含以下信息:
    • 状态机命令:表示客户端请求的数据操作指令。
    • 任期号(Term) :标识领导者的当前任期。
    • 前一个日志的任期号和索引:用于验证日志的一致性。
  1. Follower节点处理日志复制请求
    • Follower接收到日志复制请求后,会使用前一个日志的任期号和索引来对比自己的日志。
    • 如果匹配,则接受该日志条目并回复确认(OK)。
    • 如果不匹配,则拒绝该日志条目并回复错误(Error),指示存在日志不一致的情况。
  1. Leader处理拒绝响应
    • 当领导者收到拒绝复制的响应时,它会回溯到更早的日志条目,尝试找到与跟随者共同的日志点。
    • 领导者继续发送带有更早日志任期号和索引的复制请求,直到找到双方都认同的日志条目。
    • 一旦找到共同的日志点,跟随者从这个索引开始复制后续的日志条目,最终使本地日志与领导者保持一致。
  1. 日志提交
    • 在日志复制过程中,领导者会持续重试直至成功。
    • 一旦超过半数的节点确认复制了相同的日志条目,领导者认为该条目已被提交(committed),可以应用到状态机。
    • 已提交的日志条目会被视为达成共识,确保所有节点上的数据一致性。

优化:

快速回退:不用一条一条回退

  • 确定冲突点: 如果跟随者的日志与领导者在某个索引 m.Index 处的日志条目不匹配,那么跟随者需要告诉领导者一个“提示”(hint),即日志可能匹配的最大 (index, term)
  • 如何找到提示点: 跟随者会在自己的日志中查找:
  • 任期(term)小于或等于领导者在 MsgApp 消息中提供的任期(LogTerm)。
  • 索引(index)小于或等于领导者在 MsgApp 消息中提供的索引(Index)。
    // hintIndex 参数无论如何都是合法的
    //  r.raftLog.lastIndex() 会返回日志的第一条索引
    hintIndex := min(m.Index, r.raftLog.lastIndex())
	hintIndex, hintTerm := r.raftLog.findConflictByTerm(hintIndex, m.LogTerm)
	r.send(pb.Message{
		To:         m.From,
		Type:       pb.MsgAppResp,
		Index:      m.Index,
		Reject:     true,
		RejectHint: hintIndex,
		LogTerm:    hintTerm,
	})

func (l *raftLog) findConflictByTerm(index uint64, term uint64) (uint64, uint64) {
	for ; index > 0; index-- {
		// If there is an error (likely ErrCompacted or ErrUnavailable), we don't
		// know whether it's a match or not, so assume a possible match and return
		// the index, with 0 term indicating an unknown term.
		if ourTerm, err := l.term(index); err != nil {
			return index, 0
		} else if ourTerm <= term {
			return index, ourTerm
		}
	}
	return 0, 0
}

批处理: 一次复制多条日志

流水线处理:在etcd/raft的实现中,leader在向follower发送完日志复制请求后,不会等待follower响应,而是立即更新其nextIndex,并继续处理,以提高吞吐量。

5. 安全性

5.1. 选举安全性

选举安全性要求一个任期 Term 内只能有一个 leader,即不能出现脑裂现象,否者 raft 的日志复制原则很可能出现数据覆盖丢失的问题。Raft 算法通过规定若干投票原则来解决这个问题:

  • 一个任期内,follower 只会投票一次票,且先来先得;
  • Candidate 存储的日志至少要和 follower 一样新;
  • 只有获得超过半数投票才有机会成为 leader;

5.2. Leader Append-Only

Raft 算法规定,所有的数据请求都要交给 leader 节点处理,要求:

  1. leader 只能日志追加日志,不能覆盖日志
  2. 只有 leader 的日志项才能被提交,follower 不能接收写请求和提交日志;
  3. 只有已经提交的日志项,才能被应用到状态机中;
  4. 选举时限制新 leader 日志包含所有已提交日志项;

5.3. 日志匹配

这点主要是为了保证日志的唯一性,要求:

  1. 如果在不同日志中的两个条目有着相同索引和任期号,则所存储的命令是相同的;
  2. 如果在不同日志中的两个条目有着相同索引和任期号,则它们之间所有条目完全一样;

5.4.领导者完整性

Raft 规定:只有拥有最新提交日志的 follower 节点才有资格成为 leader 节点。

具体做法:candidate 竞选投票时会携带最新提交日志,follower 会用自己的日志和 candidate 做比较。

  • 如果 follower 的更新,那么拒绝这次投票;
  • 否则根据前面的投票规则处理。这样就可以保证只有最新提交节点成为 leader;

5.5. 状态机安全性

leader 只能提交当前任期 Term 的日志,旧任期 Term(以前的数据)只能通过当前任期 Term 的数据提交来间接完成提交。简单的说,日志提交有两个条件需要满足:

  • 当前任期;
  • 复制结点超过半数;

下面举个例子来解释为什么需要这个原则,如下图:

假设在任期 Term2 中,Follower1 是领导者,并且已经将 LogIndex3 复制到了 Follower2,同时正在向 Follower3 复制该日志。此时,Follower1 突然宕机。

进入任期 Term3,进行了一次新的领导者选举。Follower5 发起投票请求,并成功获得了自己、Follower3Follower4 的选票(共3/5),因此成为新的领导者。在任期 Term3 内,Follower5 接收并提交了客户端请求 LogIndex3-5,但这些日志尚未复制到其他节点,随后 Follower5 宕机。

在任期 Term4,再次进行了领导者选举。这次 Follower1 发起选举,并获得了自己、Follower2Follower3Follower4 的选票(共4/5),成为新的领导者。Follower1 继续将 LogIndex3 成功复制到了 Follower3,使得 LogIndex3 被超过半数的节点确认。接着,Follower1 在本地提交了 LogIndex3,然后自身也宕机。

再次进入任期 Term4,Follower5 发起了新一轮选举,并同样获得了自己、Follower2Follower3Follower4 的选票(共4/5),成为新的领导者。此时,其他节点需要强制复制 Follower5 的日志,导致 Follower1Follower2Follower3 的日志被覆盖。

6. 配置变更

6.1. 简单成员变更算法

简单成员变更算法限制每次只能增加或移除一个节点。这样可以保证新配置与旧配置的quorum至少有一个相同的节点,因为一个节点在同一term仅能给一个节点投票,所以这能避免多主问题。

6.2. 联合一致

联合共识算法可以一次变更多个成员,但是需要在进入新配置前先进入一个“联合配置”,所有的数据请求既要旧配置中达成大多数,又要在新配置中达成大多数。当联合配置成功提交后,集群可以开始进入新配置。

另外,需要注意的是,同一时间只能有一个正在进行的配置变更操作,在提议配置变更请求时,如果已经在进行配置变更,那么该提议会被丢弃(被改写成一条无任何意义的日志条目)。

7. 只读请求

在raft 算法里面,保证数据读取的线性一致性方式有多种:

  • Log Read
  • ReadIndex
  • Lease Read

7.1. Log Read

最直接实现线性一致性读的方法是将读请求也通过Raft的日志机制处理,即Log Read。这种方法简单且依赖于已有的Raft机制:

  • 过程:将读请求作为一条普通的Raft日志条目提交,待该日志条目被应用到状态机时,返回读取的状态给客户端。
  • 优点:完全依赖现有的Raft机制,确保了强一致性。
  • 缺点:延迟高、吞吐量低,因为每个读请求都需要达成一轮共识并落盘。

7.2. ReadIndex

为了优化只读请求的性能,避免完整日志机制带来的开销,引入了Read Index方法。这种方法在保证线性一致性的同时减少了网络和磁盘I/O开销:

  • 步骤
    1. 记录当前的commit index作为read index
    2. 向集群中的所有节点广播一次心跳消息,确认自己为合法领导者。
    3. 等待本地apply index大于等于记录的read index
    4. 执行只读操作,并将结果返回给客户端。
  • 优势:只需要一轮心跳广播,不需要落盘,显著提升吞吐量。
  • 特殊考虑:新领导者当选后需要等待至少提交一条空日志,以确保其commit index反映集群的真实状态。
  • 扩展:支持Follower Read,即跟随者可以通过领导者获取read index并在本地提供线性一致的只读服务。

7.3. Lease Read

为了进一步优化延迟,Lease Read利用心跳与时钟同步来确认领导者的合法性,从而减少额外的心跳广播:

  • 原理:当领导者在某个时间点收到quorum数量的响应时,它可以在一个安全的时间窗口内认为自己是合法领导者。这个窗口通常设置为略小于选举超时时间减去时钟漂移的影响。
  • 过程
    1. 领导者发送心跳消息并记录开始时间戳。
    2. 收到quorum数量的响应后,领导者计算出一个租约有效期。
    3. 在租约有效期内,领导者可以直接使用其commit index作为read index,执行只读操作。
  • 优点:显著降低了延迟,提升了读取性能。
  • 注意事项:需要配合Check Quorum机制以防止在网络分区情况下出现陈旧读取;同时要考虑时钟漂移对租约有效性的影响,选择合适的安全时间窗口。