排查自研分布式 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 监控,发现两个核心指标极度异常:
-
Leader 切换频繁:
rate(raft_leader_changes_total[1m])出现规律性尖刺。 -
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 的防线,其演变过程如下:
-
心跳超时与 Term 膨胀: Leader A 正常发送心跳(
MsgHeartbeat),但节点 C 收不到。节点 C 的选举定时器超时,根据协议,它将自身转为 Candidate,Term加 1(变为 143),并向全网广播MsgVote。 -
毒药广播: 因为是单向隔离,节点 C 的
MsgVote成功发送到了 A 和 B。 -
强制降级: Leader A 收到节点 C 的
MsgVote,虽然节点 C 的日志可能不是最新的,但 Raft 的强规则是:一旦收到Term大于自身当前Term的消息,当前节点必须无条件转为 Follower 并更新自己的Term。 -
无法当选与死循环: 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 引擎初始化阶段,必须强制开启 PreVote 和 CheckQuorum。这两个配置是高可用集群的“左右护法”。
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)去掩盖问题。