Redis 哨兵选举机制(Raft)
问题
Redis Sentinel 选举主节点的算法是什么?跟 Raft 协议是什么关系?生产环境有哪些容易踩的坑?
分析
哨兵干了什么
Redis Sentinel(哨兵)负责三件事:监控、通知、自动故障转移。当主节点挂了,哨兵集群需要自己选出一个 leader,由这个 leader 执行"从节点晋升新主"的操作。
选 leader 这一步,参考了 Raft 协议,但不是完整实现,属于"Raft-like"。
Raft 核心机制回顾(为了对比)
要理解 Sentinel 选举的局限,先快速过一遍 Raft 的核心机制。Raft 把共识问题拆成三个子问题:Leader 选举、日志复制、安全性。
- Term(任期):时间划分为递增的 term,每个 term 最多一个 leader
- 选举超时(Election Timeout):Follower 在规定时间内没收到 leader 心跳,转为 Candidate 发起选举,超时时间 150-300ms 随机化
- 投票规则:每个 term 每台服务器只能投一票,Candidate 必须拿到
> N/2票 - 日志复制:leader 把客户端的写操作包装成 Log Entry,通过 AppendEntries RPC 复制到所有 Follower,过半确认后才可以提交(commit)
- 安全性:Candidate 的日志必须至少和 Follower 一样新(比较 lastLogIndex 和 lastLogTerm),否则 Follower 拒绝投票。这保证了已提交的日志不会丢失
Sentinel 只复用了"选 leader"这一层,而且砍掉了日志复制,所以叫 Raft-like。
选举流程拆解
第一步:确认主节点真挂了(S_DOWN → O_DOWN)
单个哨兵发现主节点 ping 超时,先标记为 S_DOWN(主观下线)。然后问其他哨兵:"你觉得主节点挂了没?" 如果至少 quorum 个哨兵都确认挂了,标记为 O_DOWN(客观下线),触发选举。
# 伪代码:哨兵判断 ODOWN
def check_odown(sentinel, master, quorum):
votes = 1 # 自己先算一票
for peer in sentinel.peers:
if peer.sentinel_master_down(master.name):
votes += 1
if votes >= quorum:
master.odown = True
# 触发选举
start_election(sentinel, master)这里的 quorum 通常在 sentinel.conf 里配置。生产坑:quorum 设得太高(比如 5 节点哨兵设 quorum=5),网络抖动丢掉一个哨兵,永远凑不够 O_DOWN,故障转移失效。
第二步:自荐 + 拉票
触发选举的哨兵给自己投一票(leader_epoch += 1),并向其他哨兵发 SENTINEL is-master-down-by-addr 请求,附带自己的 leader_epoch。
注意这里的 leader_epoch 对应 Raft 的 term。但和 Raft 的一个关键区别:Raft 的 term 必须持久化到磁盘,选举恢复后重新读;Sentinel 的 epoch 只在内存中维护。哨兵全部重启后 epoch 归零,如果旧主还活着但没来得及降级,可能出现双写。
第三步:投票规则
其他哨兵收到拉票请求,判断逻辑:
- 如果当前 epoch 还没投过票,同意
- 如果已经有更高 epoch 的候选投过票,拒绝(Raft 的"每 term 一票"原则)
- 如果收到更低 epoch 的拉票,忽略
第四步:胜出
拿到超过半数(> N/2)票的哨兵成为 leader,然后执行故障转移。
# 伪代码:选举计票
def elect_leader(sentinel, master):
votes = {}
for peer in sentinel.peers:
if peer.vote_for == sentinel.id:
votes[peer.id] = peer.epoch
if len(votes) > len(sentinel.peers) / 2:
return sentinel # 胜出
return None选举超时:Sentinel 没有 Raft 的随机化选举超时机制,而是依赖 sentinel failover-timeout(默认 3 分钟)。这意味着 Sentinel 的选举触发速度比 Raft 慢几个数量级——Raft 在 300ms 内完成,Sentinel 可能要等几十秒甚至几分钟。
第五步:故障转移——选从节点
leader 选出来之后,从候选从节点中挑一个升主,筛选规则:
- 优先级(
slave-priority)越小越优先,0 表示不参与选举 - 复制偏移量(replication offset)越大越优先,数据越新
- runid 越小越优先(兜底)
# 伪代码:选主节点
def select_new_master(slaves):
# 过滤掉断连、优先级为 0 的
candidates = [s for s in slaves if s.is_connected and s.priority > 0]
# 按优先级升序,相同按复制偏移量降序,再相同按 runid 升序
candidates.sort(key=lambda s: (s.priority, -s.replication_offset, s.runid))
return candidates[0] if candidates else None这里有个隐含陷阱:复制偏移量只反映主从之间的数据同步进度,不代表数据一致性。如果从节点在同步过程中断连了,它的偏移量停在某个位置,leader 选择它升主后,丢失的数据只能靠主节点重启后追回。所以 slave-priority 配置要配合预期的一致性和可用性要求来设置。
选出来后,leader 向其他从节点发送 SLAVEOF new_master,把原主节点标记为从节点(恢复后自动切换)。
完整时序图
Sentinel-1 Sentinel-2 Sentinel-3 Master Slave-A Slave-B
| | | | | |
|--- ping timeout -->| | | | |
|<-- S_DOWN --------| | | | |
| | | | | |
|--- is-master-down? -->| | | | |
|<-- yes ------------| | | | |
|--- is-master-down? ------>| | | | |
|<-- no -------------| | | | |
| | | | | |
| quorum=2, 2/3 确认, O_DOWN | | | |
| leader_epoch=3 | | | |
| | | | | |
|--- vote_for(epoch=3) -->| | | | |
|<-- vote_yes ----------| | | | |
|--- vote_for(epoch=3) ------>| | | | |
|<-- vote_no (已投 Sentinel-2) | | | | |
| | | | | |
| [2/3 票, 胜出] | | | |
| | | | | |
|----------- SLAVEOF Slave-A ------------->| | | |
|--- SLAVEOF Slave-A ------------------------>| | | |
| | | | | |
|--- 标记旧主为 slave ---->| | | | |
| | | | | |
| [故障转移完成] | Slave-A 旧主 |
| | | | 升 NEW | |
| | | | Master | |哨兵选举 vs Raft 的关键区别
| 维度 | Raft | Sentinel |
|---|---|---|
| 日志复制 | 有严格的日志复制机制,写操作必须过半确认才提交 | 没有日志复制,只关心谁当 leader,不关心数据完整性 |
| Term 持久化 | term 持久化到磁盘,重启后恢复 | epoch 只在内存中维护,哨兵全挂后归零 |
| Term 连续性 | 严格递增,未参与 Term 的节点不能投票 | epoch 递增,但无严格连续性保障 |
| 选举超时 | 150-300ms 随机化,避免 split vote | 固定 failover_timeout(默认 3 分钟),速度慢 2-3 个数量级 |
| 安全性保证 | 通过日志比较(lastLogIndex, lastLogTerm)保证已提交日志不丢失 | 无日志比较,选从节点只看复制偏移量,不保证一致性 |
| 脑裂保护 | 通过日志复制 + 多数派保证线性一致性 | 靠 epoch 标记拒绝旧主写入,不保证强一致 |
| 心跳机制 | 空 AppendEntries RPC,周期性发送 | ping 命令,超时时间可配置 |
核心差异一句话:Raft 是强一致共识算法,Sentinel 是"够用就行"的 Raft-like 实现。
生产环境实战坑
坑一:哨兵最少几个节点
3 个(至少 2 个确认才 ODOWN)。如果只有 2 个哨兵,一个挂了,另一个无法凑够 quorum,故障转移永远触发不了。
生产建议:3 个或 5 个奇数节点,quorum 设为 2 或 3。
坑的变形:哨兵 3 节点但 quorum=3,一次网络抖动丢一个哨兵,剩下 2 个哨兵中 1 个把主标记 S_DOWN,但另一个哨兵不确认,永远凑不够 O_DOWN。实际遇到过——某业务线线上 Redis 主挂了 2 分钟没触发故障转移,排查发现 quorum=3 但一个哨兵机器硬盘 IO 打满导致响应超时,实际只有 2 个哨兵活着。
坑二:密码配置不一致
Sentinel 连接从节点也需要密码。如果 requirepass 和 masterauth 配置不一致,从节点升主后会因为密码不对连不上,导致故障转移失败。
# 务必保持一致
requirepass YourPassword
masterauth YourPassword实际案例:某生产环境升级 Redis 6 到 7,改了 requirepass 但忘了改 masterauth,一个月后主节点宕机,从节点升主后新主无法认证旧从,集群陷入"无主"状态 8 分钟,手工介入才恢复。
坑三:脑裂(split-brain)
网络分区发生时,两个分区都可能选出哨兵 leader。但集群纪元(epoch)保证唯一性——故障转移后 current_epoch 递增,旧主恢复后收到 epoch 更新的消息,发现自己落后,自动降级为从节点。
# 脑裂保护示意
epoch_new = 5
epoch_old_master = 3 # 旧主还在用旧 epoch
if epoch_new > epoch_old_master:
old_master.reject_write() # 拒绝写入
old_master.become_slave(new_master)但这里有个坑:旧主恢复后拒绝写入之前的几秒,客户端可能已经写入了新数据。如果旧主和新主分区期间都接受了写入,epoch 机制只能保证后续一致性,不能挽回已丢失的数据。
实际场景:某电商 Redis 集群在 AWS 可用区之间出现网络延迟,两个哨兵各选出一个 leader,两个主节点都接受了写入,导致约 500 条订单数据不一致。最终靠业务日志逐条修复。
坑四:主观下线误判
网络抖动可能导致哨兵误判 S_DOWN,触发不必要的选举。调优 down-after-milliseconds(默认 30000ms),根据网络状况适当放大。
建议:如果部署在同一机房内网,down-after-milliseconds 可以设 10000-15000ms;跨可用区则设 30000-50000ms,避免频繁选举。
坑五:哨兵自身脑裂(选举失败)
极端情况:3 个哨兵分布在 3 个可用区,如果两个可用区之间网络中断,哨兵可能永远选不出 leader(两个分区都只有 1 个哨兵在线,各自拿到 1 票,达不到 2 票)。这种情况下的妥协方案:将哨兵部署在同一个可用区,跨 AZ 用 Redis Cluster 而非 Sentinel。
坑六:从节点数据过期
Sentinel 选从节点只看复制偏移量,不考虑从节点数据是否过期(比如断连了 2 天)。如果选了断连很久的从节点升主,客户端读取到的数据是 2 天前的。通过 min-slaves-to-write 和 min-slaves-max-lag 参数可以部分缓解,但不能完全解决。
# 至少 1 个从节点同步延迟不超过 10 秒,才允许主节点写入
min-slaves-to-write 1
min-slaves-max-lag 10总结
- Sentinel 选举是 Raft-like,不是完整 Raft 实现,没有日志复制
- 选举流程:O_DOWN 确认 → 自荐拉票 → 过半胜出 → 执行故障转移
- 选从节点:优先级 > 复制偏移量 > runid
- 核心差异:Sentinel 没有日志复制和持久化 term,选举超时长达 3 分钟,安全性远弱于 Raft
- 生产配置:3 个奇数哨兵、密码一致、
down-after-milliseconds合理设置 - 最坑的脑裂问题靠 epoch 机制兜底,但不保证强一致,网络分区期间可能丢数据
- 面试加分:能说出 Sentinel 和 Raft 在 term 持久化、日志复制、选举超时随机化三个维度的差异,以及脑裂场景下 epoch 机制的实际局限