本文主要讨论网络分区等场景下各个节点,尤其是 leader 节点在做什么,以加深对etcd-raft模块的了解。
网络分区
如上图所示,假设在 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。
这里有几个问题需要思考下:
- 旧 leader s1 在做什么?需要退位吗?
- 为什么需要 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) } } }这段函数流程如注释所示,有两点需要注意的是:
- tickHeartbeat 是哪里触发的?
- 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 }可以看出leader发pb.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