同时对多个 Redis 从节点执行 failover,为什么总有几个切不过去?

6 阅读7分钟

做过 Redis Cluster 滚动升级或跨机房迁移的人,对 CLUSTER FAILOVER 应该不陌生。它看起来是个很直接的命令——指定一个从节点,它把自己提升成主节点,原主降级,slot 归属不变,正常情况下数据也不丢。

在一次维护里,你想把几个主节点从机房 A 迁移到机房 B,写了一段脚本:对机房 B 的从节点依次执行 CLUSTER FAILOVER FORCE,两条命令之间 sleep 2。执行后前两个切换成功,最后一个没有响应,日志里反复出现:

Currently unable to failover: Waiting for votes, but majority still not reached
...
Currently unable to failover: Failover attempt expired

这并非脚本本身的问题。Redis 官方 issue 区有人用几乎相同的方式遇到了同样的情况(3 主 9 从、双机房、间隔 2 秒顺序 FORCE,仍然有一个主节点没能切换成功)。原因在于 Redis Cluster 的底层共识机制:这些看似彼此独立的切换操作,实际上共用一套投票系统,并发执行时会相互消耗对方的选票。

先看清 CLUSTER FAILOVER 的三种模式

CLUSTER FAILOVER 有三种模式,差别在于是否走投票流程。

模式是否握手是否需要多数 master 投票典型场景
无参数(普通)是,与主节点握手计划维护、滚动升级
FORCE否,主节点可能已宕机主节点故障后强制切换
TAKEOVER否,完全绕过共识极端场景,官方明确说不安全

关键差别在于:普通模式和 FORCE 模式都必须获得多数 master 节点的投票授权,才能生成新的 configEpoch、完成切换。 也就是说,它们都需要先走完一轮选举。

只有 TAKEOVER 是单方面自我提升:它自行将 currentEpoch 加一,赋值给 configEpoch,随即宣布自己是 master,无需通知其他节点,也无需获得同意。官方文档明确警告这可能导致集群配置不一致,需谨慎使用。

会出问题的,正是普通和 FORCE 这两种模式,因为它们都需要走投票。

Redis Cluster 本质上是个类 Raft 的共识系统

Redis Cluster 用三个称为 "epoch" 的值来仲裁集群状态,概念上和 Raft 里的 term 相同,只是名称不同。

  • currentEpoch:集群级别的逻辑时钟,相当于"任期编号"。节点之间通信时会同步这个值,取较大的一方。
  • configEpoch:每个 master 持有的"拓扑版本号"。谁当 master、slot 归谁,都用它来仲裁——两个节点争夺同一个 slot 时,configEpoch 大的获胜。
  • lastVoteEpoch:每个 master 记录的"上次投票所处的任期"。作用是防止同一个任期内重复投票。

failover 的投票流程大致如下(相关逻辑在 Redis 源码 src/cluster.c 中):

Mermaid 图表 1转存失败,建议直接上传图片文件

关键在 master 收到请求时的检查:请求方的 currentEpoch 必须大于 master 自身的 lastVoteEpoch,它才会投票,并将 lastVoteEpoch 更新为该值。 因此,同一个任期内,一个 master 只能投一票。

这条规则,是后续一系列问题的根源。

同时 failover 时,到底发生了什么

假设两个从节点 A 和 B 分别属于不同的主节点,几乎同时发起 failover。

假设集群里所有节点的 currentEpoch 当前都是 N。A 和 B 各自独立地将 currentEpoch 加一,变为 N,然后同时向所有 master 广播投票请求。

Mermaid 图表 2转存失败,建议直接上传图片文件

因为 A 和 B 都从相同的初始 epoch 出发,各自只加了同样的一次,它们发出的请求中 currentEpoch 是相等的。无论 master 先收到哪一方的请求,都会将 lastVoteEpoch 设为 N;随后到达的那个请求必然满足"epoch ≤ lastVoteEpoch",从而被拒绝。

结果取决于网络时序:

  • A 先到:在多数 master 那里都先到达,获得足够票数,成功切换;B 因票数不足而失败。
  • 票被平分:A 和 B 各获得一部分,双方都无法达到多数,两个都失败

第二种情况最为棘手:命令都已执行,结果一个都没切换成功,currentEpoch 还被额外抬高。

更麻烦的是,失败后如果立刻重试,新发起的选举 currentEpoch 为 N+1,理论上能避开上一轮的 lastVoteEpoch。但如果多个从节点又在同一时刻重试,同样的竞争会再次发生,epoch 也会在短时间内被快速抬高——社区里也有人观察到 epoch 在短时间内暴涨是 failover 反复失败的一个诱因。

失败的真正原因,与 configEpoch 碰撞无关

提到"两个 failover 冲突",很多人第一反应是 configEpoch 发生了碰撞。事实并非如此。

Redis Cluster 对 configEpoch 碰撞有明确的自动仲裁算法(cluster spec 中有详细说明):当发现两个 master 持有相同的 configEpoch 时,node ID 较小的那个会主动递增自己的 currentEpoch,将 configEpoch 设为更大的值,再通过 gossip 传播出去。这套机制本身能自洽收敛,无需人工干预。

真正导致并发 failover 失败的,是前面那套投票互斥机制——同一个 epoch 内,一个 master 只投一票。从节点 A 和 B 用相同的 currentEpoch 去争取同一批 master 的投票,必然有一方、甚至双方被拒绝。

两者可以这样区分:

  • configEpoch 碰撞 → 有自动仲裁,能自行解决。
  • 投票竞争(lastVoteEpoch 互斥) → 没有自动绕过的办法,只能靠时间错开。

间隔多久才安全

遗憾的是,Redis 并未提供任何"failover 互斥锁"或"最小间隔"之类的配置项。cluster-node-timeout 管理的是故障检测,对手动 failover 的投票竞争没有影响。唯一的办法是在操作层面将切换串行化,为每次切换留出足够的传播时间。

一个相对稳妥的操作流程:

  1. 一次只切换一个,不要并发,也不要用循环无间隔地批量执行。
  2. 每切换完一个,用 CLUSTER NODES 确认角色已经从 slave 变成 master。
  3. 额外等待几秒,让新的 configEpoch 通过 gossip 在集群中传播开(gossip 的 PING 间隔约 1 秒,传遍整个集群通常要数秒)。社区里间隔 2 秒仍然出问题的案例说明,这个余量需要多留一些,建议 5 秒以上。
  4. 确认前一个彻底落地后,再执行下一个。
  5. 如果某个 failover 失败,不要立刻重试。epoch 需要时间稳定,立刻重试很可能再次遇到同样的竞争,还会把 epoch 进一步抬高。等待 30 秒以上再重试更为稳妥。

落实到脚本中,大致是:每次 failover 后轮询 CLUSTER NODES 直到角色切换,再 sleep 一个固定余量,而不仅是简单的 sleep 2

回到共识的本质

这个问题的根源在于:很多人把 Redis Cluster 当作"带主从复制的数据库",以为 failover 是一种针对单个节点的独立操作。实际上它的底层是一套类 Raft 的共识系统,currentEpoch、configEpoch、lastVoteEpoch 是全集群共享的状态,投票授权是全集群范围内的稀缺资源。

理解这一点后,很多操作上的"坑"就能解释了——并发 failover 失败,本质上是多个候选人在同一个任期内争夺同一批选票,谁都拿不到多数。

做批量主从切换、滚动升级或跨机房迁移时,可以把 CLUSTER FAILOVER 当作一次选举来处理:一次投一场,等结果落定,再投下一场。


参考来源