首页 > 基础资料 博客日记

当 leader 被隔离: etcd 网络分区深度分析

2026-06-10 15:00:04基础资料围观1

文章当 leader 被隔离: etcd 网络分区深度分析分享给大家,欢迎收藏极客资料网,专注分享技术知识


etcd-raft 节点在 followleadercandidate 状态流转。状态转移图如下:

image

图片摘自 https://raft.github.io/raft.pdf

正常情况下,各节点在自己的角色里好好干活。但是如果出现异常,比如网络分区后,各个节点会做什么呢?

本文主要讨论网络分区等场景下各个节点,尤其是 leader 节点在做什么,以加深对 etcd-raft 模块的了解。

网络分区

image

如上图所示,假设在 t1 时刻 s1 是集群的 leader 节点,t2 时刻发生网络分区(脑裂)导致 s1/s2 在分区 A,s3/s4/s5 在分区 B。

此时,由于分区 B 中无 leader,B 中的 follower 节点在到达 electionTimeout 将转换为 candidate 发起 preVote 预投票。假设 s3 是 candidate 节点,s4/s5 预投票给 s3,接着 s3 发起 Vote 消息,并获得 s4/s5 加上自己的投票,获得集群超过半数以上(5/2+1)投票,当选为 leader。

这里有几个问题需要思考下:

  1. 旧 leader s1 在做什么?需要退位吗?
  2. 为什么需要 preVote 预投票?

旧 leader 会做什么?

现在集群中有两个 leader,一个分区 A 的旧 leader,一个分区 B 的新 leader。由于新 leader 获得多数节点的投票,只要正常做 leader 的工作就行。接下来我们把重点放在旧 leader 上,看看分区后旧 leader 在做什么。

首先,旧 leader 不会主动退位,它会正常做 leader 的事情。给 follower 发心跳消息。由于网络隔离只有 s2 收到 leader 心跳消息并回复。
旧 leader 收到 s2 的回复,将 s2 标记为 RecentActive: true。该标记会在一个 electionTimeout 周期性重置,leader 通过这个标记判断自己是不是 leader。
旧 leader 超过 electionTimeout 会发 pb.MsgCheckQuorum 进入 raft 状态机,判断自己是不是 leader。
由于只有 s2 的 标记是 electionTimeout 周期内活跃的,其它节点都是不活跃的。raft 判断节点未得到多数节点的响应,降级为 follower。

这里的关键是 RecentActive 标志位,raft 没有根据回复的消息来统计票数确定是否是 leader,而是根据统计一个 electionTimeout 周期内 RecentActive 节点数来统计,这种滑动窗统计的方式很好的避免了网络延迟,拥塞,抖动等导致频繁切换 leader 的情况。

接下来从源码角度分析这一流程,阅读的 etcd 源码版本为 release-3.6

旧 leader 发心跳消息给 follower

// go.etcd.io/raft/v3/raft.go

// tickHeartbeat is run by leaders to send a MsgBeat after r.heartbeatTimeout.
func (r *raft) tickHeartbeat() {  
    // 每次发送心跳自增 heartbeatElapsed 和 electionElapsed
    r.heartbeatElapsed++  
    r.electionElapsed++  
  
    // 如果 electionElapsed 超过 electionTimeout,则发起 pb.MsgCheckQuorum
    // 确认自己是否是 leader
    if r.electionElapsed >= r.electionTimeout {  
       // 一旦进入确认逻辑,重置 electionElapsed,下一次继续确认
       r.electionElapsed = 0  
       // checkQuorum 默认打开
       if r.checkQuorum {  
        // 进入节点状态机处理 pb.MsgCheckQuorum 消息
          if err := r.Step(pb.Message{From: r.id, Type: pb.MsgCheckQuorum}); err != nil {  
             r.logger.Debugf("error occurred during checking sending heartbeat: %v", err)  
          }  
       }  
       // If current leader cannot transfer leadership in electionTimeout, it becomes leader again.  
       if r.state == StateLeader && r.leadTransferee != None {  
          r.abortLeaderTransfer()  
       }  
    }  
  
    // 如果 节点降级成 follower 则返回,只有 leader 可以执行 tickHeartbeat 方法
    if r.state != StateLeader {  
       return  
    }  
  
    // 如果还是 leader 并且 heartbeatElapsed 超过 heartbeatTimeout
    // 重置 heartbeatElapsed,继续 follower 发心跳消息
    if r.heartbeatElapsed >= r.heartbeatTimeout {  
       r.heartbeatElapsed = 0  
       if err := r.Step(pb.Message{From: r.id, Type: pb.MsgBeat}); err != nil {  
          r.logger.Debugf("error occurred during checking sending heartbeat: %v", err)  
       }  
    }  
}

这段函数流程如注释所示,有两点需要注意的是:

  1. tickHeartbeat 是哪里触发的?
  2. electionElapsed 变量有什么作用?

第一个问题,tickHeartbeat 是上层应用层触发,应用层维护一个定时器,定时器周期性的往 tick 通道内写数据,算法层 node 消费 tick 通道,然后将请求发送给 raft.tickHeartbeat

具体的流程可参考 raft 工程化案例之 etcd 源码实现 写的很好,很详细,就不赘述了。

第二个问题,electionElapsed 对于 follower 来说是比较好理解的变量,如果 follower 收到心跳等消息,它会重置 electionElapsed。表示现在 leader 还在,安心做好 follower 就行。对于 leader 来说,leader 用这个变量是为了表明如果 leader 超过 electionTimeout,它会发 pb.MsgCheckQuorum 消息给自己的 raft 来判断自己是不是合法 leader。实际是复用了这个变量做了不同的事情。

follower 接收到心跳消息并回复

// go.etcd.io/raft/v3/raft.go

func stepFollower(r *raft, m pb.Message) error {  
    switch m.Type {  
    case pb.MsgProp:  
       ... 
    case pb.MsgHeartbeat:  
       // follower 接收到 leader 的心跳消息
       // 重置 electionElapsed
       r.electionElapsed = 0 
       // 只有 leader 可以发送 MsgHeartbeat 消息
       // 将本机的 raft.lead From
       r.lead = m.From  
       r.handleHeartbeat(m)
    }
    return nil
}

func (r *raft) handleHeartbeat(m pb.Message) {  
    r.raftLog.commitTo(m.Commit)  
    // 发送心跳回复消息给 leader
    r.send(pb.Message{To: m.From, Type: pb.MsgHeartbeatResp, Context: m.Context})  
}

leader 接受心跳回复消息

// go.etcd.io/raft/v3/raft.go

func stepLeader(r *raft, m pb.Message) error {
    ...
    switch m.Type {
    case pb.MsgHeartbeatResp: 
        // 将 follower 节点的 RecentActive 设为 true
        // 表示该节点在 electionTimeout 周期内是活跃的
		pr.RecentActive = true  
		pr.MsgAppFlowPaused = false
		...
	}
	...
}

这里 leader 并没有统计回复心跳消息的票数,而是将返回心跳消息的 follower 节点的 RecentActive 标记为 true。leader 根据这个标记判断 follower 节点的活跃状态。

那么 leader 是在哪里退位的呢?

leader 退位

答案还是在 tickHeartbeat 函数。当 electionElapsed 累积到超过 electionTimeout 时进入 r.Step(pb.Message{From: r.id, Type: pb.MsgCheckQuorum})

// go.etcd.io/raft/v3/raft.go

func (r *raft) Step(m pb.Message) error {
	...
	switch m.Type {
	...
	default:  
	    err := r.step(r, m)  
	    if err != nil {  
	       return err  
	    } 
	} 
	return nil
}

进入 leader 自己的状态机处理 pb.MsgCheckQuorum 消息:

// go.etcd.io/raft/v3/raft.go

func stepLeader(r *raft, m pb.Message) error {
	switch m.Type {
	case pb.MsgCheckQuorum:  
		// 进入 raft.trk.QuorumActive 判断 leader 的 follower 是不是活跃的
	    if !r.trk.QuorumActive() {  
	       // 如果不活跃,则降级成 follower
	       r.logger.Warningf("%x stepped down to follower since quorum is not active", r.id)  
	       r.becomeFollower(r.Term, None)  
	    }  
		    // Mark everyone (but ourselves) as inactive in preparation for the next  
		    // CheckQuorum.    r.trk.Visit(func(id uint64, pr *tracker.Progress) {  
		    // 不管活跃不活跃都要重置节点的 RecentActive 标记
		    // 这一步非常重要,每个统计周期就是根据它来判断 leader 是否合法
		    // 所以每个 electionTimeout 统计周期都要重置该标记
	       if id != r.id {  
	          pr.RecentActive = false  
	       }  
	    })  
    return nil
}

// 根据节点的 RecentActive 标记判断 leader 是否合法
func (p *ProgressTracker) QuorumActive() bool {  
    votes := map[uint64]bool{}  
    p.Visit(func(id uint64, pr *Progress) {  
       if pr.IsLearner {  
          return  
       }  
       votes[id] = pr.RecentActive  
    })  
  
    return p.Voters.VoteResult(votes) == quorum.VoteWon  
}

可以看出 leaderpb.MsgCheckQuorum 消息给自己,如果 leader 的 follower 节点活跃数未超过半数以上则 leader 将降级成 follower。

为什么需要 preVote?

还是回到分区的示例中,在分区 B 中 s3 发起预投票,投票最终当选为 leader。从这个流程并没有看出 preVote 的优势有多大,我们把目光集中在分区 A 中。

假设分区 A 中 s1 降级成 follower,分区 A 中有两个 follower s1 和 s2。其中某一个 follower(假设是 s2) 到达 electionTimeout。

如果没有 preVote,s2 会转成 candidate 状态,自增 term(假设当前 term=10)成 11:

// go.etcd.io/raft/v3/raft.go

func (r *raft) becomeCandidate() {  
    // TODO(xiangli) remove the panic when the raft implementation is stable  
    if r.state == StateLeader {  
       panic("invalid transition [leader -> candidate]")  
    }  
    r.step = stepCandidate  
    // 这里很重要,candidate 节点会自增自己的 term
    r.reset(r.Term + 1)  
    r.tick = r.tickElection  
    r.Vote = r.id  
    r.state = StateCandidate  
    r.logger.Infof("%x became candidate at term %d", r.id, r.Term)  
  
    traceBecomeCandidate(r)  
}

由于分区 A 没有足够的选票,投票结果将为 VotePending

// go.etcd.io/raft/v3/raft.go

func stepCandidate(r *raft, m pb.Message) error {
	...
	switch m.Type {
	case myVoteRespType:  
	    gr, rj, res := r.poll(m.From, m.Type, !m.Reject)  
	    r.logger.Infof("%x has received %d %s votes and %d vote rejections", r.id, gr, m.Type, rj)  
	    switch res {  
	    // 如果是 VoteWon 则成为 leader
	    case quorum.VoteWon:  
	       if r.state == StatePreCandidate {  
	          r.campaign(campaignElection)  
	       } else {  
	          r.becomeLeader()  
	          r.bcastAppend()  
	       }  
	    // 如果是 VoteLost 则成为 follower
	    case quorum.VoteLost:  
		    // pb.MsgPreVoteResp contains future term of pre-candidate  
		    // m.Term > r.Term; reuse r.Term
		    r.becomeFollower(r.Term, None)  
	    }
	    ...
	}
	return nil
}

这里的关键在于投票结果是 VotePending 而不是 VoteWonVoteLost,s2 会继续做 candidate。然后等 s1 的 electionTimeout(s1 一直没收到 leader 的心跳)到期后会转为 candidate,自增自己的 term 到 12(s2 在收到 s1 的 vote 消息后会将自己的 term 更新成 11),s2 发 vote 消息给 s1,s1 接收到比自己更大 term 的消息,会降级成 follower。

可以看到,term 在 s1 和 s2 之间轮流递增。

如果后面网络恢复了,s1/s2 的 term 都会比 s3 的大。

假设 s1 是 candidate,s1 给 s3 发 term 更大的 Vote 消息,在老的 raft 实现中 raft leader 接收到 term 更大的消息,降级成 follower。
假设 s2 是 follower,s3 给 s2 发 心跳/MsgApp 消息,s2 返回给 s3 自己的 term,s3 判断返回的 term 比我大,然后退位降级成 follower。

在没有 preVote 的情况下,s1/s2 都会导致 leader 降级成 follower,因为它们有更大的 term。

term 的优先级要比 index 高的,但是在竞选时还要看 index,s1 和 s2 虽然有更大的 term,但是 index 却比 s3 要小,s1 和 s2 也选不上 leader,导致由于分区造成集群产生一次无效选举。

如果有 preVote 的话,节点不会自增 term,而是发信息给 follower 自己是否能做 candidate,如果得到多数票才会转为 candidate,竞选成功的成功率会高很多:

// go.etcd.io/raft/v3/raft.go

func (r *raft) becomeCandidate() {   
    if r.state == StateLeader {  
       panic("invalid transition [leader -> candidate]")  
    }  
    r.step = stepCandidate  
    // candidate 状态下 term 会自增
    r.reset(r.Term + 1)  
    r.tick = r.tickElection  
    r.Vote = r.id  
    r.state = StateCandidate  
    r.logger.Infof("%x became candidate at term %d", r.id, r.Term)  
  
    traceBecomeCandidate(r)  
}

// preCandiate 状态下 term 不会自增
func (r *raft) becomePreCandidate() {  
    if r.state == StateLeader {  
       panic("invalid transition [leader -> pre-candidate]")  
    }  
    // Becoming a pre-candidate changes our step functions and state,  
    // but doesn't change anything else. In particular it does not increase    
    // r.Term or change r.Vote.    
    r.step = stepCandidate  
    r.trk.ResetVotes()  
    r.tick = r.tickElection  
    r.lead = None  
    r.state = StatePreCandidate  
    r.logger.Infof("%x became pre-candidate at term %d", r.id, r.Term)  
}

在带入之前的逻辑,不管 s1/s2 怎么更新 preCandidate,它们的 term 都不变。在网络分区恢复后也不会影响 leader s3。

在新版 raft 中即使 term 更新,如果 leader 还是合法 leader(通过 lease 和 checkQuorum 判断) 的话也不会根据 term 降级:

func (r *raft) Step(m pb.Message) error {
	switch {
	case m.Term == 0:  
	    // local message
	case m.Term > r.Term:  
    if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {  
       // 检查是否 leader 主动退位
       force := bytes.Equal(m.Context, []byte(campaignTransfer))  
       // 如果 leader 是有效 leader 的话
       inLease := r.checkQuorum && r.lead != None && r.electionElapsed < r.electionTimeout  
       if !force && inLease {   
          // 直接返回当前 leader 还是有效的
          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  
       }  
    }  
    ...

这里对于 follower 来说如果它收到了 pb.MsgVotepb.MsgPreVote 消息,它会判断当前 leader 是否还有效,如果 follower 发现我刚给 leader 投过票并且它是合法 leader,follower 会忽略这个 term 更高的消息。

如果是 leader 收到了 term 更高的 pb.MsgVotepb.MsgPreVote 消息,它会判断在一个 electionTimeout 统计周期内我是不是合法 leader,如果是的话则忽略该消息。

通过 preVote 保证了集群的 term 不会递增,符合 raft 的语义逻辑,并且可以防止集群无意义选举,通过 lease 给无意义选举上了锁,即使 term 更大,如果 leader 还有效的话也会拒绝这个投票消息。

参考资料



文章来源:https://www.cnblogs.com/xingzheanan/p/20422212
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云