分类: 分布式系统架构

  • 深入 Raft 幽灵节点排查:单向网络隔离引发的 Term 飞涨与 PreVote 拦截实战

    排查自研分布式 KV(基于 go.etcd.io/etcd/raft/v3 v3.5.0)频繁无故切换 Leader 导致 QPS 跌零时发现,单向网络隔离会导致“幽灵节点”无法接收心跳,从而不断自增 Term 发起选举。高版本 Term 的投票请求会穿透隔离,强制合法 Leader 降级引发选举风暴。核心解法是全量开启 Raft PreVote(预投票)机制,并在配合 CheckQuorum,在自增 Term 前验证网络连通性,从协议层阻断脑裂假象。

    0x00 故障现场:毫无征兆的 Leader Flapping

    排查过程中,监控面板上出现了一个极为诡异的现象:集群整体流量不高,CPU/内存均无压力,但 API Server 报出大量 503 Service Unavailable

    调出 Prometheus 监控,发现两个核心指标极度异常:

    1. Leader 切换频繁: rate(raft_leader_changes_total[1m]) 出现规律性尖刺。

    2. Term 飞涨: 集群的 raft_term 指标像脱缰的野马,短时间内从 142 飙升到了 15403

    拉取当前 Leader(节点 A)的核心报错日志,发现其被强制逼退:

    {"level":"info","ts":"...","caller":"raft/raft.go:1004","msg":"[raft] node A stepped down to follower since error or received message with higher term","term":15403}
    

    紧接着,节点 A 重新发起选举,拿回 Leader 身份,但没过几秒,再次被逼退。整个集群陷入了无休止的“选举-当选-被逼退”的死亡循环中,此时 I/O 停滞,业务读写全被阻塞。

    0x01 定位元凶:单向网络隔离引发的“毒药”

    顺着日志,我将目光锁定在节点 C。节点 C 一直处于 Follower 状态,但它的 raft_term 却是全场最高的。

    登录节点 C 宿主机,通过 tcpdump 抓包分析发现了一个典型的单向网络隔离(One-way Partition)现象:

    # 在节点 C 上抓取与节点 A (Leader) 的 Raft 通信
    tcpdump -i eth0 host <Node_A_IP> and port 2380 -nn -vv
    

    抓包结果显示:节点 C 能向外发送数据包,但接收不到任何来自节点 A 的数据包。 检查网络层发现,是某次变更不慎在节点 A 所在宿主机的 iptables 的 OUTPUT 链中,针对节点 C 的 IP 配置了 DROP

    协议教科书里往往假设网络是完全断开的双向隔离,但在实际物理机房中,非对称路由、交换机单播风暴拦截、iptables 误配引发的单向隔离才是最致命的毒药。

    0x02 为什么单向网络隔离会引发全局选举风暴?

    在标准 Raft 协议中,一切以 Term(任期)为尊。单向隔离彻底击穿了标准 Raft 的防线,其演变过程如下:

    1. 心跳超时与 Term 膨胀: Leader A 正常发送心跳(MsgHeartbeat),但节点 C 收不到。节点 C 的选举定时器超时,根据协议,它将自身转为 Candidate,Term 加 1(变为 143),并向全网广播 MsgVote

    2. 毒药广播: 因为是单向隔离,节点 C 的 MsgVote 成功发送到了 A 和 B。

    3. 强制降级: Leader A 收到节点 C 的 MsgVote,虽然节点 C 的日志可能不是最新的,但 Raft 的强规则是:一旦收到 Term 大于自身当前 Term 的消息,当前节点必须无条件转为 Follower 并更新自己的 Term

    4. 无法当选与死循环: A 降级后集群无 Leader,开始新一轮选举。A 和 B 互相通信,A 重新当选(Term=144)。但节点 C 依然收不到心跳,再次超时,Term 变为 145,再次发送 MsgVote 逼退 A。

    节点 C 就像一个幽灵,自己永远无法当选(因为收不到其他节点的投票响应),但却能通过不断自增的 Term 作为“毒药”,把正常运行的 Leader 拉下马。

    0x03 PreVote 源码剖析:在拔剑前先确认身份

    为了解决这个标准 Raft 的缺陷,etcd/raft 引入了 PreVote(预投票)机制。其核心思想非常克制:在正式增加 Term 之前,先发起一次模拟投票;只有在确保自己能获得多数派选票时,才真正增加 Term 发起正式选举。

    翻开 go.etcd.io/etcd/raft/v3 的底层源码(raft.go),我们可以看到状态切换的区别:

    // tickElection 在选举超时后被调用
    func (r *raft) tickElection() {
        // ... 
        if r.preVote {
            // 开启了 PreVote:先进入 PreCandidate 状态,不增加 Term
            r.Step(pb.Message{From: r.id, Type: pb.MsgHup})
        } else {
            // 未开启 PreVote:直接进入 Candidate 状态,Term + 1 (危险行为)
            r.campaign(campaignElection)
        }
    }
    
    func (r *raft) campaign(t CampaignType) {
        // ...
        if t == campaignPreElection {
            r.becomePreCandidate() // 注意:这里调用后,r.Term 不会增加
            voteMsg = pb.MsgPreVote
        } else {
            r.becomeCandidate()    // 这里调用后,r.Term 会 +1
            voteMsg = pb.MsgVote
        }
        // 发送投票请求
        for _, id := range r.prs.Voters.IDs() {
            if id == r.id { continue }
            r.send(pb.Message{Term: term, To: id, Type: voteMsg, ...})
        }
    }
    

    PreVote 拦截的精妙之处在于其他节点的响应逻辑: 当正常节点 A(Leader)收到节点 C 的 MsgPreVote 时,因为 MsgPreVote 携带的是节点 C 当前的 Term(并没有加1),A 会判断自己当前仍然是合法的 Leader(未过 Lease 期/选举超时时间),因此会直接拒绝给节点 C 投预选票。 节点 C 拿不到多数派的预选票,就永远无法进入 Candidate 状态,Term 也永远不会增加,集群脑裂假象被彻底扼杀。

    0x04 落地实战:防御性架构的配置规范

    在自研系统的 Raft 引擎初始化阶段,必须强制开启 PreVoteCheckQuorum。这两个配置是高可用集群的“左右护法”。

    import "go.etcd.io/etcd/raft/v3"
    
    func newRaftNode(id uint64, peers []raft.Peer, storage *raft.MemoryStorage) raft.Node {
        config := &raft.Config{
            ID:                        id,
            ElectionTick:              10,
            HeartbeatTick:             1,
            Storage:                   storage,
            MaxSizePerMsg:             1024 * 1024,
            MaxInflightMsgs:           256,
    
            // 【防御性配置一】强制开启 PreVote 拦截网络孤岛引发的 Term 飞涨
            PreVote:                   true,
    
            // 【防御性配置二】强制开启 CheckQuorum
            // 允许 Leader 周期性检查自己是否仍然能连接到多数派,
            // 如果不能,Leader 会主动 stepDown,防止出现双 Leader 假象下的脏读
            CheckQuorum:               true, 
        }
    
        // 启动 Raft 状态机
        return raft.StartNode(config, peers)
    }
    

    配置下发并滚动重启集群后,我们再次通过 iptables 模拟针对单节点的网络隔离。 监控显示:被隔离的节点后台会不断发起 MsgPreVote,但被存活节点拒绝。主集群的 Leader 坚如磐石,raft_term 曲线保持绝对平稳,业务 QPS 0 抖动。

    0x05 常见问题 (Q&A)

    Q1:开启 PreVote 后,如果真实的 Leader 发生硬件宕机,选举耗时会变长吗? 会增加一次 RPC 往返(RTT)的耗时。因为候选者需要先走完 PreElection 阶段,拿到预选票后,再走正式的 Election 阶段。但在同城机房内,一次 RTT 通常在 1ms 以内,相比于默认 1000ms 的选举超时(Election Timeout),这点延迟对可用性的影响微乎其微,换来的却是极高的系统稳定性。

    Q2:如果网络完全断开(双向隔离),PreVote 还能发挥作用吗? 能。在双向隔离中,孤岛节点发不出预投票,自己也会一直处于 Follower/PreCandidate 状态,Term 不会增加。当网络恢复后,它重新接入集群时,其 Term 与主集群一致,通过正常的 MsgApp (AppendEntries) 就能无缝对齐日志,不会对现有 Leader 造成任何冲击。

    Q3:为什么不单纯依靠调大 Election Timeout 来规避网络抖动带来的频繁选举? 单纯调大 Election Timeout 是一种掩耳盗铃的做法。它确实能掩盖短暂的网络抖动,但代价是极大地延长了真实故障发生时的 MTTR(平均恢复时间)。发生真实物理宕机时,集群需要等待漫长的 Timeout 才会开始重选 Leader,这段时间内业务是完全不可用的。Raft 的调优原则是:用协议本身的严谨性(PreVote)去解决逻辑问题,而不是用粗暴的延迟(增大 Timeout)去掩盖问题。