分布式数据库的节点故障不是概率问题,而是时间问题。当Leader节点宕机时,整个集群必须在毫秒级完成新Leader的选举,否则上层业务就会感知到服务中断。这个过程远比单机故障复杂,因为你要同时处理网络分区、脑裂和数据一致性的三角难题。Raft协议把这个过程拆解得很清楚:每个节点有三种身份——Leader、Follower、Candidate。正常情况下只有一个Leader处理所有写请求,Follower被动接收日志。当Follower在选举超时时间内没收到Leader的心跳,就自动转为Candidate,给自己投一票,然后向其他节点索要选票。拿到多数票的Candidate晋升为新Leader,立即开始发送心跳阻止新一轮选举。这个机制的精妙之处在于随机超时设计,每个节点的选举超时在150ms到300ms之间随机取值,极大降低了多个节点同时发起选举导致平票的概率。

选举触发的真实场景

实际生产环境中,Leader选举被触发的原因远不止节点宕机这一种。慢GC导致的心跳超时是最常见的误触发场景。JVM的一次Full GC可能持续数秒,远超心跳间隔,Follower误以为Leader已死,发起不必要的选举。网络抖动同样致命,瞬时丢包率飙升会导致心跳连续丢失,集群进入选举状态。还有一种情况是磁盘IO打满,Leader虽然进程存活,但无法及时将日志刷盘并响应心跳,同样会触发选举。针对这些场景,生产环境通常会把选举超时调大到秒级,比如3到5秒,同时配合Multi-Raft架构,将不同范围的数据分片交由不同的Raft Group管理,避免单点Leader压力过大。TiDB的Placement Driver在这方面做得比较成熟,它把元信息管理和调度职责从Raft Leader中剥离出来,让Leader只负责数据日志的复制,心跳压力大幅降低。

选举过程中的数据一致性保障

选举不是选出一个新Leader就完事了,新Leader必须保证拥有所有已提交的日志条目,这是Raft协议的核心约束。实现方式是在投票阶段加入日志比较逻辑:Candidate发起投票时会带上自己最后一条日志的Term和Index,投票方只有在自己日志不比Candidate旧的情况下才投赞成票。这个机制天然阻止了日志落后的节点当选Leader。但这里有个工程细节容易踩坑:日志比较只比较Term和Index,不比较日志内容本身。如果旧Leader在崩溃前写了部分日志但未提交,新Leader上任后会把冲突的日志用自己的日志覆盖,Follower无条件接受Leader的覆盖。这就要求上层状态机必须是幂等的,或者数据库本身在应用层再做一次去重判断。CockroachDB的做法是在Raft之上又封装了一层MVCC,即使日志被覆盖重放,也能通过时间戳判断是否已经应用过。

客户端请求重试的核心策略

Leader选举期间客户端请求必然会失败,重试策略直接决定了业务的可用性。最简单的做法是客户端无脑重试,但这样会带来两个问题:一是重试风暴,所有客户端同时重试会把新Leader瞬间打爆;二是重复提交,如果请求已经在新Leader上执行成功但响应丢失,重试会导致重复执行。解决重试风暴的标准做法是退避算法,指数退避加随机抖动。初始重试间隔设为10ms,每次失败后翻倍,上限设到1秒,同时每次间隔乘以一个0.5到1.5之间的随机系数。这样大量客户端的重试请求会在时间轴上均匀散开,不会形成脉冲式流量。

// 指数退避加随机抖动示例
func retryWithBackoff(fn func() error, maxRetries int) error {
    base := 10 * time.Millisecond
    maxBackoff := 1 * time.Second
    for i := 0; i < maxRetries; i++ {
        err := fn()
        if err == nil {
            return nil
        }
        if !isRetryable(err) {
            return err
        }
        backoff := time.Duration(float64(base) * math.Pow(2, float64(i)))
        if backoff > maxBackoff {
            backoff = maxBackoff
        }
        jitter := time.Duration(rand.Float64() * float64(backoff) * 0.5)
        time.Sleep(backoff + jitter)
    }
    return fmt.Errorf("max retries exceeded")
}
幂等性设计是重试的基石

退避算法只解决了重试风暴,重复提交问题必须靠幂等性来解决。分布式数据库的客户端驱动通常会为每个请求生成全局唯一的Request ID,服务端用这个ID做去重。TiKV的客户端在发送写请求时会带上一个单调递增的Request ID,服务端内存中维护一个最近已完成请求的ID集合,收到重复ID直接返回之前的结果而不重新执行。但这个方案在服务端重启后内存集合丢失,所以需要配合更持久的幂等机制。一种做法是在Write-Ahead Log中记录Request ID,恢复时重放去重集合。另一种做法是让业务层自己保证幂等,比如用版本号做乐观锁更新,或者用唯一键约束天然去重。实际落地时,数据库驱动层面的幂等只能保证单次RPC调用的去重,跨语句的事务幂等还是要业务侧配合实现。

处理NotLeader错误的正确姿势

客户端重试时最常见的错误码是NotLeader。当请求发到Follower或者旧Leader时,节点会返回NotLeader错误,同时在响应中携带当前已知的Leader地址。客户端收到这个错误后应该立即更新本地路由缓存,然后向新Leader发起重试。这里有个关键优化点:不要等收到NotLeader才更新缓存,客户端应该定期从集群获取最新的路由表。TiDB的客户端驱动每隔30秒会从PD获取一次Region的路由信息,这样大部分请求都能直接命中Leader。另外,NotLeader响应中携带的Leader地址可能也是过期的,因为选举可能再次发生。所以客户端需要设置一个路由刷新的最大重试次数,超过次数后强制从元数据中心拉取最新路由,避免在错误的节点上反复重试。

读写分离场景下的重试差异

很多分布式数据库支持Follower Read来提升读吞吐,但Leader选举期间Follower Read的重试策略和写请求完全不同。写请求只能发给Leader,而读请求可以发给Follower,前提是Follower的数据足够新。选举期间Follower可能暂时与Leader断开连接,读到的数据有滞后风险。客户端在重试读请求时需要明确一致性级别:如果是强一致读,就必须路由到Leader或者确认Follower已经应用了最新日志;如果是最终一致读,可以容忍一定延迟,重试时选择任意可用节点即可。OceanBase的做法是在SQL语句中支持一致性级别Hint,应用可以根据业务场景灵活选择,避免选举期间所有读请求都涌向Leader造成过载。

网络分区的棘手情况

网络分区是Leader选举中最复杂的场景。假设一个5节点集群被分成两个分区,一边3个节点一边2个节点。3节点分区可以成功选举出新Leader继续服务,2节点分区的旧Leader发现自己无法获得多数派响应后自动降级为Follower。等网络恢复后,旧Leader发现新Leader的Term比自己高,会回滚未提交的日志并同步新Leader的数据。这个机制在理论上是完备的,但工程实现中有个坑:如果旧Leader在分区期间接收了写请求但未提交,这些请求的客户端会收到失败响应,客户端重试后在新Leader上重新执行。如果业务没有做好幂等,就可能出现数据不一致。更隐蔽的问题是,有些数据库为了性能在Leader降级时不会立即中断正在执行的长事务,导致网络恢复后这些事务的修改需要回滚,回滚过程又可能阻塞新请求。

监控与快速发现选举问题

Leader选举本身是正常行为,但频繁选举就是故障信号。监控系统需要重点关注选举频率指标,正常情况下选举次数应该趋近于零。Prometheus可以采集Raft状态变化的事件计数,当每分钟选举次数超过阈值时立即告警。同时要监控心跳延迟的P99值,如果P99接近选举超时时间,说明集群已经处于不稳定边缘。日志分析也很重要,每次选举都会在节点日志中留下完整的Term变化记录,通过日志聚合可以还原选举发生的完整时间线。生产环境建议把选举超时、心跳间隔、Raft日志复制延迟这些关键指标做成Grafana面板,按Region或Raft Group维度展示,方便快速定位是全局问题还是局部热点问题。

客户端侧的最佳实践总结

把重试逻辑封装在数据库驱动层而不是业务代码中,这是最基本的原则。驱动层能拿到集群拓扑信息,可以做出更智能的路由决策。重试次数要有上限,一般设置为3到5次,超过上限后向上层抛出异常让业务做降级处理。连接池需要感知节点角色变化,当收到NotLeader错误时主动销毁到旧Leader的连接,避免连接池中残留大量无效连接。对于事务型操作,重试时要重新开启事务,不能在旧事务上下文上继续提交。最后,客户端要记录每次重试的详细日志,包括目标节点、错误类型、重试间隔,这些数据是排查分布式系统问题的第一手资料。把这些实践落地后,Leader选举对业务的影响可以控制在秒级,对用户几乎无感知。