主题
第 9 章 哨兵(Sentinel)高可用
学习目标:理解 Redis 在主从复制基础之上「自动救火」的高可用方案——哨兵。看完之后能完整画出**「主节点宕机 → 哨兵发现 → 哨兵投票 → 选 Leader → 选新主 → 重排从节点 → 通知客户端」**整条链路;能解释 SDOWN / ODOWN / Quorum / Raft 选举的细节;能说清楚为什么哨兵至少要 3 个、为什么必须奇数;能写出客户端正确感知主切换的代码;能在面试里被追问到「故障转移期间写丢了怎么办」「脑裂如何防」时不慌。
9.1 主从复制还差点意思:从「能扛」到「能自愈」
第 8 章我们搭起了一主多从。看上去很美:
- 主挂了?还有从,数据没丢。
- 读压力大?读请求打到从。
但真出事的时候,你会发现一个尴尬的事实——主从复制只是把数据复制了一份,主节点真挂掉时它什么都不会做:
生产凌晨 3:17 主节点宕机
│
▼
┌────────────────────────────────────────┐
│ ① 监控告警把运维从被窝里捞起来 │
│ ② SSH 到一台从节点 │
│ ③ 跑 SLAVEOF NO ONE 把它升为主 │
│ ④ 改其他从节点的 slaveof 指向新主 │
│ ⑤ 改业务代码里的 Redis 地址(或改 DNS) │
│ ⑥ 重启所有应用 │
│ ⑦ 写复盘报告,被老板叫去喝茶 │
└────────────────────────────────────────┘
↑ 全程 30 分钟起步,业务停摆这显然不是高可用,这叫「高人工」。 我们需要一个机制能:
- 24h 盯着 主从节点是否还活着;
- 达成共识 ——别因为我自己网络抖动就乱推流程;
- 自动选个新主,把其他从节点重新挂上去;
- 告诉客户端 主节点换地方了。
这就是 Sentinel(哨兵) 要干的活。
🏢 生活类比:把 Redis 集群想象成一栋写字楼,主节点是「值班组长」,从节点是「副组长们」。
- 以前:组长突发疾病不在岗,需要打电话叫物业经理(运维)赶来现场,临时指认一个副组长接班,再挨个通知所有住户「以后找新组长」,全程 30 分钟。
- 现在:楼里安排了 3 个保安(哨兵),每隔几秒巡视一次组长是否在岗;发现组长不在了,三个保安先打电话互相确认(防止自己看花眼),确认无误后开个简短的会,按规则推举一个副组长上位,再用楼内广播(pub/sub)通知所有住户:「新组长是 3 楼老王。」整个过程几十秒搞定,不需要叫物业经理。
9.2 哨兵的三大任务
哨兵(Sentinel)是 Redis 自带的、独立部署的高可用组件——本质上还是一个 redis-server 进程,只是用 --sentinel 模式启动,加载的是 sentinel.conf 而不是 redis.conf,对外只说「哨兵协议」(一种简化的 Redis 命令子集)。
它身兼三职:
┌────────────────────────────────────────────────────────────┐
│ ① 监控(Monitoring) │
│ 每秒 PING 一次它认识的所有节点(主、从、其他哨兵), │
│ 超过 down-after-milliseconds 没回复 → 标记为「主观下线 SDOWN」│
├────────────────────────────────────────────────────────────┤
│ ② 通知(Notification) │
│ 节点状态变化(+sdown / -sdown / +odown / +switch-master) │
│ 通过 Redis pub/sub 频道广播;运维或客户端订阅即可收到通知 │
├────────────────────────────────────────────────────────────┤
│ ③ 自动故障转移(Failover) │
│ 达到 quorum 个哨兵都认为主下线 → 升级为「客观下线 ODOWN」 │
│ 哨兵之间用 Raft-like 算法选出 Leader │
│ Leader 按既定规则选出新主,重排从节点,更新自身配置 │
└────────────────────────────────────────────────────────────┘📌 有些资料把「配置提供者(configuration provider)」单列为第 4 项任务,因为哨兵集群本身就是客户端发现「当前主节点地址」的服务发现入口。本章为了简洁把它并入「通知」里。
9.2.1 哨兵是怎么「认识」其他节点的
启动哨兵时只需要在配置里告诉它主节点地址:
conf
sentinel monitor mymaster 127.0.0.1 6379 2
# ↑名字 ↑主 IP ↑端口 ↑quorum哨兵只要连上主节点,就能:
sentinel ──── INFO replication ────► master
◄──── 返回所有 slaves 的 IP/port
sentinel ──── SUBSCRIBE __sentinel__:hello ──► master
◄──── 其他哨兵也在这个频道里发自我介绍也就是说只需告诉哨兵主在哪儿,从节点和兄弟哨兵会被自动发现,无需手工维护。
9.2.2 哨兵之间怎么互相发现
每个哨兵每隔 2 秒往主节点的特殊频道 __sentinel__:hello 里 publish 一条「我是谁、我看到的主是什么状态」的消息:
hello message:
sentinel_ip , sentinel_port , sentinel_runid , sentinel_epoch ,
master_name , master_ip , master_port , master_config_epoch订阅同一频道的其他哨兵就能拿到这份信息,从而把彼此加入「兄弟列表」。主节点在这里只起一个「公告板」的作用,它本身不参与哨兵之间的协商。
9.3 哨兵集群:为什么至少 3 个、为什么是奇数
哨兵自己也可能挂、也可能网络抖动,所以生产上从不部署单哨兵。
9.3.1 单哨兵的两大坑
坑一:单点失效。 唯一的哨兵自己挂了,整个高可用机制就消失了。
坑二:脑裂误判。 唯一的哨兵跟主节点之间网络抖了一下,它会立刻认为主死了,于是「自顾自地」发起故障转移,把还活着的主废掉,业务侧出现两个主同时被写入的灾难场景。
单哨兵脑裂示意:
sentinel ╳╳╳╳╳╳╳ master (网络抖了 10s)
│
▼ 我说主死了,发起 failover
选了 slave1 当新主 → 客户端 A 已切走
10s 后网络恢复:
原 master 还在,客户端 B 还在写它 ← 数据分叉!9.3.2 多哨兵:要「商量着办」
把决策权交给一个集群而不是单个进程,每次「主死了吗」都要由 ≥ N/2 + 1 个哨兵投票通过才作数。这种「多数派达成共识」的思路就是分布式系统的常识。
那要几个哨兵呢?看下面这张表就明白了:
┌────────┬──────────┬───────────────┬────────────────────┐
│ 哨兵数 │ 容忍宕机 │ majority 需要 │ 评价 │
├────────┼──────────┼───────────────┼────────────────────┤
│ 1 │ 0 │ 1 │ ❌ 单点 │
│ 2 │ 0 │ 2 │ ❌ 挂一个就废了,2 个 │
│ │ │ │ 还会脑裂(各自一票)│
│ 3 │ 1 │ 2 │ ✅ 最小生产配置 │
│ 4 │ 1 │ 3 │ ⚠️ 比 3 还差,多花钱 │
│ 5 │ 2 │ 3 │ ✅ 关键业务推荐 │
│ 6 │ 2 │ 4 │ ⚠️ 不如 5 │
│ 7 │ 3 │ 4 │ ✅ 极端高可用 │
└────────┴──────────┴───────────────┴────────────────────┘结论:
- 至少 3 个哨兵,否则容忍不了任何宕机。
- 优先选奇数:偶数比上一个奇数多花机器,但容忍故障数不变(4 还是只能挂 1 个,跟 3 一样),还多了产生「2:2」死锁的风险。
- 跨机房部署:如果只部署在同一台物理机或同一机架上,机器一挂三个一起没——容灾就成了笑话。
💡 quorum vs majority 区别:
- quorum(仲裁数,配置项里那个数字):判定「客观下线 ODOWN」需要的票数,可以小于多数派(比如 5 个哨兵设 quorum=3)。
- majority(多数派,自动计算):选 Leader 时的票数,永远是 ⌊N/2⌋+1,与 quorum 无关。
- failover 真正发起还需要拿到 majority——也就是说哪怕 quorum=2,但当前只有 2 个哨兵在线(共 5 个)也无法发起故障转移。
9.4 主观下线 SDOWN vs 客观下线 ODOWN
这是面试 100% 会问的概念,记住这个口诀:「我觉得 → SDOWN,大家觉得 → ODOWN」。
9.4.1 SDOWN(Subjectively Down,主观下线)
sentinel ──── PING ────► master
<──── PONG ──── (正常)
──── PING ────► master
<──── (无响应) ─ time++
持续超过 down-after-milliseconds(默认 30000ms):
sentinel ╳ master ←── 我(一个哨兵)认为它挂了
状态:master 进入「主观下线 SDOWN」
并通过 pub/sub 发出 +sdown 事件关键点:
- 这是单个哨兵的判断,不能据此发起故障转移。
- 触发条件只看「PING 超时」一件事,不看 PING 失败的原因。
- 仅适用于主节点才会引发后续 ODOWN 流程;从节点 / 其他哨兵的 SDOWN 只用于状态展示。
9.4.2 ODOWN(Objectively Down,客观下线)
一旦某个哨兵判定主节点 SDOWN,它会主动找其他兄弟哨兵确认:
sentinel A ──── SENTINEL is-master-down-by-addr ────► sentinel B
► sentinel C
► sentinel D
◄──── 1(我也认为它挂了)── B
◄──── 0(我看它还活着)── C
◄──── 1(我也认为它挂了)── D
A 自己 +1,统计「认为下线」的哨兵数 = 3
配置中 quorum = 2 → 3 ≥ 2 ✅ 升级为客观下线
状态:master 进入「客观下线 ODOWN」
并通过 pub/sub 发出 +odown 事件完整的判定时序:
时间轴
│
│ T0 哨兵 A、B、C、D 都正常监控 master
│
│ T1 master 网络抖动,停止响应
│
│ T0+down-after-milliseconds (30s)
│ A 先发现:标记 A→master 为 SDOWN,发 +sdown
│
│ T0+30s+ε
│ A 立刻向 B/C/D 发 SENTINEL is-master-down-by-addr
│ 同时 B/C/D 自己也陆续达到超时,各自标记 SDOWN
│
│ T0+~31s
│ A 收到回复:3 票(含自己)≥ quorum=2
│ → 升级 ODOWN,发 +odown
│
│ T0+~31s
│ A/B/C/D 进入 Leader 选举流程(见 9.5)
▼9.4.3 SDOWN vs ODOWN 一图速记
┌──────────────────┬─────────────────┬──────────────────────┐
│ │ SDOWN │ ODOWN │
├──────────────────┼─────────────────┼──────────────────────┤
│ 谁判定 │ 单个哨兵 │ ≥ quorum 个哨兵 │
│ 触发条件 │ PING 超时 │ SDOWN + 多哨兵确认 │
│ 适用对象 │ 主/从/哨兵均可 │ 仅对主节点有意义 │
│ 引发动作 │ 仅广播 +sdown │ 触发 Leader 选举 │
│ 可能误判 │ 会(网络抖动) │ 几乎不会(多方确认) │
└──────────────────┴─────────────────┴──────────────────────┘⚠️ 千万别把 quorum 设成 1——那等于绕过共识,回到了单哨兵脑裂的老坑里。
9.5 哨兵 Leader 选举:Raft-like 协议
ODOWN 之后,还得选出一个哨兵当 Leader 来真正执行故障转移——总不能 3 个哨兵同时给从节点发 SLAVEOF NO ONE,那就乱套了。
哨兵之间用一个简化版的 Raft 算法选 Leader。
9.5.1 关键概念
┌──────────────┬─────────────────────────────────────────────┐
│ epoch(纪元)│ 类似 Raft 的 term,单调递增的整数 │
│ │ 每次发起新一轮选举就 +1 │
├──────────────┼─────────────────────────────────────────────┤
│ runid │ 每个哨兵启动时生成的 40 字符随机 ID │
├──────────────┼─────────────────────────────────────────────┤
│ vote │ 每个哨兵在每个 epoch 里只能投一票 │
│ │ (含给自己投的那一票) │
├──────────────┼─────────────────────────────────────────────┤
│ majority │ 超过半数:⌊N/2⌋ + 1 │
└──────────────┴─────────────────────────────────────────────┘9.5.2 选举流程(5 步)
step1. 谁先发现 ODOWN,谁就先动手
── current_epoch += 1
── 给自己投一票
── 向所有兄弟哨兵发:
SENTINEL is-master-down-by-addr <ip> <port> <epoch> <my_runid>
↑ 想当 Leader 就填 runid
填 * 表示只是查询状态
step2. 收到请求的哨兵:
── 如果对方的 epoch <= 我已知的 epoch → 拒绝(旧选举)
── 如果在这个 epoch 我已经投过别的人 → 拒绝
── 否则 → 投给对方,记录「epoch=X,投给了 runid=Y」
── 回复:(我对主下线的判断, 我投的 leader_runid, leader_epoch)
step3. 候选哨兵收齐回复:
── 统计投给自己的票数
── 如果 ≥ max(quorum, majority) → 当选 Leader
── 否则等待下一轮(epoch 又会被某个哨兵 +1)
step4. 当选 Leader 后才能执行故障转移
── 选新主(见 9.6)
── 让新主 SLAVEOF NO ONE
── 让其他从 SLAVEOF 新主
── 把客观下线广播 +switch-master 事件
step5. 失败的候选也不会瞎等
── 通过订阅 +switch-master 知道选举有结果了
── 不再发起新选举9.5.3 ASCII 投票示意
sentinel A sentinel B sentinel C
(epoch=7) (epoch=7) (epoch=7)
│ │ │
│ epoch=8 投我自己
├──────► is-master-down? want_leader=A_id ──────►
│ │
│ B:在 epoch=8 我还没投人 → 投给 A
│ │
│ ◄── voted=A_id, leader_epoch=8 ─────────────
│
├──────────────────────────► is-master-down? want_leader=A_id ──►
│
│ C:在 epoch=8 我还没投 → 投给 A
│
│ ◄────────────── voted=A_id, leader_epoch=8 ────────
│
A 收到 3 票(含自己)≥ majority(2) → 当选 Leader9.5.4 为什么选举能终止(活性证明的直觉)
- 选举失败时哨兵会等一个随机延迟再重试,避免反复同时刻发起;
- 每轮失败 epoch 都会 +1,旧的投票自动失效;
- 只要网络稳定 + 多数派在线,几次重试一定能选出 Leader。
💡 和 Raft 的差异:哨兵选举只用于单次故障转移,没有持续的 leader 概念,转移完成之后下次故障再选一次新的 Leader。Raft 的 Leader 是「持续负责日志复制」的,性质不一样。
9.6 新主节点选择算法(5 步过滤)
Leader 选好后,下一步就是从一堆从节点里挑出一个最合适的当新主。Redis 用的是5 步级联过滤,前面的条件不通过直接淘汰,相当于「初赛 → 复赛 → 决赛」。
┌────────────────────────────────────────────────────────────┐
│ 候选池:所有挂在原主下面的从节点 │
└────────────────────────────────────────────────────────────┘
│
▼ Step 1: 先把不健康的踢掉
┌────────────────────────────────────────────────────────────┐
│ ① 过滤掉 SDOWN / ODOWN / 断线的从 │
│ ── 显然这种刚自己都自身难保,不能当新主 │
└────────────────────────────────────────────────────────────┘
│
▼ Step 2: 再把响应慢的踢掉
┌────────────────────────────────────────────────────────────┐
│ ② 过滤掉最近 (down-after-milliseconds × 10) 内 │
│ 没正常回 INFO 的从 │
│ ── 一个一直慢半拍的从扛不住主的写流量 │
└────────────────────────────────────────────────────────────┘
│
▼ Step 3: 优先级排序
┌────────────────────────────────────────────────────────────┐
│ ③ 按 slave-priority(在每个从的 redis.conf 里配)排序 │
│ ── 数值越小优先级越高 │
│ ── 0 表示「永不当主」(只读副本场景常用) │
│ ── 默认 100 │
│ ── 同优先级才进入下一步比较 │
└────────────────────────────────────────────────────────────┘
│
▼ Step 4: 数据最新的优先
┌────────────────────────────────────────────────────────────┐
│ ④ 按复制偏移量 replication offset 大者优先 │
│ ── offset 越大表示从主复制到的数据越多 │
│ ── 选数据最新的当新主,丢失数据最少 │
└────────────────────────────────────────────────────────────┘
│
▼ Step 5: 最后兜底
┌────────────────────────────────────────────────────────────┐
│ ⑤ 按 runid 字典序最小者优先 │
│ ── runid 是节点启动时随机生成的 40 字符 ID │
│ ── 纯粹为了打破平局,让结果可重现 │
└────────────────────────────────────────────────────────────┘
│
▼
🏆 新主诞生举个具体例子:
3 个从节点,初始状态:
slave1: priority=100, offset=12000, runid=aaa..., 在线
slave2: priority=100, offset=12500, runid=bbb..., 在线
slave3: priority= 5, offset= 9000, runid=ccc..., 但断线
Step 1: slave3 断线 → 淘汰
剩 [slave1, slave2]
Step 2: 都没超时 → 都通过
剩 [slave1, slave2]
Step 3: 同 priority=100 → 都通过
剩 [slave1, slave2]
Step 4: slave2.offset(12500) > slave1.offset(12000)
→ 选 slave2
注意:slave3 虽然 priority=5(最高优),但前面就被淘汰了。
如果 slave3 在线,它会赢 priority 那一步而成为新主。💡 典型踩坑:把跨机房的从节点 priority 调到最低(数值最大)能避免故障转移时把主切到远端机房,进而避免读写延迟暴增。
9.7 故障转移完整流程串讲
把 9.4 ~ 9.6 串成一张大图:
T0 ┌──────────────────────────────────────────────────────────┐
│ master 突然不响应 │
└──────────────────────────────────────────────────────────┘
▼
T1 ┌──────────────────────────────────────────────────────────┐
│ 每个 sentinel 各自 PING 超时 → 主观下线 SDOWN │
│ 广播 +sdown master mymaster ... │
└──────────────────────────────────────────────────────────┘
▼
T2 ┌──────────────────────────────────────────────────────────┐
│ sentinel 之间互相 SENTINEL is-master-down-by-addr │
│ ≥ quorum 票 → 客观下线 ODOWN │
│ 广播 +odown master mymaster ... │
└──────────────────────────────────────────────────────────┘
▼
T3 ┌──────────────────────────────────────────────────────────┐
│ epoch += 1,发起 Raft-like 选举 │
│ 拿到 majority 票数的成为 Leader │
│ 广播 +elected-leader epoch ... │
└──────────────────────────────────────────────────────────┘
▼
T4 ┌──────────────────────────────────────────────────────────┐
│ Leader 跑 5 步过滤选出 new-master │
│ 广播 +selected-slave new-master-info │
└──────────────────────────────────────────────────────────┘
▼
T5 ┌──────────────────────────────────────────────────────────┐
│ Leader → new-master:SLAVEOF NO ONE │
│ REPLICAOF NO ONE (Redis 5+ 别名) │
│ 等它确认变成 master │
│ 广播 +promoted-slave │
└──────────────────────────────────────────────────────────┘
▼
T6 ┌──────────────────────────────────────────────────────────┐
│ Leader → 其他健康从:SLAVEOF new-master │
│ 并行下发,但用 parallel-syncs 控制并发数(默认 1) │
│ 广播 +slave-reconf-sent / +slave-reconf-done │
└──────────────────────────────────────────────────────────┘
▼
T7 ┌──────────────────────────────────────────────────────────┐
│ Leader 更新自己保存的 master 配置 │
│ 广播 +switch-master mymaster old-ip port new-ip port │
│ ↑ 客户端就是订阅这条来自动重连新主 │
└──────────────────────────────────────────────────────────┘
▼
T8 ┌──────────────────────────────────────────────────────────┐
│ 原 master 如果某天活过来: │
│ sentinel 会让它 SLAVEOF new-master,降为从节点 │
└──────────────────────────────────────────────────────────┘整个流程一般在 30~60 秒之内完成(取决于 down-after-milliseconds 和网络)。这个数字看起来还有点长,是因为「过早判定挂了」的代价(脑裂、误切)远比「晚一点判定」的代价高,所以默认配置故意保守。
💡 故障转移期间客户端会怎样?
- 写:会失败一段时间(旧主连不上、新主还没产生),需要客户端重试 + 指数退避。
- 读:如果连的是从节点,从节点本身没挂还能继续读(但数据会停在切换前的那一刻,无法接收新写入)。
- 只要客户端用支持 Sentinel 的 SDK(见 9.8),它会自动跟着切换,不需要业务层手动改地址。
9.8 客户端如何感知主节点切换
到这一步,主已经换人了。但客户端代码里写的是老主的 IP/Port,连过去就是个被废掉的从节点(或干脆是死进程)。怎么办?
有两种思路:
9.8.1 思路一:自己订阅 +switch-master 频道
每次连接哨兵,订阅那个特殊事件频道:
python
import redis
sentinel = redis.Redis(host="127.0.0.1", port=26379)
ps = sentinel.pubsub()
ps.subscribe("+switch-master")
for msg in ps.listen():
if msg["type"] == "message":
# 数据格式:"mymaster old_ip old_port new_ip new_port"
name, old_ip, old_port, new_ip, new_port = msg["data"].split()
print(f"主节点切换:{old_ip}:{old_port} → {new_ip}:{new_port}")
# 业务里把 Redis 客户端连接池重建到新地址这种方式最透明也最灵活,适合需要对切换做特殊处理(比如打告警、做切换审计)的场景。代码示例见 09_sentinel/code/02_failover_listener.py。
9.8.2 思路二(推荐):用支持 Sentinel 的 SDK
redis-py(以及 Jedis、Lettuce、go-redis 等主流客户端)都内置了 Sentinel 模式:
python
from redis.sentinel import Sentinel
sentinel = Sentinel(
[("sentinel-1", 26379), ("sentinel-2", 26379), ("sentinel-3", 26379)],
socket_timeout=0.5,
)
master = sentinel.master_for("mymaster", socket_timeout=0.5)
slave = sentinel.slave_for ("mymaster", socket_timeout=0.5)
master.set("foo", "bar") # 写:内部会先去问哨兵当前主是谁
slave.get("foo") # 读:内部会从健康的从里随机挑一个Sentinel 类的内部实现做了三件关键事:
1. 启动时连任一哨兵,发送 SENTINEL get-master-addr-by-name mymaster
── 拿到当前主地址
2. 订阅 +switch-master 频道
── 主切换时立刻收到通知,重建连接池
3. 业务调用 master_for / slave_for 时返回的实际是个「带自动重连」的代理
── 任意一次操作失败 → 自动询问哨兵 → 拿到最新地址 → 重连代码示例见 09_sentinel/code/01_sentinel_client.py。
9.8.3 切换瞬间的写丢失风险
要诚实地说一句:Sentinel 模式下故障转移期间一定有写丢失风险。
T0 master 写入 X,但还没复制给从就挂了(master 上有 X,从节点没有)
T1 sentinel 发起故障转移,选了某个从当新主
T2 新主上没有 X,业务读取时拿不到 X
T3 即便原 master 活过来变成从,它会被 SLAVEOF 触发全量同步,本地的 X 直接丢缓解办法:
min-slaves-to-write 1+min-slaves-max-lag 10:master 在没有至少 1 个 lag<10s 的从时拒绝写入,把写丢失风险换成可用性下降。- 业务侧关键写入用「两阶段」:先写持久化数据库,再缓存到 Redis。
- 真要 100% 不丢需要走 Redis 7+ 的 WAIT 命令或换更强一致的方案(比如配合 Raft 的 redis-raft 项目)。
9.9 实操:跑一遍配套代码
实战代码见 09_sentinel/code/:
01_sentinel_client.py:用redis-py的Sentinel类连接哨兵集群(如果环境无哨兵,自动降级用 mock,演示 API 用法)02_failover_listener.py:订阅+switch-master等故障转移频道,把主切换事件实时打印03_sentinel_config_explained.py:把sentinel.conf里关键配置项的含义打印解释(不真连,纯文档化)
浏览器演示见 09_sentinel/demo.html:
- ① 故障转移全流程动画 —— 点「主节点宕机」按钮,看 1 主 2 从 + 3 哨兵的整套流程一步步播放
- ② SDOWN vs ODOWN 判定 —— 手动控制「哨兵 X 觉得主死了」的开关,观察 quorum 满足时升级为 ODOWN
- ③ Sentinel 配置解读器 —— 左边贴一段
sentinel.conf,右边逐行解释每个参数的含义和影响
9.10 本章小结
┌────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├────────────────────────────────────────────────────────┤
│ │
│ ① 主从复制只复制数据,不会自动切换 → 需要哨兵 │
│ │
│ ② 哨兵三大任务:监控 / 通知 / 自动故障转移 │
│ │
│ ③ 哨兵至少 3 个、推荐奇数、跨机房部署 │
│ │
│ ④ SDOWN:单哨兵看法;ODOWN:≥quorum 个哨兵共识 │
│ │
│ ⑤ Leader 选举用 Raft-like:epoch + 一票制 + majority │
│ │
│ ⑥ 新主选择 5 步:在线 → 响应快 → priority → offset → │
│ runid 兜底 │
│ │
│ ⑦ 故障转移:SDOWN → ODOWN → 选 Leader → 选新主 → │
│ SLAVEOF NO ONE → 重排其他从 → +switch-master │
│ │
│ ⑧ 客户端用支持 Sentinel 的 SDK 自动跟随切换 │
│ │
│ ⑨ 故障转移期间一定可能丢写:min-slaves-to-write 是缓解 │
│ │
└────────────────────────────────────────────────────────┘9.11 面试高频题
Q1:哨兵的核心作用是什么?为什么至少要 3 个?
考察点:高可用基础概念、奇偶数选择的理解。
标准答案:
哨兵(Sentinel)是 Redis 自带的高可用方案,主要解决「主节点宕机后没人自动切换」的问题。它有三个核心任务:
- 监控:定期 PING 主、从、其他哨兵,发现宕机;
- 通知:通过 pub/sub 把节点状态变化广播给客户端 / 运维;
- 自动故障转移:达成共识后选 Leader、选新主、重排从节点。
为什么至少 3 个:
- 1 个:单点失效,且自身网络抖动会引发误切(脑裂)。
- 2 个:挂任意 1 个就只剩 1 个,跟单点没区别;而且 2 个时若彼此判断分歧(1:1)无法达成多数派,反而更糟。
- 3 个:majority = 2,能容忍 1 个哨兵宕机或网络分区,是最小生产配置。
加分项:
- 优先选奇数哨兵:4 个比 3 个多花机器但容忍数还是 1,且更容易出现 2:2 死锁;
- 推荐跨机房 / 跨可用区部署,否则机器一挂哨兵集体阵亡;
- 区分 quorum(判 ODOWN 用,可配置)和 majority(选 Leader 用,自动 ⌊N/2⌋+1)。
Q2:SDOWN 和 ODOWN 的区别?
考察点:哨兵决策机制的细节。
标准答案:
| 维度 | SDOWN(主观下线) | ODOWN(客观下线) |
|---|---|---|
| 判定主体 | 单个哨兵 | ≥ quorum 个哨兵 |
| 判定依据 | PING 超过 down-after-milliseconds | SDOWN + 多哨兵互相确认 |
| 适用对象 | 主 / 从 / 哨兵均可 | 仅对主节点有意义 |
| 引发动作 | 仅广播 +sdown 事件 | 触发 Leader 选举 + 故障转移 |
典型流程:哨兵 PING 主超时 → 自己标记 SDOWN → 向其他哨兵发 SENTINEL is-master-down-by-addr 询问 → 收到 ≥ quorum 个「确认下线」回复 → 升级为 ODOWN → 进入选举。
加分项:
- ODOWN 只对主节点有意义——从节点 / 其他哨兵的 ODOWN 没有定义。
down-after-milliseconds默认 30s,调小会更敏感但更易误判,调大反应更慢但更稳。- quorum 应当 ≤ 哨兵数 / 2 + 1 才有意义,最小 2,绝不可设 1。
Q3:哨兵是怎么选 Leader 的?讲讲 Raft-like 的细节。
考察点:分布式共识的理解,能不能讲清 epoch / 一票 / majority。
标准答案:
哨兵选 Leader 用一个简化版 Raft:
- 每个哨兵维护一个
current_epoch(类似 Raft 的 term),单调递增。 - 谁先发现 ODOWN,谁就把 epoch +1,先给自己投一票,向其他哨兵发
SENTINEL is-master-down-by-addr <ip> <port> <epoch> <my_runid>。 - 收到请求的哨兵:
- 若对方 epoch 比自己已知的小 → 拒绝(旧选举);
- 若在同一个 epoch 已经投过别人 → 拒绝(一票制);
- 否则 → 投给对方,记下「epoch=X 投给 runid=Y」。
- 候选哨兵收到 ≥ max(quorum, majority) 票即当选 Leader。
- 当选 Leader 才有权执行故障转移。
- 没拿够票 → 等随机延迟、epoch +1,重试。
加分项:
- 哨兵选举的 Leader 是「单次故障转移」级别,不是持续角色;
- 每轮重试的随机延迟避免「同时刻发起选举导致永远拿不到多数」的活锁;
- 当选 Leader 还需要满足 majority——quorum 仅够判 ODOWN,但 failover 必须多数派同意;
- 与原版 Raft 区别:哨兵没有日志复制,因此不需要日志匹配/提交 index 等步骤。
Q4:新主节点是按什么规则选出来的?
考察点:故障转移细节,能不能讲清 5 步过滤。
标准答案:5 步级联过滤:
- 过滤掉断线 / SDOWN / ODOWN 的从——不健康直接淘汰。
- 过滤掉响应慢的——最近
down-after-milliseconds × 10没正常 INFO 的踢掉。 - 按
slave-priority排序——数值越小优先级越高,0 表示永不当主。 - 按复制偏移量
offset排序——offset 大的数据最新,丢失最少。 - 按
runid字典序兜底——前面全平局时按 runid 排序,纯粹为了结果可重现。
在线? → 响应快? → priority 最高? → offset 最大? → runid 最小?
↓No 淘汰 ↓No 淘汰 ↓相等下一步 ↓相等下一步 ↓选这个加分项:
- 跨机房从节点常配高
slave-priority(数值大)来避免被切到远端; - 报表 / 备份只读副本配
slave-priority=0永久排除; - 即使 offset 最大也可能有数据丢失(主写完没复制完就挂了)。
Q5:故障转移期间客户端的请求会失败吗?怎么处理?
考察点:高可用的「实操痛点」。
标准答案:
会失败。失败窗口大约 = down-after-milliseconds(30s)+ 选举耗时(几秒到十几秒),合计 30~60s。在此期间:
- 写:旧主不可达 + 新主未产生 → 写请求超时或被拒。
- 读:连从节点的读请求一般还能继续,但从节点被切为新主期间会有短暂不可用。
处理方式:
- 客户端用支持 Sentinel 的 SDK(
redis.sentinel.Sentinel、Jedis 的JedisSentinelPool、Lettuce 的RedisURI哨兵模式等)——切换后自动重连新主,不需要业务层改地址。 - 重试 + 指数退避:把短暂的连接失败当作可重试错误,配合幂等性设计避免重复扣款等副作用。
- 熔断 + 兜底:失败一定时间后切到本地缓存 / DB 直查 / 返回降级数据。
- 写丢失缓解:
min-slaves-to-write 1+min-slaves-max-lag 10让主在没有健康从时拒写,把写丢失换成可用性下降。
加分项:
- 分布式锁等「强语义」场景在故障转移期间务必小心,Redlock 之争 与之相关(详见 Ch11);
- 真正零丢失要换 redis-raft / 数据库代理 + 双写等更强一致方案;
- 不要在客户端缓存「哪个 IP 是主」,所有判断都走 Sentinel SDK。
Q6:哨兵 + 主从 vs Cluster 集群,怎么选?
考察点:架构选型能力。
标准答案:
| 维度 | Sentinel + 主从 | Cluster |
|---|---|---|
| 数据容量 | 受限于单主内存 | 水平分片,理论无上限 |
| 写吞吐 | 单主,不能横向扩展 | 16384 槽分到多主,写可扩展 |
| 读吞吐 | 多从可分担读 | 多主 + 多从 都能分担 |
| 高可用 | ✅ | ✅ |
| 复杂度 | 低,部署简单 | 高,运维门槛高 |
| 客户端要求 | Sentinel 模式 SDK | Cluster 模式 SDK,要懂 MOVED/ASK |
| 典型场景 | 中小规模、读多写少 | 大数据量、高写吞吐 |
选型建议:
- 数据量 < 单机内存(一般 < 32GB)+ 单主写能撑住 → Sentinel 足矣。
- 数据量 > 单机内存,或写 QPS 顶不住单主 → 上 Cluster。
- 不要为了「显得高级」上 Cluster——哨兵简单稳定,运维成本低;Cluster 的 multi-key 操作(事务、Pipeline、Lua)会受 hash slot 限制,需要业务侧改造。
加分项:
- 第三种方案:应用层分片 + 多套主从+哨兵——十年前微博、知乎等都用过,现在大多被 Cluster 替代但灵活性最高;
- Cloud 场景下还有 Codis、Twemproxy、Redis Enterprise 等 Proxy 方案;
- Cluster 也是建立在主从复制之上的,主从 + Sentinel 是 Cluster 的基础——不学懂 9 章直接上 10 章会非常吃力。
📌 下一章预告:第 10 章我们看 Cluster 集群与数据分片——16384 个槽位、Gossip 协议、MOVED/ASK 重定向、跨槽事务的限制。Cluster 才是 Redis 真正撑大流量的「最终形态」。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
Ch9 配套代码 1 / 3 —— Sentinel 客户端用法
演示:
1. 用 redis-py 的 Sentinel 类连接哨兵集群
2. master_for() / slave_for() 自动找当前主 / 从
3. 故障转移后自动重连,业务无感
4. 如果本地没有哨兵环境,自动降级为 mock 演示 API 用法
前置:
pip install redis>=4.0
典型哨兵端口约定:26379 / 26380 / 26381
"""
import time
from typing import List, Tuple
try:
import redis
from redis.sentinel import Sentinel, MasterNotFoundError
HAS_REDIS = True
except ImportError:
HAS_REDIS = False
SENTINELS: List[Tuple[str, int]] = [
("127.0.0.1", 26379),
("127.0.0.1", 26380),
("127.0.0.1", 26381),
]
MASTER_NAME = "mymaster"
def section(title: str) -> None:
print("\n" + "=" * 60)
print(title)
print("=" * 60)
def demo_real_sentinel() -> bool:
"""尝试连真实哨兵;连不上返回 False 走 mock。"""
section("Demo 1: 连接真实 Sentinel 集群")
try:
sentinel = Sentinel(SENTINELS, socket_timeout=0.5)
master_addr = sentinel.discover_master(MASTER_NAME)
print(f" 当前主节点地址 : {master_addr}")
slaves = sentinel.discover_slaves(MASTER_NAME)
print(f" 当前从节点列表 : {slaves}")
master = sentinel.master_for(MASTER_NAME, socket_timeout=0.5,
decode_responses=True)
slave = sentinel.slave_for(MASTER_NAME, socket_timeout=0.5,
decode_responses=True)
master.set("ch9:hello", "from-sentinel")
print(f" 主写入成功 : ch9:hello = {master.get('ch9:hello')!r}")
time.sleep(0.2)
print(f" 从读取成功 : ch9:hello = {slave.get('ch9:hello')!r}")
master.delete("ch9:hello")
return True
except (MasterNotFoundError, redis.ConnectionError, OSError) as e:
print(f" ⚠️ 连不上哨兵: {e}")
print(" ⤳ 切到 mock 模式继续演示 API 用法")
return False
class _MockSentinel:
"""没有真实哨兵时的演示替身,只为打印 API 调用形态。"""
def __init__(self, sentinels, socket_timeout):
self.sentinels = sentinels
self._master = ("127.0.0.1", 6379)
self._slaves = [("127.0.0.1", 6380), ("127.0.0.1", 6381)]
print(f" [mock] 已配置 {len(sentinels)} 个哨兵")
def discover_master(self, name):
print(f" [mock] SENTINEL get-master-addr-by-name {name}")
return self._master
def discover_slaves(self, name):
print(f" [mock] SENTINEL slaves {name}")
return list(self._slaves)
def trigger_failover(self):
old = self._master
self._master, self._slaves[0] = self._slaves[0], self._master
print(f" [mock] 触发 failover: master {old} → {self._master}")
def demo_mock_sentinel() -> None:
section("Demo 2: Mock 模式 —— 不连真实哨兵也能学 API")
sentinel = _MockSentinel(SENTINELS, socket_timeout=0.5)
print(f" 当前主 : {sentinel.discover_master(MASTER_NAME)}")
print(f" 当前从 : {sentinel.discover_slaves(MASTER_NAME)}")
print("\n ── 模拟一次主节点故障转移 ──")
sentinel.trigger_failover()
print(f" failover 后主 : {sentinel.discover_master(MASTER_NAME)}")
print(f" failover 后从 : {sentinel.discover_slaves(MASTER_NAME)}")
print("\n 💡 真实使用时,redis-py 的 Sentinel 类会订阅 +switch-master")
print(" 频道,主切换后内部连接池自动重建,业务调用 master_for() 拿到")
print(" 的代理对象会无感地连到新主,不需要手工重连。")
def demo_retry_pattern() -> None:
section("Demo 3: 业务调用层的重试 + 退避模板")
code = """\
def safe_set(master, key, value, retries=3):
delay = 0.1
for i in range(retries):
try:
return master.set(key, value)
except (redis.ConnectionError, redis.TimeoutError) as e:
print(f" 第 {i+1} 次失败: {e},{delay}s 后重试")
time.sleep(delay)
delay *= 2 # 指数退避
raise RuntimeError("Redis 不可达,触发降级")
"""
print(code)
print(" 💡 故障转移期间会有 30~60s 的写不可用窗口,")
print(" 重试 + 指数退避 + 兜底降级是必备组合。")
if __name__ == "__main__":
if not HAS_REDIS:
print("❌ 未安装 redis 库,请先 pip install redis")
raise SystemExit(1)
if not demo_real_sentinel():
demo_mock_sentinel()
demo_retry_pattern()python
"""
Ch9 配套代码 2 / 3 —— 订阅 +switch-master 监听主节点切换
演示:
1. 订阅哨兵的多个事件频道(+sdown / +odown / +switch-master / ...)
2. 把每条事件实时打印 + 高亮关键字段
3. 真实哨兵不可达时,自动切到内置事件流回放
前置:
pip install redis>=4.0
哨兵默认监听 26379 端口
事件频道清单(最常用):
+sdown 某节点进入主观下线
-sdown 某节点恢复
+odown 主节点客观下线
+new-epoch epoch 增加(选举开始)
+vote-for-leader 投票
+elected-leader 选出 Leader
+failover-state-* 故障转移过程状态机
+switch-master ⭐主切换完成(客户端最关心的事件)
+slave-reconf-* 从节点重排
"""
import time
from typing import Iterable
try:
import redis
HAS_REDIS = True
except ImportError:
HAS_REDIS = False
CHANNELS = [
"+sdown", "-sdown",
"+odown", "-odown",
"+new-epoch",
"+vote-for-leader",
"+elected-leader",
"+failover-state-select-slave",
"+failover-state-send-slaveof-noone",
"+selected-slave",
"+promoted-slave",
"+slave-reconf-sent",
"+slave-reconf-done",
"+switch-master",
]
def banner(text: str) -> None:
print("\n" + "─" * 60)
print(text)
print("─" * 60)
def render_event(channel: str, data: str) -> None:
"""把一条哨兵事件渲染成可读输出。"""
ts = time.strftime("%H:%M:%S")
if channel == "+switch-master":
parts = data.split()
if len(parts) == 5:
name, old_ip, old_port, new_ip, new_port = parts
print(f"[{ts}] ⭐ +switch-master {name}")
print(f" old: {old_ip}:{old_port}")
print(f" new: {new_ip}:{new_port} ← 客户端应切到这里")
return
print(f"[{ts}] {channel:35} {data}")
def listen_real(host: str = "127.0.0.1", port: int = 26379) -> bool:
banner(f"Demo 1: 连真实哨兵 {host}:{port} 监听事件")
try:
sentinel = redis.Redis(host=host, port=port, decode_responses=True)
sentinel.ping()
except (redis.ConnectionError, OSError) as e:
print(f" ⚠️ 连不上哨兵: {e}")
return False
ps = sentinel.pubsub(ignore_subscribe_messages=True)
ps.subscribe(*CHANNELS)
print(f" 已订阅 {len(CHANNELS)} 个频道,监听中... (Ctrl-C 退出)")
print(" ⤳ 触发故障转移后这里会实时刷出事件流")
try:
for msg in ps.listen():
if msg["type"] == "message":
render_event(msg["channel"], msg["data"])
except KeyboardInterrupt:
print("\n 已停止")
return True
def replay_mock_events() -> None:
"""没有哨兵时回放一次「典型故障转移」的完整事件流。"""
banner("Demo 2: Mock 回放 —— 一次完整故障转移会刷出哪些事件")
fake = [
("+sdown", "master mymaster 127.0.0.1 6379"),
("+odown", "master mymaster 127.0.0.1 6379 #quorum 2/2"),
("+new-epoch", "8"),
("+vote-for-leader", "abcd1234... 8"),
("+elected-leader", "master mymaster 127.0.0.1 6379"),
("+failover-state-select-slave", "master mymaster 127.0.0.1 6379"),
("+selected-slave", "slave 127.0.0.1:6380 @ mymaster"),
("+failover-state-send-slaveof-noone", "slave 127.0.0.1:6380"),
("+promoted-slave", "slave 127.0.0.1:6380 @ mymaster"),
("+slave-reconf-sent", "slave 127.0.0.1:6381 → 127.0.0.1:6380"),
("+slave-reconf-done", "slave 127.0.0.1:6381"),
("+switch-master", "mymaster 127.0.0.1 6379 127.0.0.1 6380"),
]
for ch, data in fake:
render_event(ch, data)
time.sleep(0.4)
print("\n 💡 真实场景里这些事件会在 30~60s 内连续刷出来,")
print(" 运维一般会把 +switch-master 接到告警 / 配置中心,")
print(" 业务侧客户端则用 SDK 自动跟随,无需感知中间过程。")
def main() -> None:
if not HAS_REDIS:
print("❌ 未安装 redis 库,请先 pip install redis")
return
if not listen_real():
replay_mock_events()
if __name__ == "__main__":
main()python
"""
Ch9 配套代码 3 / 3 —— sentinel.conf 配置项解读
本脚本不连任何 Redis,只是把生产常用的 sentinel.conf 配置项
以「示例值 + 含义 + 调参建议 + 常见踩坑」的方式集中输出,方便速查。
直接运行:
python 03_sentinel_config_explained.py
或者过滤某项:
python 03_sentinel_config_explained.py monitor
"""
import sys
import textwrap
from dataclasses import dataclass
@dataclass
class ConfItem:
name: str
sample: str
desc: str
tuning: str
pitfall: str
CONFIG: list[ConfItem] = [
ConfItem(
name="port",
sample="port 26379",
desc="哨兵自身监听的端口。客户端 / 兄弟哨兵都通过这个端口跟它说话。",
tuning="一台机器跑多个哨兵实例时端口要错开(26379/26380/26381)。",
pitfall="不要和 redis-server 端口冲突。",
),
ConfItem(
name="dir",
sample='dir "/var/lib/redis-sentinel"',
desc="哨兵的工作目录。哨兵会把当前已知的主从拓扑回写到 sentinel.conf。",
tuning="必须可写。容器化时记得挂卷,否则重启后状态丢失。",
pitfall="只读目录会让哨兵在故障转移后无法持久化新的主地址。",
),
ConfItem(
name="monitor",
sample="sentinel monitor mymaster 127.0.0.1 6379 2",
desc="""\
告诉哨兵监控哪个主节点。4 个参数依次是:
① 主节点逻辑名(客户端 SDK 用这个名字找主)
② 主节点 IP
③ 主节点端口
④ quorum:判定 ODOWN 所需的最少哨兵数""",
tuning="quorum 一般取 ⌈N/2⌉(5 个哨兵设 3,3 个哨兵设 2)。",
pitfall="quorum 设成 1 等于绕过共识,回到单哨兵脑裂。",
),
ConfItem(
name="down-after-milliseconds",
sample="sentinel down-after-milliseconds mymaster 30000",
desc="哨兵 PING 多久没回复就把节点判为主观下线(SDOWN)。",
tuning="""\
在线核心业务 5000~10000ms 比较敏感,
非核心 / 网络抖动多的环境保留默认 30000ms 更稳。""",
pitfall="设得太小会因短时网络抖动频繁误切;太大故障恢复慢。",
),
ConfItem(
name="parallel-syncs",
sample="sentinel parallel-syncs mymaster 1",
desc="故障转移时,让多少个从节点 同时 SLAVEOF 新主进行同步。",
tuning="""\
1 是最稳妥(一个个排队同步,期间从可读旧数据),
从节点多 + 主能扛同步压力时可以设到 2~3 加快收敛。""",
pitfall="设得过大会让全部从同时全量同步,主节点 IO 被打爆 + 客户端读全部不可用。",
),
ConfItem(
name="failover-timeout",
sample="sentinel failover-timeout mymaster 180000",
desc="""\
一次故障转移整个流程的超时时间,包含:
· 选 Leader
· 选新主
· 让新主 SLAVEOF NO ONE
· 重排其他从
超时 = 视为本轮失败,下一轮选举可重新发起。""",
tuning="默认 180s 一般够用;从节点特别多(>10)时可以放大到 300s。",
pitfall="设得太小会让大集群一直「选举失败 → 重试」无法稳定。",
),
ConfItem(
name="auth-pass",
sample="sentinel auth-pass mymaster s3cret!",
desc="主从节点的连接密码。哨兵要能登入主从才能 INFO/SLAVEOF。",
tuning="生产强烈推荐设密码,且哨兵自己的 requirepass 也要配。",
pitfall="主和从的 requirepass / masterauth 必须一致,否则故障转移后从挂不上新主。",
),
ConfItem(
name="notification-script",
sample="sentinel notification-script mymaster /opt/notify.sh",
desc="任何 +sdown / +odown / +switch-master 等事件触发时,调用这个脚本。",
tuning="一般用来做钉钉 / 邮件 / 短信告警。",
pitfall="""\
脚本必须 60s 内退出,否则哨兵会 SIGKILL;
脚本异常退出码会被记日志但不影响故障转移流程。""",
),
ConfItem(
name="client-reconfig-script",
sample="sentinel client-reconfig-script mymaster /opt/reload-lb.sh",
desc="""\
主切换完成后调用,常用于通知 LVS / Nginx / 配置中心
把上游 Redis 地址换成新主。""",
tuning="客户端用 Sentinel SDK 时这个脚本可以不配;只在有外部代理层时需要。",
pitfall="脚本传入参数顺序:master_name role state from_ip from_port to_ip to_port",
),
ConfItem(
name="deny-scripts-reconfig",
sample="sentinel deny-scripts-reconfig yes",
desc="禁止通过 SENTINEL SET 命令在线修改 notification-script 等高危项。",
tuning="生产必开,避免被攻破后用脚本路径注入恶意命令。",
pitfall="老版本默认 no,升级后注意。",
),
ConfItem(
name="resolve-hostnames",
sample="sentinel resolve-hostnames yes",
desc="允许在 sentinel monitor 里写主机名而不是 IP(Redis 6.2+)。",
tuning="K8s / 容器场景非常有用,IP 漂移时不用改配置。",
pitfall="DNS 解析失败时哨兵会拒绝该主节点的故障转移。",
),
ConfItem(
name="announce-ip / announce-port",
sample="sentinel announce-ip 10.0.0.5\nsentinel announce-port 26379",
desc="哨兵向其他哨兵 / 客户端宣告自己时使用的 IP/端口。",
tuning="跨 NAT / Docker 网络时必填,否则别人收到的是容器内网 IP 没法连。",
pitfall="忘配会出现「能监控但客户端连不上」的诡异现象。",
),
]
def render(item: ConfItem) -> str:
lines = []
lines.append("┌" + "─" * 72)
lines.append(f"│ ▎{item.name}")
lines.append("├" + "─" * 72)
lines.append("│ 示例:")
for ln in item.sample.splitlines():
lines.append(f"│ {ln}")
lines.append("│ 含义:")
desc = textwrap.dedent(item.desc).strip()
for ln in desc.splitlines():
lines.append(f"│ {ln}")
lines.append("│ 调参建议:")
tuning = textwrap.dedent(item.tuning).strip()
for ln in tuning.splitlines():
lines.append(f"│ {ln}")
lines.append("│ 踩坑提醒:")
pitfall = textwrap.dedent(item.pitfall).strip()
for ln in pitfall.splitlines():
lines.append(f"│ ⚠️ {ln}")
lines.append("└" + "─" * 72)
return "\n".join(lines)
def main() -> None:
keyword = sys.argv[1].lower() if len(sys.argv) > 1 else None
print("=" * 74)
print(" sentinel.conf 关键配置项速查 ".center(74, "="))
print("=" * 74)
shown = 0
for item in CONFIG:
if keyword and keyword not in item.name.lower():
continue
print(render(item))
print()
shown += 1
if keyword and shown == 0:
print(f"⚠️ 没有匹配 {keyword!r} 的配置项")
print(f" 可用项:{', '.join(c.name for c in CONFIG)}")
return
print("─" * 74)
print("📌 完整生产模板(3 哨兵)")
print("─" * 74)
print(textwrap.dedent("""\
port 26379
dir "/var/lib/redis-sentinel"
sentinel monitor mymaster 10.0.0.10 6379 2
sentinel auth-pass mymaster s3cret!
sentinel down-after-milliseconds mymaster 10000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000
sentinel deny-scripts-reconfig yes
sentinel announce-ip 10.0.0.20
"""))
if __name__ == "__main__":
main()01_sentinel_client.py ↗ · 02_failover_listener.py ↗ · 03_sentinel_config_explained.py ↗