KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 哨兵:从"主库挂了"到"客户端跟上新主"的完整链路 — keel 龙骨
主从复制让数据多了一份,但主库挂了之后谁来选新主、谁来告诉客户端地址变了?哨兵干的就是这件事。这一章沿一次真实发生的切换往下走:谁先发现、怎么达成共识、怎么选新主、切换时写入怎样、以及最关键的一环——客户端怎么知道。
主从复制让数据多了一份,但主库挂了之后谁来选新主、谁来告诉客户端地址变了?哨兵干的就是这件事。这一章沿一次真实发生的切换往下走:谁先发现、怎么达成共识、怎么选新主、切换时写入怎样、以及最关键的一环——客户端怎么知道。
一、现场:主库进程被杀,站点写不进去了
凌晨三点,主库所在机器的 Redis 进程被 OOM killer 杀掉。此时从库还在,数据也还在(已同步的部分),但:
- 没有机制会自动把从库变成主库(
REPLICAOF不会自己解除); - 应用配置里写死了主库地址,于是所有写操作报"连接被拒";
- 人工处理流程是:挑一个从库 →
REPLICAOF NO ONE→ 把其他从库改指向它 → 改应用配置 → 重启应用。
这套流程哪怕熟练也要几分钟,凌晨三点则需要更久。哨兵把它自动化了。
先明确哨兵不是什么:它不是代理,不转发请求,不参与读写分离,也不做负载均衡。它只做四件事——监控、通知、自动故障转移、充当配置提供者(告诉客户端当前主库是谁)。最后一件才是客户端真正依赖的能力。
二、概念边界:两个"下线"与两个"多数"
| 概念 | 谁做出的判断 | 触发条件 | 后果 |
|---|---|---|---|
| SDOWN(主观下线) | 单个哨兵 | 该哨兵在 down-after-milliseconds 内没收到主库有效回复 |
只有这一个哨兵这么认为,不触发任何动作 |
| ODOWN(客观下线) | 达到 quorum 个哨兵都说 SDOWN | 哨兵之间互相询问 SENTINEL is-master-down-by-addr |
主库"确定"挂了,可以开始转移 |
然后是两个极易混淆的数字:
- quorum:判定 ODOWN 需要多少个哨兵同意,写在配置里。
- majority(多数派):真正动手做故障转移前,要先在哨兵之间选出一个领头羊,选举需要超过半数哨兵的票。
只有 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"
逐项解释:
flags: master—— 正常就是这个值;出现s_down/o_down说明它已经被判定下线(排障第一眼看这个)。num-slaves: 2—— 哨兵自动发现从库(通过读主库的INFO replication),不需要你列举从库地址。num-other-sentinels: 1—— 哨兵之间也互相发现。这是哨兵集群健康度的直接指标:3 个哨兵正常时应该都是 2;如果某个哨兵上报 0,说明它与同伴失联了。config-epoch—— 配置纪元,每次成功切换都会递增,用来让所有哨兵对"当前谁是主"达成一致。quorum/failover-timeout/parallel-syncs—— 下面逐个说。
三个参数的真实含义:
| 参数 | 作用 | 调大的代价 |
|---|---|---|
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
把每一行翻译成动作(# 开头是重要事件,* 是普通信息):
+sdown→+odown #quorum 1/1:先主观、后客观,quorum 达成;+elected-leader:某个哨兵赢得了领头羊选举(多数派投票);+failover-state-select-slave→+selected-slave:按规则挑出要扶正的从库;+failover-state-send-slaveof-noone:向它发REPLICAOF NO ONE;+failover-state-wait-promotion→+promoted-slave:等它INFO上报role:master;+failover-state-reconf-slaves:让其余从库改指向新主;+failover-end→+switch-master:对外可见的切换完成点,客户端订阅的就是这个事件。
注意第二次切换里的 +failover-end-for-timeout:它出现在"旧主已不可用、无法完成收尾"的情况下,说明切换流程有超时兜底,不会因为等一个死掉的节点而卡死。
选新主的规则(按优先级)
replica-priority(从库配置,默认 100;设为 0 表示永不被选为主),值小者优先;- 优先级相同时,选
slave_repl_offset最大的那个(数据最新的); - 再相同,选 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 章 |
八、主动破坏:三个必须做的演练
- 杀主库进程:观察
+sdown→+odown→+switch-master的时间差,记下你的系统实际不可用时长。这是唯一真实的 HA 指标。 - 手工强切:
redis-cli -p 26379 SENTINEL FAILOVER mymaster,不需要搞挂机器就能演练。注意它无视主库健康状态强制执行。 - 断掉一个哨兵:观察
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 章讲哨兵方案的诚实边界——什么数据会丢、锁为什么会失效、什么时候"高可用"是错觉。