Skip to content

分布式设计模式手册:选举、租约、故障检测与 Gossip

本文是分布式系统系统学习系列的 L3 实战篇。前置:28. 共识算法:从 Paxos 到 Raft 的推导29. 协调服务:ZooKeeper 与 etcd。 学完可以配合面试题食用:22. Gossip 协议06. 分布式 ID 与时钟回拨

不是玄学,是工程套路

分布式系统里很多问题看起来是"杂项"——节点怎么发现对方挂了、lease 怎么续的、选主怎么避免脑裂、集群信息怎么传播的。但拆开看,每个问题都有几个成熟的解决模式,反复出现在不同系统里。

把这些问题单独拎出来,不是为了增加概念,而是为了让你看到:ZooKeeper session 超时、etcd lease 续期、HDFS NameNode 的 fencing token、Cassandra 的 gossip 散播,背后用的是同一套模式。

故障检测:不是"挂了",是"你猜"

单机里判断进程还活着很简单:进程挂了 OS 通知你。分布式不行——对方可能挂了、可能网络分段、可能 GC 停了 30 秒。

心跳的局限性

最直接的方式是心跳,每 N 秒发一次 ping。但心跳有俩硬伤:

  • 超时阈值不好定:设短了,GC 慢请求就误判,触发不必要的 rebalance;设长了,真的挂了你等很久才反应
  • 网络不是均匀的:跨 DC 的 RTT 可能从 10ms 到 100ms 抖动,固定阈值很难适配

φ-accrual 自适应检测

Accrual(累加)检测器不直接说"死/活",而是输出一个怀疑度 φ 值:收到的消息越久没有更新,φ 值越大。调用方自己决定阈值——比如 φ >= 3 时认为挂了,φ >= 5 时确认不可恢复。

python
import math, time

class PhiAccrualDetector:
    def __init__(self, threshold=3.0, window=1000):
        self.threshold = threshold
        self.intervals = []  # 毫秒级间隔
        self.last = time.monotonic_ns() // 1_000_000
        # 滑动窗口,最大记录 window 个间隔

    def heartbeat(self):
        now = time.monotonic_ns() // 1_000_000
        interval = now - self.last
        self.intervals.append(interval)
        if len(self.intervals) > 1000:
            self.intervals.pop(0)
        self.last = now

    def phi(self):
        """返回当前怀疑度"""
        if not self.intervals:
            return 0.0
        now = time.monotonic_ns() // 1_000_000
        elapsed = now - self.last       # 距上次心跳多久
        mean = sum(self.intervals) / len(self.intervals)
        # 简化为指数分布近似(实际可用正态分布更准)
        return -math.log10(math.exp(-elapsed / max(mean, 1)))

    def is_suspected(self):
        return self.phi() >= self.threshold

核心区别:传统心跳是 0/1 二值,accrual 是 连续值。节点 GC 暂停 5 秒,传统心跳可能直接判死触发 rebalance,accrual 在 φ 短暂升高后很快恢复,不会误判。

Cassandra 用的就是这种思路(org.apache.cassandra.gms.FailureDetector),只是实现更复杂、用了正态分布模拟。

租约 Lease:时间换确定性

租约的核心是有一个权威(通常是协调节点)给一个有限期承诺,持租方在期限内拥有某项权利。到期必须续约,续不上就放弃。

续期风暴

一个经典问题:很多客户端同时持有租约,到期时间接近,续约请求集中在同一时刻集中爆发。数据库集群的 session 超时、ZooKeeper 连接重连都是同一类问题。

解法:续期时间加随机抖动(jitter),拉平峰值。比如原本 60 秒续一期,实际用 60 + random(-3, 3) 秒。

雪花漂移案例

Kafka 头部写入时,leader 副本的 lease 可能因为 GC 漂移早过期,follower 以为 leader 挂了,触发重新选主,导致写入抖动。解决方案是 lease 续期在 GC 暂停后补发一个"心跳探活",而不是等超时触发。

选举模式:选一个老大干活

Bully 算法

最简单的选主:节点按 ID 排序,谁大(或谁小)谁当 leader。发现 leader 挂了,所有节点向比自己大的节点发选举消息,如果没人回应,自己就是老大。

Bully 的缺点——每次选主都 O(n²) 的消息量,而且节点 ID 固定,不能保证当选的节点状态最新。

Raft 内置选举

Raft 把选举做进了共识协议里:任期(term)+ 随机超时 + 多数派投票。不是单独一个选主组件,而是和数据复制、日志提交绑在一起。

mermaid
sequenceDiagram
    participant F1 as Follower A
    participant F2 as Follower B
    participant F3 as Follower C

    Note over F1,F3: 超时无 Leader 心跳
    F1->>F1: 任期+1, 转 Candidate
    F1->>F2: 请求投票 (term=5)
    F1->>F3: 请求投票 (term=5)
    F2->>F1: 投票 (term=5)
    F3->>F1: 投票 (term=5)
    Note over F1: 收到多数派 → 成为 Leader
    F1->>F2: AppendEntries (心跳)
    F1->>F3: AppendEntries (心跳)

Raft 选举的聪明之处在于"随机超时"——三台 Follower 的超时时间不同,几乎不可能同时发起选举,所以大多数情况只有一个人变成 Candidate,一轮完成。

脑裂的防线

脑裂(split-brain)指两个节点同时认为自己是 leader,做冲突的决策。重要的防线两个:

  1. Quorum(多数派):只有拿到超过半数的节点支持,才算选主成功。Raft/etcd/ZK 都靠这个。
  2. Fencing token:选主成功后,给新 leader 一个递增的 token,旧 leader 的 token 已过期,外部系统(如 HDFS 的 QJM)只接受 token 更大的写操作。

Gossip:谣言是怎么传遍集群的

Gossip 协议的核心思想:每个节点周期性地随机选几个节点交换信息。信息像病毒一样扩散,最终所有节点都知道。

Push / Pull / Push-Pull

  • Push:主动把我知道的告诉你。适合少量信息快速传播,但浪费带宽(你不知道对方是否已经知道)
  • Pull:你问我知道的。适合反熵,但需要对方先发起请求
  • Push-Pull:我先告诉你我的,你再告诉我你的。最少轮数收敛,也是实际最常用的

Gossip 的收敛速度:节点数 N,每轮每个节点选 f 个邻居,O(log N) 轮可达全集群。

python
import random

class GossipNode:
    def __init__(self, node_id, peers):
        self.id = node_id
        self.peers = peers  # 集群全部节点列表
        self.state = {}     # key -> (value, version)

    def gossip_round(self, fanout=3):
        # 随机选 fanout 个节点,push-pull
        targets = random.sample([p for p in self.peers if p != self.id],
                                min(fanout, len(self.peers) - 1))
        for target in targets:
            # 1. push 自己的 state
            target._receive(self.id, self.state)
            # 2. pull 对方的 state(实际实现是合并后返回差异)
            for k, (v, ver) in target.state.items():
                if k not in self.state or self.state[k][1] < ver:
                    self.state[k] = (v, ver)

    def _receive(self, from_id, remote_state):
        for k, (v, ver) in remote_state.items():
            if k not in self.state or self.state[k][1] < ver:
                self.state[k] = (v, ver)

SWIM(Scalable Weakly-consistent Infection-style Membership)

Gossip 的成员关系管理变体。SWIM 把故障检测和成员信息传播分开:

  • 每轮随机选一个节点 ping,如果超时则问另一个节点间接 ping 它
  • 新节点加入 / 离开的信息通过 gossip 传播出去
  • 不依赖中心化协调,适合大规模集群(数百到数千节点)

Consul 就用了 SWIM(通过 memberlist 库)。

心跳超时参数怎么定

很多系统手册里会写"建议 TickTime*2",但具体指什么?

以 ZooKeeper 为例:

  • tickTime = 基本时间单位(默认 2000ms)
  • initLimit = 用于 leader 选举的 tick 数(默认 10,即 20 秒)
  • syncLimit = follower 与 leader 的同步超时 tick 数(默认 5,即 10 秒)

经验公式:TickTime × 2 到 TickTime × 20 之间。

  • 同机房、低延迟(< 2ms RTT):选 TickTime × 2 ~ × 5
  • 跨 DC、高延迟(50-200ms RTT):选 TickTime × 10 ~ × 20
  • 还要考虑 Java GC 停顿:Full GC 如果超过超时时间,节点会被误判踢出集群

常见误区与小结

  • 误区:心跳超时设得越短越好。超时越短,误判率越高,触发不必要的 rebalance 反而降低可用性。应该先用监控了解正常情况下的 RTT 分布,再设阈值。
  • 误区:Gossip 适合所有场景。Gossip 是最终一致,信息传播有延迟(几轮 gossip 周期)。需要强一致读的场景,还是得靠 Raft 的线性读。
  • 误区:Lease 续期只要到期前发就行。续期请求本身可能因为网络延迟或 GC 暂停而错过窗口,设计时应该允许多次续期,且续期 ttl 要大于最大预期延迟。
  • 误区:Fencing token 可有可无。没有 fencing token 的保护,旧 leader 在网络分区恢复后依然可能写数据,造成数据不一致。fencing 是最后的物理防线。
  • 误区:故障检测就是"心跳超时"。现代系统用 φ-accrual 或 SWIM 这类自适应检测,比固定阈值可靠得多。

小结

这篇梳理了分布式系统里四个最常用的"工程模式":故障检测、租约、选举、Gossip。它们不是互相独立的——Cassandra 用 gossip 传播成员信息,用 φ-accrual 做故障检测,用 gossip 的 SWIM 层辅助选主决策。理解这些模式,比死记硬背具体的实现细节有用得多。

下一篇 33. 工程案例剖析:etcd、TiDB 与 Spanner 的取舍 会把学到的所有模式放到真实系统里验证——看 etcd 怎么用 Raft + 租约做元数据存储,TiDB 怎么用 Percolator + Raft 做分布式事务,Spanner 怎么用 TrueTime + Paxos 做全球强一致。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1