Raft 工程实践中的暗礁:从一次 Leader 异常切换看 Pre-Vote 与日志截断边界

上午刚跟业务线把一个分布式 KV 集群的可用性抖动问题复盘完。现象很简单:一个 5 节点的集群,其中一个 Follower 节点因为宿主机网络硬件模块短暂卡死,与集群断联了大约 15 秒。网络恢复的瞬间,集群稳定运行了几个月的 Leader 突然主动 StepDown,导致整个集群在重新选主的几秒内出现了短暂的写拒绝。 这是一个非常典型的分布式共识协议在工程落地时的边界场景。理论上的 Raft 协议在处理网络分区时逻辑清晰,但在真实的生产环境中,简单的理论往往会引发级联反应。今天借着这个排查现场,把 Raft 工程实现中的 Leader 选举机制、任期膨胀(Term Inflation)以及日志复制中的冲突截断逻辑拆解一下。

破坏性节点与任期膨胀 (Term Inflation)

在标准 Raft 论文的描述中,Follower 如果在 electionTimeout 内没有收到 Leader 的心跳(Heartbeat),就会认为 Leader 挂了,随之将自己的状态切换为 Candidate,自增当前任期号(Term),并向外发起 RequestVote RPC。 当网络发生非对称分区(孤岛现象)时,问题就来了。 在上午的案例中,脱网的那个 Follower(我们称之为 Node-E)收不到 Leader 的心跳。于是它开始不断触发选举超时。由于网络不通,它的拉票请求发不出去,自然也收不到多数派的响应。 于是,Node-E 陷入了一个死循环:超时 -> 自增 Term -> 发起选举 -> 再次超时 -> 再次自增 Term。 15秒后网络恢复,此时 Node-E 的 Term 已经膨胀到了一个非常大的值。它带着这个巨大的 Term 向原 Leader 发送了任意一条消息(比如拉票请求,或者回复 Leader 的心跳)。 Raft 在工程实现中有一条铁律:任何节点,只要收到 Term 大于自身当前 Term 的消息,必须立刻无条件降级为 Follower。 我们来看 etcd/raft 底层核心状态机 raft.go 中的这段经典逻辑:

Go
func (r *raft) Step(m pb.Message) error {
    // ... 
    if m.Term > r.Term {
        if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {
            // 处理投票请求的特殊逻辑
        }
        // 【核心触发点】:发现更大的任期号
        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)
        // 原 Leader 被迫降级
        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)
        }
    }
    // ...
}
Go

这就是导致上午集群抖动的元凶。一个本该默默追赶数据的“落后节点”,用一个虚高的 Term 把健康的 Leader 给“刺杀”了。

工程解法:Pre-Vote 机制的底层逻辑

为了解决这个“破坏性服务器(Disruptive Server)”问题,Raft 提出了 Pre-Vote(预投票)机制。这也是主流分布式数据库(TiKV, CockroachDB, etcd)在工程实践中必定会引入的优化。 Pre-Vote 的核心思想是:在真正增加 Term 并发起选举之前,先用当前的 Term + 1 去试探性地问一下大家,我有没有可能赢? 如果开启了 Pre-Vote,节点在超时后不会立刻变成 Candidate,而是变成 PreCandidate。 它会发送 MsgPreVote 消息。只有当集群中多数派节点回复确认(表明它们也确实没收到 Leader 心跳,且你的日志足够新),它才会真正自增 Term 并发起正式投票(MsgVote)。 在 etcd/raft 中,这个状态转换的工程实现如下:

Go
func (r *raft) campaign(t CampaignType) {
    var term uint64
    var voteMsg pb.MessageType
    if t == campaignPreElection {
        r.becomePreCandidate()
        voteMsg = pb.MsgPreVote
        // Pre-Vote 阶段,试探性的 term 是当前 term + 1,但本地状态机不持久化这个 term
        term = r.Term + 1
    } else {
        r.becomeCandidate()
        voteMsg = pb.MsgVote
        term = r.Term
    }
    // 统计选票,如果是单节点直接胜出
    if _, _, res := r.poll(r.id, voteRespMsgType(voteMsg), true); res == quorum.Won {
        if t == campaignPreElection {
            r.campaign(campaignElection) // Pre-Vote 赢得多数派,发起正式选举
        } else {
            r.becomeLeader()
        }
        return
    }
    // 向其他节点广播投票请求
    for id := range r.prs.Voters {
        if id == r.id {
            continue
        }
        r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm()})
    }
}
Go

上午的集群之所以中招,排查后发现是因为业务方在这个定制版本的配置中,不慎将 election_ticksheartbeat_ticks 的比例设置过小,且显式关闭了 PreVote 选项。修正配置后,这种幽灵抖动被彻底阻断。

日志复制的边界:Index 冲突与快速截断截断(Fast Log Rejection)

在处理完网络分区引发的选举风暴后,我们要面对分布式共识的另一个深水区:日志复制的冲突处理。 假设在上述场景中,原 Leader 在断网前刚刚接收了一条客户端的写请求,追加到了本地日志(Index=100,Term=5),但还没来得及同步给 Follower。 网络恢复后,新的 Leader(Term=6)已经产生,并且也接收了新的写请求(Index=100,Term=6)。 此时,新 Leader 会向原 Leader 发送 MsgApp(追加日志请求)。Raft 必须保证日志的强一致性(Log Matching Property)。原 Leader 收到 MsgApp 后,发现本地 Index=100 的日志 Term 是 5,而 Leader 发来的是 6。冲突发生了。 在基础的 Raft 论文中,Leader 遇到 Follower 拒绝日志时,会把该 Follower 的 nextIndex 减 1,然后再试,直到找到两者日志匹配的点。 但在工程实现中,如果积压了大量冲突日志,每次回退 1 个 Index 会导致巨大的 RPC 交互开销(逐条回退)。etcd/raft 在这里做了一个极其优雅的工程优化:RejectHint(拒绝暗示)。 当 Follower 发现日志冲突时,它不仅会回复拒绝,还会在 MsgAppResp 中附带一个 RejectHint(通常是冲突任期的第一条日志的 Index,或者 Follower 本地的 LastIndex)。 Follower 端的核心处理逻辑:

Go
func (l *raftLog) maybeAppend(index, logTerm, committed uint64, ents ...pb.Entry) (lastnewi uint64, ok bool) {
    // 检查 index 和 logTerm 是否匹配
    if l.matchTerm(index, logTerm) {
        // 如果匹配,寻找有没有冲突的 entries
        lastnewi = index + uint64(len(ents))
        ci := l.findConflict(ents)
        switch {
        case ci == 0:
        case ci <= l.committed:
            // 异常边界:试图覆盖已提交的日志,触发 panic (永远不应该发生)
            l.logger.Panicf("entry %d conflict with committed entry [committed(%d)]", ci, l.committed)
        default:
            // 发现未提交的冲突日志,进行截断并追加新日志
            offset := index + 1
            l.append(ents[ci-offset:]...)
        }
        l.commitTo(min(committed, lastnewi))
        return lastnewi, true
    }
    return 0, false
}
Go

maybeAppend 返回 false 时,Follower 会构造拒绝响应:

Go
// 构造拒绝响应,附带本地日志信息加速回溯
m.Index = r.raftLog.lastIndex()
m.Reject = true
m.RejectHint = r.raftLog.lastIndex()
Go

Leader 收到带有 RejectHintMsgAppResp 后,会直接大幅度调整 nextIndex

Go
if m.Reject {
    // 收到拒绝,根据 RejectHint 快速倒推 nextIndex
    if pr.MaybeDecrTo(m.Index, m.RejectHint) {
        r.logger.Debugf("%x decreased progress of %x to [%s]", r.id, m.From, pr)
        if pr.State == tracker.StateReplicate {
            pr.BecomeProbe()
        }
        r.sendAppend(m.From)
    }
}
Go

通过这种工程上的妥协与优化,新 Leader 可以用最少的 RTT 跨越整个 Term 的冲突日志,强制覆盖原 Leader 那些未提交的“脏数据”,使得系统迅速恢复到一致状态。

结语

分布式系统的诡异之处在于,协议的伪代码看几遍就能懂,但在面对真实的网卡丢包、CPU 瞬时争抢、或者磁盘 IO 导致 Fsync 耗时超标(超过 Election Timeout)时,状态机的流转会呈现出混沌的特征。 无论是 Pre-Vote 防御机制,还是 RejectHint 的加速截断,都是我们在面对不可靠的物理基础设施时,在 Raft 基础理论上打的一块块工程补丁。排查这类问题,不仅要求对业务层的监控指标了然于胸,更要求在脑海中随时能够勾勒出底层状态机的流转图。