Skip to content

第 9 章 哨兵(Sentinel)高可用

学习目标:理解 Redis 在主从复制基础之上「自动救火」的高可用方案——哨兵。看完之后能完整画出**「主节点宕机 → 哨兵发现 → 哨兵投票 → 选 Leader → 选新主 → 重排从节点 → 通知客户端」**整条链路;能解释 SDOWN / ODOWN / Quorum / Raft 选举的细节;能说清楚为什么哨兵至少要 3 个、为什么必须奇数;能写出客户端正确感知主切换的代码;能在面试里被追问到「故障转移期间写丢了怎么办」「脑裂如何防」时不慌。


9.1 主从复制还差点意思:从「能扛」到「能自愈」

第 8 章我们搭起了一主多从。看上去很美:

  • 主挂了?还有从,数据没丢。
  • 读压力大?读请求打到从。

真出事的时候,你会发现一个尴尬的事实——主从复制只是把数据复制了一份,主节点真挂掉时它什么都不会做

                    生产凌晨 3:17 主节点宕机


        ┌────────────────────────────────────────┐
        │  ① 监控告警把运维从被窝里捞起来            │
        │  ② SSH 到一台从节点                       │
        │  ③ 跑 SLAVEOF NO ONE 把它升为主          │
        │  ④ 改其他从节点的 slaveof 指向新主         │
        │  ⑤ 改业务代码里的 Redis 地址(或改 DNS)   │
        │  ⑥ 重启所有应用                          │
        │  ⑦ 写复盘报告,被老板叫去喝茶              │
        └────────────────────────────────────────┘
              ↑ 全程 30 分钟起步,业务停摆

这显然不是高可用,这叫「高人工」。 我们需要一个机制能:

  1. 24h 盯着 主从节点是否还活着;
  2. 达成共识 ——别因为我自己网络抖动就乱推流程;
  3. 自动选个新主,把其他从节点重新挂上去;
  4. 告诉客户端 主节点换地方了。

这就是 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         │ ✅ 极端高可用         │
└────────┴──────────┴───────────────┴────────────────────┘

结论

  1. 至少 3 个哨兵,否则容忍不了任何宕机。
  2. 优先选奇数:偶数比上一个奇数多花机器,但容忍故障数不变(4 还是只能挂 1 个,跟 3 一样),还多了产生「2:2」死锁的风险。
  3. 跨机房部署:如果只部署在同一台物理机或同一机架上,机器一挂三个一起没——容灾就成了笑话。

💡 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) → 当选 Leader

9.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 直接丢

缓解办法:

  1. min-slaves-to-write 1 + min-slaves-max-lag 10:master 在没有至少 1 个 lag<10s 的从时拒绝写入,把写丢失风险换成可用性下降。
  2. 业务侧关键写入用「两阶段」:先写持久化数据库,再缓存到 Redis。
  3. 真要 100% 不丢需要走 Redis 7+ 的 WAIT 命令或换更强一致的方案(比如配合 Raft 的 redis-raft 项目)。

9.9 实操:跑一遍配套代码

实战代码见 09_sentinel/code/

  • 01_sentinel_client.py:用 redis-pySentinel 类连接哨兵集群(如果环境无哨兵,自动降级用 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 自带的高可用方案,主要解决「主节点宕机后没人自动切换」的问题。它有三个核心任务:

  1. 监控:定期 PING 主、从、其他哨兵,发现宕机;
  2. 通知:通过 pub/sub 把节点状态变化广播给客户端 / 运维;
  3. 自动故障转移:达成共识后选 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-millisecondsSDOWN + 多哨兵互相确认
适用对象主 / 从 / 哨兵均可仅对主节点有意义
引发动作仅广播 +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

  1. 每个哨兵维护一个 current_epoch(类似 Raft 的 term),单调递增。
  2. 谁先发现 ODOWN,谁就把 epoch +1,先给自己投一票,向其他哨兵发 SENTINEL is-master-down-by-addr <ip> <port> <epoch> <my_runid>
  3. 收到请求的哨兵:
    • 若对方 epoch 比自己已知的小 → 拒绝(旧选举);
    • 若在同一个 epoch 已经投过别人 → 拒绝(一票制);
    • 否则 → 投给对方,记下「epoch=X 投给 runid=Y」。
  4. 候选哨兵收到 ≥ max(quorum, majority) 票即当选 Leader。
  5. 当选 Leader 才有权执行故障转移。
  6. 没拿够票 → 等随机延迟、epoch +1,重试。

加分项

  • 哨兵选举的 Leader 是「单次故障转移」级别,不是持续角色;
  • 每轮重试的随机延迟避免「同时刻发起选举导致永远拿不到多数」的活锁;
  • 当选 Leader 还需要满足 majority——quorum 仅够判 ODOWN,但 failover 必须多数派同意;
  • 与原版 Raft 区别:哨兵没有日志复制,因此不需要日志匹配/提交 index 等步骤。

Q4:新主节点是按什么规则选出来的?

考察点:故障转移细节,能不能讲清 5 步过滤。

标准答案:5 步级联过滤:

  1. 过滤掉断线 / SDOWN / ODOWN 的从——不健康直接淘汰。
  2. 过滤掉响应慢的——最近 down-after-milliseconds × 10 没正常 INFO 的踢掉。
  3. slave-priority 排序——数值越小优先级越高,0 表示永不当主。
  4. 按复制偏移量 offset 排序——offset 大的数据最新,丢失最少。
  5. runid 字典序兜底——前面全平局时按 runid 排序,纯粹为了结果可重现。
在线? → 响应快? → priority 最高? → offset 最大? → runid 最小?
   ↓No 淘汰   ↓No 淘汰     ↓相等下一步       ↓相等下一步       ↓选这个

加分项

  • 跨机房从节点常配高 slave-priority(数值大)来避免被切到远端;
  • 报表 / 备份只读副本配 slave-priority=0 永久排除;
  • 即使 offset 最大也可能有数据丢失(主写完没复制完就挂了)。

Q5:故障转移期间客户端的请求会失败吗?怎么处理?

考察点:高可用的「实操痛点」。

标准答案

会失败。失败窗口大约 = down-after-milliseconds(30s)+ 选举耗时(几秒到十几秒),合计 30~60s。在此期间:

  • :旧主不可达 + 新主未产生 → 写请求超时或被拒。
  • :连从节点的读请求一般还能继续,但从节点被切为新主期间会有短暂不可用。

处理方式

  1. 客户端用支持 Sentinel 的 SDKredis.sentinel.Sentinel、Jedis 的 JedisSentinelPool、Lettuce 的 RedisURI 哨兵模式等)——切换后自动重连新主,不需要业务层改地址。
  2. 重试 + 指数退避:把短暂的连接失败当作可重试错误,配合幂等性设计避免重复扣款等副作用。
  3. 熔断 + 兜底:失败一定时间后切到本地缓存 / DB 直查 / 返回降级数据。
  4. 写丢失缓解min-slaves-to-write 1 + min-slaves-max-lag 10 让主在没有健康从时拒写,把写丢失换成可用性下降。

加分项

  • 分布式锁等「强语义」场景在故障转移期间务必小心,Redlock 之争 与之相关(详见 Ch11);
  • 真正零丢失要换 redis-raft / 数据库代理 + 双写等更强一致方案;
  • 不要在客户端缓存「哪个 IP 是主」,所有判断都走 Sentinel SDK。

Q6:哨兵 + 主从 vs Cluster 集群,怎么选?

考察点:架构选型能力。

标准答案

维度Sentinel + 主从Cluster
数据容量受限于单主内存水平分片,理论无上限
写吞吐单主,不能横向扩展16384 槽分到多主,写可扩展
读吞吐多从可分担读多主 + 多从 都能分担
高可用
复杂度,部署简单高,运维门槛高
客户端要求Sentinel 模式 SDKCluster 模式 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 ↗