heartbeat - quorum - election
06. 心跳、多数派与选主:哨兵在每个系统里的名字
0. 本章先解决什么问题
上一章搭好了主从:主写从读,日志复制。下一个问题自然冒出来:主挂了怎么办。人肉切换太慢,自动切换就要回答三个连环问题:
- 怎么知道主真的挂了?(判死)
- 谁来做这个判断?判断者自己挂了怎么办?(裁判的可用性)
- 选哪个从顶上?旧主复活了回来捣乱怎么办?(选主与脑裂)
Redis 给这套机制起名叫哨兵(Sentinel),但同样三个问题 MySQL、Kafka、ZooKeeper、etcd 全都要答,答案惊人地趋同:心跳加超时判死,多数派投票防误判,单调递增的纪元编号防脑裂。本章把每一家的完整流水线都摆开。
这张图怎么读
从左到右是故障转移的时间线:心跳超时产生怀疑,多个观察者凑成多数派把怀疑坐实,选出新主并把纪元编号加一,旧主复活后发现自己的纪元过期,只能降级。
1. 判死为什么难:分布式系统的第一个坑
单机世界里进程死没死,操作系统一清二楚。跨机器就只剩一种探测手段:发消息,等回应。而”没回应”有三种完全不同的原因:
- 它真死了
- 它活着,但太慢(CPU 打满、磁盘卡住、JVM 正在 Full GC 停顿)
- 它活得好好的,网络断了(分区)
探测者无法区分这三种情况。这是原理性的:慢和死在有限时间内不可区分,任何工程手段都只能缓解。所以所有系统的判死长一个样:
周期发心跳(ping),超过阈值没回应,标记为”疑似下线”
阈值是个两难滑杆,两头都有真实事故:
- 太短:一次 Full GC 停顿几秒,节点被误判死亡,触发无谓切换。
Java 程序员对此要格外有感: ZooKeeper 会话超时、
Kafka 消费者被踢出组、Sentinel 误判主观下线,
三个”经典生产事故”的元凶经常都是同一次 GC
(垃圾回收器 里的 STW 就是这么漏到分布式层的) - 太长:真故障要多熬几十秒才开始自救,可用性预算全烧在等待上
Sentinel 的 down-after-milliseconds(默认 30 秒)、Kafka 的 session.timeout.ms、ZooKeeper 的 sessionTimeout,全是这根杆上的刻度。
2. 裁判也会死:多数派的数学
让一个监控节点判死,监控节点自己就成了新单点。所以裁判必须是一群;而一群裁判意见不合时,用投票收敛。投票的门槛为什么定在”过半”,一句话的数学:
任意两个”过半数”集合必然有交集。
所以同一时刻不可能有两个决议都拿到过半支持:
不可能同时选出两个主,不可能同时通过两个互相矛盾的提案。
网络分区时,最多只有一个分区能凑出过半,
另一边自动失去行动能力(宁可停摆,不搞分裂)。
由此直接推出部署数量的讲究:
3 台: 过半 = 2,容忍挂 1 台
4 台: 过半 = 3,还是只容忍挂 1 台(多花一台,容错没涨)
5 台: 过半 = 3,容忍挂 2 台
-> 裁判集群总是奇数台;偶数只多成本不多容错
3. Redis Sentinel:一套完整的参考实现
把整条流水线从头到尾过一遍,后面每家系统都是它的变体。
3.1 平时:三条周期任务织出监控网
- 每 1 秒:每个哨兵 ping 主库、从库、其他哨兵(判活的心跳)
- 每 10 秒:向主库发 INFO,从回复里自动发现从库列表
(所以配置里只用写主库地址,从库是问出来的) - 每 2 秒:通过主库的一个约定频道发布/订阅”你好”消息,
哨兵之间由此互相发现,同样不用互相配置地址
3.2 判死:主观下线到客观下线
主观下线 SDOWN: 某个哨兵自己 ping 主库,超过 down-after-milliseconds
没有有效回复,它单方面标记”我觉得主挂了”
客观下线 ODOWN: 该哨兵向同伴发 is-master-down-by-addr 询问,
同意”主挂了”的哨兵数(含自己)>= 配置的 quorum,升级为客观下线
quorum 挡的是单个哨兵的网络抖动: 你连不上不代表大家连不上
3.3 选领头哨兵:故障转移的主持人
客观下线成立后,不能所有哨兵一起动手,要先选一个领头来主持:
发起者把纪元 epoch 加一,向所有哨兵拉票
每个哨兵在每个 epoch 里只有一票,先到先得
拿到”全体哨兵的过半数”且不少于 quorum 的成为领头
注意分工: quorum 只管 ODOWN 判定门槛,
领头选举永远要求过半,两个数字管两道门
拉平票就 epoch 再加一重来(随机延时错开,很快收敛)
3.4 故障转移:五步走完
- 筛候选: 从库里踢掉断线太久的
(断联超过 down-after-milliseconds x 10 的数据太旧,没资格) - 排序挑新主: replica-priority 小者优先
-> 复制 offset 大者优先(数据最全,直接呼应上一章的复制进度)
-> runid 小者兜底(纯粹为了结果确定) - 对新主执行 REPLICAOF NO ONE,等它 INFO 里角色变 master
- 让其余从库 REPLICAOF 新主
(parallel-syncs 控制同时切几个: 切换会触发同步,
一起切等于所有从库同时不可用,通常设 1 逐个来) - 改写所有哨兵的配置并广播 +switch-master 事件
老主被记成”待降级”,复活后哨兵命令它 REPLICAOF 新主
客户端怎么跟上切换:连接哨兵模式的客户端(Jedis/Lettuce 的 sentinel 模式)启动时问哨兵”当前主是谁”,并订阅 +switch-master 频道,收到事件就重建连接池指向新主。整个链路里应用配置从头到尾只写哨兵地址,不写主库地址。
3.5 丢数据的两个窗口和缓解
窗口一: 异步复制。老主没来得及发给新主的最后一段写入,切换即丢
窗口二: 脑裂。老主只是被网络隔离,客户端还在往它写;
等它降级成从库、全量同步新主,这段写入整个被清掉
缓解(写在老主身上的自保配置):
min-replicas-to-write 1 身边至少要有 1 个从库
min-replicas-max-lag 10 且它的复制延迟不超过 10 秒
否则本主拒绝写入
-> 被隔离的老主发现从库全联系不上,自动拒写,把窗口二压到最小
(代价: 正常运行时从库全挂也会拒写,可用性换安全性)
4. Redis Cluster:同一配方内嵌进 gossip
集群模式没有独立哨兵,配方原样内嵌进节点之间:
Redis Cluster 的故障转移分三步:
- 判死:节点之间随机互相
PING,gossip 消息顺便携带槽位图和节点状态。
超过cluster-node-timeout没有响应时,单个节点先标记PFAIL(主观下线);当 gossip 收集到过半主节点都把目标标为PFAIL,状态升级为FAIL(客观下线),再广播给全网。 - 选主:故障主节点的从库不会同时参选。它们按复制 offset 排名,数据越完整,rank 越靠前;实际延迟约为
500ms + 随机值 + rank × 1000ms,让数据最完整的从库优先发起。
候选者递增currentEpoch并拉票,只有主节点有投票权,每个 epoch 只能投一票,拿到过半主节点选票即当选。 - 接管:新主节点接管旧主的槽位,递增
configEpoch,再广播全网更新槽位表。
对照第 3 节逐行都能对上:PFAIL/FAIL 对 SDOWN/ODOWN,主节点投票对领头选举,rank 延迟对”offset 大者优先”(把排序变成了起跑时间差)。
位置变了,配方没变。细节in yori - 持久化 & 集群 &哨兵。
5. 同一配方在别家的名字
5.1 ZooKeeper:选主专业户
ZAB 协议的选举规则一句话:先比 zxid(谁的日志更新),再比 myid(纯粹打破平局),拿到过半票的当 leader。
写请求全部由 leader 编号排序,广播到过半 follower 确认才提交。Kafka 老架构把”谁当 controller”这个选主问题外包给它:所有 broker 抢着在 ZooKeeper 创建同一个临时节点,创建成功者当选,其他人挂 watch 等它死。
5.2 Kafka KRaft:把外包收回来
新架构去掉 ZooKeeper,controller 小集群自己跑 Raft 选主和复制元数据。
每个 partition 的 leader 则由 controller 从 ISR 里指定(数据面的选主被收编成控制面的一次决策)。
上一章的 leader epoch 在这里闭环:每任 partition leader 的任期号由 controller 发放,旧 leader 的写入被任期号拒之门外。
5.3 MySQL:从裸方案到 MGR
keepalived + VIP(裸方案): 两台机器跑 VRRP 心跳,
主挂了备机把虚拟 IP 抢过来。没有多数派,
网络分区时两台各自认为对方死了,双主同时持有 VIP 写入,
脑裂风险完全自担,要靠额外的 fencing(强制断电/断网旧主)兜底
MGR 组复制: 事务先在本地执行不提交,把”改了哪些行”的
写集合(行主键哈希 + 版本)经组通信层广播,
组通信保证全体节点以同一顺序收到(全序广播,多数派确认)
每个节点独立跑同一套认证规则: 检查写集合和已提交事务有没有
改同一行的冲突,有冲突则按全序里先到的提交、后到的回滚
(first committer wins 的乐观并发,规则确定所以各节点结论一致)
单主/多主模式,分区时少数派自动只读。
配 MySQL Router 做路由就是官方 InnoDB Cluster
云 RDS: 把这一整章打包成产品卖,你只看到一个连接串
5.4 Raft:把全章零件组装成标准品
etcd、KRaft、TiKV、Nacos 的一致性内核都是 Raft,值得完整走一遍流程,因为它把本章每个零件都用上了:
Raft 把选主和日志安全收进同一套规则:
- 角色与任期:节点只有
leader、follower、candidate三种角色;全局使用单调递增的term标识任期。 - 触发选举:
follower在随机化选举超时(例如 150~300 ms)内没有收到 leader 心跳,就把term加一、转为candidate并向全体拉票。随机超时用于错开候选者,避免大家同时参选导致票被瓜分。 - 投票规则:每个节点在一个 term 内最多投一票,通常先到先得;候选人的日志还不能比投票者旧,先比较最后一条日志的 term,再比较日志长度。日志不完整的节点因此很难拿到多数票,这和 Sentinel 优先选择高 offset 从库是同一思路。
- 当选与压制:候选者拿到过半票后成为 leader,并立即周期性发送心跳,阻止新的选举超时。
- 复制与提交:客户端写入交给 leader,leader 追加日志并广播;过半节点落盘确认后才提交。term 与多数派交集共同保证,新 leader 必然包含全部已经提交的日志。
- 封印旧主:所有消息都携带 term;节点看到更高 term 会自动降级为 follower,旧 leader 的命令也会因任期过期而被拒绝。
6. 纪元编号:防脑裂的最后一道锁
把各家的”版本号”排在一起看:
Raft: term
ZooKeeper: epoch(zxid 的高 32 位)
Kafka: controller epoch / partition leader epoch
Redis Cluster: currentEpoch / configEpoch
Sentinel: 故障转移 epoch
机制完全一致:每次选主编号加一;所有指令都带编号;收到旧编号的指令直接拒绝。旧主从分区里回来,带着过期编号喊”我是主”,全网看一眼编号就让它降级。
这个思想值得抽出来单独存档:用单调递增的版本号淘汰旧时代的一切指令。它在小尺度上的化身随处可见:乐观锁的 version 字段(悲观锁 & 乐观锁 里 CAS 防 ABA 的版本号)、分布式锁的 fencing token(拿锁时领一个递增号,写存储时带上,存储端拒绝比已见过的小的号,防”锁过期后旧持有者的迟到写”)。
判死永远可能误判,纪元编号保证误判的后果可以被事后纠正,两者是搭档而不是替代。
7. 脑裂的完整图景:两道防线
把前面的零件合成一张防脑裂全图:
脑裂的成因: 网络分区 + 判死误判,两边各自选出或保留了一个”主”
第一道防线(选主侧): 多数派投票。
少数派分区凑不齐过半,根本选不出新主;
老主若在少数派,它的降级只是时间问题
第二道防线(数据侧): 纪元编号 + 写入门槛。
即便老主还没意识到自己被废,它的写入带着旧纪元被拒
(Raft/Kafka/Cluster),或因联系不上从库而自我拒写
(Redis min-replicas)
裸 VIP 方案两道防线都没有,所以必须外挂 fencing(断电旧主)
8. 联系实际:高可用方案的检查清单
评审或设计任何”自动故障转移”,六个问题挨个问:
- 判死阈值多少?一次 Full GC 或网络抖动会不会触发误切换?
- 裁判是单点还是多数派?几台?奇数吗?跨机房怎么摆
(两机房 2+2 永远凑不出过半,第三机房放一票是标准解)? - 新主怎么选?有没有”数据最全者优先”?
- 旧主复活怎么办?纪元编号、自我拒写、外部 fencing 至少占一样吗?
- 切换窗口里的写入丢不丢?业务能不能容忍?
- 客户端怎么知道换主了?(订阅通知/重查路由/DNS,各多久收敛?)
第 5 问最容易被糊弄。异步复制加自动切换的组合,丢失窗口是原理性存在的:方案宣称”绝不丢数据”,要么它用了多数派同步写(延迟涨了),要么它在撒谎。
9. 学完本章你能解决什么问题
- “没回应”的三种原因为什么原理上不可区分,阈值两头各是什么事故?
- 过半数的那句数学是什么,为什么 4 台不比 3 台强?
- Sentinel 三条周期任务各干什么,quorum 和过半分别把哪道门?
- 故障转移五步里,parallel-syncs 和候选筛选各防什么坑?
- min-replicas 两个参数怎么把脑裂写入窗口压小,代价是什么?
- Cluster 的 rank 延迟起跑把哪个排序规则变成了时间差?
- Raft 的随机选举超时、日志新旧比较、过半提交各保什么性质?
- fencing token 和乐观锁 version 和 leader epoch 共享什么思想?
- 两机房部署为什么永远差一票,标准解是什么?
核心一句话:判死靠心跳但永远可能误判,所以用多数派收敛意见;新主要数据最全,旧主靠单调递增的纪元封印;脑裂要选主侧和数据侧两道防线。哨兵只是这套配方在 Redis 里的商品名。