跳转至

Redis 哨兵(Sentinel)机制

💡 一句话概述

主从复制解决了"读扩展 + 数据冗余",但主节点挂了还得靠人爬起来改配置、切客户端;哨兵(Sentinel)就是那个替你值班的运维——它持续监控主从,主挂了就自动投票选一个从升为主,并把新主地址告诉所有客户端。它只解决**高可用**,不解决**容量与写扩展**(写仍然只有一个主),而且因为是异步复制,切换瞬间**可能丢数据**。


🔑 核心概念

  1. 哨兵(Sentinel):独立进程(默认端口 26379),不存业务数据,只负责监控、通知、自动故障转移、提供主地址。
  2. 主观下线 SDOWN:单个哨兵在 down-after-milliseconds 内 PING 不通某个实例——"我觉得它挂了"。
  3. 客观下线 ODOWN:多个哨兵都认为主挂了,票数 >= quorum——"大家投票确认它挂了"。只有 master 会被判 ODOWN。
  4. Leader 选举:故障转移不是谁想干就能干,哨兵之间用类 Raft 算法选出一个 Leader 来执行,需要拿到 max(quorum, 哨兵数/2+1) 票。
  5. Configuration Provider:客户端不直连 Redis IP,而是先问哨兵"mymaster 现在的主是谁",切主后客户端自动感知新地址。

📝 详解

1. 为什么需要哨兵:主从复制的"最后一公里"

先打个比方。主从复制像给病人配了备份的病历——一个主治医生(master)负责开药写字(可写),几个实习医生(replica)抄一份病历、帮忙接待轻症病人(可读)。抄病历这件事解决了两个问题:实习医生也能看病(读扩展),主治医生桌子烧了病历还在(数据冗余)。

但问题来了:主治医生突然心梗倒地,谁来接班?

没有哨兵的时候,流程是这样的(也就是所谓"人工干预"):

凌晨 3:00   master 进程 OOM 被 kill
凌晨 3:01   监控告警响了(或者没响)
凌晨 3:10   值班同学被电话叫醒,揉着眼睛登机器
凌晨 3:15   确认 master 真挂了,不是网络抖动
凌晨 3:20   挑一个数据最全的 replica,执行 SLAVEOF NO ONE
凌晨 3:25   把其他 replica 改成 SLAVEOF 新主
凌晨 3:30   改业务配置里的 master IP,滚动重启所有服务实例
凌晨 3:45   服务恢复。中间 45 分钟,全站写入不可用。

这 45 分钟就是"最后一公里"。哨兵做的事,就是把上面这一整套流程**自动化 + 秒级完成**:

环节 人工运维 哨兵
发现主挂了 监控告警 → 人看 → 人判断 每秒 PING,down-after-milliseconds 后自动判下线
避免误判 靠人的经验("是不是网络抖了?") 多个哨兵投票(quorum),少数派说了不算
选新主 人肉比对 INFO replication 的 offset 按优先级 / offset / runid 自动排序
切主 手敲 SLAVEOF 命令 Leader 自动下发 REPLICAOF NO ONE
通知客户端 改配置文件 + 重启服务 发布 +switch-master,客户端自动重连新主
耗时 几十分钟 通常几秒到几十秒

⚠️ 注意:哨兵**不会**帮你解决"写太多了扛不住"和"内存装不下了"这两个问题。那需要分片(Redis Cluster 或业务层分库)。

2. 哨兵的四大功能

Redis 官方文档把哨兵的职责归纳成四件事,面试常考:

功能 英文 具体做什么
监控 Monitoring 持续检查 master、replica、其他哨兵是否正常工作(PING + INFO 轮询)
通知 Notification 发现异常时,通过 API 通知运维系统,再由运维系统转发邮件 / 钉钉 / 企业微信 / Slack
自动故障转移 Automatic Failover master 挂了,自动把一个 replica 提升为新 master,并让其他 replica 改挂到新主
配置提供者 Configuration Provider 客户端启动时不连 Redis,而是先连哨兵问:"服务名 mymaster 现在的主节点地址是什么?"

这四个功能是有**顺序依赖**的:先能监控,才谈得上通知;能通知 + 能投票,才敢做故障转移;故障转移做完了,还得有个统一的地方告诉客户端"新主是谁",否则切了也白切。

关于"通知"多说一句:哨兵自己**不直接发邮件**。它的做法是把事件 PUBLISH 到自己进程内的 pub/sub 频道(如 +sdown、+odown、+switch-master),或者调用 sentinel notification-script 配置的脚本。你的运维系统订阅这些频道,再决定往哪个 IM 推。

哨兵内部事件频道(在哨兵进程的 26379 端口上订阅):
  +reset-master     主地址被重置
  +slave / +replica 发现新的从节点
  +failover-state-* 故障转移进入某个阶段
  +failover-end     故障转移完成
  +switch-master    主从切换完成 ← 客户端最关心的一个
  +sdown / -sdown   主观下线 / 主观下线解除
  +odown / -odown   客观下线 / 客观下线解除

3. 部署形态:独立进程,生产至少 3 个

哨兵**不是** Redis 的一个功能开关,它是一个**独立的进程**,虽然用的是同一份 redis-server 二进制:

# 两种等价的启动方式
redis-sentinel /etc/redis/sentinel.conf
redis-server /etc/redis/sentinel.conf --sentinel

启动后它监听 26379 端口(哨兵之间互相同步用的也是这个端口,不是 6379)。它不存业务数据,只存"谁是我的 master、有哪些 replica、有哪些哨兵同伴"这些元信息,而且**会把自己写回 sentinel.conf**——所以哨兵的配置文件启动后会变,别用只读卷挂载它。

一套典型的哨兵集群长这样:

              ┌──────────── 哨兵集群(独立进程,:26379)─────────────┐
              │   ┌───────────┐   ┌───────────┐   ┌───────────┐      │
   客户端 ───►│   │Sentinel-1 │   │Sentinel-2 │   │Sentinel-3 │      │
 (先问主地址)│   └─────┬─────┘   └─────┬─────┘   └─────┬─────┘      │
              │         └──── hello 消息互相发现/互相监控 ────┘         │
              └─────────────────────────┬────────────────────────────┘
                                        │  每 1s  PING
                                        │  每 2s  向 __sentinel__:hello 发 hello
                                        │  每 10s INFO(顺带发现新 replica)
        ┌───────────────────────────────┼───────────────────────────────┐
        ▼                               ▼                               ▼
 ┌──────────────┐    异步复制     ┌──────────────┐              ┌──────────────┐
 │   master     ├───────────────► │  replica-1   │              │  replica-2   │
 │10.0.0.21:6379│                 │10.0.0.22:6379│              │10.0.0.23:6379│
 │   可读可写    │                 │    只读      │              │    只读      │
 └──────▲───────┘                 └──────────────┘              └──────────────┘
        │
        └──── 客户端从哨兵拿到地址后,直连 master 读写

生产环境的三条硬要求:

要求 为什么
至少 3 个哨兵 选举需要多数派。2 个哨兵时挂 1 个只剩 1 个,凑不出多数派,故障转移直接瘫痪
奇数个 4 个哨兵的多数派是 3,容错能力跟 3 个哨兵一样(都只能挂 1 个),白多一台机器
分布在不同物理机 / 不同机架 / 不同机房 一台机器挂了同时带走 master 和它上面的哨兵,等于哨兵数量凭空少一个

容错能力对照表(多数派 = 哨兵数/2 + 1):

哨兵数量 多数派 最多能挂几个哨兵还能故障转移
1 1 0(挂了整个高可用没了,纯玩具)
2 2 0(挂 1 个就废,比 3 个还差)
3 2 1 ✅ 最小生产配置
5 3 2 ✅ 推荐跨机房配置
7 4 3(一般用不到)

一个极其重要的对称性:哨兵节点自己也**被其他哨兵监控**。Sentinel-1 挂了,Sentinel-2 和 Sentinel-3 会把它标成 SDOWN,后续选举自动不再算它那一票。

4. 主观下线 SDOWN vs 客观下线 ODOWN

这是哨兵最容易考混的一对概念。用护士的比方:SDOWN 是"我这个班次的护士发现 3 号床没呼吸了",ODOWN 是"叫来另外两个护士一起确认,大家都说没呼吸了"。

  • SDOWN(Subjectively Down,主观下线):单个哨兵在 down-after-milliseconds 时间内向某个实例发 PING,没有收到**有效回复**(有效回复包括 +PONG、-LOADING、-MASTERDOWN 三种),就单方面标记它为 SDOWN。
  • ODOWN(Objectively Down,客观下线):SDOWN 只是"一家之言",可能是网络抖动、可能是这个哨兵自己网卡抽风。所以对 master,哨兵还要做第二步:向其他哨兵发 SENTINEL is-master-down-by-addr 询问"你们也觉得主挂了吗"。当认为主已下线的哨兵数量(含自己)>= quorum 时,这个哨兵才把 master 标记为 ODOWN。
graph TD
    A["哨兵每 1s PING 一个实例"] --> B{"down-after-milliseconds 内<br/>收到有效回复?"}
    B -->|是| C["状态 online"]
    B -->|否| D["标记 SDOWN(主观下线)"]
    D --> E{"它是什么角色?"}
    E -->|"replica / 其他哨兵"| F["停在 SDOWN<br/>永远不会升级成 ODOWN"]
    E -->|master| G["向其他哨兵发<br/>SENTINEL is-master-down-by-addr"]
    G --> H{"认为主下线的哨兵数<br/>≥ quorum ?"}
    H -->|否| I["仅 SDOWN,不触发故障转移<br/>(继续问,票数够了再说)"]
    H -->|是| J["标记 ODOWN(客观下线)<br/>→ 触发 Leader 选举"]

关键结论(面试高频):

实例类型 会 SDOWN 吗 会 ODOWN 吗 原因
master ✅ ✅ 只有主挂了才需要故障转移,值得多哨兵投票确认
replica ✅ ❌ 从挂了不影响可用性,没必要投票
其他 sentinel ✅ ❌ 哨兵挂了只需从选举名单里摘掉即可

还有一点很反直觉:ODOWN 是"局部共识",不是全局状态。Sentinel-1 凑够了 3 票认为主 ODOWN,Sentinel-2 可能因为自己刚好也问了一圈但票没凑够,仍然认为只是 SDOWN。这没关系——只要**有一个**哨兵认定 ODOWN 并发起选举,故障转移就能往前走。

另外,SENTINEL is-master-down-by-addr 这个命令是**一鱼两吃**的:

SENTINEL is-master-down-by-addr <ip> <port> <current-epoch> <runid>

第 4 个参数 runid 传 "*"  → 我只是想问问主挂没挂(探测阶段)
第 4 个参数 runid 传自己的 runid → 我在给自己拉票(选举阶段)

返回值里会带上 leader(我认为的 Leader 是谁)和 vote-granted(我给不给你投票),选举就是复用这个命令完成的。

5. 故障转移完整流程(重点)

把整条链路走一遍。假设 3 个哨兵、quorum = 2、down-after-milliseconds = 5000。

sequenceDiagram
    autonumber
    participant C as 客户端
    participant S1 as Sentinel-1
    participant S2 as Sentinel-2
    participant S3 as Sentinel-3
    participant M as master 21
    participant R1 as replica-1 22
    participant R2 as replica-2 23

    Note over S1,M: 阶段一 下线判定
    S1->>M: PING(每秒)
    Note over S1,M: 连续 5000ms 无有效回复
    Note over S1: master 标记 SDOWN
    S1->>S2: SENTINEL is-master-down-by-addr 21 6379 epoch *
    S2-->>S1: down=1(我也认为它挂了)
    S1->>S3: SENTINEL is-master-down-by-addr 21 6379 epoch *
    S3-->>S1: down=1
    Note over S1: 赞同票 3 大于等于 quorum=2 → ODOWN

    Note over S1,S3: 阶段二 Leader 选举(类 Raft)
    S1->>S1: current_epoch++,先投自己一票
    S1->>S2: is-master-down-by-addr 21 6379 epoch5 S1runid
    S2-->>S1: vote-granted, leader=S1
    S1->>S3: is-master-down-by-addr 21 6379 epoch5 S1runid
    S3-->>S1: vote-granted, leader=S1
    Note over S1: 得票 3 大于等于 max(quorum=2, 3/2+1=2) → 当选 Leader

    Note over S1,R2: 阶段三 选新主 + 执行切换
    Note over S1: 候选排序:priority → offset → runid<br/>选中 replica-1
    S1->>R1: REPLICAOF NO ONE
    R1-->>S1: OK
    Note over S1: 每秒轮询 R1 的 INFO<br/>直到 role:master
    S1->>R2: REPLICAOF 10.0.0.22 6379
    S1->>S2: 通过 __sentinel__:hello 广播新配置(config_epoch++)
    S1->>S3: 同上
    M-->>S1: (master 复活了)
    S1->>M: REPLICAOF 10.0.0.22 6379(旧主降级为从)
    S1-->>C: PUBLISH +switch-master mymaster 21 6379 22 6379
    C->>R1: 重新解析主地址,连上新主,写入恢复

下面把六个步骤逐个拆开讲。

步骤 1:客观下线判定

见上一节。down-after-milliseconds 是这个阶段唯一的旋钮,它同时决定了**误判概率**和**发现速度**:

取值 效果
太小(如 1000ms) 一次 GC 停顿 / 网络抖动就可能触发故障转移,误切
默认 30000ms 稳妥但慢,故障后 30 秒才开始动手
生产常用 5000~10000ms 在"发现速度"和"抗抖动"之间取平衡

步骤 2:Leader 选举

为什么需要 Leader? 因为故障转移是个"只能有一个人在操作"的动作。如果 3 个哨兵同时认定主挂了、同时去提升 replica,那 replica-1 被 S1 提升、replica-2 被 S2 提升,就会出现**两个主同时接受写入**——数据直接分叉,这是灾难。所以必须选一个代表来干活。

哨兵用的是一套**简化版 Raft**:

① 每个哨兵维护一个 current_epoch(纪元),启动时从本地配置读,选举时 +1
② 谁先认定 ODOWN,谁就先当候选人:epoch++,投票给自己,然后拿着自己的 runid
   去向其他哨兵发 is-master-down-by-addr 拉票
③ 每个哨兵在同一个 epoch 内【只能投一票,先到先得】
   —— 已经投给别人了,就回复对方 leader=<已投的那位>
④ 候选人收到的票数 >= max(quorum, 哨兵总数/2 + 1) → 当选 Leader
⑤ 本轮没选出 Leader(票不够 / 候选人对撞)→ 不会立刻重来,而是等一个
   与 failover-timeout 相关的间隔(并做随机化)后,epoch 再 +1 开始新一轮选举
   —— 随机化是为了避免两个候选人每轮都同时发起、互相把对方的票吃掉

这里藏着**全文最重要的一句话**:

quorum 只决定"判定主下线需要几票",它不决定"能不能执行故障转移"。 真正执行 failover 的 Leader,必须拿到**哨兵总数的多数派**(哨兵数/2 + 1)选票。

举个数字例子,3 个哨兵、quorum = 1:

存活哨兵数 能否判 ODOWN(需 >= 1 票) 能否选出 Leader(需 >= 2 票) 结果
3 ✅ ✅ 正常故障转移
2 ✅(1 票就够) ✅(2 票刚好等于多数派) 能故障转移
1 ✅(它自己一票就够) ❌(只有 1 票,多数派要 2 票) 认定主挂了,但永远切不动

所以 quorum = 1 绝不等于"一个哨兵就能自己切主"。这也是为什么"哨兵只部署 2 个甚至 1 个"是致命错误——挂了之后系统会卡在一个非常别扭的状态:明明知道主挂了,就是不肯切。

步骤 3:Leader 挑选新主

Leader 从所有 replica 里挑一个"最合适的"。这是一个**三级排序**,前一级分不出胜负才看下一级:

第 0 步:先淘汰不合格的
  ✗ 已经 SDOWN 的 replica
  ✗ 与哨兵断开连接太久的 replica
  ✗ master_link_down_time > down-after-milliseconds × 10 的 replica
     (主从链路断了太久,数据落后太多,提升它等于丢一大片数据)
  ✗ 最近 5 秒内没响应过哨兵 PING 的 replica

第 1 级:replica-priority(旧名 slave-priority)—— 值越小优先级越高
        replica-priority = 0 表示【永远不参与选举】(人工禁用的副本)
        默认值 100

第 2 级:replication offset 最大 —— 数据最全,丢数据最少
        offset 是从主节点复制过来的字节数偏移量,越大说明追得越紧

第 3 级:run id 最小 —— 字典序最小的那个
        纯粹为了打破平局,保证选举结果确定、可复现

为什么 offset 是第二重要的判据? 因为 Redis 主从是**异步复制**的:master 收到写请求 → 立刻回 OK 给客户端 → 再异步把命令发给 replica。所以任何时刻 replica 的数据都**落后** master 一点点。挑 offset 最大的那个,就是把这点损失压到最小。

步骤 4:执行切换

Leader 对被选中的 replica 发 REPLICAOF NO ONE(旧名 SLAVEOF NO ONE),然后**每秒轮询它的 INFO**,直到看到 role:master 才继续往下走。

接着对其余 replica 发 REPLICAOF <新主IP> <新主端口>,让它们改挂到新主。这一步受 parallel-syncs 控制:

parallel-syncs = 1(默认):
  replica-2 先同步 → 同步完 → replica-3 再同步
  ✅ 任何时刻都有尽可能多的 replica 在线提供读服务
  ❌ 整体恢复时间长

parallel-syncs = 2:
  replica-2 和 replica-3 同时同步
  ✅ 恢复快
  ❌ 同步期间大量 replica 不可读,读请求全压到新主上

parallel-syncs 越小,故障转移期间对服务的影响越小,但整个恢复过程越慢。副本数量多(比如 5 个以上)时可以适当调大到 2~3。

步骤 5:旧主复活后自动降级

这是哨兵非常贴心的一个设计。老 master 修好了重新启动,它内存里还留着 replicaof 为空的状态,会以为自己是主,开始接受写入——这就双主了。

哨兵的处理:老 master 一上线,哨兵立刻向它发送 REPLICAOF <新主IP> <新主端口>,把它降级成新主的从节点,然后它开始全量/增量同步新主的数据。

故障转移前                              故障转移后
──────────────────────────            ──────────────────────────
  master(21) ✗ 挂了                      replica-1(22) ★ 新主,可写
     ▲      ▲                                ▲      ▲
     │      └─── replica-2(23) 只读          │      └─── replica-2(23) 只读
     └────────── replica-1(22) 只读          └────────── master(21) 只读
                                                       ↑ 复活后自动降级
  客户端 → 问哨兵 → 21:6379                  客户端 → 问哨兵 → 22:6379

旧主复活那一刻的写入会全部丢失

从"master 挂掉"到"哨兵把它降级为 replica"这段时间里,如果 master 其实没挂、只是网络分区(哨兵连不上它,但客户端还连得上),它仍在接受写入。等分区恢复、它被降级为 replica 时,会**清空自己的数据去同步新主**——这期间的所有写入,永久丢失,无法找回。这就是"脑裂",详见常见陷阱。

步骤 6:配置传播与客户端通知

切换完成后,新配置需要让所有人知道。哨兵用了**两套不同的通道**,很容易搞混:

通道 在哪里 给谁看 干什么
__sentinel__:hello 被监控的 master / replica 上的 pub/sub 频道 其他哨兵 哨兵每 2 秒往这里发一条 hello 消息,内容是"我是谁、我的 IP 端口、我认为当前主是谁、主的 config_epoch"。用于**哨兵互相发现**和**配置传播**
+switch-master 等 哨兵自己(26379 端口)上的 pub/sub 频道 客户端 / 运维系统 通知"主地址从 A 变成了 B"

hello 消息的格式(在 master 上 PSUBSCRIBE __sentinel__:* 就能抓到):

<sentinel-ip>,<sentinel-port>,<sentinel-runid>,<current-epoch>,
<master-name>,<master-ip>,<master-port>,<master-config-epoch>

例:
10.0.0.11,26379,8a2f3...,5,mymaster,10.0.0.22,6379,7

客户端的正确姿势:不要自己硬编码 Redis 主节点 IP,而是把哨兵地址列表交给客户端库,由库去处理"问地址 + 订阅 +switch-master + 重连"这一整套。见下面的代码示例。

6. 关键配置项

sentinel.conf 里的配置项分两类:手写的**和**哨兵自己维护的。手写的只有前 5 个,剩下的是哨兵运行时自动追加进配置文件的——这就是为什么你的 sentinel.conf 启动几次后会越来越长。

配置项 说明 默认值
sentinel monitor <master-name> <ip> <port> <quorum> 声明要监控的主节点。master-name 是客户端寻址用的服务名;quorum 是判定 ODOWN 所需票数 无(必填)
sentinel down-after-milliseconds <master-name> <ms> 多久 PING 不通就判主观下线 30000
sentinel failover-timeout <master-name> <ms> 故障转移各环节的超时基准:重启一轮 failover 需等 2× 该值;纠正挂错主的 replica 需等 1×;取消未产生变更的 failover 也用它 180000(3 分钟)
sentinel parallel-syncs <master-name> <num> 故障转移时**同时**向新主发起同步的 replica 数量。越小对读服务影响越小,恢复越慢 1
sentinel auth-pass <master-name> <password> 被监控 Redis 设了 requirepass 时,哨兵连它要用的密码 无
sentinel auth-user <master-name> <username> 配合 ACL 使用的用户名(Redis 6+) 无
sentinel announce-ip / announce-port 哨兵对外宣告的地址,容器 / NAT 环境必填 自动探测
sentinel notification-script <master-name> <path> 主观/客观下线时调用的通知脚本 无
sentinel client-reconfig-script <master-name> <path> 切主完成后调用,用于自动改客户端配置 无
requirepass(写在 sentinel.conf 里) 给哨兵自己设密码,客户端用 SentinelPassword 连 无

**哨兵自动维护、不要手改**的项:sentinel myid、sentinel config-epoch、sentinel leader-epoch、sentinel known-replica、sentinel known-sentinel、sentinel current-epoch。

而下面这些是写在 Redis 实例自己的 redis.conf 里,不是 sentinel.conf——这是实操中最高频的踩坑点:

配置项(redis.conf) 写在哪个节点 说明 默认值
replica-priority(旧名 slave-priority) replica 选新主时的优先级,值越小越优先,0 = 永不当选 100
min-replicas-to-write master 在线从库少于 N 个时主节点拒绝写入,用于**缓解脑裂** 0(关闭)
min-replicas-max-lag master 从库延迟超过 N 秒就不算"在线从库",与上面配套 10
replica-serve-stale-data replica 与主断开时,继续用旧数据应答还是直接报错 yes
requirepass 全部实例 实例自己的访问密码,哨兵侧要配 sentinel auth-pass 才能连上 无

一张图理清三类文件的分工:

┌── sentinel.conf ──────────────┐   ┌── master 的 redis.conf ─────┐   ┌── replica 的 redis.conf ───┐
│ 谁是我的监控对象               │   │ 我自己怎么防脑裂            │   │ 我要跟谁同步、我能不能当主  │
│                               │   │                            │   │                            │
│ sentinel monitor mymaster ... │   │ min-replicas-to-write 1    │   │ replicaof 10.0.0.21 6379   │
│ sentinel down-after-ms ...    │   │ min-replicas-max-lag 10    │   │ replica-priority 100       │
│ sentinel failover-timeout ... │   │ requirepass ...            │   │ replica-serve-stale-data   │
│ sentinel parallel-syncs ...   │   │ appendonly yes             │   │ appendonly yes             │
│ sentinel auth-pass mymaster.. │   │                            │   │                            │
│ requirepass ...(哨兵自己的)  │   │                            │   │                            │
└───────────────────────────────┘   └────────────────────────────┘   └────────────────────────────┘
        ▲ 决定"怎么切"                        ▲ 决定"切的时候少丢数据"           ▲ 决定"谁能被切成主"

两个最容易写错位置的配置

  • replica-priority 写进 sentinel.conf:不生效。它是 Redis 实例自己的属性,哨兵是通过每 10 秒一次的 INFO 命令**读**出来的,不是在哨兵侧配的。想立即生效可以直接在 replica 上执行 CONFIG SET replica-priority 50(注意 CONFIG SET 不落盘,重启会丢,最终还是得写进 redis.conf)。
  • min-replicas-to-write 写进 sentinel.conf:同样不生效,它必须在**被监控的 master** 的 redis.conf 里。而故障转移后新主是从某个 replica 升上来的——所以实践上**所有实例的 redis.conf 都统一带上这两行**,才能保证不管谁当主都有防脑裂保护。

7. 哨兵之间是怎么互相认识的

新手最常问的问题:"我起了 3 个哨兵,需要在每个哨兵的配置里把另外两个的地址都写上吗?"

不需要。 只写 sentinel monitor 指向 master 就够了,其余全自动:

graph LR
    A["Sentinel-1 启动<br/>只配了 monitor master"] -->|"每 2s 发布 hello 到<br/>__sentinel__:hello"| M["master:6379<br/>pub/sub 频道"]
    B["Sentinel-2 启动<br/>只配了 monitor master"] -->|订阅| M
    C["Sentinel-3 启动<br/>只配了 monitor master"] -->|订阅| M
    M -->|"转发 hello"| B
    M -->|"转发 hello"| C
    B -.->|"解析出 Sentinel-1 的地址<br/>建立直连,写入 known-sentinel"| A
    C -.->|"同上"| A

机制拆开看:

  1. 每个哨兵在 master(和每个 replica)上 SUBSCRIBE __sentinel__:hello;
  2. 同时每 2 秒**向这个频道 **PUBLISH 一条自己的 hello 消息(含自己的 IP、端口、runid、epoch,以及它认为的 master 地址和 config_epoch);
  3. 其他哨兵订阅到这条消息,就"认识"了这个新同伴,建立起哨兵之间的直连(走 26379 端口),并把它记进本地配置的 sentinel known-sentinel;
  4. 同理,哨兵每 10 秒**对 master 执行一次 INFO,从返回结果里解析出所有 replica 的地址,自动记进 sentinel known-replica——**所以新增一个 replica 也不需要改哨兵配置。

唯一的硬要求:新哨兵必须能连上 master,否则它谁也不认识。

🔥 一个坑:如果 master 设了 requirepass,而你忘了在 sentinel.conf 里配 sentinel auth-pass,哨兵会连不上 master,于是**发现不了任何其他哨兵,也发现不了任何 replica**——整个哨兵集群静默失效,SENTINEL masters 看着正常但 num-other-sentinels 是 0。排查哨兵问题时,先看这个数字。

每个哨兵都在跑的定时任务(这张表能解释上面所有"自动发现"行为):

周期 对谁 做什么 作用
每 1 秒 所有 master、replica、其他哨兵 发 PING 判定 SDOWN / ODOWN 的唯一依据
每 2 秒 master(通过 pub/sub) 向 __sentinel__:hello 发布一条 hello 消息 让同伴发现自己;同步当前 master 地址与 config_epoch
每 10 秒 master、replica 发 INFO 发现新的 replica;确认 master 当前角色(role: 字段)

所以:

  • 新哨兵上线后,**最多 2 秒**就会被其他哨兵通过 hello 消息发现;
  • 新 replica 挂到 master 上后,**最多 10 秒**就会被哨兵通过 INFO 发现并纳入监控;
  • 判定一个实例下线,至少需要 down-after-milliseconds(因为它就是"连续 PING 失败多久"的阈值),这也是故障发现速度的下限。

8. 客户端寻址协议:怎么找到"当前主"

"配置提供者"是四个功能里最容易被忽略、但对业务代码影响最大的一个。核心思想一句话:

客户端永远不要记住 master 的地址,只记住哨兵的地址和 master 的名字。

一次完整的寻址流程:

sequenceDiagram
    autonumber
    participant App as Go 服务
    participant S as 哨兵 26379
    participant M as master

    Note over App,S: ① 启动阶段(一次性)
    App->>S: SENTINEL get-master-addr-by-name mymaster
    S-->>App: ["10.0.0.21", "6379"]
    App->>App: 用该地址建立连接池
    App->>S: SUBSCRIBE +switch-master<br/>(保持长连接,等推送)

    Note over App,M: ② 正常读写(不再碰哨兵)
    App->>M: SET / GET ...
    M-->>App: OK

    Note over App,S: ③ 故障转移发生
    S-->>App: +switch-master mymaster<br/>10.0.0.21 6379 → 10.0.0.22 6379
    App->>App: 丢弃旧连接池,指向新主
    App->>M: 重连新主,业务恢复

关键点:正常读写期间客户端是不碰哨兵的,它拿着地址直连 master,性能跟普通客户端完全一样。哨兵只在两个时刻被用到——启动时问一次地址,以及切主时收一次推送。

那"定期拉取"体现在哪?两条兜底路径:

路径 触发方式 说明
推送(主) 客户端在哨兵上 SUBSCRIBE +switch-master 切主完成的瞬间就知道,延迟最低。go-redis、Jedis、redis-py 都用这条
重连时拉取(兜底 1) 命令报错(连接失败 / READONLY) 连接池发现连接坏了,重新调 get-master-addr-by-name 解析
定期轮询(兜底 2) 定时器周期性询问哨兵 有些实现(如早期 Jedis、部分自研客户端)会每隔若干秒主动问一次,防止 pub/sub 长连接静默断开导致漏消息

自己实现客户端时,最小可用版本就是这样(伪代码):

// 极简版哨兵寻址:真实项目请直接用 NewFailoverClient,这里只为看清原理
// 片段省略了 import,实际需要:context / errors / strings / sync / time
// 以及 github.com/redis/go-redis/v9
type sentinelAwareClient struct {
    masterName string
    sentinels  []string // 哨兵地址列表,全填上
    cur        *redis.Client
    curAddr    string // 自己记住当前主地址,避免依赖客户端库的内部字段
    mu         sync.RWMutex
}

// resolveMaster 挨个问哨兵"当前主是谁",第一个答上来的就用
// 任何一个哨兵挂掉都不影响 —— 这就是为什么 SentinelAddrs 要填全
func (c *sentinelAwareClient) resolveMaster(ctx context.Context) (string, error) {
    for _, addr := range c.sentinels {
        sc := redis.NewSentinelClient(&redis.Options{Addr: addr, DialTimeout: 2 * time.Second})
        res, err := sc.GetMasterAddrByName(ctx, c.masterName).Result()
        sc.Close()
        if err == nil && len(res) == 2 {
            return res[0] + ":" + res[1], nil
        }
    }
    return "", errors.New("所有哨兵均不可达,无法解析主节点地址")
}

// refresh 定期 + 出错时调用,把连接池指向最新主节点
func (c *sentinelAwareClient) refresh(ctx context.Context) error {
    addr, err := c.resolveMaster(ctx)
    if err != nil {
        return err
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.cur != nil && c.curAddr == addr {
        return nil // 地址没变,什么都不做(避免无意义地重建连接池)
    }
    if c.cur != nil {
        c.cur.Close() // 关掉指向老主的连接池
    }
    c.cur = redis.NewClient(&redis.Options{Addr: addr})
    c.curAddr = addr
    return nil
}

// watch 订阅切主事件,一有推送立刻刷新(推送优先,轮询兜底)
func (c *sentinelAwareClient) watch(ctx context.Context) {
    // 注意:这里连的是哨兵端口,不是 Redis 端口
    sentinelConn := redis.NewClient(&redis.Options{
        Addr:        c.sentinels[0],
        DialTimeout: 2 * time.Second,
    })
    pubsub := sentinelConn.Subscribe(ctx, "+switch-master")
    defer pubsub.Close()
    defer sentinelConn.Close() // 别泄漏连接,哨兵侧也要维持连接池

    ch := pubsub.Channel()
    ticker := time.NewTicker(30 * time.Second) // 定期兜底轮询
    defer ticker.Stop()

    for {
        select {
        case msg, ok := <-ch:
            if !ok {
                return // pub/sub 连接被关掉,退出,由外层重启
            }
            // payload: "mymaster 10.0.0.21 6379 10.0.0.22 6379"
            if strings.Contains(msg.Payload, c.masterName) {
                _ = c.refresh(ctx)
            }
        case <-ticker.C:
            _ = c.refresh(ctx) // pub/sub 静默断开时的兜底
        case <-ctx.Done():
            return
        }
    }
}

生产环境的三种接入方式

方式 做法 适用
客户端直连哨兵(主流) NewFailoverClient / Jedis JedisSentinelPool / redis-py Sentinel 自研服务、能改代码的场景
VIP / LVS 漂移 切主时由脚本把 VIP 漂到新主,客户端仍连固定 VIP 老系统改造成本高的场景
代理层 Twemproxy / Codis / 云厂商 Redis 代理,代理自己去问哨兵 多语言混用、想统一收口连接管理

9. 哨兵的局限

局限 说明 应对
① 只解决高可用,不解决容量和写扩展 无论挂几个 replica,写入永远只有一个 master,单机内存上限、单线程写入上限一个都没突破 需要分片:Redis Cluster,或业务层一致性哈希分库
② 异步复制 → 切主可能丢数据 master 回 OK 给客户端之后、还没同步给 replica 就挂了,这部分写入随旧主一起消失。脑裂场景下丢得更多 min-replicas-to-write 1 + min-replicas-max-lag 10 缩小窗口;关键数据落 DB,别把 Redis 当唯一真源
③ 客户端必须支持哨兵协议 客户端如果硬编码 10.0.0.21:6379,切主后它会一直往老地址撞墙,报连接超时或 READONLY You can't write against a read only replica 用 NewFailoverClient 这类封装;或走 VIP / 代理层(如 Twemproxy、Codis、云厂商代理)
④ 故障转移不是瞬时的 从"主挂"到"客户端能写新主",通常是 down-after-milliseconds + 选举 + 提升 + 客户端重连,几秒到几十秒 业务侧做重试与降级(写失败进本地队列 / 返回友好错误),别假设 Redis 永远可用
⑤ 哨兵自身可能误判 哨兵所在机器负载高、网络抖动,都可能触发不必要的 failover 调大 down-after-milliseconds;哨兵跨机房部署;避免哨兵和高负载服务混部

哨兵 vs Redis Cluster 怎么选?

维度 Sentinel Cluster
解决什么 高可用(主挂了自动切) 高可用 + 水平扩展(数据分片到多主)
写入能力 单主,等于单机上限 多主,可线性扩展
数据分布 每个节点都有全量数据 16384 个 slot 分片存储
客户端复杂度 低(只多一步"问主地址") 高(要处理 MOVED / ASK 重定向)
命令限制 无 跨 slot 的多 key 命令受限(MSET、Lua 脚本要用 hash tag)
适用规模 数据量单机装得下(几十 GB 以内) 数据量大 / 写入压力大
运维成本 低(多起 3 个小进程) 高(至少 3 主 3 从 6 个节点起)

结论:数据量单机装得下 → 主从 + 哨兵,简单够用;装不下或写压力顶不住 → Cluster。


💻 代码示例

sentinel.conf 配置片段

# /etc/redis/sentinel.conf  (三个哨兵节点上内容基本一致,只有 myid 不同)

# 哨兵监听端口,默认 26379
port 26379

# 哨兵自己的密码(Redis 5.0.1+),客户端用 SentinelPassword 连接
requirepass "sentinel-secret"

# 容器 / NAT 环境下必须显式宣告自己的地址,否则其他哨兵连不上你
# announce-ip 10.0.0.11
# announce-port 26379

# ============ 核心:声明要监控的主节点 ============
# 格式:sentinel monitor <服务名> <master-ip> <master-port> <quorum>
# 服务名 mymaster 是客户端寻址用的 key,全集群必须一致
# quorum=2:3 个哨兵中至少 2 个认为主挂了,才判定 ODOWN
sentinel monitor mymaster 10.0.0.21 6379 2

# 主节点有 requirepass 时必须配,否则哨兵连不上 master,
# 后果是发现不了 replica、也发现不了其他哨兵(静默失效)
sentinel auth-pass mymaster "redis-secret"
# Redis 6+ ACL 场景还需要:
# sentinel auth-user mymaster sentinel_user

# 5 秒 PING 不通就判主观下线(默认 30000,生产建议调小到 5000~10000)
sentinel down-after-milliseconds mymaster 5000

# 故障转移时,同时向新主同步的从节点数量
# 1 = 串行同步,读服务影响最小,恢复最慢(默认)
sentinel parallel-syncs mymaster 1

# 故障转移各环节的超时基准,默认 180000(3 分钟)
sentinel failover-timeout mymaster 180000

# 异常时调用脚本通知运维系统(脚本自己决定发邮件还是发钉钉)
# sentinel notification-script mymaster /opt/redis/notify.sh
# 切主完成后自动改客户端配置(不推荐,建议让客户端走哨兵协议)
# sentinel client-reconfig-script mymaster /opt/redis/reconfig.sh

# ↓↓↓ 以下由哨兵自动维护,绝对不要手改 ↓↓↓
# sentinel myid 8a2f3c...
# sentinel config-epoch mymaster 0
# sentinel leader-epoch mymaster 0
# sentinel known-replica mymaster 10.0.0.22 6379
# sentinel known-sentinel mymaster 10.0.0.12 26379 5b1e...

被监控的 master 的 redis.conf 里,建议加上防脑裂的两行:

# 至少要 1 个从库在线、且延迟不超过 10 秒,主节点才接受写入
# 网络分区时,被孤立的旧主会自动拒绝写入 —— 用可用性换一致性,避免脑裂丢数据
min-replicas-to-write 1
min-replicas-max-lag 10

启动与运维命令

# 启动哨兵(两种方式等价)
redis-sentinel /etc/redis/sentinel.conf
redis-server /etc/redis/sentinel.conf --sentinel

# 连上哨兵(注意端口是 26379,不是 6379)
redis-cli -h 10.0.0.11 -p 26379 -a sentinel-secret
# ===== 查看哨兵掌握的全局信息 =====
127.0.0.1:26379> SENTINEL masters
# 返回所有被监控 master 的详情:ip/port/flags/num-slaves/num-other-sentinels/quorum/...
# ⚠️ 排查第一眼看 num-other-sentinels:应该是 2(3 哨兵集群),是 0 就说明没发现同伴

127.0.0.1:26379> SENTINEL master mymaster
# 只看 mymaster 这一个主的详情

127.0.0.1:26379> SENTINEL replicas mymaster      # Redis 5.0 前叫 SENTINEL slaves
# 列出哨兵已知的所有从节点

127.0.0.1:26379> SENTINEL sentinels mymaster
# 列出哨兵已知的所有同伴哨兵

# ===== 客户端寻址:问"当前主是谁" =====
127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster
1) "10.0.0.21"
2) "6379"

# ===== 健康检查与演练 =====
127.0.0.1:26379> SENTINEL ckquorum mymaster
# OK 3 usable Sentinels. Quorum and failover authorization can be reached
# 检查当前存活的哨兵数是否够完成一次故障转移 —— 上线前必跑!

127.0.0.1:26379> SENTINEL failover mymaster
# 手动触发一次故障转移(不判断主是否真的挂了),用于演练
# ⚠️ 生产环境慎用,这会造成一次真实的主从切换和数据丢失窗口

# ===== 动态增删监控(无需重启哨兵) =====
127.0.0.1:26379> SENTINEL monitor newmaster 10.0.0.31 6379 2
127.0.0.1:26379> SENTINEL remove oldmaster
127.0.0.1:26379> SENTINEL set mymaster down-after-milliseconds 10000
127.0.0.1:26379> SENTINEL set mymaster quorum 2

# ===== 直接在 master 上抓哨兵的 hello 消息(调试用) =====
redis-cli -h 10.0.0.21 -p 6379 PSUBSCRIBE __sentinel__:hello

# ===== 在哨兵上订阅切主事件 =====
redis-cli -h 10.0.0.11 -p 26379 SUBSCRIBE +switch-master

Go 客户端:go-redis v9 接入哨兵

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "strings"
    "time"

    "github.com/redis/go-redis/v9"
)

// newFailoverRDB 创建一个"哨兵感知"的 Redis 客户端。
//
// 关键点:这里填的【不是】Redis 主节点的地址,而是【哨兵集群】的地址。
// go-redis 内部会做三件事:
//  1. 启动时向哨兵发 SENTINEL get-master-addr-by-name,解析出当前主节点地址;
//  2. 订阅哨兵的 +switch-master 频道,故障转移完成后第一时间拿到新主地址;
//  3. 遇到连接错误时主动重新解析主地址(兜底,防止漏掉 pub/sub 消息)。
func newFailoverRDB() *redis.Client {
    return redis.NewFailoverClient(&redis.FailoverOptions{
        // 必须与 sentinel.conf 里 `sentinel monitor <master-name>` 的名字完全一致
        MasterName: "mymaster",

        // 哨兵地址列表。建议全部填上:go-redis 会随机挑一个连,
        // 连不上就自动换下一个,所以哨兵挂了客户端也能自愈
        SentinelAddrs: []string{
            "10.0.0.11:26379",
            "10.0.0.12:26379",
            "10.0.0.13:26379",
        },

        // 哨兵自己的密码(sentinel.conf 里的 requirepass),Redis 5.0.1+ 才有
        SentinelPassword: "sentinel-secret",
        // 哨兵开启 ACL 时的用户名(Redis 6+)
        // SentinelUsername: "sentinel_user",

        // 被监控 Redis 实例的密码(redis.conf 里的 requirepass)
        // 注意区分:上面是"连哨兵"的密码,这里是"连 Redis"的密码
        Password: "redis-secret",
        DB:       0,

        // 连哨兵的超时,别用默认值裸奔——哨兵不可达时能更快 failover 到下一个地址
        SentinelClientConfig: &redis.Options{
            DialTimeout:  2 * time.Second,
            ReadTimeout:  2 * time.Second,
            WriteTimeout: 2 * time.Second,
            PoolSize:     5,
        },

        // 连接池与超时:故障转移期间这几项决定了你"多久能发现连不上老主"
        PoolSize:     100,
        MinIdleConns: 10,
        DialTimeout:  2 * time.Second,
        ReadTimeout:  500 * time.Millisecond,
        WriteTimeout: 500 * time.Millisecond,

        // 重试:故障转移通常几秒内完成,配合下面的业务层重试可以平滑度过
        MaxRetries:      3,
        MinRetryBackoff: 100 * time.Millisecond,
        MaxRetryBackoff: 2 * time.Second,

        // 主从路由策略(默认只读写主节点):
        //   RouteByLatency: true → 按延迟选最快的节点读(可能读到从库)
        //   RouteRandomly:  true → 随机选节点读
        //   ReplicaOnly:    true → 只读从库
        // ⚠️ 读从库会读到异步复制的滞后数据,强一致场景别开
        RouteByLatency: false,
    })
}

// setWithFailoverRetry 写操作的重试封装。
//
// 故障转移窗口内会碰到两类典型错误:
//   - 连接类错误(老主进程没了 / 网络不通)→ 重试即可,go-redis 会自动重新解析主地址
//   - READONLY 错误(老主复活后被降级为从库,客户端还连着它)→ 必须重试
func setWithFailoverRetry(ctx context.Context, rdb *redis.Client, key, val string) error {
    const maxAttempts = 4
    var lastErr error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err := rdb.Set(ctx, key, val, 10*time.Minute).Err()
        if err == nil {
            return nil
        }
        lastErr = err

        // 只对"可能是切主导致"的错误重试,业务错误(如 OOM、WRONGTYPE)直接返回
        if !isRetryable(err) {
            return err
        }

        log.Printf("[redis] 第 %d 次写入失败,疑似故障转移中:%v", attempt, err)

        // 退避等待,给哨兵留出完成 failover 的时间
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(time.Duration(attempt) * 500 * time.Millisecond):
        }
    }
    return fmt.Errorf("写入 %s 失败,重试 %d 次后放弃: %w", key, maxAttempts, lastErr)
}

// isRetryable 判断错误是否属于"切主导致的临时不可用"。
// 这里统一用错误消息匹配,避免依赖具体版本的错误类型定义;
// 生产代码可以在此基础上再叠加 context.DeadlineExceeded 等判断。
func isRetryable(err error) bool {
    if err == nil {
        return false
    }
    // redis.Nil 表示 key 不存在,是正常的业务返回,绝不能重试
    if errors.Is(err, redis.Nil) {
        return false
    }
    msg := err.Error()
    for _, kw := range []string{
        // 老主复活后被降级为 replica,往它写就是这个错误
        "READONLY",
        // 老主进程没了 / 网络不通 / 超时
        "connection refused", "connection reset", "EOF", "timeout", "i/o timeout",
    } {
        if strings.Contains(msg, kw) {
            return true
        }
    }
    return false
}

func main() {
    ctx := context.Background()
    rdb := newFailoverRDB()
    defer rdb.Close()

    // 看一眼当前解析到的主节点地址(故障转移后再调一次,地址会变)
    if addr, err := masterAddr(ctx, rdb); err == nil {
        log.Printf("[redis] 当前主节点:%s", addr)
    }

    if err := setWithFailoverRetry(ctx, rdb, "user:1001:name", "alice"); err != nil {
        log.Fatalf("写入失败: %v", err)
    }
    log.Println("写入成功")
}

// masterAddr 演示"手动问哨兵要主地址"的底层做法。
// 平时不需要这么写,NewFailoverClient 已经帮你做了;
// 但排查问题、或者自己实现客户端时,这就是核心那一步。
func masterAddr(ctx context.Context, rdb *redis.Client) (string, error) {
    sc := redis.NewSentinelClient(&redis.Options{
        Addr:     "10.0.0.11:26379",
        Password: "sentinel-secret",
    })
    defer sc.Close()

    // 等价于 redis-cli 里的 SENTINEL get-master-addr-by-name mymaster
    addr, err := sc.GetMasterAddrByName(ctx, "mymaster").Result()
    if err != nil {
        return "", err
    }
    return addr[0] + ":" + addr[1], nil // []string{"10.0.0.21", "6379"}
}

📌 客户端到底是怎么感知切主的? 两条路,go-redis 两条都走了: 主动推送 —— 客户端在哨兵上 SUBSCRIBE +switch-master,故障转移完成的一刻哨兵就 PUBLISH 一条 +switch-master mymaster 10.0.0.21 6379 10.0.0.22 6379,客户端立刻把连接池指向新主; 被动兜底 —— 万一 pub/sub 连接也断了漏掉消息,客户端在下一次操作报连接错误时会重新向哨兵解析主地址。


⚠️ 常见陷阱

陷阱一:哨兵只部署 1~2 个,多数派永远凑不出来

现象:master 挂了,哨兵日志刷了满屏 +odown,但死活不动手 failover,SENTINEL masters 里 flags 一直是 master,odown。 原因:判定 ODOWN 只需要 quorum 票,但**执行 failover 需要多数派票**(哨兵数/2 + 1)。2 个哨兵挂 1 个只剩 1 个,多数派要 2 票,永远选不出 Leader;1 个哨兵同理(多数派要 1 票看似够,但单点哨兵自己挂了整套高可用就没了)。 解法:生产**至少 3 个哨兵,推荐奇数,且分散在不同物理机 / 机架 / 机房**。上线前必跑 SENTINEL ckquorum <master-name> 验证。

陷阱二:把 quorum 当成「执行 failover 需要的票数」

现象:以为配 sentinel monitor mymaster ... 1 就能让任意一个哨兵独立决定切主,于是只部 2 个哨兵 + quorum=1,觉得"容错更高了"。 原因:quorum 的语义只是**"多少个哨兵赞同,才能把 master 标成 ODOWN",它是下线判定的门槛,不是选举门槛。选举 Leader 需要的是 max(quorum, 哨兵总数/2 + 1) 票。 **解法:记死这句话——quorum 管"认不认为主挂了",多数派管"敢不敢动手切主"。3 哨兵集群 quorum 设 2 是常见配置;quorum 设 1 也能工作(更容易认定 ODOWN,但更容易被单个哨兵的误判带节奏)。

陷阱三:脑裂——旧主还在接受写入,切主后这些写全部丢失

现象:网络分区时,哨兵和 replica 都连不上 master,判定 ODOWN 并提升了新主;但**另一侧的客户端还连着老 master 并且写入成功**(客户端拿到了 OK)。分区恢复后,老 master 被降级为 replica,执行 REPLICAOF 时会**清空自己的数据集去全量同步新主**——分区期间的所有写入,永久蒸发。 原因:Redis 主从是异步复制 + AP 取向,没有 Raft 那种"写请求必须多数派确认"的机制。老 master 不知道自己已经被废黜了。 解法:在**每个 master 的 redis.conf** 里加——

min-replicas-to-write 1     # 在线从库少于 1 个时,主节点拒绝所有写入
min-replicas-max-lag 10     # 从库延迟超过 10 秒就不算"在线"

被孤立的老 master 发现身边没有从库了,会主动拒绝写入(客户端收到错误),从而把数据丢失窗口压缩到 min-replicas-max-lag 秒以内。代价是牺牲了一点可用性:正常状态下如果所有从库都挂了,主库也会拒绝写入。此外,业务侧对关键数据(订单、余额)必须落 DB,别拿 Redis 当唯一真源。

陷阱四:客户端硬编码 master IP,切主后全线连不上

现象:哨兵那边故障转移做得漂漂亮亮,业务侧却大面积报错——connection refused(老主进程没了),或者更阴间的 READONLY You can't write against a read only replica.(老主复活被降级为从库,客户端还死连着它写)。 原因:代码里写死了 redis.NewClient(&redis.Options{Addr: "10.0.0.21:6379"}),压根没走哨兵协议,切主对它来说等于"地址失效"。 解法:换成 redis.NewFailoverClient,把**哨兵地址列表 + MasterName** 交给客户端库;或者在中间加一层 VIP / 代理(云厂商的 Redis 代理、Twemproxy、Codis)屏蔽地址变化。同时务必实现**写重试**:切主的几秒窗口内,第一次失败要能自动重试到新主上。


🏋️ 练习题

练习 1:3 个哨兵、quorum = 2,现在挂了 2 个哨兵只剩 1 个存活,同时 master 也挂了。剩下的这个哨兵能完成故障转移吗?为什么?

提示:想一想"判定 ODOWN"和"选举 Leader"分别需要多少票。

答案

不能。 分两步看:

  1. 判定 ODOWN:需要 >= quorum = 2 票。只剩 1 个哨兵,它自己一票不够 → 连客观下线都判不了(严格说它会标 SDOWN,但凑不够 2 票,不会标 ODOWN)。
  2. 选举 Leader:需要 max(quorum, 哨兵数/2 + 1) = max(2, 3/2+1) = 2 票。同样只有 1 票。

两道门槛都过不去,所以剩下的哨兵只会干瞪眼。这就是"哨兵数量必须为奇数且至少 3 个"的根本原因——3 个哨兵最多容忍挂 1 个(剩 2 个刚好等于多数派 2)。挂 2 个就等于整套高可用机制失效。上线前用 SENTINEL ckquorum mymaster 检查当前是否具备 failover 授权能力。

练习 2:哨兵挑选新主时,replica-priority、replication offset、run id 三个条件的优先级顺序是什么?为什么 offset 要排在 run id 前面?

提示:想一想每个条件代表什么业务含义。

答案

顺序:replica-priority → replication offset → run id(在此之前还会先淘汰掉 SDOWN、断连、master_link_down_time 超过 down-after-milliseconds × 10 的不合格副本)。

  • replica-priority(值越小越优先,0 = 永不当选,默认 100):这是**人为干预**的手段。比如某个 replica 机器配置差、或者跨机房延迟高,就给它设个大一点的优先级,或者直接设 0 让它只做冷备。人说了算,所以排第一。
  • replication offset(越大越好):代表这个副本**同步到了多少数据**。offset 最大 = 落后主节点最少 = 提升它丢的数据最少。这是**数据完整性**考量,排第二。
  • run id(字典序最小者胜):run id 是实例启动时随机生成的 40 位字符串,跟数据质量**毫无关系**。它纯粹是为了打破前两级都平局的情况,保证选举结果**确定且可复现**,避免出现"两个哨兵各自选了不同副本"的分歧。

所以 offset 必须在 run id 前面:offset 是有业务意义的选择,run id 只是无意义的决胜规则。反过来的话,等于随机挑一个副本当主,白白多丢一批数据。

练习 3:你的 Go 服务用 NewFailoverClient 接入哨兵,某天日志里出现 READONLY You can't write against a read only replica.。请解释这个错误产生的完整过程,以及应该怎么处理。

提示:什么情况下客户端会连着一个"从库"往里写数据?

答案

产生过程(脑裂或切主滞后场景):

  1. 网络抖动,哨兵判定老 master 10.0.0.21 ODOWN,选举 Leader 后把 10.0.0.22 提升为新主;
  2. 但你的服务此刻 pub/sub 消息漏了 / 连接池里的老连接还没失效,仍然握着指向 10.0.0.21 的连接;
  3. 老 master 复活(或分区恢复),哨兵向它下发 REPLICAOF 10.0.0.22 6379,它**被降级成从库**;
  4. 你的服务往这个"曾经的 master、现在的 replica"发写命令,Redis 返回 READONLY You can't write against a read only replica.

处理方式:

  • 客户端层:确认用的是 NewFailoverClient 而不是 NewClient 硬编码地址;go-redis 收到这个错误后会标记连接失效并重新向哨兵解析主地址,所以配置 MaxRetries: 3 + 指数退避基本就能自愈。
  • 业务层:写操作包一层重试(见代码示例的 setWithFailoverRetry),把 READONLY、connection refused、connection reset、timeout 归为可重试错误,重试 2~4 次、总耗时控制在几秒内。
  • 数据层:重试全部失败时,别静默吞掉——落本地队列 / 返回明确错误码给上游,让关键写入有补偿路径。
  • 根因层:如果这个错误频繁出现,说明发生了脑裂,检查 master 上有没有配 min-replicas-to-write 1 + min-replicas-max-lag 10。

🔗 相关链接