Redis Cluster 的应用契约:hash slot、MOVED/ASK 与在线迁移
Cluster 不只是「把数据分到多台机器」。它把 slot 路由、多 key 操作必须同槽、客户端重定向、在线迁移和故障提升都变成了应用必须遵守的契约。
从一个失败的请求开始。用户资料和订单列表分别存在 user:1001:profile 和 user:1001:orders,单机时一条 MGET 就能取回,搬到 Cluster 之后:
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 怎样找到节点
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 的行为:
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) → 同样的异常三、客户端怎样处理重定向
支持 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)的途中:
向源节点 GET 已搬走的 key → ASK 13513 <n1 的地址>
直接向目标节点 GET 同一个 key → MOVED 13513 <n3 的地址>
向目标节点 ASKING 后再 GET → v49490
迁移完成后向旧节点 GET → MOVED 13513 <n1 的地址>第二行说明为什么 ASKING 不能省:迁移完成之前,目标节点并不认为自己负责这个 slot。
四、在线迁移实际搬的是 key
迁移 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 / 0 | 616µs |
--cluster reshard 迁移 1000 个 slot(1.4s) | 237,870 次 | 0 / 0 | 288µs |
迁移前后集群的 key 总数都是 12,004,redis-cli --cluster check 显示 16384 个 slot 全部覆盖。
单个大 key 是例外。MIGRATE 要在源节点上序列化整个 key、在目标节点上反序列化,两边都会阻塞:
{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_memory | SET 调用次数 |
|---|---|---|---|---|
| n1 | 4,463 | 3,727 | 27.5MB | 73,923 |
| n2 | 5,461 | 54,328 | 18.9MB | 150,908 |
| n3 | 6,460 | 3,947 | 5.4MB | 74,662 |
三种倾斜同时出现在这张表里:
- key 数倾斜:n2 的 slot 数居中,key 数却是其他节点的十几倍,因为 slot 6093 一个 slot 就有 5 万个 key;
- 容量倾斜:n1 的 key 最少,内存却最多,因为那个 24.5MB 的大 Hash 迁到了它上面;
- 访问倾斜:n2 的
SET调用次数是其他节点的两倍,写入集中在它负责的 slot 上;一个热 key 只能由一个节点处理,加节点也分不走,只能靠本地缓存或拆分,见 多级缓存、预热与热点数据。
所以监控要同时看每个节点的 slot 数、key 数、内存、QPS 和延迟,不能用「slot 分布均匀」代表负载均匀。
六、扩容与缩容 Runbook
- 评估:按内存、QPS、网络带宽中最紧张的一项估算需要的节点数,找出大 key 和热点 tag。
- 加入空节点:
redis-cli --cluster add-node,为它加上 replica。 - 小批迁移:
--cluster reshard一次迁移一部分 slot,观察客户端的重定向与错误、源和目标的延迟、复制状态。 - 继续迁移,直到负载达到目标分布;
--cluster check确认 slot 全覆盖、没有处于迁移状态的 slot。 - 客户端:确认所有客户端都支持 Cluster 并能处理
MOVED与ASK;有本地缓存 slot 映射的,确认会刷新。 - 回滚:迁移可以反向进行;中途失败时用
--cluster fix处理停留在MIGRATING/IMPORTING的 slot。 - 缩容:先把要下线节点的 slot 全部迁走,确认它为空,再
del-node。
七、故障与一致性边界
实测杀掉 primary n2(cluster-node-timeout 5000),同时一个 JedisCluster 客户端每秒写入 200 次:
+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 原子性边界。
配套实验
- codesphere-labs/cache/redis-cluster-resharding:hash slot 与跨 slot、MOVED、手动迁移中的 ASK、
--cluster reshard、大 key 迁移、倾斜与 primary 故障提升,业务负载使用 JedisCluster 5.2.0(验证记录)
参考资料