「腾讯云 NoSQL」技术之 Redis 篇:针对集群选举投票冲突的优化方案

导语:在 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 的设计,这种场景理论上是可以自动恢复的:

● 总共 5 个 voting primary,挂掉 2 个,还剩 3 个幸存(P3、P4、P5),刚好等于多数票门槛 ⌊5/2⌋ + 1 = 3;

● 故障 shard1 和 shard2 各有 1 个健康 replica(R1、R2)存活,候选人就位;

● 所以理论上,shard1 和 shard2 的 replica 应该各自拉票、各自当选、集群自动恢复。

这里先统一一个术语: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 的自动故障转移简单划分的话可以分三个部分:判死 → 选举 → 切流。

  1. 判死:PFAIL → FAIL 的状态流转

● PFAIL(疑似故障,Possible failure):节点 A 在本地观察到节点 B 心跳超时,仅在 A 自己的视角里把 B 标记为 PFAIL。这是单点判断,不对外承诺。

● FAIL(确认故障,Failure):当某个主节点通过 gossip 汇总到,包含自己在内的多数主节点都已经把 B 视作 PFAIL 时,它就把 B 升级为 FAIL,并向全集群广播这条 FAIL 消息。

需要强调的是,这里的"多数"只对主节点计票------副本既没有判死的投票权,也不参与 PFAIL → FAIL 的认定。这套票源和后面 failover 阶段的投票主体完全重合:同一批主节点(voting primary),先后在故障转移的两个关键环节各投一次票------先投票确认"它确实挂了",再投票选出"由谁来接替"。

这种两阶段机制是为了避免单点误判:先让每个节点独立观测,再用多数共识托底。一旦共识达成,FAIL 消息会以广播方式扩散到整个集群------目的是让所有节点尽快对同一份故障视图达成一致,给后续的 failover 流程一个明确、统一的起跑线。

从协议工程的角度看,这是一个非常合理的设计;但它也带来一个无法回避的副作用:所有相关的 replica 几乎会同时收到 FAIL 信号,并同时进入"准备选举"状态。这正是后文要展开的"惊群"问题的前置条件。

  1. 选举:副本拉票、主节点投票、多数即胜

故障 primary 的每个副本都是潜在候选人,故障转移开始后会向集群里所有 voting primary 发送 FAILOVER_AUTH_REQUEST(epoch=N) 拉票。投票规则非常简单,但有两条关键约束:

  1. 每个 voting primary 在同一 epoch 内最多只投一票;

  2. 先到先得,谁的拉票请求先到,这一票就归谁,既不做"谁更适合"的比较,也不会回头反悔。

当某个候选人拿到 ⌊N/2⌋+1 票时胜出,这里的 N 也就是 CLUSTER INFO 里的 cluster_size 字段------所有持有 slot 的主节点数,无论它们当前是健康还是已经 FAIL。换句话说,主节点挂掉并不会让分母变小,多数门槛也不会随之下调。

这是经典的 quorum 设计:多数票门槛保证了同一 epoch 内至多有一个候选人能凑够胜出票数,从根上杜绝"两个副本同时升主"的脑裂可能。

  1. 切流:新主广播身份,集群路由刷新

新主当选后会自增 configEpoch 并向集群广播新配置,宣告自己接管原主的所有 slot(s)。其它节点收到这条带更高 configEpoch 的消息后,更新本地的槽归属表,把这些 slot 的所有权转移到新主名下。

客户端一侧的路由刷新通常由两件事驱动:

● 请求原主失败:原主已经宕机,客户端按旧拓扑打过去只会拿到连接失败或超时,进而触发重连与拓扑刷新逻辑;

● 拉取最新拓扑:智能集群客户端会通过 CLUSTER SLOTS / CLUSTER SHARDS(周期性,或在连接异常时主动触发)重新获取拓扑,感知到新主后把后续请求路由过去。

也就是说,故障场景下客户端是先"撞"到原主不可用,再靠拓扑刷新找到新主------这也意味着故障转移耗时越长,客户端这段"找不到主"的窗口就越久。

整套流程的关键在于第 2 步"选举",下面拆开看副本侧和主节点侧各自的规则。

三、 副本选举机制:什么时候、按什么节奏拉票

一个故障 primary 下面可能挂着多个副本(例如一主多从)。如果它们一发现主挂了就同时冲上去拉票,立刻会撞上一条硬规则:每个 voting primary 在同一 epoch 内只有一票、且先到先得**。**多个副本同时发请求,等于把这有限的选票瓜分到了好几个候选人头上------谁都凑不齐 ⌊N/2⌋+1,选举原地空等,最终超时失败。

这正是前面埋下的"惊群"问题。为了避免它,Cluster 在副本侧设计了一套精细的节奏控制,核心思路就一句话:让副本错开出手,尽量保证同一时刻只有"最该上"的那个副本去**。**

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 所有副本里的排名,规则是:

● 数据 offset 越大(数据越新)→ rank 越小 → 延迟越短;

● offset 相同时,node_id 字典序较小的副本 rank 更小(Valkey 8.0 引入)。

排名的意图很直接:让数据最新的副本最有机会先发起选举并胜出,避免数据较旧的副本"抢跑"上位、造成不必要的数据丢失。

2) 选举的两个时间窗口:选举超时 + 重试间隔

副本侧的节奏控制其实由两个独立的计时器组成,理清它们的关系,才能看清"重试预算"究竟从何而来。

auth_timeout:单次选举的超时时间

副本在 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) 候选条件:data_age 决定副本"够不够新"

副本每次尝试发起或推进 failover 前,都要先过一道数据陈旧度检查------data_age。它衡量的是"副本与主节点上一次有效通信到现在过了多久",单位毫秒。

需要先说清楚 data_age 的局限:它只是副本单方视角的"我多久没收到主的消息了",并不能区分主到底是真宕机了,还是出现了网络分区。但在没有第三方仲裁的前提下,副本要自己判断"数据够不够新",几乎只能依赖这个本地指标。

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 一致性取舍。

关键洞察:所谓"3 次重试"其实是 data_age 自然耗尽的副产物

回看上一节那条时间线(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)。

epoch 是什么

Cluster 里其实有两个容易混淆的 epoch。

currentEpoch(当前集群任期号):它是整个集群共享的一个逻辑时钟。每个节点本地都保存着自己看到的 currentEpoch,并通过 gossip 不断向集群里见过的最大值看齐------正常情况下,所有节点最终会收敛到同一个值。当一个副本决定发起选举时,它会先把 currentEpoch + 1,以这个新值作为本次拉票请求的 epoch------也就是前面日志里反复出现的 epoch N。

configEpoch(节点配置版本号):和 currentEpoch 的"全局共享"不同,每个主节点都有一个属于自己、且在集群中唯一的 configEpoch,用来标记"我这份配置有多新"。当副本赢得选举、升级为新主时,它会把本轮选举的 epoch 取作自己的 configEpoch。由于 epoch 单调递增,configEpoch 越大就代表配置越新------一旦出现 slot 归属冲突(两个节点都声称拥有同一批 slot),集群就以 configEpoch 更大的一方为准。

为什么"一 epoch 一票"不能动

这是 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 张票最终怎么分,无非两种结果:

● 2-1 分裂:两个候选人各分到一部分票,谁都够不到 3 票门槛------两场选举双双失败。

● 3-0 集中: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 人),照样没人过线。

更要命的是,整个流程的健壮性,完全押在"延迟恰好把两个副本错开"这一个概率事件上**:**

● 延迟错开了:一轮就搞定,秒级自愈;

● 延迟没错开:一路失败,直到 shard 彻底失去自动恢复能力。

而且这条路上没有任何"中途自救"的机制:副本不知道自己已经被瓜分(没有 NACK),也没有谁能出来打破僵局,只能眼睁睁看着预算耗光。于是每次故障的恢复结果都不可预测------而对云服务来说,"不可预测"几乎是最糟糕的属性:运维无法给出确定性的 SLA 承诺,而 SLA 恰恰是云服务对客户的核心承诺之一。

小概率事件,乘以巨大基数

你可能会想:选举既然有 0~500ms 的随机延迟,两个副本恰好落在同一时间点的概率并不大,是不是大多数情况根本不会瓜分?

理论上确实如此,而且瓜分的触发条件其实相当苛刻:它要求两个副本在 0~500ms 的随机延迟后,恰好命中相近的 failover_auth_time,并且在每一轮重试里都重复这个时序巧合------只要任何一轮的延迟把它们错开,僵局当场就解开了。正因如此,大多数情况下瓜分根本触发不了,在自建机房里跑一两套集群,可能很多年都撞不上一次。

但反过来看,这也意味着:一旦每一轮都没能错开,这条路就会一直走到黑------3 次重试预算耗尽、data_age 超限、副本无法 failover,shard 就此失去自动恢复能力,没有任何中途转机。

把同一套"小概率"放到云厂商的尺度上,这份侥幸就站不住了。云上同时运行着成千上万套 Redis/Valkey 集群,从几个节点的小集群到几百节点的大集群都有。于是:

● 哪怕单个集群每年只有 0.1% 的概率撞上"两主同时挂 + 瓜分",乘以海量基数后,几乎每天都会落在某个集群头上

● Redis/Valkey 多被用作核心缓存,扛着主链路的大部分流量,一旦不可用,业务侧立刻就会感知到延迟飙升、接口超时、上游服务被连锁拖慢;

● 哪怕只是 30 秒、1 分钟的不可用,对大型在线应用都可能是事故级的损失

所以团队对每一段不可用时间都极度敏感。这种敏感不只是"少一次人工救火"------集群每早一秒自愈,客户业务就少一秒抖动,端到端可用性就多一分保障。云上的 Redis/Valkey 不只是"我们的服务",更是承载客户业务的关键基础设施

我们见过太多"运气差了一点"的故障:拓扑健康、副本数据新鲜、多数派幸存,仅仅因为几个主节点恰好一起卡进瓜分的死循环,集群最终就是无法自动恢复。每次复盘都指向同一个结论------这不是配置问题,也不是部署问题,而是协议本身的缺陷。把缺陷从代码层堵上,远比在外面堆监控、写兜底脚本更治本。

规模放大:当大量主节点一起故障

5 分片下的"概率游戏",到了大规模集群就不再是游戏,而是必然的灾难。

腾讯云研发团队在 Valkey Keyspace Beijing 2025 大会上分享过一组实测数据:Valkey 8.1 之前,128 分片集群杀掉 63 个主节点(接近一半但不到一半),在默认配置下,99% 的情况集群无法自动恢复

这不是"恢复得慢",而是真的恢复不了,必须等集群管理员手动介入。原因不难理解:候选副本越多,票被打散得越碎,"赌赢"的概率越低;同时集群越大,"多个副本恰好同时拉票"的巧合也越频繁------前面 5 分片场景里那个"得运气够差才会触发"的边界条件,到 128 分片这个体量基本就成了日常。5 分片下罕见的瓜分死局,在 128 分片上会高频上演**。**

而"大量主节点同时故障"在云上一点都不罕见:

● 一个 AZ 的核心交换机故障,可能一次性带走该 AZ 内几十个主节点;

● 一台物理机宕机,会让其上所有主节点同时进入 FAIL;

● 网络分区把集群切成两半,每一边都看到大量主节点失联。

新机制要解决的正是这件事------让多主节点同时故障时的恢复过程保持一致、有序,而不再依赖随机抽签**。**

六、解决方案:让分片排队选举

这次优化为什么这么重要

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 之间。

排序键:为什么是 shard_id

错峰要可靠,排序键必须满足一个硬性要求:所有副本在各自视角下算出来的顺序必须完全一致------否则各排各的,照样会撞。

Valkey PR #1018 选用 shard_id 的字典序。原因很直接:shard_id 是 shard 维度的全局稳定标识,同一 shard 下所有副本看到的值完全相同,既稳定又无歧义,任何副本据此算出的分片排队顺序都一模一样。

这里藏着一个容易被误解的点:我们要的并不是"谁更该先上"的公平,而是"所有人对顺序达成共识"的确定性。shard_id 的字典序也许对人毫无意义、谁先谁后纯属随机,但只要全局一致,就足以把"同时抢票"拆成"轮流拉票"------错峰需要的全部,就只是这份共识。

两层 rank,各司其职

至此,副本的选举延迟由两层 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 发起)完成选举后,就不必再出场了。所以确定性延迟的碰撞,落不到真正参与竞争的那批选手身上。

七、算法实现拆解

Step 1:计算"故障分片排名"

对当前 replica 而言,统计集群里所有失败的 primary 中,shard_id 比自己小的有几个:

实现层面有一点要提醒:shard_id 不是字面的"1" 和 "2",而是节点创建时生成的 40 字符随机标识。所以这里比较的是一串随机十六进制的字典序------排队顺序既不按 shard 编号、也不按故障先后(如第六章所说,它对人不可预测,但对每个副本完全一致,这正是错峰所需)。

举例:在我们的故事里 shard1、shard2 同时故障,假设 shard1 的 shard_id 字典序恰好小于 shard2,那么:

● shard1 的副本(R1)→ failed_primary_rank = 0

● shard2 的副本(R2)→ failed_primary_rank = 1

Step 2:把 rank 翻译成延迟

在原有的副本选举调度公式上,叠加一项与 failed_primary_rank 成正比的延迟:

代入故事场景的数据(为聚焦错峰效果,下表略去 0~500ms 随机延迟):

|-----------|-------------------------|----------|--------------|
| Shard | failed_primary_rank | 额外延迟 | 大致发起选举时刻 |
| shard1 | 0 | 0 | t ≈ 500ms |
| shard2 | 1 | 500ms | t ≈ 1000ms |

关键就在那确定的 500ms 间隔:随机延迟会让两个时刻各自浮动,但 failed_primary_rank 带来的这 500ms 是确定性偏移,足以让两个 shard 大概率不再撞在同一瞬间------shard1 先进场抢票,shard2 退后半秒,等它进场时第一轮往往已经分出胜负。

Step 3:动态修正

集群是活的------gossip 消息可能姗姗来迟:副本忽然发现"原来还有一个 shard 也挂了",rank 从 1 变成 2,就该再多等 500ms;也可能某个 shard 已经完成故障转移,rank 从 2 变回 1,就该少等 500ms。算法支持实时修正:

注意 added_delay 可正可负:排队不是一锤定音,而是随着集群视图的变化持续校准------这让错峰在 gossip 有延迟、故障陆续发生的真实环境里依然站得住。

Step 4:留好 fast path

排队是为了稳定,但稳定不应该成为效率的对立面。手动故障转移直接旁路整套排队机制------管理员手动处理流程时,绝不希望还被排队:

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

八、效果对比

改造前:踩踏现场(5 分片 / 2 主挂)

改造后:错峰发车(5 分片 / 2 主挂)

串行化的隐性收益:先恢复的,反过来增援后恢复的

串行选举还有一个容易被忽略的好处:先恢复的 shard,会反过来给后恢复的 shard 当"援军"****。

故障期间,能投票的只有幸存的主节点,而多数票门槛 ⌊N/2⌋+1 也并不会因为有主节点挂掉而降低。当排在前面的 shard 完成选举、副本升为新主后,这个新主立刻成为一个合格的投票者,加入投票池。于是越靠后的 shard,面对的有效投票者一轮比一轮多,凑齐多数票也就越来越容易------每一轮选举,都站在前一轮的胜利成果之上**。**

还是 5 分片、shard1 与 shard2 同时挂的场景:恢复前只有 shard3、shard4、shard5 三个主能投票,而门槛正好是 3------并发抢票时三票被两个候选瓜分,谁都不够。串行之后,shard1 先用满这三票完成选举,新主随即加入投票池;轮到 shard2 时,可投票的主节点变成了四个,拿 3 票绰绰有余。这正是"串行"比"并发"根本性更优的地方:它不只是错开了时间,还让每一步的成功,为下一步铺好了路。

不只是兜底:日常可用性的全面提升

这次改动常被概括成"让大规模故障也能自愈",但这种说法其实低估了它的价值。除了在最极端场景下提供兜底恢复,排队机制对日常运行的集群同样有显著收益:

大幅减少选票瓜分**:**哪怕只是两个主节点几乎同时被判 FAIL(差距不到 500ms),原本可能因随机延迟恰好撞车的两个副本,如今被 shard_id 顺序稳稳错开,撞车概率大大降低;

大幅减少选举超时与重试**:**瓜分一旦避免,副本就不必干等超时、反复重试,一次干净的选举几百毫秒即可完成;

加快集群恢复速度**:**单分片故障的恢复时间几乎不变(与原算法一致),但多分片场景从"几分钟乃至永不可用"压缩到"每分片约 1 秒、依次恢复";

更高的可用性**:**选举越顺畅,集群对外服务的中断窗口就越短。

换句话说,这个改动既是大规模故障下的兜底保障,也是日常运行中的稳定器------它让集群的故障转移,从一场"概率游戏"变成一条"确定性流程"。

实际收益

先看一组对比:

这些数字并不是测试环境里刻意构造的极端 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% 杜绝,比如:

gossip 视图不一致**:**消息瞬时延迟,让两个 shard 的副本对"当前有哪些故障 shard"看法不同,failed_primary_rank 各算各的,错峰自然就乱了;

● **延迟抹平了间隔:**两边的 random(0, 500) 恰好取到相近数值,再叠加一点网络延迟,500ms 的错峰差就被吃掉了。

万一冲突还是发生了,会怎样?回顾选举规则:

  1. 同一 epoch 只有一个胜者,落选副本拿不到多数票;

  2. 落选副本要先死等到选举超时(auth_timeout,约 2 × node_timeout,默认 30 秒)才判本轮作废;

  3. 然后还要继续等到 auth_retry_time 满 60 秒(从本轮 auth_time 起算)才能触发下一轮(epoch + 1)。

明明几十毫秒后就有人胜出了,落选方却要先熬 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 个幸存主,理论上能恢复。

● 没有排队机制:2 个副本抢同一个 epoch,3 张票按"先到先得"被打散成 2-1,无人过 3 票门槛;如果每轮重试两副本都被延迟撞到一起,3 次预算一路耗光,最终因 data_age 超限放弃自动恢复------一切都押在"延迟够不够开"的运气之上。

● 有排队机制:R1 看到 failed_primary_rank=0 立即进场、拿下全部 3 票;R2 退让 500ms,等它进场时投票池已扩大到 4 个主,稳稳拿满------集群稳定自愈。

把规模拉到 128 分片,对比更触目惊心:从团队实测的"99% 无法恢复",到"全部按序自愈"。而万一排队没拦住、冲突仍然发生,还有 Valkey PR #1009 的"快速失败"兜底,把代价从"60 秒一个空等周期"压缩到几百毫秒。

整个排队改动的核心代码可能就 30 来行,但它绝不是一次孤立的灵光一现,而是腾讯云团队围绕"大规模集群故障转移"持续打磨的一条优化线上的一环:

● Valkey PR #762(8.0):让 shard 内部 offset 相同的副本按 node_id 排队;

● Valkey PR #1009(8.1,回移植至 8.0):冲突分出胜负时,让落选方快速失败、立即重试;

● Valkey PR #1018(8.1):让 shard 之间按 shard_id 排队。

三者环环相扣,背后是多年大规模运营 Redis/Valkey 服务沉淀下来的经验------我们运行的实例数量足够大、规模足够多样,于是那些"理论上极小概率"的瓜分场景,在我们这里就成了"大概率事件"。每一次不及预期的故障转移时长,都在提醒我们:集群自愈,还有改进的空间。

这次改动看起来思路很简单------不过是按 shard_id 给故障分片排个队------但它背后是一连串"单看合理、组合起来却互相耦合"的协议设计:epoch 单票、auth_timeout 30s、auth_retry_time 60s、data_age 160s。任何一条单独看都没问题,凑在一起却会在多主同时故障时,把 shard 卡进无法自愈的恶性循环。这个改动真正的价值,就是打破了那个循环,让"自动恢复"这条承诺在大规模、多故障的场景下也能兑现**。**

更重要的是,它是更高层次容灾能力的基石。当我们谈论可用区级别的高可用------AZ 整个挂掉、集群依然能稳健自愈------前提就是底层的故障转移协议在多主同时挂时不会哑火**。**如果连"两个主一起挂"都救不回来,再漂亮的多 AZ 部署架构,也只是纸面上的容灾。