bigywater.tech
Redis · 77

Redis Cluster Epoch

Redis Cluster 里 epoch 到处都是:CLUSTER INFO 里有 cluster_current_epoch 和 cluster_my_epoch,nodes.conf 里每一行有 epoch,日志里选举叫「for epoch 7」。它们各是什么、谁比谁大意味着什么?这篇通过三个可复现的实验来看:新建集群、人为制造冲突、以及冻结/解冻一个旧主库时 epoch 如何决定「谁的槽位声明有效」——顺便发现一个能丢数据的窗口。

两种 epoch
current_epoch(集群逻辑时钟)与 configEpoch(每个主库槽位归属的版本号)
约 116 ms
两个 configEpoch = 5 的节点握手后,服务器自动分开所用的时间
3 / 3
旧主库解冻后仍回复 OK、随后却丢失的写入次数

新建的集群里的 epoch

实测 · 本机执行输出
  7430 M  current_epoch = 6  my_epoch(configEpoch) = 1
  7431 M  current_epoch = 6  my_epoch(configEpoch) = 2
  7432 M  current_epoch = 6  my_epoch(configEpoch) = 3
  7433 S  current_epoch = 6  my_epoch(configEpoch) = 1
  7434 S  current_epoch = 6  my_epoch(configEpoch) = 2
  7435 S  current_epoch = 6  my_epoch(configEpoch) = 3

每个节点的 CLUSTER INFO 有两个 epoch:cluster_current_epoch(整个集群的逻辑时钟,所有节点尽量保持一致,这里都是 6)和 cluster_my_epoch(本节点的 configEpoch)。三个主库的 configEpoch 依次是 1、2、3,是 --cluster create 时分配的;每个从库的 my_epoch 等于它主库的。

configEpoch 冲突会自动解决

实测 · 本机执行输出
  握手前 7440: my_epoch = 5
  握手前 7441: my_epoch = 5
  握手前 7442: my_epoch = 0
  +     1 ms  各节点 (my_epoch, current_epoch): {7440: (5, 5), 7441: (5, 5), 7442: (0, 0)}
  +   116 ms  各节点 (my_epoch, current_epoch): {7440: (6, 6), 7441: (5, 6), 7442: (0, 6)}

configEpoch 用来在两个节点都声称拥有同一个槽时分出胜负,所以每个主库必须有不同的值。我用 CLUSTER SET-CONFIG-EPOCH 手工把两个新节点都设成 5,让它们握手:约 116 ms 内其中一个的 configEpoch 被推进到 6(同时 current_epoch 成为 6),另一个保持 5——冲突消失了。第三个节点没有设置,它的 configEpoch 保持 0,但 current_epoch 与集群对齐。哪一个被推进由什么规则决定(我推测与节点 ID 有关,官方文档也是这么说),我没有做对照验证。

冻结旧主库:更大的 epoch 赢

用 SIGSTOP 冻结,SIGCONT 恢复;重复三次
实测 · 本机执行输出
第 1 次:X=7451 从库=7454;从库升主用了 5809 ms
   X 的 (current,my) epoch:冻结前 (6, 2) → 解冻后 (7, 7);新主库 7454:(6, 2) → (7, 7)
   解冻后 0.7 ms 内经旧连接 SET 的结果:OK;旧主库变成从库用了 10 ms;最终 key 在新主库上的值 = 'after-failover',在旧主库上 = Err('MOVED 9094 127.0.0.1:7454')
第 2 次:X=7451 从库=7453;从库升主用了 5871 ms
   X 的 (current,my) epoch:冻结前 (6, 2) → 解冻后 (7, 7);新主库 7453:(6, 2) → (7, 7)
   解冻后 0.9 ms 内经旧连接 SET 的结果:OK;旧主库变成从库用了 11 ms;最终 key 在新主库上的值 = 'after-failover',在旧主库上 = Err('MOVED 9094 127.0.0.1:7453')
第 3 次:X=7451 从库=7454;从库升主用了 5449 ms
   X 的 (current,my) epoch:冻结前 (6, 2) → 解冻后 (7, 7);新主库 7454:(6, 2) → (7, 7)
   解冻后 1.4 ms 内经旧连接 SET 的结果:OK;旧主库变成从库用了 13 ms;最终 key 在新主库上的值 = 'after-failover',在旧主库上 = Err('MOVED 9094 127.0.0.1:7454')

冻结旧主库直到从库升主(约 5.8 s)。升主的从库申请了新的 epoch:current_epoch 6 → 7,新主库的 configEpoch 也是 7,比旧主库的 2 大。解冻旧主库之后,它收到新主库对同一批槽位的声明——configEpoch 更大者胜出,于是旧主库把自己重新配置为新主库的从库,epoch 变成 (7, 7),用时 10–13 ms。

那 10 ms 里发生了什么:一次被接受的写入

关键在解冻的那一瞬间:旧主库进程恢复运行,但它还没收到新 epoch 的消息,仍然以为自己是这些槽位的主库。我提前打开了一条到旧主库的连接,解冻后立刻(约 0.7 ms 内)发一次 SET:三次实验里它都返回了 OK。随后旧主库降为从库、与新主库同步,这次被 OK 的写入被丢弃:最终该 key 在新主库上的值是故障转移之后写入的 after-failover。这是「已确认的写入在故障转移中丢失」的一个具体窗口,长度在毫秒量级,并且需要「客户端还连着旧主库」这个条件才会撞上。这类窗口的更多形态见第 93 篇。

注意这里用的是冻结/解冻(进程暂停后恢复),它对应的是「主库因 GC/虚拟机暂停等原因暂时假死」这类场景;和第 75 篇 kill -9(进程彻底消失)不同,后者不会有「旧主库醒来」的这一幕。

分步演示:epoch 如何决定谁是主

Redis Cluster Epoch
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Redis 8.10.2 独立实例(本机回环端口,save 关闭),redis-cli 风格输出由自写的 RESP 客户端打印;计时用 time.perf_counter,数字每次运行会有小幅抖动。冲突实验用三个新节点;冻结实验每次一个新的 3 主 3 从集群,冻结用 SIGSTOP、恢复用 SIGCONT,「解冻后的写入」通过冻结前已经建立好的连接发送
    • 没有验证:冲突时到底由哪个节点让步的规则(只观察到一次)、更长的冻结时间、多个从库的选举 epoch、写入通过新建连接(而不是旧连接)时是否同样被接受、cluster-replica-no-failover 等选项的影响
    京ICP备2026067502号-1京公网安备11010502063487号