主题
分布式设计模式手册:选举、租约、故障检测与 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,做冲突的决策。重要的防线两个:
- Quorum(多数派):只有拿到超过半数的节点支持,才算选主成功。Raft/etcd/ZK 都靠这个。
- 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 做全球强一致。
参考
- Cassandra 官方文档 - Failure Detection & Gossip: https://cassandra.apache.org/doc/latest/cassandra/architecture/gossip.html
- SWIM 论文(Scalable Weakly-consistent Infection-style Process Group Membership Protocol)
- HashiCorp memberlist 库:https://github.com/hashicorp/memberlist