Skip to content

Redis Cluster 的应用契约:hash slot、MOVED/ASK 与在线迁移 ​

Cluster 不只是「把数据分到多台机器」。它把 slot 路由、多 key 操作必须同槽、客户端重定向、在线迁移和故障提升都变成了应用必须遵守的契约。

从一个失败的请求开始。用户资料和订单列表分别存在 user:1001:profile 和 user:1001:orders,单机时一条 MGET 就能取回,搬到 Cluster 之后:

text
CLUSTER KEYSLOT user:1001:profile    → 2549
CLUSTER KEYSLOT user:1001:orders     → 4492
MGET user:1001:profile user:1001:orders
(error) CROSSSLOT Keys in request don't hash to the same slot

两个 slot 恰好都由同一个节点负责,请求照样被拒绝。改成 {user:1001}:profile 和 {user:1001}:orders 后,两个 key 都落在 slot 5712,MGET 正常返回。本文用 Redis 8.10.1 的 3 primary + 3 replica 集群实测路由、迁移、倾斜和故障提升,说明应用要为 Cluster 做哪些改变。

一、先说结论 ​

  • 多 key 操作必须在同一个 slot,而不只是同一个节点。 MGET、MULTI、Lua 都受这个限制;JedisCluster 甚至在客户端就拒绝了跨 slot 的 MGET 和 EVAL。用 hash tag 把相关的 key 放进同一个 slot。
  • MOVED 更新映射,ASK 只重定向一次。 迁移中向源节点读已搬走的 key 得到 ASK;直接问目标节点得到 MOVED(回到源);先发 ASKING 再读才能拿到值。
  • 支持 Cluster 的客户端能让迁移对业务透明。 手动迁移一个 slot 和用 redis-cli --cluster reshard 迁移 1000 个 slot 时,JedisCluster 各自做了 20 多万次读写,错误都是 0。
  • 大 key 让迁移变成阻塞。 迁移一个 24.5MB、50 万字段的 Hash,MIGRATE 执行了 212ms,源节点上的请求最多等了 206ms。
  • slot 均匀不等于负载均匀。 一个 hash tag 把 5 万个 key 固定在一个 slot 上,这个节点的 key 数是其他节点的十几倍。
  • 故障提升同样依赖异步复制与超时。 primary 被杀后约 6 秒被判定失败、7 秒完成提升;期间写到它的请求在客户端里等了 7.8 秒。

二、key 怎样找到节点 ​

user:1001:profileuser:1001:orders{user:1001}:profile{user:1001}:ordersslot 2549slot 4492slot 5712两个 key 同槽CRC16 mod 16384n1slot 0—5460n2slot 5461—10922CROSSSLOT同节点也不行MGET 成功同一个 slot
图 1 · 两个看似相关的 key 落在不同的 slot,即使这两个 slot 恰好在同一个节点上,MGET、事务和 Lua 也会被拒绝;用 hash tag 让它们只按 {user:1001} 计算,就落在同一个 slot

Cluster 把 key 空间分成 16384 个 slot:slot = CRC16(key) mod 16384。每个 primary 负责一部分 slot,replica 复制它的 primary。

如果 key 里有 {...},并且花括号里不是空的,就只用第一对花括号里的内容计算 slot,这就是 hash tag。把同一个用户、同一个订单的相关 key 用同一个 tag,它们就一定在同一个 slot,多 key 命令、事务和 Lua 都能用。代价是这些 key 永远不能分开,tag 选得太粗会把大量数据压在一个 slot 上(见第五节)。

实测 JedisCluster 5.2.0 的行为:

text
mget("user:1001:profile", "user:1001:orders")
  → JedisClusterOperationException: Keys must belong to same hashslot.
mget("{user:1001}:profile", "{user:1001}:orders")   → [p, o]
eval(脚本, 2 个不同 slot 的 key)                     → 同样的异常

三、客户端怎样处理重定向 ​

迁移前 / 迁移完成后GET k → 旧节点-MOVED 5712 新节点slot 已不归我更新 slot 映射,发往新节点迁移中(源 MIGRATING,目标 IMPORTING)GET k → 源节点-ASK 5712 目标节点k 已经搬走ASKING + GET k → 目标只这一次,不改映射不带 ASKING 直接问目标→ -MOVED 回源节点k 还没搬走时,源节点直接回复源节点上不存在的 key 才会 ASK
图 2 · MOVED 表示 slot 已经归属别的节点,客户端应更新自己的 slot 映射;ASK 只在迁移期间出现,表示「这一个 key 去目标节点问一次」,客户端先发 ASKING 再重试,不更新映射

支持 Cluster 的客户端在启动时读取 slot 映射(CLUSTER SHARDS 或 CLUSTER SLOTS),之后每个请求直接发给负责该 slot 的节点。映射过期时,服务器用两种回复纠正它:

  • MOVED <slot> <地址>:这个 slot 已经归别的节点,客户端应更新映射。实测向不负责 slot 2549 的节点发 GET,回复 MOVED 2549 <n1 的地址>。
  • ASK <slot> <地址>:只在迁移期间出现,表示「这个 key 已经搬到目标节点,这一次去那里问」。客户端要先发 ASKING 再发原命令,并且不更新映射,因为 slot 的其他 key 可能还在源节点。

实测迁移 slot 13513(n3 → n1)的途中:

text
向源节点 GET 已搬走的 key         → ASK 13513 <n1 的地址>
直接向目标节点 GET 同一个 key     → MOVED 13513 <n3 的地址>
向目标节点 ASKING 后再 GET        → v49490
迁移完成后向旧节点 GET            → MOVED 13513 <n1 的地址>

第二行说明为什么 ASKING 不能省:迁移完成之前,目标节点并不认为自己负责这个 slot。

四、在线迁移实际搬的是 key ​

① 目标节点CLUSTER SETSLOT sIMPORTING 源ID② 源节点CLUSTER SETSLOT sMIGRATING 目标ID③ 源节点,循环GETKEYSINSLOT +MIGRATE … KEYS …④ 所有节点CLUSTER SETSLOT sNODE 目标ID直到 slot 为空迁移期间:源节点上已经搬走的 key 回复 ASK,还没搬的照常处理;新写入的 key 如果源上不存在,也会被 ASK 到目标redis-cli --cluster reshard 自动完成这四步;单个大 key 只能一次 MIGRATE,执行期间源和目标都被阻塞
图 3 · 迁移 slot 实际是按批搬 key:目标先标记 IMPORTING、源标记 MIGRATING,再用 MIGRATE 分批搬运,最后把 slot 的归属通知所有节点;大 key 会让单次 MIGRATE 阻塞源和目标

迁移 slot 的四步:目标节点标记 IMPORTING,源节点标记 MIGRATING,源节点循环 CLUSTER GETKEYSINSLOT + MIGRATE 分批搬 key,最后在所有节点上执行 CLUSTER SETSLOT <slot> NODE <目标>。redis-cli --cluster reshard 自动完成这些步骤。

两次迁移中都有一个 JedisCluster 客户端在持续读写(写入后立即读回校验):

迁移期间的读写错误 / 读到错误的值每秒 p99 最高
手动迁移 1 个 slot(1000 个 key,每批 100 个,6.6s)259,888 次0 / 0616µs
--cluster reshard 迁移 1000 个 slot(1.4s)237,870 次0 / 0288µs

迁移前后集群的 key 总数都是 12,004,redis-cli --cluster check 显示 16384 个 slot 全部覆盖。

单个大 key 是例外。MIGRATE 要在源节点上序列化整个 key、在目标节点上反序列化,两边都会阻塞:

text
{big}:hash:500,000 个字段,MEMORY USAGE 24.5MB
源节点 SLOWLOG:MIGRATE 212ms
同一时间源节点上 PING 的最大往返:206ms

迁移之前先用 redis-cli --bigkeys 找出大 key,拆开或者单独安排;迁移时控制每批的 key 数和 MIGRATE 的超时。

五、三种倾斜 ​

实测迁移之后,写入 5 万个带同一个 hash tag {hot} 的 key:

节点slot 数key 数used_memorySET 调用次数
n14,4633,72727.5MB73,923
n25,46154,32818.9MB150,908
n36,4603,9475.4MB74,662

三种倾斜同时出现在这张表里:

  • key 数倾斜:n2 的 slot 数居中,key 数却是其他节点的十几倍,因为 slot 6093 一个 slot 就有 5 万个 key;
  • 容量倾斜:n1 的 key 最少,内存却最多,因为那个 24.5MB 的大 Hash 迁到了它上面;
  • 访问倾斜:n2 的 SET 调用次数是其他节点的两倍,写入集中在它负责的 slot 上;一个热 key 只能由一个节点处理,加节点也分不走,只能靠本地缓存或拆分,见 多级缓存、预热与热点数据。

所以监控要同时看每个节点的 slot 数、key 数、内存、QPS 和延迟,不能用「slot 分布均匀」代表负载均匀。

六、扩容与缩容 Runbook ​

  1. 评估:按内存、QPS、网络带宽中最紧张的一项估算需要的节点数,找出大 key 和热点 tag。
  2. 加入空节点:redis-cli --cluster add-node,为它加上 replica。
  3. 小批迁移:--cluster reshard 一次迁移一部分 slot,观察客户端的重定向与错误、源和目标的延迟、复制状态。
  4. 继续迁移,直到负载达到目标分布;--cluster check 确认 slot 全覆盖、没有处于迁移状态的 slot。
  5. 客户端:确认所有客户端都支持 Cluster 并能处理 MOVED 与 ASK;有本地缓存 slot 映射的,确认会刷新。
  6. 回滚:迁移可以反向进行;中途失败时用 --cluster fix 处理停留在 MIGRATING / IMPORTING 的 slot。
  7. 缩容:先把要下线节点的 slot 全部迁走,确认它为空,再 del-node。

七、故障与一致性边界 ​

实测杀掉 primary n2(cluster-node-timeout 5000),同时一个 JedisCluster 客户端每秒写入 200 次:

text
+0.0s   n2 被 SIGKILL
+6.0s   其他节点把 n2 标记为 fail?(超过 node timeout 没有回应)
+6.1s   多数 primary 同意,n2 被标记为 FAIL,集群状态变为 fail
+6.8s   n6(n2 的 replica)获得授权
+6.9s   n6 成为 primary,接管 n2 的 slot,集群状态恢复 ok
结果    已确认 9,577 条,丢失 0 条;写入错误 0 次,但有一次写入在客户端里等了 7.8 秒

客户端没有报错,是因为 JedisCluster 在内部重试(实验配置为最多 10 次、总计 20 秒)。对业务来说,这 7.8 秒表现为一次非常慢的请求;单线程的写入者在这段时间里什么也写不了。重试时间要和业务的超时预算一起设计。

在一致性上,Cluster 的提升和 Sentinel 一样依赖异步复制:这次没有丢数据,是因为复制几乎没有积压;网络分区时,被隔离在少数一侧的 primary 在 node timeout 之前仍会接受写入,这些写入在它被取代后丢失,原理见 Redis Sentinel 故障切换。默认 cluster-require-full-coverage yes 时,只要有 slot 没人负责,整个集群都拒绝请求,这也是上面出现「集群状态 fail」的原因。

八、常见误区 ​

  • 「在同一个节点上就能一起操作」:必须在同一个 slot,实测两个同节点的 slot 也返回 CROSSSLOT。
  • 「ASK 和 MOVED 一样处理」:ASK 不更新映射,而且要先发 ASKING,否则目标节点会把请求 MOVED 回源节点。
  • 「在线迁移没有抖动」:小 key 迁移对业务透明,大 key 的 MIGRATE 实测阻塞了 212ms。
  • 「slot 均匀就是负载均匀」:一个 hash tag 就能让一个节点的 key 数变成其他节点的十几倍。
  • 「上了 Cluster 就有强一致」:故障提升依赖异步复制,跨 slot 也没有原子操作。

小结 ​

迁移到 Cluster 时,应用要做三件事:用 hash tag 把需要一起操作的 key 放进同一个 slot,并控制 tag 的粒度;使用能处理 MOVED 与 ASK 的客户端,并把它的重试时间放进超时预算;在迁移和扩容前找出大 key 与热点,监控每个节点的 key 数、内存和访问量。Cluster 解决的是容量和吞吐的水平扩展,一致性边界仍然是异步复制。

跨 slot 的原子操作受限后,并发控制的写法见 Redis 原子性边界。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权