
关注腾讯云开发者,一手技术干货提前解锁👇
在 Redis/Valkey 集群中,"自动故障转移"是高可用设计的核心能力之一,也是云服务向客户承诺 SLA 的基础。然而,当集群规模扩大到上百分片、且出现多个主节点同时故障时,传统选举机制会因“选票瓜分"而陷入恢复困境——实测数据显示,128 分片集群在半数主节点同时宕机时,约 99% 的情况下无法自动恢复。本文以一个 5 分片集群的典型故障场景为切入点,依次梳理 Redis/Valkey Cluster 自动故障转移的判死、选举、切流三个阶段,分析 epoch 单票约束与 auth_timeout、auth_retry_time、data_age 等参数在多主同时故障下的相互作用,并介绍腾讯云团队在 Valkey PR #1018 中提出的分片排队选举优化方案:通过引入 shard_id 字典序的故障分片排名,使多个故障分片按确定顺序错峰发起选举,从而降低选票冲突概率,提升集群自愈成功率。
作者:朱彬彬
作为熟悉 Redis/Valkey 集群的运维或者研发人员,你大概率心里有一个朴素的信念:只要使用了 Redis/Valkey 集群,每个分片配上副本,主节点宕机的时候,集群一定能够自动恢复——这本就是上集群的核心理由之一。
这个信念在绝大多数日子里都成立。但有一种场景,它会被无情打破。
设想你维护着一个 Valkey/Redis 集群:5 个分片、每分片一主一从、共 10 个节点。标号为 shard1 ~ shard5,主节点 P1 ~ P5,副本 R1 ~ R5。看上去都符合最佳实践:分片均匀、主从分离、副本就位。
某天,P1 和 P2 因为各种各样的原因同时宕机了——可能是同一台物理机断电,可能是同一台虚拟机母机故障,也可能是网络分区把这两个节点同时隔离了,也可能是用户慢查将节点打挂。原因不重要,关键是结果:两个分片的主节点在同一时刻被判为 FAIL。
按 Redis/Valkey Cluster 的设计,这种场景理论上是可以自动恢复的:
这里先统一一个术语:voting primary 是指持有 slot(s) 的主节点。在 Redis/Valkey Cluster 里只有这类主节点会参与 cluster_size 和法定人数(quorum)的计算,也只有它们能在 failover 中投票。下文除非特别说明,提到"主节点投票"指的都是 voting primary 投票。
但你打开监控一看,CLUSTER INFO 里的 cluster_state 始终停在 fail 状态——十秒、三十秒、一分钟过去,状态没有任何变化。再翻副本日志,一组熟悉的警告信息在反复打印、循环上演:

一次选举从延迟开始、发起、等票,最后以 Failover attempt expired 收场;下一个 epoch 又重新走一遍同样的流水线。
副本不是没在尝试,而是每一次尝试都没能凑齐多数票,全部以失败超时告终。
那这些选举到底卡在哪一步?把视角切到其它健康的 voting primary 上,日志里能看到这样两类记录交错出现:

同一个 epoch 内,每个 voter 只能投出一票;一部分 voter 把票投给了副本R1,另一部分投给了副本 R2;当后到的请求再来要票时,得到的就是 already voted 的拒绝。这正是"选票瓜分"留在日志里的警告日志:选票没丢,只是被分摊到了多个候选人头上,谁都凑不齐多数。
又过了一会儿,副本干脆连尝试都不再尝试,shard1 和 shard2 就此永久卡在 fail 状态,不会再自动恢复。副本最后一行日志写得很直白:

副本与主节点失联的时长越过了 cluster-replica-validity-factor 划定的红线,副本认为它已经不再适合接管数据——自动故障转移到此为止,剩下的事只能交给人。
你不得不起来执行手动故障转移(manual failover)——集群没能通过自动故障转移自愈,那个朴素信念被打破了。而比起这通深夜电话本身,更难受的是故障窗口里的业务损伤:客户的应用层会看到大量请求超时、缓存命中率骤降、上游服务被连锁拖慢,对在线业务来说,这每一秒都是真金白银的损失。
明明数学上 3 票门槛也能凑齐绝大多数,为什么实际就是不行?这套在 Redis/Valkey Cluster 中久经考验的自动故障转移机制,为什么会在"两个主同时挂"这种并不罕见的场景下哑火?要回答这个问题,得先理解 Redis/Valkey Cluster 的自动故障转移是怎么工作的。
Redis/Valkey Cluster 的自动故障转移简单划分的话可以分三个部分:判死 → 选举 → 切流。

2.1 判死:PFAIL → FAIL 的状态流转
需要强调的是,这里的"多数"只对主节点计票——副本既没有判死的投票权,也不参与 PFAIL → FAIL 的认定。这套票源和后面 failover 阶段的投票主体完全重合:同一批主节点(voting primary),先后在故障转移的两个关键环节各投一次票——先投票确认"它确实挂了",再投票选出"由谁来接替"。
这种两阶段机制是为了避免单点误判:先让每个节点独立观测,再用多数共识托底。一旦共识达成,FAIL 消息会以广播方式扩散到整个集群——目的是让所有节点尽快对同一份故障视图达成一致,给后续的 failover 流程一个明确、统一的起跑线。
从协议工程的角度看,这是一个非常合理的设计;但它也带来一个无法回避的副作用:所有相关的 replica 几乎会同时收到 FAIL 信号,并同时进入"准备选举"状态。这正是后文要展开的"惊群"问题的前置条件。
2.2 选举:副本拉票、主节点投票、多数即胜
故障 primary 的每个副本都是潜在候选人,故障转移开始后会向集群里所有 voting primary 发送 FAILOVER_AUTH_REQUEST(epoch=N) 拉票。投票规则非常简单,但有两条关键约束:
当某个候选人拿到 ⌊N/2⌋+1 票时胜出,这里的 N 也就是 CLUSTER INFO 里的 cluster_size 字段——所有持有 slot 的主节点数,无论它们当前是健康还是已经 FAIL。换句话说,主节点挂掉并不会让分母变小,多数门槛也不会随之下调。
这是经典的 quorum 设计:多数票门槛保证了同一 epoch 内至多有一个候选人能凑够胜出票数,从根上杜绝"两个副本同时升主"的脑裂可能。
2.3 选举:切流:新主广播身份,集群路由刷新
新主当选后会自增 configEpoch 并向集群广播新配置,宣告自己接管原主的所有 slot(s)。其它节点收到这条带更高 configEpoch 的消息后,更新本地的槽归属表,把这些 slot 的所有权转移到新主名下。
客户端一侧的路由刷新通常由两件事驱动:
也就是说,故障场景下客户端是先"撞"到原主不可用,再靠拓扑刷新找到新主——这也意味着故障转移耗时越长,客户端这段"找不到主"的窗口就越久。
整套流程的关键在于第 2 步"选举",下面拆开看副本侧和主节点侧各自的规则。
一个故障 primary 下面可能挂着多个副本(例如一主多从)。如果它们一发现主挂了就同时冲上去拉票,立刻会撞上一条硬规则:每个 voting primary 在同一 epoch 内只有一票、且先到先得。多个副本同时发请求,等于把这有限的选票瓜分到了好几个候选人头上——谁都凑不齐 ⌊N/2⌋+1,选举原地空等,最终超时失败。
这正是前面埋下的"惊群"问题。为了避免它,Cluster 在副本侧设计了一套精细的节奏控制,核心思路就一句话:让副本错开出手,尽量保证同一时刻只有"最该上"的那个副本去拉票。
3.1 不会立即发起选举:先等一段延迟
副本检测到主 FAIL 后不会立刻广播拉票,而是计算一个未来的毫秒时间戳 failover_auth_time,等到那个时刻再发:

failover_auth_time 是 Cluster 选举里最关键的状态变量,它表示"这个副本可以发起选举的时间点":now < failover_auth_time 就乖乖等着,now ≥ failover_auth_time 才能发起拉票。

那么问题来了:为什么要先等一段延迟,而不是发现主挂了就立刻拉票?这段看似浪费的等待,其实扛起了两个关键的协议前提。
第一,让 FAIL 消息扩散到多数主节点。 FAIL 状态要经过 gossip 在集群里传播,多数派主节点必须先各自把这个 primary 认定为 FAIL,才会在副本拉票时投出有效票——否则它们以为"主还活着",会直接拒绝授权 failover。如果副本太早拉票,请求可能在很多主节点还没收到 FAIL 消息时就到了,那票自然拿不到。
第二,让副本之间交换复制偏移量 replication offset。 副本计算自己的 replica_rank 需要知道同 shard 内其它副本的 offset,而这个信息同样靠 gossip 携带传播。多等一会儿,副本才能对"我在同伴里数据有多新"形成正确认知,rank 才算得准。算错 rank 的代价不小:一个数据较旧的副本若误算出更优的 rank,就会过早发起拉票,借着"先到先得"抢先上位,导致本可避免的数据丢失。
简单说,固定 500ms 负责"准备好选举的前置信息"(协议正确性),随机延迟负责"前置信息就绪后避免和同伴撞车"(工程稳定性)。至于公式里的第三段 replica_rank × 1000ms,解决的是"谁更有资格先上"。
为什么要这么"克制"?因为一次选举失败的代价非常大(后面会详细分析)。以默认 cluster-node-timeout = 15s 为例,单次选举失败要先熬完 30 秒等票超时(auth_timeout = node_timeout × 2),再继续等到 60 秒 时才能进入下一轮重试(auth_retry_time = auth_timeout × 2)——白白搭进去一分钟。相比之下,开头多花几百毫秒等一等,就能把成功率从"碰运气"提到"基本必胜",这笔账非常划算。Cluster 选举的所有时间窗口设计,本质都在做同一件事:用一点点等待,换几个数量级的成功概率。
至于 replica_rank(源码里由 clusterGetSlaveRank 或 clusterGetReplicaRank 计算),它是当前副本在同 shard 所有副本里的排名,规则是:
排名的意图很直接:让数据最新的副本最有机会先发起选举并胜出,避免数据较旧的副本"抢跑"上位、造成不必要的数据丢失。
3.2 选举的两个时间窗口:选举超时 + 重试间隔
副本侧的节奏控制其实由两个独立的计时器组成,理清它们的关系,才能看清"重试预算"究竟从何而来。
副本在 failover_auth_time 时刻发出 FAILOVER_AUTH_REQUEST(epoch=N) 后,最多等 auth_timeout 这么久来收集投票:

如果在这个窗口内没拿到多数票,本轮选举(epoch=N)就算超时作废——这就是"选举超时(election timeout)"。注意:它衡量的是"本轮拉票等待多久会超时",而不是整轮选举的总时间。

auth_retry_time:两次选举之间的重试间隔
副本一轮拉票超时作废后,并不能马上重来,必须先等待一段时间,才能发起下一轮选举。这段时间就是 auth_retry_time:

需要注意它是从上一次的 failover_auth_time 算起,而不是从超时那刻算起:超时发生在第 30s,重试却要等到第 60s。换句话说,副本在超时之后还得再干等一个 auth_timeout(约 30s),才能用 epoch=N+1 发起新一轮选举。这就是“选举重试(election retry)”的节奏限制。

合在一起:auth_age 这把度量尺
源码里还有一个非常关键的核心变量 auth_age:

它表示"距离原计划的选举开始时间点过去了多久",是选举判定状态用的核心度量。前面两条关键判断都依赖它:

有个细节值得记一下:节点启动时 failover_auth_time 被初始化为 0,于是 auth_age 一开始是个巨大的数,自然满足 auth_age > auth_retry_time,触发首次选举。这也是为什么"把 failover_auth_time 重置为 0"就能让下个clusterCron 立即触发新选举——这正是 Valkey PR #1009"快速失败"的实现机理(详见第九章)。

这条时间线最后为什么停在 t=160s、为什么"最多三轮"不是写死的而是被卡出来的——答案都在 data_age 这道检查里,下一节专门拆解。
整体看:

3.3 候选条件:data_age 决定副本"够不够新"
副本每次尝试发起或推进 failover 前,都要先过一道数据陈旧度检查——data_age。它衡量的是"副本与主节点上一次有效通信到现在过了多久",单位毫秒。
需要先说清楚 data_age 的局限:它只是副本单方视角的"我多久没收到主的消息了",并不能区分主到底是真宕机了,还是出现了网络分区。但在没有第三方仲裁的前提下,副本要自己判断"数据够不够新",几乎只能依赖这个本地指标。

这个"减去 cluster_node_timeout"的细节很关键:它恰好补偿了判 FAIL 所需的时间,使得 data_age 的起点对齐到"主该被判 FAIL 的时刻"。
这条检查的官方文档来自 Redis 7.0 redis.conf 里 cluster-replica-validity-factor 配置项的注释,挑出最核心的几段直接引用:

简单说,数据陈旧度检查的计算式是:

关键配置项解释:

代入默认配置计算 data_age 上限:

也就是说,副本与主断连超过 160 秒,就会永久失去自动 failover 的资格——再用这么旧的数据上位,可能会丢掉更多本可保住的写入。而把 cluster-replica-validity-factor 设为 0,就进入"最大可用性模式":哪怕数据再陈旧也允许副本顶上——这是一次典型的可用性 vs 一致性取舍。
回看上一节那条时间线(auth_time 落在 t=0 / 60 / 120s),叠加 data_age 的 160s 上限,"最多三轮"就自然浮现了:

这里有个执行顺序上的关键点:源码里 data_age 检查排在"是否发起新一轮"的判断之前。所以一旦 t > 160s,clusterCron 副本故障转移检查每次进来都先撞上 data_age 这道墙直接返回,压根走不到"发起第四轮"那一步,data_age 不是"让第四轮失败",而是让第四轮从未开始。
副本发起拉票后,真正有权投票的只有集群中的 voting primary(持有 slot 的主节点)。每个主节点收到一个拉票请求时会逐项检查,全部通过才会投出这一票:

这五条里,第 2 条正是选票瓜分的根源,下面会重点拆解。
多说一句第 4 条:源码注释明确写着它"不是算法正确性所必需的,只是让基本情况更线性"。但在选票瓜分场景里,它反而帮倒忙——一个 voter 为某个故障主的副本投票后,2 × node_timeout(默认 30s)内就不能再为同一个故障主投票,相当于把这张票"锁死"了一段时间。
更麻烦的是,这条限制会抵消掉各种"快速重试"的努力。设想这样一个链路:第一轮选举因为瓜分失败了,副本(或运维通过手动 failover)想马上发起新一轮重选——可此时那些 voter 还困在 2 × node_timeout 的限制里,对这个故障主一律拒投。于是新一轮拉票即便快速发起,照样一票难求,只能眼睁睁等满 auth_timeout 再次超时。换句话说,你越想加速恢复,这条限制就越是拦在路上:无论是 Valkey PR #1009 的快速失败、还是人工介入的手动故障转移,只要落在锁定期内,都会被这张"投不出去的票"卡死,白白又赔进去一个超时周期。
正因为它对正确性并非必需、却实打实地拖慢了恢复,Valkey 已在 8.1 版本移除了这条限制(参考 Valkey PR #1356 / #1305)。
Cluster 里其实有两个容易混淆的 epoch。
currentEpoch(当前集群任期号):它是整个集群共享的一个逻辑时钟。每个节点本地都保存着自己看到的 currentEpoch,并通过 gossip 不断向集群里见过的最大值看齐——正常情况下,所有节点最终会收敛到同一个值。当一个副本决定发起选举时,它会先把 currentEpoch + 1,以这个新值作为本次拉票请求的 epoch——也就是前面日志里反复出现的 epoch N。
configEpoch(节点配置版本号):和 currentEpoch 的"全局共享"不同,每个主节点都有一个属于自己、且在集群中唯一的 configEpoch,用来标记"我这份配置有多新"。当副本赢得选举、升级为新主时,它会把本轮选举的 epoch 取作自己的 configEpoch。由于 epoch 单调递增,configEpoch 越大就代表配置越新——一旦出现 slot 归属冲突(两个节点都声称拥有同一批 slot),集群就以 configEpoch 更大的一方为准。
这是 Redis/Valkey Cluster 防脑裂的最后一道墙。如果同一个 epoch 允许投两次,理论上同一任期就能选出两个新主,集群分裂成两个权威写入源,两边都接受写入,就会导致数据不一致,这是灾难性后果。
所以哪怕它带来了"选票被打散"的副作用,社区也绝不会碰这条铁律。所有优化都必须在不动"一 epoch 一票"的前提下去化解冲突——这恰恰也是本文要介绍的优化(Valkey PR #1018)所守的边界:它不改任何投票规则,只调整副本"何时发起选举"的时机。
铺垫到这里,回到开头那 5 分片的故事。P1、P2 同时 FAIL,幸存的 voting primary 只剩 3 个:P3、P4、P5(多数票门槛 = 3)。故障 shard1 下面的 R1、shard2 下面的 R2 恰巧同时发起选举——它们都把 currentEpoch + 1,都用同一个 epoch 拉票:

3 张票被打散为 2-1,谁都过不了 3 票门槛,两场选举同时失败。
3 个 voter 面对 2 个候选人,按"先到先得"投票,这 3 张票最终怎么分,无非两种结果:

而无论哪种结果,只要一场选举失败,落败的副本都得走同一条冷却路径:先等 auth_timeout 满 30s 判本轮作废,再等 auth_retry_time 到点(从本轮 auth_time 起算,合计 60s),才能发起下一轮——每 60s 才换来一次重试机会。区别在于:3-0 至少救回了一个 shard,而 2-1 是都一起陷进这个 60s 的重试循环。
按副本侧的重试规则,每一轮选举从发起到下一轮触发,要走完整整 60 秒(30s 等 auth_timeout 判本轮作废 + 再 30s 等 auth_retry_time 到点)。但问题的关键在于:重试并不会改变冲突的结构。只要下一轮里两个副本又在相近的时间点进场——比如 gossip 同步的节奏让它们的 failover_auth_time 依旧几乎一致——票就还是按老样子分(3 票分给 2 人),照样没人过线。
更要命的是,整个流程的健壮性,完全押在"延迟恰好把两个副本错开"这一个概率事件上:
而且这条路上没有任何"中途自救"的机制:副本不知道自己已经被瓜分(没有 NACK),也没有谁能出来打破僵局,只能眼睁睁看着预算耗光。于是每次故障的恢复结果都不可预测——而对云服务来说,"不可预测"几乎是最糟糕的属性:运维无法给出确定性的 SLA 承诺,而 SLA 恰恰是云服务对客户的核心承诺之一。
你可能会想:选举既然有 0~500ms 的随机延迟,两个副本恰好落在同一时间点的概率并不大,是不是大多数情况根本不会瓜分?
理论上确实如此,而且瓜分的触发条件其实相当苛刻:它要求两个副本在 0~500ms 的随机延迟后,恰好命中相近的 failover_auth_time,并且在每一轮重试里都重复这个时序巧合——只要任何一轮的延迟把它们错开,僵局当场就解开了。正因如此,大多数情况下瓜分根本触发不了,在自建机房里跑一两套集群,可能很多年都撞不上一次。
但反过来看,这也意味着:一旦每一轮都没能错开,这条路就会一直走到黑——3 次重试预算耗尽、data_age 超限、副本无法 failover,shard 就此失去自动恢复能力,没有任何中途转机。
把同一套"小概率"放到云厂商的尺度上,这份侥幸就站不住了。云上同时运行着成千上万套 Redis/Valkey 集群,从几个节点的小集群到几百节点的大集群都有。于是:
所以团队对每一段不可用时间都极度敏感。这种敏感不只是"少一次人工救火"——集群每早一秒自愈,客户业务就少一秒抖动,端到端可用性就多一分保障。云上的 Redis/Valkey 不只是"我们的服务",更是承载客户业务的关键基础设施。
我们见过太多"运气差了一点"的故障:拓扑健康、副本数据新鲜、多数派幸存,仅仅因为几个主节点恰好一起卡进瓜分的死循环,集群最终就是无法自动恢复。每次复盘都指向同一个结论——这不是配置问题,也不是部署问题,而是协议本身的缺陷。把缺陷从代码层堵上,远比在外面堆监控、写兜底脚本更治本。
5 分片下的"概率游戏",到了大规模集群就不再是游戏,而是必然的灾难。
腾讯云研发团队在 Valkey Keyspace Beijing 2025 大会上分享过一组实测数据:Valkey 8.1 之前,128 分片集群杀掉 63 个主节点(接近一半但不到一半),在默认配置下,99% 的情况集群无法自动恢复。
这不是"恢复得慢",而是真的恢复不了,必须等集群管理员手动介入。原因不难理解:候选副本越多,票被打散得越碎,"赌赢"的概率越低;同时集群越大,"多个副本恰好同时拉票"的巧合也越频繁——前面 5 分片场景里那个"得运气够差才会触发"的边界条件,到 128 分片这个体量基本就成了日常。5 分片下罕见的瓜分死局,在 128 分片上会高频上演。
而"大量主节点同时故障"在云上一点都不罕见:
新机制要解决的正是这件事——让多主节点同时故障时的恢复过程保持一致、有序,而不再依赖随机抽签。
Redis/Valkey 集群的核心能力之一就是自动故障转移——节点挂了,集群自己救自己。这个承诺在小集群里很容易兑现;但集群越大、运营越久,“同一时刻多个主节点一起挂”就越不是小概率,而是迟早会发生的事。
而老算法在多分片同时故障下会概率性、甚至确定性地失败。99% 无法自动恢复,意味着 99% 的概率要人工介入、99% 的概率业务请求持续受损——这等于把“集群自愈”这条承诺直接打穿了。偏偏集群规模越大,对自愈的依赖越强、人工介入的代价越高。
所以这次优化的核心目标只有一句话:让大规模集群在多分片同时故障时,依然能自动恢复。
这也是为什么 Valkey 8.1 把它写进 release notes、社区后续围绕“千节点集群高可用”做了一系列配套优化、腾讯云也在 Valkey Keyspace Beijing 2025 上专门讲它——自愈能力,是大规模集群的生命线。
所有副本同时进场,是问题的根源。反过来,如果能让它们按某种全局一致的顺序错峰进场——每个时间窗口里只有一个 shard 在拉票,其它 shard 退后等待——epoch 冲突自然就不会发生。
Valkey PR #1018 为此引入了“故障分片排名”(failover_failed_primary_rank):把当前所有故障分片排成一个全局一致的队,每个副本在原有延迟之上,再按自己所属分片的排名追加一段延迟。排在前面的分片先发起选举、先拿票,靠后的分片自动退后、依次跟上——原本一拥而上的抢票,就变成了一个接一个的排队进场。
落到公式上,就是在前面介绍过的 failover_auth_time 的计算式后面,再接上第四段:

这个思路非常简单,但恰好命中要害。说白了,用一段适当的延迟时间来避免选票瓜分——这正是 Cluster 选举一直以来在 shard 内部对副本做的事,Valkey PR #1018 把它从 shard 内部扩展到了 shard 之间。
错峰要可靠,排序键必须满足一个硬性要求:所有副本在各自视角下算出来的顺序必须完全一致——否则各排各的,照样会撞。
Valkey PR #1018 选用 shard_id 的字典序。原因很直接:shard_id 是 shard 维度的全局稳定标识,同一 shard 下所有副本看到的值完全相同,既稳定又无歧义,任何副本据此算出的分片排队顺序都一模一样。
这里藏着一个容易被误解的点:我们要的并不是“谁更该先上”的公平,而是“所有人对顺序达成共识”的确定性。shard_id 的字典序也许对人毫无意义、谁先谁后纯属随机,但只要全局一致,就足以把“同时抢票”拆成“轮流拉票”——错峰需要的全部,就只是这份共识。
至此,副本的选举延迟由两层 rank 共同决定,彼此正交、互不干扰:

两层叠加,一次解决了两个正交的问题:replica_rank 管“同一分片里选谁”,failed_primary_rank 管“哪个分片先选”——既保证每个分片选出的是数据最新的副本,又保证多个分片不会同时抢票。
需要说明一个数学细节:两层 rank 是线性叠加的(1000 × replica_rank + 500 × failed_primary_rank),而 1000 恰是 500 的整数倍,所以理论上不同副本确实可能算出完全相同的延迟——例如某 shard 的 2 号副本(replica_rank = 1)与另一个排名靠后两位的 shard 的领头副本,延迟都是 1000ms。但这并不影响错峰,关键就在“领头”二字:每个 shard 真正下场拉票的只有它的领头副本(replica_rank = 0),而所有领头副本的延迟恰好等于 500 × failed_primary_rank,随 shard 排名两两严格错开、永不重合。会发生延迟重合的只可能是非领头副本,而它们通常在本 shard 的领头副本(早至少 1000ms 发起)完成选举后,就不必再出场了。所以确定性延迟的碰撞,落不到真正参与竞争的那批选手身上。
7.1 Step 1:计算"故障分片排名"
对当前 replica 而言,统计集群里所有失败的 primary 中,shard_id 比自己小的有几个:

实现层面有一点要提醒:shard_id 不是字面的“1” 和 “2”,而是节点创建时生成的 40 字符随机标识。所以这里比较的是一串随机十六进制的字典序——排队顺序既不按 shard 编号、也不按故障先后(如第六章所说,它对人不可预测,但对每个副本完全一致,这正是错峰所需)。
举例:在我们的故事里 shard1、shard2 同时故障,假设 shard1 的 shard_id 字典序恰好小于 shard2,那么:
7.2 Step 2:把 rank 翻译成延迟
在原有的副本选举调度公式上,叠加一项与 failed_primary_rank 成正比的延迟:

代入故事场景的数据(为聚焦错峰效果,下表略去 0~500ms 随机延迟):
Shard | failed_primary_rank | 额外延迟 | 大致发起选举时刻 |
|---|---|---|---|
shard1 | 0 | 0 | t ≈ 500ms |
shard2 | 1 | 500ms | t ≈ 1000ms |
7.3 Step 3:动态修正
集群是活的——gossip 消息可能姗姗来迟:副本忽然发现“原来还有一个 shard 也挂了”,rank 从 1 变成 2,就该再多等 500ms;也可能某个 shard 已经完成故障转移,rank 从 2 变回 1,就该少等 500ms。算法支持实时修正:

注意 added_delay 可正可负:排队不是一锤定音,而是随着集群视图的变化持续校准——这让错峰在 gossip 有延迟、故障陆续发生的真实环境里依然站得住。
7.4 Step 4:留好 fast path
排队是为了稳定,但稳定不应该成为效率的对立面。手动故障转移直接旁路整套排队机制——管理员手动处理流程时,绝不希望还被排队:

一行 failover_auth_time = now 就把前面那条四段延迟公式整体短路了:自动故障转移场景下从容排队、确定性恢复,手动故障转移场景下零延迟即时响应——两种诉求各走各的路。


串行选举还有一个容易被忽略的好处:先恢复的 shard,会反过来给后恢复的 shard 当“援军”。
故障期间,能投票的只有幸存的主节点,而多数票门槛 ⌊N/2⌋+1 也并不会因为有主节点挂掉而降低。当排在前面的 shard 完成选举、副本升为新主后,这个新主立刻成为一个合格的投票者,加入投票池。于是越靠后的 shard,面对的有效投票者一轮比一轮多,凑齐多数票也就越来越容易——每一轮选举,都站在前一轮的胜利成果之上。
还是 5 分片、shard1 与 shard2 同时挂的场景:恢复前只有 shard3、shard4、shard5 三个主能投票,而门槛正好是 3——并发抢票时三票被两个候选瓜分,谁都不够。串行之后,shard1 先用满这三票完成选举,新主随即加入投票池;轮到 shard2 时,可投票的主节点变成了四个,拿 3 票绰绰有余。这正是“串行”比“并发”根本性更优的地方:它不只是错开了时间,还让每一步的成功,为下一步铺好了路。
这次改动常被概括成“让大规模故障也能自愈”,但这种说法其实低估了它的价值。除了在最极端场景下提供兜底恢复,排队机制对日常运行的集群同样有显著收益:
换句话说,这个改动既是大规模故障下的兜底保障,也是日常运行中的稳定器——它让集群的故障转移,从一场"概率游戏"变成一条"确定性流程"。
先看一组对比:

这些数字并不是测试环境里刻意构造的极端 case,而是云上会被反复触发的真实场景。其中最典型的一个触发源,就是机器级故障。
为了提升资源利用率,云上同一台物理机往往会混部同一集群的多个分片主节点。这种部署一旦遇上物理机宕机、断电或网卡故障,落在这台机器上的多个主节点会在同一瞬间一起进入 FAIL——"多主同时故障"于是从理论上的小概率,变成了机房里时不时就会上演的现实,而它恰恰是选票瓜分最容易被触发的场景。
现网已有金融、互联网等多个行业的客户实际踩中过这个问题:一台物理机带走若干分片主节点后,集群因为选票瓜分迟迟选不出新主,自愈时间被大幅拉长、甚至需要人工介入。优化上线后,相同的机器级故障场景下,各分片按序错峰选举、新主快速产生,集群在秒级内完成自愈,全程无需人工干预——对依赖高可用 SLA 的在线业务来说,这意味着一次"几乎无感"的故障转移。
当然,错峰排队把冲突概率压到了极低,但并不能保证 100% 不发生。那么万一冲突真的还是发生了,集群又靠什么兜底?
排队机制把“两个副本同 epoch 竞选”的概率降到了极低——而且是分两层排队的:shard 内部由 Valkey PR #762(Valkey 8.0 引入)让 replication offset 相同的副本按 node_id 字典序错开,解决了“同 shard 多副本 replica_rank 平局、同时拉票”的老问题;shard 之间则由本文主角 Valkey PR #1018 让故障分片按 shard_id 错开。这两层其实是同一套“排队错峰”思想,只是作用在不同粒度上。
但即便两层排队都到位了,冲突仍不能 100% 杜绝,比如:
万一冲突还是发生了,会怎样?回顾选举规则:
明明几十毫秒后就有人胜出了,落选方却要先熬 30 秒等本轮超时、再等够 60 秒才能进入下一轮——这段时间几乎全是白白空耗,硬生生将恢复拖慢了一个数量级。Valkey PR #1009(Valkey 8.1 引入,并作为 bugfix 移植到 8.0)把这种“傻等”换成了“快速感知 + 立即放弃”。
胜出的副本升主后,会用新的 configEpoch(等于本轮选举的 epoch)广播自己的新配置。落选副本通过 gossip 收到这个声明时,就明白了:“已经有节点用我手头这个 epoch 当选了——这个 epoch 被消费掉了,我再等也赢不了。”
判定逻辑可以简化为:

重置之后,副本会在下个周期触发新选举——而且这时胜者往往已经升主、加入了投票池,落选方的下一轮反而更容易成功。

放弃一场注定要输的选举,远比死撑等待高效:重试早一点开始,整体恢复就早一点结束。
把视野拉开,Valkey 其实是用三个机制共同守住多分片故障下的选举:

前两个是“预防”——同一套排队思想分别作用在 shard 内、shard 间,把冲突概率从大概率压到极小概率;第三个是“兜底”——把那极小概率事件的代价,从“空等一个 60 秒周期”降到“几百毫秒重试”。
但要分清兜底的边界:PR #1009 的前提是“有人胜出”,它靠胜者广播的 configEpoch 通知落选方。如果冲突演变成谁都没拿到多数票的纯瓜分死局,没有胜者、也就没有 configEpoch 广播,PR #1009 不会触发——这种死局正是 PR #762 / #1018 的排队预防要从源头避免的。预防让死局别发生,兜底让“有胜有负”的冲突别空等,各管一段,互相补充。
这也呼应了分布式系统里的一条通则:“避免失败”和“快速失败”往往必须并存。再聪明的避让算法也无法覆盖所有时序竞争,因此必须有一条“竞争出现时如何快速感知并恢复”的兜底路径。
回到开头的故事:2 个 primary 同时被标记 FAIL,3 个幸存主,理论上能恢复。
把规模拉到 128 分片,对比更触目惊心:从团队实测的“99% 无法恢复”,到“全部按序自愈”。而万一排队没拦住、冲突仍然发生,还有 Valkey PR #1009 的“快速失败”兜底,把代价从“60 秒一个空等周期”压缩到几百毫秒。
整个排队改动的核心代码可能就 30 来行,但它绝不是一次孤立的灵光一现,而是腾讯云团队围绕“大规模集群故障转移”持续打磨的一条优化线上的一环:
三者环环相扣,背后是多年大规模运营 Redis/Valkey 服务沉淀下来的经验——我们运行的实例数量足够大、规模足够多样,于是那些“理论上极小概率”的瓜分场景,在我们这里就成了“大概率事件”。每一次不及预期的故障转移时长,都在提醒我们:集群自愈,还有改进的空间。
这次改动看起来思路很简单——不过是按 shard_id 给故障分片排个队——但它背后是一连串“单看合理、组合起来却互相耦合”的协议设计:epoch 单票、auth_timeout 30s、auth_retry_time 60s、data_age 160s。任何一条单独看都没问题,凑在一起却会在多主同时故障时,把 shard 卡进无法自愈的恶性循环。这个改动真正的价值,就是打破了那个循环,让“自动恢复”这条承诺在大规模、多故障的场景下也能兑现。
更重要的是,它是更高层次容灾能力的基石。当我们谈论可用区级别的高可用——AZ 整个挂掉、集群依然能稳健自愈——前提就是底层的故障转移协议在多主同时挂时不会哑火。如果连“两个主一起挂”都救不回来,再漂亮的多 AZ 部署架构,也只是纸面上的容灾。
-End-