Redis Sentinel:新主库是怎么选出来的?
一句话结论(30s)
哨兵选主的本质是「多节点投票 + 三轮打分」的自动化故障转移:因为单个哨兵可能误判网络抖动,所以先靠 quorum 把主观下线升级为客观下线,再用 Raft 多数派选出一个 Leader 哨兵执行切换,最后按 replica-priority、复制偏移量、runID 三轮打分选出数据最完整的新主——用秒级不可用换来无需人工介入。
核心原理(2min)
主流程分四步:① 哨兵持续 PING 判定下线——单哨兵超时是 SDOWN,向其他哨兵确认、超过 quorum 同意升级为 ODOWN 才触发故障转移;② 多个哨兵用 Raft 风格多数派投票选出一个 Leader(需过半且超过 max(quorum, N/2+1));③ Leader 按三轮打分选新主——先比 replica-priority(越小越优先、0 永不选主),再比 slave_repl_offset(复制进度越大丢数据越少),最后用 runID 字典序兜底避免平票;④ Leader 向新主发 SLAVEOF NO ONE、向其余从库发 SLAVEOF 指向新主,并通过 Pub/Sub 通知客户端。关键安全配置是 min-slaves-to-write + min-slaves-max-lag:旧主被隔离后从库归零、拒绝写入,从而关闭脑裂窗口。
底层深入(5-10min)
哨兵集群的两件事情
- 监控(Monitoring):哨兵节点持续 PING 主库和从库,判定存活
- 故障转移(Failover):主库宕机 → 哨兵选出新主 → 通知所有从库和新客户端
主库下线的判定
不是单个哨兵说了算——分两步:
SDOWN (Subjectively Down — 主观下线):
单个哨兵 PING 主库,超过 down-after-milliseconds 无响应 → 自己认为主下线了
ODOWN (Objectively Down — 客观下线):
哨兵向其他哨兵确认 "你们也觉得主下线了吗?"
→ 超过 quorum 个哨兵确认 → ODOWN(客观事实 → 触发故障转移)
quorum = sentinel monitor mymaster <ip> <port> <quorum> 中的值。 3 个哨兵、quorum=2 意味着需要 ≥2 个哨兵同意才触发切换。
停下来想一想:哨兵为什么要用「多数派判客观下线」,而不是单个哨兵说了算?因为单个哨兵的网络可能刚好与主库之间抖动或分区——它自己 PING 不通,不代表主库真的挂了。若此时就触发切换,会「冤枉」一个健康的主库、无谓地搬一次家。所以先 SDOWN(自己怀疑)、再拉上其他哨兵投票,超过 quorum 才升级 ODOWN、才触发故障转移,本质是用「多视角交叉验证」把误判率降下来。
源码:主观下线 sentinelCheckSubjectivelyDown
/* sentinel.c — sentinelCheckSubjectivelyDown() 主观下线判定核心 */
/* Update the SDOWN flag. We believe the instance is SDOWN if:
*
* 1) It is not replying.
* 2) We believe it is a master, it reports to be a slave for enough time
* to meet the down_after_period, plus enough time to get two times
* INFO report from the instance. */
if (elapsed > ri->down_after_period ||
(ri->flags & SRI_MASTER &&
ri->role_reported == SRI_SLAVE &&
mstime() - ri->role_reported_time >
(ri->down_after_period+sentinel_info_period*2)))
{
/* Is subjectively down */
if ((ri->flags & SRI_S_DOWN) == 0) {
sentinelEvent(LL_WARNING,"+sdown",ri,"%@");
ri->s_down_since_time = mstime();
ri->flags |= SRI_S_DOWN;
}
}
这段是单个哨兵自己的判断:只要 elapsed(距上次收到响应的时间)超过 down_after_period 就置上 SRI_S_DOWN 标志并广播 +sdown 事件。注意第二种情况——主库自己上报自己变成了从库(被降级),同样视为下线,这是防止旧主脑裂后仍被哨兵当主库的特殊兜底。
源码:客观下线 sentinelCheckObjectivelyDown
/* sentinel.c — sentinelCheckObjectivelyDown() 客观下线判定 */
void sentinelCheckObjectivelyDown(sentinelRedisInstance *master) {
dictIterator di;
dictEntry *de;
unsigned int quorum = 0, odown = 0;
if (master->flags & SRI_S_DOWN) {
/* Is down for enough sentinels? */
quorum = 1; /* the current sentinel. */
/* Count all the other sentinels. */
dictInitIterator(&di, master->sentinels);
while((de = dictNext(&di)) != NULL) {
sentinelRedisInstance *ri = dictGetVal(de);
if (ri->flags & SRI_MASTER_DOWN) quorum++;
}
dictResetIterator(&di);
if (quorum >= master->quorum) odown = 1;
}
/* Set the flag accordingly to the outcome. */
if (odown) {
if ((master->flags & SRI_O_DOWN) == 0) {
sentinelEvent(LL_WARNING,"+odown",master,"%@ #quorum %d/%d",
quorum, master->quorum);
master->flags |= SRI_O_DOWN;
master->o_down_since_time = mstime();
}
}
}
客观下线是「自己 1 票 + 遍历其他哨兵的 SRI_MASTER_DOWN 标志计数」。只有 quorum >= master->quorum 才置 SRI_O_DOWN,这正是前面 quorum 配置的实际落地。SRI_MASTER_DOWN 是其他哨兵通过 SENTINEL is-master-down-by-addr 投票回填的,所以这是真正的多节点多数派,而非单点臆断。
Leader 选举:谁去执行切换?
多个哨兵可能同时检测到 ODOWN,都想去执行切换。哨兵之间先选举一个 Leader:Raft 风格的多数派投票——每个哨兵只能投一票,获得过半数票 + 超过 max(quorum, N/2+1) 的哨兵成为 Leader,负责执行故障转移。
停下来想一想:为什么 Leader 选举要求「过半」,即超过
max(quorum, N/2+1)?因为如果允许少于半数就能当选,可能出现两个哨兵各自拉拢一部分支持者、同时自封 Leader,各自执行一套切换,最终选出两个不同的新主——这就是「双主脑裂」。过半要求保证任意时刻最多只有一个哨兵拿到多数票,Leader 唯一,切换动作才唯一。
新主库的三轮打分
Leader 哨兵选新主库不是随机的——按优先级分三级打分,任一级决出唯一胜者即停止:
第一轮:replica-priority(配置优先级)
redis-cli> CONFIG SET replica-priority 100 # 越小越优先,0 = 永不选为主
第二轮:slave_repl_offset(复制进度)
主从异步复制的必然结果——各从库的同步进度不同。slave_repl_offset 越大,说明与原主库的同步越完整,丢失数据最少。
redis-cli> INFO replication | grep slave_repl_offset
第三轮:runID(字典序)
前两轮分数相同(多个从库同步进度一样),用 runID 的字典序兜底——越小越优先。纯确定性决策,避免平票。
三轮打分的本质:尽量选出配置上最合适、数据上最完整的新主库。
停下来想一想:三轮打分的顺序为什么是 priority → offset → runID,能换吗?顺序不能乱——它体现的是「先尊重运维意图、再追求数据完整、最后用确定性兜底」的优先级:第一轮 priority 让运维能把「永远不想当主」的机器(0)排除、把「更想当主」的机器往前排;第二轮 offset 比的是谁从旧主同步得更完整、丢数据更少,这是选主最该关心的;第三轮 runID 字典序纯粹是「两难自解」的确定性规则,避免平票时随机选。如果先比 offset 再比 priority,运维的配置意图就会被覆盖,所以顺序是有讲究的。
源码:三轮打分比较器 compareSlavesForPromotion
/* sentinel.c — compareSlavesForPromotion() 供 qsort 使用的三轮比较器 */
/* Helper for sentinelSelectSlave(). This is used by qsort() in order to
* sort suitable slaves in a "better first" order, to take the first of
* the list. */
int compareSlavesForPromotion(const void *a, const void *b) {
sentinelRedisInstance **sa = (sentinelRedisInstance **)a,
**sb = (sentinelRedisInstance **)b;
char *sa_runid, *sb_runid;
if ((*sa)->slave_priority != (*sb)->slave_priority)
return (*sa)->slave_priority - (*sb)->slave_priority;
/* If priority is the same, select the slave with greater replication
* offset (processed more data from the master). */
if ((*sa)->slave_repl_offset > (*sb)->slave_repl_offset) {
return -1; /* a < b */
} else if ((*sa)->slave_repl_offset < (*sb)->slave_repl_offset) {
return 1; /* a > b */
}
/* If the replication offset is the same select the slave with that has
* the lexicographically smaller runid. Note that we try to handle runid
* == NULL as there are old Redis versions that don't publish runid in
* INFO. A NULL runid is considered bigger than any other runid. */
sa_runid = (*sa)->runid;
sb_runid = (*sb)->runid;
if (sa_runid == NULL && sb_runid == NULL) return 0;
else if (sa_runid == NULL) return 1; /* a > b */
else if (sb_runid == NULL) return -1; /* a < b */
return strcasecmp(sa_runid, sb_runid);
}
这个 qsort 比较器就是「三轮打分」的直接翻译:第一轮比较 slave_priority(越小越优先),第二轮比较 slave_repl_offset(越大越靠前),第三轮用 strcasecmp 比较 runid 字典序兜底。优先级不同时直接返回差值,后两轮只有在前一轮平局时才会被执行。
源码:选主入口 sentinelSelectSlave
/* sentinel.c — sentinelSelectSlave() 先过滤候选,再排序取第一 */
sentinelRedisInstance *sentinelSelectSlave(sentinelRedisInstance *master) {
sentinelRedisInstance **instance =
zmalloc(sizeof(instance[0])*dictSize(master->slaves));
sentinelRedisInstance *selected = NULL;
int instances = 0;
dictIterator di;
dictEntry *de;
mstime_t max_master_down_time = 0;
if (master->flags & SRI_S_DOWN)
max_master_down_time += mstime() - master->s_down_since_time;
max_master_down_time += master->down_after_period * 10;
dictInitIterator(&di, master->slaves);
while((de = dictNext(&di)) != NULL) {
sentinelRedisInstance *slave = dictGetVal(de);
mstime_t info_validity_time;
if (slave->flags & (SRI_S_DOWN|SRI_O_DOWN)) continue;
if (slave->link->disconnected) continue;
if (mstime() - slave->link->last_avail_time > sentinel_ping_period*5) continue;
if (slave->slave_priority == 0) continue;
/* If the master is in SDOWN state we get INFO for slaves every second.
* Otherwise we get it with the usual period so we need to account for
* a larger delay. */
if (master->flags & SRI_S_DOWN)
info_validity_time = sentinel_ping_period*5;
else
info_validity_time = sentinel_info_period*3;
if (mstime() - slave->info_refresh > info_validity_time) continue;
if (slave->master_link_down_time > max_master_down_time) continue;
instance[instances++] = slave;
}
dictResetIterator(&di);
if (instances) {
qsort(instance,instances,sizeof(sentinelRedisInstance*),
compareSlavesForPromotion);
selected = instance[0];
}
zfree(instance);
return selected;
}
排序之前先做一轮硬性过滤:自己已经 SDOWN/ODOWN 的、连接断开的、slave_priority == 0(永不选主)的、INFO 数据过期太久的、与主库失联超过容忍时间的从库全部跳过。过滤后剩下的候选交给 qsort + 上面的三轮比较器,取 instance[0] 即得分最高的那个——这就是新主。
选出新主后做什么
1. Leader 向新主发 SLAVEOF NO ONE → 角色切换为主库
2. Leader 向其他从库发 SLAVEOF new_master_ip port → 切换到新主
3. Leader 通过 Pub/Sub 通知所有客户端新主地址
4. 客户端重连,从新主继续读写
整个切换过程有秒级不可用时间——客户端需要检测到连接断开,重连,从哨兵获取新主地址。
脑裂防护
网络分区后,旧主库被隔离,哨兵选出了新主库。旧主库仍以为自己还是主库,继续接受写入 → 两个主库同时存在 → 数据冲突。
防护方案:
min-slaves-to-write 1 # 至少 1 个从库在线
min-slaves-max-lag 10 # 从库复制延迟不能超过 10 秒
原理: 旧主被隔离后,它的从库都切割到新主那边去了
→ 旧主的从库数 = 0 → 不满足 min-slaves-to-write
→ 旧主拒绝写入 → 脑裂窗口被关闭
这两行配置是生产环境 Redis 哨兵部署的必备安全配置。
停下来想一想:为什么
min-slaves-to-write能关掉脑裂窗口?脑裂的危害在于「旧主被隔离后仍接收写入」,这部分写入注定要和新的主库数据冲突、且无法同步。而隔离发生时,旧主的从库通常已跟着哨兵切到新主那边去了,旧主自己身边的从库数骤降为 0;此时min-slaves-to-write判定「健康从库不足」直接拒绝写入,等于在旧主被彻底孤立的那一刻就掐断它的写入入口,脑裂窗口自然关闭。
总结
| 阶段 | 机制 | 关键参数 |
|---|---|---|
| 下线判定 | 多哨兵投票确认客观下线 | quorum, down-after-milliseconds |
| Leader 选举 | Raft 多数派投票 | 哨兵总数 |
| 新主选择 | 三轮打分机制 | replica-priority, offset, runID |
| 脑裂防护 | 从库数量+延迟限制 | min-slaves-to-write, min-slaves-max-lag |
章末提问
1. 哨兵为什么要用多数派(quorum)判客观下线,而不是单个哨兵说了算?
结论先行:因为单个哨兵可能因网络抖动误判,多数派交叉验证才能把误判率降下来。 因为:SDOWN 只是「一个哨兵自己 PING 不通」,可能是它到主库的网络分区,而非主库真挂;ODOWN 需要 quorum 个哨兵都认为主下线,多个独立视角同时失联的概率远低于单点误判,于是才把「触发故障转移」这个不可逆动作交给多数派共识。
2. Leader 选举为什么要求「过半」且超过 max(quorum, N/2+1)?
结论先行:为了在任意时刻最多只有一个哨兵拿到多数票,保证切换动作唯一,避免双主。 因为:少于半数就能当选,会出现两个哨兵各自拉拢支持者同时自封 Leader、各选一个不同新主的脑裂局面;过半(Raft 多数派)保证任何一次投票最多只有一个获胜者,Leader 唯一,切换才唯一。
3. 三轮打分的顺序为什么是 priority → offset → runID,能不能换?
结论先行:不能换,顺序体现「先尊重运维意图、再追求数据完整、最后确定性兜底」的优先级。 因为:priority 是运维显式配置(0 = 永不选主),必须最先尊重;offset 比的是谁从旧主同步得最完整、丢数据最少,是选主最核心的诉求;runID 字典序只是在前两轮平局时提供确定性结果,避免随机平票。若先比 offset,运维的配置意图就会被覆盖。
4. min-slaves-to-write 为什么能防脑裂?它防的是哪种脑裂?
结论先行:它防的是「旧主被网络分区隔离后仍接收写入」造成的双主数据冲突。 因为:隔离发生时旧主的从库通常已切到新主,旧主身边健康从库数归零;min-slaves-to-write 判定从库不足就拒绝写入,等于在旧主被孤立那一刻掐断写入入口,冲突数据根本产生不了。它防的不是「选主」脑裂,而是「写入口」脑裂。
5. 哨兵故障转移的「秒级不可用」是怎么产生的?能完全避免吗?
结论先行:不可用来自「检测下线 + 投票 + 切换 + 客户端重连」这一串动作的耗时,不能完全避免,只能缩短。 因为:主库真挂后,哨兵要先等 down-after-milliseconds 超时、再投票判 ODOWN、选 Leader、选新主、发 SLAVEOF、客户端才发现断连并重连,每步都有延迟;这是用「秒级不可用」换取「无需人工介入」的自动化代价,可以通过调小 down-after-milliseconds、优化客户端重连来缩短,但不可能为零。