KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 哨兵:从"主库挂了"到"客户端跟上新主"的完整链路 — keel 龙骨

主从复制让数据多了一份,但主库挂了之后谁来选新主、谁来告诉客户端地址变了?哨兵干的就是这件事。这一章沿一次真实发生的切换往下走:谁先发现、怎么达成共识、怎么选新主、切换时写入怎样、以及最关键的一环——客户端怎么知道。

主从复制让数据多了一份,但主库挂了之后谁来选新主、谁来告诉客户端地址变了?哨兵干的就是这件事。这一章沿一次真实发生的切换往下走:谁先发现、怎么达成共识、怎么选新主、切换时写入怎样、以及最关键的一环——客户端怎么知道。


一、现场:主库进程被杀,站点写不进去了

凌晨三点,主库所在机器的 Redis 进程被 OOM killer 杀掉。此时从库还在,数据也还在(已同步的部分),但:

这套流程哪怕熟练也要几分钟,凌晨三点则需要更久。哨兵把它自动化了。

先明确哨兵不是什么:它不是代理,不转发请求,不参与读写分离,也不做负载均衡。它只做四件事——监控、通知、自动故障转移、充当配置提供者(告诉客户端当前主库是谁)。最后一件才是客户端真正依赖的能力。

二、概念边界:两个"下线"与两个"多数"

概念 谁做出的判断 触发条件 后果
SDOWN(主观下线) 单个哨兵 该哨兵在 down-after-milliseconds 内没收到主库有效回复 只有这一个哨兵这么认为,不触发任何动作
ODOWN(客观下线) 达到 quorum 个哨兵都说 SDOWN 哨兵之间互相询问 SENTINEL is-master-down-by-addr 主库"确定"挂了,可以开始转移

然后是两个极易混淆的数字:

只有 quorum 不够。3 个哨兵、quorum=2,若其中一个哨兵自己也挂了,剩下的 2 个可以判定 ODOWN,但选领头羊需要 majority=2,两个都活着才能选出——这是可以的;但如果只剩 1 个哨兵活着,就永远选不出领头羊,切换不会发生。

一句话记忆:quorum 决定"认为它挂了",majority 决定"有没有资格动手"。

三、一次完整运行:哨兵起来后它知道什么

哨兵的最小配置(三个哨兵时 quorum 设 2):

port 26379
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 15000
sentinel parallel-syncs mymaster 1

启动方式(把同一个可执行文件当哨兵跑,这也是 Windows 上唯一的启动方式):

redis-server /path/to/sentinel.conf --sentinel

问它"你现在知道什么"(实测输出,字段有删节):

127.0.0.1:26379> SENTINEL master mymaster
 1) "name"
 2) "mymaster"
 3) "ip"
 4) "127.0.0.1"
 5) "port"
 6) "6379"
 7) "flags"
 8) "master"
 9) "last-ok-ping-reply"
10) "1003"
11) "down-after-milliseconds"
12) "5000"
13) "role-reported"
14) "master"
15) "config-epoch"
16) "0"
17) "num-slaves"
18) "2"
19) "num-other-sentinels"
20) "1"
21) "quorum"
22) "1"
23) "failover-timeout"
24) "15000"
25) "parallel-syncs"
26) "1"

逐项解释:

三个参数的真实含义:

参数 作用 调大的代价
down-after-milliseconds 多久没响应就判 SDOWN 发现故障更慢;调太小则网络抖动就误判
failover-timeout 一次转移的各类超时上限,也决定"同一主库两次切换之间的最小间隔"(约 2 倍) 切换节奏变慢
parallel-syncs 切换后同时向新主做全量同步的从库数量 值大:新主压力大(多个从库同时要 RDB);值小:从库追上得更慢

parallel-syncs 常被忽略:切换后所有从库都要重新与新主同步,而且通常是全量同步(新主的 replid 变了,第 01 章的判定规则)。设为 1 表示一次只让一个从库同步,对新主最友好。

四、切换全过程:一次真实切换的日志

下面是一次实际发生的切换(本机构建;先用 SENTINEL FAILOVER 手工触发,效果与真实宕机一致):

[46844] ... # +elected-leader master mymaster 127.0.0.1 6379
[46844] ... # +selected-slave slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[46844] ... # +promoted-slave slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[46844] ... # +failover-end master mymaster 127.0.0.1 6379
[46844] ... # +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6381

另一次由真实宕机触发的切换给出了更完整的中间状态(注意这次旧主在切换过程中也挂了):

[46844] ... # +sdown master mymaster 127.0.0.1 6381
[46844] ... # +odown master mymaster 127.0.0.1 6381 #quorum 1/1
[46844] ... # +failover-state-select-slave master mymaster 127.0.0.1 6381
[46844] ... * +failover-state-send-slaveof-noone slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6381
[46844] ... * +failover-state-wait-promotion slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6381
[46844] ... # +failover-state-reconf-slaves master mymaster 127.0.0.1 6381
[46844] ... # +failover-end-for-timeout master mymaster 127.0.0.1 6381
[46844] ... # +failover-end master mymaster 127.0.0.1 6381
[46844] ... # +switch-master mymaster 127.0.0.1 6381 127.0.0.1 6380
[46844] ... # +sdown slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6380

把每一行翻译成动作(# 开头是重要事件,* 是普通信息):

  1. +sdown → +odown #quorum 1/1:先主观、后客观,quorum 达成;
  2. +elected-leader:某个哨兵赢得了领头羊选举(多数派投票);
  3. +failover-state-select-slave → +selected-slave:按规则挑出要扶正的从库;
  4. +failover-state-send-slaveof-noone:向它发 REPLICAOF NO ONE;
  5. +failover-state-wait-promotion → +promoted-slave:等它 INFO 上报 role:master;
  6. +failover-state-reconf-slaves:让其余从库改指向新主;
  7. +failover-end → +switch-master:对外可见的切换完成点,客户端订阅的就是这个事件。

注意第二次切换里的 +failover-end-for-timeout:它出现在"旧主已不可用、无法完成收尾"的情况下,说明切换流程有超时兜底,不会因为等一个死掉的节点而卡死。

选新主的规则(按优先级)

  1. replica-priority(从库配置,默认 100;设为 0 表示永不被选为主),值小者优先;
  2. 优先级相同时,选 slave_repl_offset 最大的那个(数据最新的);
  3. 再相同,选 runid 字典序最小的(只为确定性)。

第 2 条是"切换后数据尽量新"的唯一保障,但它不保证最新——这是第 04 章的主题。

五、链路图:四个进程边界上的完整切换

sequenceDiagram
    participant S as 哨兵集群(≥3 个)
    participant M as 旧主 6379
    participant R as 从库 6381
    participant C as 应用客户端

    S->>M: PING(每秒)
    M--xS: 超时无响应(down-after-milliseconds)
    Note over S: +sdown(主观下线)
    S->>S: 互相询问,达到 quorum
    Note over S: +odown(客观下线)
    S->>S: 领头羊选举(需 majority 票)
    Note over S: +elected-leader
    Note over S: 选新主:priority → offset → runid
    S->>R: REPLICAOF NO ONE
    R-->>S: INFO 上报 role:master
    Note over S: +promoted-slave
    S->>M: (尝试)REPLICAOF 新主
    M--xS: 已宕机,无响应
    Note over S: +switch-master(对外可见的完成点)
    C->>M: 仍在用旧连接写入
    M--xC: 连接失败 / READONLY
    C->>S: SENTINEL get-master-addr-by-name mymaster
    S-->>C: 127.0.0.1:6381
    C->>R: 用新地址重连,继续写入

图里最关键的是最后三步:切换不由哨兵"通知"客户端完成,而是客户端在发现连接失效后主动回来问。下一节讲这件事。

六、客户端怎么跟上新主:三种方式,第二种是必需的

方式一:订阅哨兵的 +switch-master 事件(加速手段)

哨兵在切换完成后会向频道发布事件:

redis-cli -p 26379 PSUBSCRIBE '*'
# 切换时收到:
# +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6381

优点是最快。但订阅连接本身也会断,断线期间的事件会丢,所以它只能是加速手段,不能是唯一手段。

方式二:失败后重新问哨兵(必需)

这是哨兵客户端的标准做法,也是 redis-py 的实现。核心逻辑:写操作失败 → 怀疑主库换了 → 重新问哨兵要地址 → 用新地址重试。

import time
import redis
from redis.sentinel import Sentinel

sentinel = Sentinel(
    [("127.0.0.1", 26379)],
    socket_timeout=0.5,
    protocol=2,
    decode_responses=True,
    sentinel_kwargs={"protocol": 2},
)
master = sentinel.master_for("mymaster")


def safe_write(key: str, value: str, attempts: int = 3) -> None:
    """切换窗口里,旧连接会指向已被降级的节点——必须重试。"""
    for i in range(attempts):
        try:
            master.set(key, value)
            return
        except (redis.exceptions.ConnectionError, redis.exceptions.ReadOnlyError) as exc:
            print(f"第 {i + 1} 次写失败:{type(exc).__name__}: {exc}")
            time.sleep(0.5)
    raise RuntimeError("写入重试耗尽")

实测到的切换后现象(本机构建,切换完成后立即用旧 master_for 连接写入):

redis.exceptions.ConnectionError: The previous master is now a slave

这条异常是设计好的信号:redis-py 的哨兵连接管理器检测到"我连的这个节点不再是主库",抛错并触发重新发现。所以业务代码唯一必须做的事就是捕获它并重试。

为什么也要捕获 ReadOnlyError:如果客户端连的是被降级的旧主,而那个进程还活着(只是变成了从库),写会返回第 01 章那条 READONLY You can't write against a read only replica.。这与"主库彻底挂掉"是两种不同的失败表现,代码要一起处理。

实测输出(切换后重新查询):

127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster
1) "127.0.0.1"
2) "6380"

方式三:每次操作前都问一遍(兜底,不推荐)

有些语言没有成熟的哨兵客户端,就每次操作前 SENTINEL get-master-addr-by-name。代价是每条命令多一次哨兵往返。能用成熟客户端就别用这个。

七、误判澄清

误解 核对 结论
"哨兵会帮我把请求转发到正确的节点" 哨兵不参与数据路径,它只在切换时改节点的角色 客户端必须自己去问地址(或订阅事件)
"quorum 达到就会切换" 日志显示:ODOWN 之后还要 +elected-leader,领头羊需要 majority 票 quorum 负责"判定",majority 负责"授权动手"
"配了哨兵客户端就万事大吉" 实测切换后旧连接抛 The previous master is now a slave 客户端必须有重试,否则切换窗口内的写入直接失败
"切换是瞬间完成的" 判定期(down-after)+ 选举 + 提升 + 重配从库,各步都耗时 存在一个真实的不可写窗口,量级是秒级
"哨兵能防止数据丢失" 复制是异步的,新主可能缺少旧主最后几条写 见第 04 章

八、主动破坏:三个必须做的演练

  1. 杀主库进程:观察 +sdown → +odown → +switch-master 的时间差,记下你的系统实际不可用时长。这是唯一真实的 HA 指标。
  2. 手工强切:redis-cli -p 26379 SENTINEL FAILOVER mymaster,不需要搞挂机器就能演练。注意它无视主库健康状态强制执行。
  3. 断掉一个哨兵:观察 num-other-sentinels 下降,验证只剩 2 个哨兵时切换仍能发生、只剩 1 个时不发生(majority 规则)。

演练时必须同时观察应用侧:写请求是否全部失败、失败持续多久、重试是否生效、有没有数据写到了已降级的旧主上。

九、生产环境怎样替换

教学替身 生产替换 要点
1 个哨兵、quorum=1 至少 3 个哨兵、奇数个、跨机器部署,quorum=2 1 个哨兵没有 HA 意义(哨兵自己挂了就没了);2 个哨兵无法形成多数派
与 Redis 同机 哨兵与数据节点分开部署 同机部署时机器宕机会同时带走数据节点和哨兵
无认证 requirepass / sentinel auth-pass(Redis 6+ 用 ACL) 客户端连哨兵也要带凭据
客户端不重试 统一的 Redis 访问层,内建重试 让每个调用点自己写重试不可维护
只看进程存活 监控 +switch-master 事件、num-other-sentinels、flags 是否出现 s_down/o_down 切换次数本身就是重要指标:频繁切换说明网络或主库有问题

十、练习与验收

练习 1:把第 04 节的完整日志按时间顺序抄一遍,每行标注"此时主库是谁、客户端如果来写会怎样"。

练习 2:用 SENTINEL FAILOVER 触发切换,同时用脚本每秒写一次,记录失败次数与持续时长,算出你系统的实际不可用窗口。

练习 3:在切换进行中持续写入,确认你的代码能捕获 ConnectionError / ReadOnlyError 并重试成功;把重试去掉再跑一次,对比差异。

验收点:不看资料,说清 SDOWN 与 ODOWN 的区别、quorum 与 majority 分属哪一步、切换的七个动作、以及客户端为什么必须"失败后重新问哨兵"。


现在能解释什么:哨兵的四项职责与它不做的三件事;判定与选举的两级机制;一次切换的完整日志与七个动作;客户端跟上新主的三种方式及必需的那种;三个必须做的演练。

下一步:第 04 章讲哨兵方案的诚实边界——什么数据会丢、锁为什么会失效、什么时候"高可用"是错觉。

进入 keel 阅读