Redis 哨兵(Sentinel)机制¶
💡 一句话概述
主从复制解决了"读扩展 + 数据冗余",但主节点挂了还得靠人爬起来改配置、切客户端;哨兵(Sentinel)就是那个替你值班的运维——它持续监控主从,主挂了就自动投票选一个从升为主,并把新主地址告诉所有客户端。它只解决**高可用**,不解决**容量与写扩展**(写仍然只有一个主),而且因为是异步复制,切换瞬间**可能丢数据**。
🔑 核心概念¶
- 哨兵(Sentinel):独立进程(默认端口
26379),不存业务数据,只负责监控、通知、自动故障转移、提供主地址。 - 主观下线 SDOWN:单个哨兵在
down-after-milliseconds内 PING 不通某个实例——"我觉得它挂了"。 - 客观下线 ODOWN:多个哨兵都认为主挂了,票数
>= quorum——"大家投票确认它挂了"。只有 master 会被判 ODOWN。 - Leader 选举:故障转移不是谁想干就能干,哨兵之间用类 Raft 算法选出一个 Leader 来执行,需要拿到
max(quorum, 哨兵数/2+1)票。 - 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
机制拆开看:
- 每个哨兵在 master(和每个 replica)上
SUBSCRIBE __sentinel__:hello; - 同时每 2 秒**向这个频道 **PUBLISH 一条自己的 hello 消息(含自己的 IP、端口、runid、epoch,以及它认为的 master 地址和 config_epoch);
- 其他哨兵订阅到这条消息,就"认识"了这个新同伴,建立起哨兵之间的直连(走 26379 端口),并把它记进本地配置的
sentinel known-sentinel; - 同理,哨兵每 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** 里加——
被孤立的老 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"分别需要多少票。
答案
不能。 分两步看:
- 判定 ODOWN:需要
>= quorum = 2票。只剩 1 个哨兵,它自己一票不够 → 连客观下线都判不了(严格说它会标 SDOWN,但凑不够 2 票,不会标 ODOWN)。 - 选举 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.。请解释这个错误产生的完整过程,以及应该怎么处理。
提示:什么情况下客户端会连着一个"从库"往里写数据?
答案
产生过程(脑裂或切主滞后场景):
- 网络抖动,哨兵判定老 master
10.0.0.21ODOWN,选举 Leader 后把10.0.0.22提升为新主; - 但你的服务此刻 pub/sub 消息漏了 / 连接池里的老连接还没失效,仍然握着指向
10.0.0.21的连接; - 老 master 复活(或分区恢复),哨兵向它下发
REPLICAOF 10.0.0.22 6379,它**被降级成从库**; - 你的服务往这个"曾经的 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。
🔗 相关链接¶
- Redis Sentinel 官方文档 — 哨兵机制的权威说明,failover 步骤与配置项以此为准
- Redis Replication 官方文档 — 主从复制原理,哨兵的前置知识
- go-redis 官方文档 — Go 客户端,
NewFailoverClient与哨兵接入示例 - Raft 共识算法 — 哨兵 Leader 选举借鉴的算法(哨兵用的是简化版,无日志复制)
- The Raft Consensus Algorithm 论文 — 想深挖 epoch / 投票 / 多数派机制的读这篇
- Redis Cluster 集群 — 本站同系列文章:哨兵解决不了容量与写扩展,Cluster 用 16384 个哈希槽做分片
- 多级缓存与读写策略 — 哨兵故障转移期间 Redis 可能短暂不可写,多级缓存是重要的兜底手段