1. 一句话速查
在 Redis Cluster(集群模式)下,任何在服务端单次执行的多 Key 操作 (如 MGET、多 Key DEL、Lua 脚本 EVAL、TxPipeline 事务),都要求所有 Key 的 Hash Slot(哈希槽)完全相同;否则 Redis 会直接返回防御性错误:
text
CROSSSLOT Keys in request don't hash to the same slot
- 需要跨 Key 原子性 (如双 Key Lua 脚本、CAS 状态流转) → 给相关 Key 加上相同的 Hash Tag
{...}。 - 不需要强原子性 (如跨用户/跨会话批量读写) → 改用普通
Pipeline(),由客户端按 Slot 自动分片路由。
2. 背景知识:Redis Cluster 的分片与路由原理
2.1 为什么要划分"哈希槽(Hash Slot)"?(解耦海量 Key 与物理节点)
假设有 3 台 Redis 节点,要把 1 亿个 Key 分散存进去:
-
如果不划分哈希槽,直接对节点数取模
hash(key) % N:当 3 台机器扩容到 4 台时,公式从
% 3变成% 4,约 75% 的 Key 算出来的节点都会改变,导致海量数据瞬间失效或全量跨节点搬迁;更致命的是,如果不遍历全部 1 亿个 Key,根本不知道哪些 Key 需要搬走。 -
引入中间层------哈希槽(Slot) : 海量 Key(动态变化,上亿个)固定公式 CRC16mod16384 16384 个 Slot(固定不变)配置映射表 物理节点 Node(动态扩缩容)
- 扩缩容时,
Key -> Slot的映射永远不变:不管集群是 3 台还是 100 台机器,同一个 Key 算出来的槽号永远固定。 - 极小元数据的"打包搬运单位" :集群不需要记录"1 亿个 Key 分别在哪台机器",只需要维护一个长度为
16384的槽位映射表(例如Slot 0~5460 -> Node A)。扩容加入 Node D 时,只需从 A、B、C 各拎出约 1365 个"槽箱子"迁给 D,修改这张16384长度的表即可。
- 扩缩容时,
2.2 哈希槽是怎么计算的?(默认规则与 {...} Hash Tag 算法)
Redis 服务端和客户端都使用完全相同的无状态函数 keyHashSlot(key) 来计算某个 Key 属于哪个槽:
- 默认规则(无
{}) :对整个 Key 字符串 计算 CRC16(XMODEM 标准)并对16384取模: slot=CRC16(key)mod16384 - Hash Tag 规则(含
{...}) :- 从左向右扫描 Key,找到第一个
{的位置s,再从s+1开始找到第一个}的位置e。 - 当且仅当
{和}存在,且中间至少有 1 个字符(即e > s + 1)时 ,Redis 只提取{和}之间的子串 来计算 CRC16,忽略花括号以外的所有前后缀: slot("prefix:{tag}:suffix")=CRC16("tag")mod16384 - 如果没有找到配对的
{},或者第一对花括号为空(即{}),则退化为默认规则,对整个 Key 计算 CRC16。
- 从左向右扫描 Key,找到第一个
直观示例对比:
| Key 示例 | 实际参与 CRC16 计算的字符串 | 说明 |
|---|---|---|
presence:u123:web |
"presence:u123:web" |
无 {},整个 Key 参与计算 |
presence:u123:ios |
"presence:u123:ios" |
与上一行算出的 Slot 不同 |
presence:{u123}:web |
"u123" |
命中 Hash Tag,仅 "u123" 参与计算 |
presence:{u123}:ios |
"u123" |
与上一行算出的 Slot 100% 相同 |
prefix:{}:suffix |
"prefix:{}:suffix" |
第一对 {} 为空,退化为整个 Key |
a:{tag1}:b:{tag2} |
"tag1" |
只认第一对 {},后面的 {tag2} 当普通字符 |
2.3 客户端怎么知道哪个 Key 在哪个节点上?
go-redis 的 ClusterClient 是一种 "胖客户端"(Smart Client) ,它不需要每次请求都去问代理,而是将算槽算法 + 路由映射表直接维护在 Go 进程的本地内存中:
- 启动时拉取全量"槽位地图"(
CLUSTER SLOTS) :- 初始化
goredis.NewClusterClient时,客户端会连接配置中的任意种子节点发送CLUSTER SLOTS命令。 - 服务端返回全部
0 ~ 16383槽位对应的主从节点IP:Port列表,客户端将其保存在本地内存数组[16384]*ClusterNode中(数组下标即槽号,元素直接指向目标节点的 TCP 连接池)。
- 初始化
- 平时发请求时,本地算哈希 + O(1) 查表直连 :
- 当调用
redis.Get(ctx, "presence:{u123}:web")时,go-redis在本地提取{u123}(无{}则取整个 Key),计算slot = CRC16("u123") % 16384。 - 直接读取内存里的
slots[slot]拿到对应节点的连接池,一步到位将请求直发给目标节点,无任何代理转发开销。
- 当调用
- 拓扑变化时,靠
-MOVED被动刷新兜底 :- 当集群发生扩缩容或主从切换后,客户端本地缓存的数组过期,请求发到旧节点会收到
-MOVED响应,此时客户端自动重发请求并异步重新执行CLUSTER SLOTS更新本地路由表。
- 当集群发生扩缩容或主从切换后,客户端本地缓存的数组过期,请求发到旧节点会收到
2.4 如果请求的 Key 发到了不包含它的主节点,会发生什么?
Redis Cluster 采用 No-Proxy(无中心代理) 设计:收到错误请求的节点不会代为转发请求,而是直接返回重定向错误让客户端自己去找正确的节点。具体分两种情况:
情况 A:槽已归属其它节点 → 返回 MOVED(永久重定向)
假设 key="foo" 属于 Slot 12182(由 Node B 10.0.0.2:6379 负责),但请求发到了 Node A:
-
Node A 算出槽号为
12182,查表发现归 Node B 管,直接拒绝并返回:text-MOVED 12182 10.0.0.2:6379 -
ClusterClient收到MOVED后自动完成两件事(对上层业务代码透明,仅多一次网络 RTT):- 立即向
10.0.0.2:6379重发GET foo并返回结果; - 更新本地路由表缓存 ,后续所有
Slot 12182的请求直接发给 Node B。
- 立即向
情况 B:该槽正在迁移中 → 返回 ASK(临时重定向)
假设 Slot 12182 正在从 Node A 迁往 Node B(处于 MIGRATING 状态,槽内部分 Key 还在 A,部分已到 B):
-
客户端先找源节点 Node A:若 Key 还在 A,直接返回结果;若 Key 已经搬去 B 了,Node A 返回:
text-ASK 12182 10.0.0.2:6379 -
ClusterClient收到ASK后:- 先向 Node B 发送前置打招呼命令
ASKING(允许本次请求访问尚未完全迁入的IMPORTING槽),紧接着发送原命令拿到数据; - 不修改本地路由表缓存 (因为该槽尚未彻底搬完,下次访问该槽的其它 Key 仍先找 Node A,直到迁移完成才变为
MOVED)。
- 先向 Node B 发送前置打招呼命令
3. 第一性原理:为什么"跨槽 = 跨节点",Redis 却依然报错 CROSSSLOT?
在 3 主节点的集群中,任意两个不同 Slot 有 1/3 的概率其实在同一个物理节点、同一个进程内存里;在单主节点的集群中,100% 的 Slot 都在同一台机器上。
但查看 Redis 源码(cluster.c -> getNodeByQuery)可以发现:服务端处理多 Key 命令时,第一步先比对所有 Key 的 Slot 是否相等 ,只要不等就立刻拦截返回 -CROSSSLOT,根本不去检查这两个 Slot 当前是否都在本机。
这是一种架构契约级的防御性报错 ,最主要的一个原因: 保证集群扩缩容(Resharding)对业务透明
Key -> Slot是静态算法,而Slot -> Node是动态分配的。- 如果允许"只要两个不同 Slot 碰巧在同一台物理节点上就能执行多 Key 操作",业务代码在 1 节点或 3 节点时碰巧能跑通;一旦某天线上扩容到 4 节点触发 Slot 迁移,原本同机的两个 Slot 被拆到不同机器上,在没有任何代码变更的情况下,线上业务会突然报错崩溃。
为什么本地开发和单测测不出来? 本地开发通常使用单实例 Redis(
Single Node Mode),单测通常使用miniredis(goredis.NewClient)。单机模式不启用集群 Slot 校验层,多 Key 命令、TxPipeline、多 Key Lua 脚本全部直接通过,直到上测试/生产环境的 Redis Cluster 才会暴露。
5. 常见高危操作对照表(编码与 Code Review 清单)
在编写或 Review Redis 代码时,看到以下 API 必须立即检查 Slot 策略:
| 操作类型 | go-redis 方法 |
是否要求同槽 | 推荐做法 |
|---|---|---|---|
| Lua 脚本 | Eval / EvalSha / Script.Run |
是 (当 len(keys) > 1) |
1. 优先把多个 Key 合并为一个 HASH(单 Key 脚本天然免跨槽) 2. 必须多 Key 时,用业务主键做 Hash Tag(如 {userID:agentID}) 3. 严禁 在 Lua 脚本里不通过 KEYS 而是用字符串拼接访问隐式跨槽 Key |
| 批量读取/写入 | MGet / MSet |
是 | 同一实体下的子键用 {entity_id} Hash Tag;不同实体的批量查询一律改用 Pipeline() |
| 批量删除 | Del(ctx, k1, k2, ...) |
是 | 若 k1, k2 不同槽,改为 pipe := rdb.Pipeline(); pipe.Del(ctx, k1); pipe.Del(ctx, k2); pipe.Exec(ctx) |
| 事务管道 | TxPipeline / TxPipelined |
是 | MULTI/EXEC 要求管道内所有 Key 同槽。若仅操作单 Key(如 HSet + Expire)则安全;若跨 Key 且无需强原子性,改用普通 Pipeline();若跨 Key 且需原子性,改用同槽 Hash Tag + Lua 脚本 |
| 普通管道 | Pipeline / Pipelined |
否(安全) | ClusterClient 会在客户端计算每个命令的 Slot,按节点自动拆包并发发送再汇总结果 |
6. Hash Tag 设计规范与常见陷阱
陷阱 1:全模块共用固定常量作为 Hash Tag 导致"单槽热点"
- 如果把整个模块的 Key 都写成
module:{fixed_tag}:<id>,虽然模块内任意多 Key 脚本都不会报CROSSSLOT,但该模块所有用户的所有数据都会被挤压到同一个 Redis 节点的一个 Slot 上,失去集群分片能力。
陷阱 2:空花括号 {} 不会生效
- Redis 规范规定:如果第一对
{}内部为空字符串(例如prefix:{}:suffix),Redis 会退化为对整个 Key 计算 CRC16。
陷阱 3:多对花括号只认第一对
- 如果 Key 是
prefix:{tag1}:middle:{tag2},Redis 只会用tag1计算 Slot ,{tag2}会被当作普通字符。