一次扩容,Redis 集群从 3 主扩到 6 主,运维按"平均分"迁移了槽位。上线后 MGET 批量取 20 个 key 全部失败,报 CROSSSLOT Keys in request don't hash to the same slot——因为批量操作要求"所有 key 在同一个槽",而扩容把它们的槽分散了。
Redis 集群的核心是"16384 个槽 + CRC16 取模"。而大部分"集群问题"不是"槽怎么分",这个"设计"而不是"运维"的定位很重要:槽分配是运维能做的,而"哪些 key 必须同槽"只能由业务决定——于是"扩容后报 CROSSSLOT"本质上是"上线前没想到这类操作"。
而是"你的 key 有没有被设计成"能落在同一个槽""。
1. 三个基本事实
| 事实 | 含义 |
|---|---|
| 16384 个槽 | 集群的"分片单位" |
CRC16(key) % 16384 |
槽的归属(不看库号) |
| 槽 → 节点 | 由集群配置决定(可迁移) |
第二项解释了"为什么集群模式下 SELECT 不可用":槽是唯一的分片维度,库号不在其中。第二项的"另一个后果"是"不同库的同名 key 会撞":db0:user:42 与 db1:user:42 若哈希相同就落到同一槽——而集群里它们"没有库号区分",于是"本该隔离的数据互相覆盖"。
2. "CROSSSLOT" 的三种触发
| 触发 | 例子 |
|---|---|
| 批量取多个无关 key | MGET a b c |
| 多 key 命令(事务/Lua) | MULTI 里的多个 key |
| 跨 key 的集合操作 | SINTERSTORE a b |
第三项(跨 key 的集合操作)最"隐":它在代码里是一行 SINTERSTORE,而在集群里要求"所有输入 key 同槽 + 输出 key 同槽"——于是"一不小心就触发"。
修法只有一条:让相关 key 落在同一个槽,而那靠"hash tag"。
// hash tag:只对 {} 内的部分做哈希
{user:42}:profile → 槽 = CRC16("user:42") % 16384
{user:42}:orders → 同一个槽
user:42:profile → 槽 = CRC16("user:42:profile")(不同槽)
// 规则:只有第一个 {} 生效;空 {} 或没有 {} 等于没写
3. "hash tag"的三种用法与风险
| 用法 | 效果 |
|---|---|
按业务实体分组({user:42}:*) |
支持批量/事务 |
按租户分组({tenant}:*) |
支持跨 key 操作,但倾斜 |
过度分组({all}:*) |
全部落一个槽(失去分片) |
第三项是"最常见的误用":为了让"所有批量操作都能跑",把所有 key 都加了同一个 tag第三项的"识别"很容易:redis-cli --cluster check 会显示各节点的"槽数与 key 数"——若某个节点占绝大多数 key,就是"过度分组"。
——于是"集群"变成了"单节点"。
第二项的"缓解"是"大租户单独拆":{tenant:big}:user:{uid} —— 于是"租户内"仍可批量,"租户间"分散。而它要求"业务上知道谁是大租户"。
第二项的风险在"热点":大租户的 key 全在一个槽,于是"一个节点扛一个大客户"。
// 好的 key 设计(按"必须一起操作的边界"分组)
{user:42}:profile, {user:42}:settings // 需要一起读 → 同 tag
order:1001, order:1002 // 独立 → 不加 tag(分散到不同槽)
// 判据:这两个 key "会不会出现在同一个批量/事务/脚本里"
4. 槽迁移的三种影响
| 影响 | 说明 |
|---|---|
迁移期间 MOVED 重定向 |
客户端要跟随 |
| 批量操作失败 | 跨槽(见上) |
| 热点槽迁移慢 | 大 key 拖慢迁移 |
第三项很实际:**一个含 100 万个元素的 key,迁移会"卡住整个槽"**第三项的"量化"是"单 key 迁移时间 ≈ 元素数 × 每元素成本":100 万元的 List,迁移可能要几十秒到几分钟——而"迁移期间对这个槽的写"会被阻塞。
——于是"扩容窗口"被"大 key"决定。
所以"扩容前的检查"应当包含"扫大 key":redis-cli --bigkeys 或 MEMORY USAGE。
"同槽验证"可以在开发期就做,不必等线上报错:
// 验证一组 key 是否同槽(用 CLUSTER KEYSLOT)
redis-cli -c CLUSTER KEYSLOT "{user:42}:profile"
redis-cli -c CLUSTER KEYSLOT "{user:42}:orders"
// 两者相同 → 可以放进同一个 MGET/MULTI/Lua
// 反例:cluster keyslot "user:42:profile" 与 "user:42:orders" → 通常不同
5. 客户端要处理的三件事
| 事项 | 说明 |
|---|---|
MOVED |
槽已迁到别的节点(更新本地映射) |
ASK |
槽正在迁移(这次去新节点,不改映射) |
| 重试 | 上面的两种都要"跟随重试一次" |
第二项的"错误后果"很具体:把 ASK 当 MOVED 会让客户端"过早"更新映射,于是"还没迁完的 key"被发到新节点,得到 MOVED 再被弹回——请求在两个节点之间来回。
第二项容易被实现错:把 ASK 当成 MOVED(更新映射)会导致"迁移期间的请求乱跳"。
// 正确的处理顺序
1 收到 MOVED:更新槽映射 → 重试(一次)
2 收到 ASK:不更新映射 → 对"这一次"请求发到目标节点
3 连续失败:退避重试(限次数),不要无限循环
// 反例:把 ASK 也当成"更新映射" → 迁移期间命中率骤降
"批量操作"的拆法有两种,选择取决于"是否必须一起成功":
// 两种拆法(同一个 MGET 20 个 key)
1 客户端分组:按槽分成 k 组,各组一次 MGET,结果合并(无原子性,但快)
2 加 hash tag:让 20 个 key 同槽(有原子性,但受"分组粒度"限制)
// 判据:这 20 个 key 是否"必须同时读到同一时刻的值"
6. 排错:五种"集群问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
CROSSSLOT |
批量 key 不同槽 | 加 hash tag 或拆分 |
| 集群变单点 | 全部 key 同一 tag | 重新设计 key |
| 某节点 CPU 高 | 热点槽/大租户 | 拆 tag 或拆租户 |
| 扩容很慢 | 大 key | 先拆大 key |
| 迁移期错误率上升 | ASK 处理错 |
修客户端 |
第二项最讽刺:"为了解决跨槽错误"而加的 tag,把集群变成了单点第二项的"检测"是"看"单节点 key 数占比"":超过 50% 就能判断"分片失效"——而"整体 QPS 能跑"会掩盖它,直到"单节点内存打满"。
——于是"能跑"但"没有分片收益"。
最后一个"容量规划"视角:槽数固定(16384),节点数可变——于是"节点越多,每节点槽越少",而"槽不是瓶颈,内存与 QPS 才是"。
// 三种规模的建议
< 30GB:3 主 3 从(每主约 16384/3 槽)
30–100GB:6–9 主(每主 5–10GB)
> 100GB:按"业务域"拆集群,而不是"在一个集群里加节点"
// 判据:单节点内存与连接数,而不是"槽的均衡"
7. 边界:什么情况不必关心槽
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单节点(非集群) | 无槽概念 | 不管 |
| 只用单 key 命令 | 无跨槽需求 | 不管 |
| 代理模式(如 Proxy) | 代理处理槽 | 看代理配置 |
| 数据量小(< 1GB) | 分片收益小 | 可用单节点/主从 |
最后一条与"客户端"有关的边界:用"集群感知"的客户端(Lettuce/Jedis Cluster)而不是"单机客户端 + 代理"——前者会"跟随 MOVED",后者依赖代理实现,而"代理对多 key 命令的支持"各不相同。
判断口径一句话:"你的业务里有没有"必须一起操作的多个 key"。有(批量取、事务、Lua),就必须设计 hash tag 让它们同槽;没有(都是单 key 操作),槽的事交给默认散列。
8. 自测
- 三个基本事实里,哪一条解释了"集群模式不能用
SELECT"? CROSSSLOT的三种触发里,哪一种最难改?为什么"多 key 命令"要求同槽?- hash tag 只对哪部分生效?"过度分组"会造成什么后果?
- 为什么"大 key"会拖慢扩容?扩容前该做什么检查?
MOVED与ASK的处理差别是什么?把ASK当MOVED会怎样?- 判断"是否需要设计 hash tag"的那个问题是什么?