凌晨两点半,DBA 群里突然弹出一条消息:集群切换完成,业务方请尽快验证。我还没来得及回完“收到”,监控大屏就飘红了。服务端日志从 INFO 瞬间刷成一片 ERROR,密密麻麻全是同一行——CROSSSLOT Keys in request don't hash to the same slot。那几分钟里,线上缓存读取成功率从 99.95% 一路掉到 60% 都不到。
说实话,从单机 Redis 切到 Redis Cluster 的那一刻,我就知道大概率会有这一劫。单机跑了三年多,代码里到底藏了多少多 key 操作,没人能拍胸脯保证。但真正面对满屏 CROSSSLOT 的时候,光有心理准备是远远不够的,你得搞清楚它到底为什么冒出来,怎么快速定位,又该怎么稳准狠地改掉。这篇就把我这次从“切集群被报错轰懵”到“拆完所有雷”的完整过程复盘出来,给正要切集群、或者已经踩进同一个坑的同学做个硬参考。
1. 迁移背景:单机 Redis 为什么必须切 Cluster
1.1 单机 Redis 的三个硬瓶颈
先说动机。一个项目从 Redis 单机起步很正常,部署简单、API 顺手、事务和 Lua 想怎么用就怎么用。但业务量上来之后,单机的天花板会卡得你很难受:
- CPU 单核瓶颈:Redis 的命令执行本质是单线程模型(6.x/7.x 的 IO 多线程只是优化了网络读写,命令执行仍然是单线程的)。一个实例的 QPS 是有上限的,扛到每秒十万级请求之后,任凭你怎么调优,单核 CPU 先打满。
- 内存扩容受限:单机内存加到 256G、512G 不是不行,但成本极其离谱,而且大内存实例的 RDB 持久化、主从同步都会变慢,故障恢复的时间也会被拉得很长。
- 写能力无法水平扩展:读流量可以挂从库解决,但写流量只能落到主节点上。一旦写成为瓶颈,单机的命运基本就走到头了。
Redis Cluster 解决了这三个问题。数据按照哈希槽自动分布到多个节点,每个节点只承担一部分总数据量,CPU 和内存都能横向扩展。所以在数据量突破单机上限、或者写 QPS 冲不上去的时候,切 Cluster 是几乎是唯一正解。
1.2 切换当晚的报错实况
迁移方案其实做得不算草率。数据用同步工具搬过去了,集群cluster info显示状态 ok,cluster nodes里主从也都正常。DBA 确认redis-cli --cluster check通过后,就把客户端连接串从单机地址切到了集群地址。
问题发生在我预期范围之内,但规模超出想象。
切完之后,首页缓存接口第一个炸了。那个接口的逻辑是拿一批用户 ID 去MGET一堆user:{uid}:profile的 key。单机版的时候,这把梭哈没有任何问题,因为所有 key 都在同一台机器上,MGET一条命令就搞定了。切到 Cluster 之后,每个用户 ID 计算出的哈希槽完全不一样,落在不同节点上,Redis 直接甩了一个CROSSSLOT回来。
紧接着,订单详情页也报了。一个 Lua 脚本里同时读了订单状态、订单地址、订单商品列表三个 key,然后做逻辑判断。这个脚本在单机时代是原子操作,切了之后连脚本都执行不了。
那段时间报错刷屏的速度,比日志采集管道消费的速度还快。我盯着屏幕,脑子里就一件事:必须马上搞清楚这些报错都是从哪几条业务链路来的,然后把全网的“多 key 操作”翻个底朝天。
2. CROSSSLOT 的底层机制:为什么多 key 命令会被拦截
2.1 16384 个哈希槽与 CRC16 计算
Redis Cluster 里所有 key 都不是按节点直接分配,而是被映射到 16384 个哈希槽中。每个主节点负责其中一段连续的槽位范围,比如节点 A 管0-5460,节点 B 管5461-10922,节点 C 管10923-16383。
计算方式很简单:对 key 做 CRC16 校验,然后对 16384 取模。
HASH_SLOT = CRC16(key) % 16384
这里有个容易踩的坑:很多人觉得 CRC16 是随便拿一个库函数就能算的。实际上 Redis 使用的是CRC16-CCITT(多项式0x1021),不是常见的 CRC16/IBM(多项式0x8005)。如果你自己写巡检脚本统计槽位分布,用错了多项式,结果会差很多,最好直接参考 Redis 源码里的crc16.c或者 redis-cli 的cluster keyslot命令来核对。
至于为什么是 16384 而不是 65536,官方作者也解释过:集群节点之间的心跳消息需要携带槽位 bitmap,16384 个槽只需要 2KB 的 bitmap,65536 个槽要 8KB,网络开销会明显变大。对于绝大多数集群规模来说,16384 个槽已经足够了。
2.2 单机版和 Cluster 版对同一命令的判定差异
这是理解 CROSSSLOT 的关键。
在单机 Redis 里,所有 key 都存在于同一个进程,MGET key1 key2这种命令天然就在一个“节点”内,Redis 不需要对 key 的归属做任何校验,直接全部查完返回。
切换到 Cluster 后,一条命令如果涉及多个 key,Redis 必须先做一件事:检查这些 key 是否都在同一个哈希槽。只有都在同一个 slot,才能保证这个命令可以在一个节点上本地完成。如果有任何一个 key 落在了不同槽位,节点就会直接拒绝执行,返回:
(error) CROSSSLOT Keys in request don't hash to the same slot
注意,这个错误是在命令执行前就被拦截了,不会出现“部分成功部分失败”的情况。Redis 集群的每个节点在收到命令时,都会调用内部的多 key 校验逻辑,一旦发现槽位不一致,立即终止。
简单类比一下:单机版就像你自己家里一台大冰箱,所有菜都放里面,做个全家宴随手拿。Cluster 版则是把菜分放到三个厨房,一个厨师在一号厨房炒菜,要对着一号厨房的锅喊“把二号厨房的酱油和四号厨房的葱拿过来”,这当然不成立。你只能先把这些材料全部搬到自己同一间厨房,再下锅。
2.3 触发 CROSSSLOT 的命令类型小结
并不是所有命令都会触发 CROSSSLOT。单 key 的命令完全不受影响,比如GET、SET、EXPIRE、HSET、LPUSH等,Cluster 的客户端会自动计算槽位并把请求路由到对应节点。
真正危险的是这些:
- 多 key 读写命令:
MGET、MSET、MSETNX、DEL、UNLINK、EXISTS、RENAME、RENAMENX - 集合间操作:
SINTER、SUNION、SDIFF、以及带STORE后缀的变体 - 阻塞/弹出类:
BLPOP、BRPOP、BRPOPLPUSH(多个 key 时) - 管道与脚本:Pipeline 中如果多个命令涉及不同槽位,服务端逐条处理时会对跨槽命令报错;
EVAL/EVALSHA中所有 key 也必须落在同一 slot - 事务:
MULTI/EXEC内的所有 key 同样要求同槽
还需要区分一个概念:MOVED和CROSSSLOT不是一回事。MOVED是单 key 命令被客户端发到了不负责该槽位的节点,Redis 返回“这个槽归另一个节点管,你去那边重试”,这是正常的重定向。而CROSSSLOT是命令本身在多 key 维度就非法了,重定向救不了。
3. 现场复盘:哪些业务代码最容易踩中 CROSSSLOT
3.1 MGET 批量预热:最典型的报错源头
先说最典型的:缓存批量预热。
我们首页有一个“关注列表动态”接口,用户打开 App 后,服务端根据用户关注的主播 ID 列表,去 Redis 批量拉取这些主播的在线状态和封面地址。原来的代码长这样:
keys = [f"anchor:{anchor_id}:online" for anchor_id in anchor_ids] online_flags = redis.mget(*keys)单机时代,这个MGET始终正常,QPS 高的时候一勺烩非常香。切集群之后,anchor:1001:online和anchor:1002:online分属不同槽位,接口瞬间大量报错。
这里还有一个隐蔽的问题:即使你只是用了MGET,但当前 key 碰巧在同一槽位,命令也会成功,这会让很多人在压测时误以为“没事”。一旦数据量变化、新 key 加入,槽位分布一变,CROSSSLOT 就可能突然爆发。所以排查不能靠运气,要按规则改。
3.2 DEL/UNLINK/EXISTS 批量操作:被忽略的雷区
比 MGET 更容易被忽略的是DEL和EXISTS。
我们当时有个定时任务,每天凌晨清理过期的用户 session key。逻辑是先用SCAN把符合条件的 key 捞出来,然后调UNLINK一次性删掉。
batch = [] for key in scan_result: batch.append(key) if len(batch) == 100: redis.unlink(*batch) # 批量删除这段代码在单机版同样跑得毫无波澜。切集群后,UNLINK后面的 key 大多数跨槽,报错刷了一整屏。这个问题比 MGET 更隐蔽,因为很多人认知里“删除”只是清理动作,没料到也会校验槽位。
同理,EXISTS k1 k2这种快速判断多个 key 是否存在的高频操作,也会踩雷。比如“批量判断用户是否在线”用的就是这种写法,线上客户端日志里能搜到大量报错。
3.3 Pipeline 与 Lua 脚本:连框架都救不了
再严重一点的是 Pipeline 和 Lua。
我们有一个数据采集服务,每秒钟会把一批埋点数据通过 Pipeline 批量写入 Redis。单机版时 Pipeline 把几十个SET/LPUSH攒在一起发给服务端,减少 RTT。切集群之后,Pipeline 里每个命令都会被路由到不同节点。如果你是用的 Jedis 或 Lettuce 的集群模式客户端,单 key 命令还好,客户端会按节点分组发送;但如果你在 Pipeline 里混入了多 key 命令(比如MGET),仍然会CROSSSLOT。如果你用的是普通客户端直连某个集群节点(没走集群模式),那连单 key 都会遇到MOVED重定向,逻辑就全乱了。
Lua 脚本的坑更重。我们订单详情页有个脚本,逻辑大体是:
local status = redis.call('GET', 'order:' .. KEYS[1] .. ':status') local address = redis.call('GET', 'order:' .. KEYS[1] .. ':address') local items = redis.call('LRANGE', 'order:' .. KEYS[1] .. ':items', 0, -1) return {status, address, items}这个脚本单机版是跨 key 原子操作,没问题。集群版有两个雷点:
- 脚本里所有 key 必须通过
KEYS参数传入,不能写死在脚本内部拼接字符串,否则集群节点无法计算槽位; - 即使通过
KEYS传入了,这些 key 也必须在同一 slot,否则依然CROSSSLOT。
当时我们用order:{order_id}:status、order:{order_id}:address、order:{order_id}:items这种带 hash tag 的形式改造后,三个 key 都落在order:{order_id}这个 tag 对应的槽位,Lua 脚本总算恢复了。
3.4 快速定位问题的排查思路
报错刷屏那晚,我是按这个顺序止血的:
- 看客户端日志里的错误堆栈:统计一下报错集中在哪条调用链、哪个命令。用 grep 或日志平台聚合,把报错最多的前 10 个操作分类。
- 顺着命令反查代码:
MGET、MSET、DEL、EVAL、Pipeline这些关键词在代码仓库里全局搜一遍,凡是传了可变长 key 列表的都要过一遍。 - 分析 key 前缀:报错虽然不带具体 key 名,但可以从服务端日志或者 Redis 的 monitor 里抓到命令和 key,再判断这些 key 是否已经带了
{tag}。 - 用 redis-cli 验证单个 key 的 slot:
redis-cli -c -p 7000 cluster keyslot anchor:1001:online,把可疑的 key 挨个槽位计算,确认是否跨槽。
这一步不是修复,是止血。真正的修复必须回到 key 设计和业务改造上,下面展开说。
4. 解决方案落地:hash tag 改造与命令拆分
4.1 hash tag 的规则与正确姿势
Redis Cluster 提供了 hash tag 机制,专门用来解决多 key 操作的问题。规则是:如果 key 里存在{和},Redis 只会用花括号中间的那一段内容来计算哈希槽。
比如下面这几个 key:
order:{210345}:statusorder:{210345}:addressorder:{210345}:items
它们的槽位计算方式都是基于210345这个 tag 做的,所以三个 key 必然落在同一个 slot。这时候无论MGET、DEL、还是 Lua 脚本,都能正常执行。
注意几个实现细节:Redis 取 tag 时是从左往右找第一个{,再找它后面的第一个},取中间内容。如果{}里面是空的,则忽略 tag,整个 key 参与哈希计算。如果 key 里没有{},也是整个 key 参与计算。
尤其要避开一个错误姿势:把业务标识放在花括号外面,比如user:1001:{profile}。这样 tag 是“profile”,所有 profile 相关 key 都挤在同一个槽,不仅解决不了问题,还会造成数据热点。
正确做法是把业务维度 ID放进花括号:
- 用户维度:
user:{1001}:profile、user:{1001}:cart - 订单维度:
order:{210345}:status、order:{210345}:items - 设备维度:
device:{mac}:heartbeat
4.2 key 设计规范:从源头消灭跨槽
如果项目还在架构设计阶段,我强烈建议一开始就把 key 规范定清楚。这次踩坑之后,我们团队定了三条铁律:
第一,同一个业务实体的多个属性,要么用 hash tag,要么直接用 Hash 数据结构。
比如订单有状态、地址、商品列表,与其拆成三个 String/List key,不如直接用一个 Hash:
order:{210345} field: status field: address field: items一个 Hash key 天然只有一个槽位,HGETALL、HMGET全部单 key 操作,彻底告别 CROSSSLOT。这个方法不是把问题掩盖,而是从数据结构层面消解了多 key 的存在。
第二,需要批量操作的数据,尽量保证它们的 tag 一致。
比如用户关注列表里要批量展示的主播信息,是主播维度数据,本来就该按主播 ID 打 tag。如果业务上确实是同一批主播,那在访问时把这些 key 的 tag 做成同一个业务分组字段(比如anchor:{batch_id}:{anchor_id})——注意这是取舍,不能为了统一 tag 导致全部数据落到同槽热点。大部分情况下,用业务自身的 ID 做 tag 已经够均匀了。
第三,禁止跨业务维度强行共置 tag。
比如把所有 key 都加{common}前缀,表面上所有 key 都在同一槽,实际等于把整个 Redis Cluster 退化成了单机容量。切集群的意义全没了,而且单点槽位成为热点后,性能还不如单机。tag 的取值粒度要能撑开足够多的槽位。
4.3 命令拆分的实操细节与取舍
不是所有场景都能用 hash tag 解决,尤其是历史代码一时改不完,或者 key 的 tag 设计本身已经无法改动。这时候命令拆分是兜底方案。
MGET 拆成 GET:最简单粗暴,循环发单个 GET。代价是 RTT 上升、QPS 上涨。如果业务对耗时敏感,就要配合本地缓存或批量并发来缓解。
# 改造前 values = redis.mget(*keys) # 改造后(并发 GET,保持响应速度) import asyncio async def get_many(keys): tasks = [redis.get(key) for key in keys] return await asyncio.gather(*tasks)Pipeline 分批:Pipeline 里如果混了跨槽命令,可以把同一个槽的命令聚成一个批次,跨槽的拆成多个批次发送。需要自己在客户端代码里实现槽位分组逻辑,或者用支持槽感知的客户端扩展。这个改动量不小,一般只对热点批量链路做。
DEL/UNLINK 分批:把跨槽的 key 按槽位分组,然后逐组删除。或者干脆退化成单 key 循环删除,配合管道也能接受。
Lua 脚本改造:所有 key 必须通过KEYS参数传入,且必须保证同槽。如果业务上确实无法同槽,就把脚本里的跨 key 逻辑拆出来,用多次调用加分布式锁来保证一致性——这属于对业务逻辑的更深改造,要谨慎评估。
4.4 客户端与中间件的兜底方案
如果公司有统一中间件团队,可以在客户端 Repository 层做一层封装。比如基于 Redis Cluster 客户端封装一个multiKeyCommand方法,底层自动判断 key 是否同槽,不同槽的情况下自动拆成多个单 key 命令再聚合结果。这样业务方不需要在每行代码里考虑槽位问题,只在调用批处理 API 时透传 key 列表即可。
这个方案的上限不低,但工程量也摆在那边。对于大多数中小团队来说,优先做代码审计 + key 改造就够了,不必一上来就开发中间件。我们当时是先用临时补丁止血,花了一个月完成 tag 改造,再考虑沉淀组件。
另外提一嘴:不要幻想用代理层(比如 Twemproxy 或 Codis)来解决 CROSSSLOT,那些方案本质上会把 Redis 的分布式语义重新包装,很多高级命令依然受限制。最稳妥的方向还是让业务 key 设计符合 Cluster 的槽位规则。
5. 迁移前预案:检查清单、灰度与回滚
5.1 代码扫描与压测
这次血泪教训告诉我:切集群之前的代码审计不能走过场。
我们后来总结了一张关键命令扫描清单,在改造前就把下表中命令在代码仓库里全部搜一遍,逐条确认是否涉及多 key、是否带 hash tag:
| 扫描命令/关键词 | 风险等级 | 说明 |
|---|---|---|
| MGET / MSET / MSETNX | 高 | 直接跨槽 |
| DEL / UNLINK 可变参数 | 高 | 批量删除 |
| EXISTS 多参 | 中 | 高频批量判断 |
| RENAME / RENAMENX | 中 | 两个 key 必须同槽 |
| SINTER / SUNION / SDIFF | 中 | 集合运算跨槽 |
| BLPOP / BRPOP 多 key | 中 | 阻塞多 key |
| Pipeline 批量写入 | 高 | 槽位分组 |
| EVAL / EVALSHA | 高 | key 必须通过 KEYS 且同槽 |
光扫描还不够,要在测试环境起一个 Cluster,把真实的业务流量录制回放一遍,或者在压测工具里构造多 key 请求,让 CROSSSLOT 在测试期就暴露出来,别等线上切完再满天飞。
5.2 灰度切换与快速回滚
正确的主从切换姿势应该是分阶段的:
- 只读流量灰度:把十分之一的读流量切换到集群侧,观察错误率和延迟。这一步主要验证多 key 读命令是否已经被改造干净。
- 读写流量灰度:读验证没问题后,再逐步放开写流量。
- 全量切换保留回滚能力:旧单机 Redis 至少保留 24 到 48 小时,连接配置和 DNS 层面做好一键回切。一旦发现新问题,先把流量切回去,再慢慢排查,不要在线上顶着报错做调试。
我们在第一次全量切换时,因为提前保留了旧实例配置,发现问题后 5 分钟内就回切到了单机,业务先恢复,然后才轮到我们安心改代码。这个回滚能力,比任何压测都让人踏实。
5.3 迁移后监控
迁移完成后,光看主从状态和 QPS 是不够的。要在监控系统里加两个关键指标:
- CROSSSLOT 报错数:日志平台里对
CROSSSLOT关键字做实时计数,任何大于 0 的波动都值得立即查。 - 槽位分布与热点:定期跑
redis-cli --cluster check,结合 key 前缀分组统计各槽数据量,及时发现因为 tag 设计不当造成的哈希倾斜。
我们当时就是靠 CROSSSLOT 报错数从 0 迅速上涨这条监控,第一时间锁定了问题接口,没有等到用户大面积反馈才被动响应。
6. 避坑速查表与独家心得
6.1 CROSSSLOT 常见场景速查表
| 操作场景 | 单机版表现 | Cluster 版表现 | 推荐处理方案 |
|---|---|---|---|
| MGET 批量读 | 正常 | 跨槽报 CROSSSLOT | 同 tag 批量读;或拆 GET 并发 |
| MSET 批量写 | 正常 | 跨槽报 CROSSSLOT | 同 tag 批量写;或拆 SET |
| DEL/UNLINK 多 key | 正常 | 跨槽报 CROSSSLOT | 拆单 key 删除或按槽分组 |
| EXISTS 多 key | 正常 | 跨槽报 CROSSSLOT | 拆单 key 判断,缓存中间结果 |
| RENAME k1 k2 | 正常 | 跨槽报 CROSSSLOT | 保证同 tag,或拆成复制+删除 |
| SINTER/SUNION | 正常 | 跨槽报 CROSSSLOT | 把集合 key 做同 tag 设计 |
| Pipeline 多命令 | 正常 | 单 key 会自动路由;多 key 仍报 CROSSSLOT | 按槽分组,或避免多 key |
| EVAL 脚本 | 正常 | key 必须同槽,且通过 KEYS 传入 | hash tag 统一 key |
| MULTI/EXEC 事务 | 正常 | 事务内 key 必须同槽 | 尽量用 Lua 脚本替代事务 |
6.2 几条独家心得
最后分享几条这次踩坑后沉淀下来的经验,不是书本上能看到的。
第一,hash tag 不是银弹,设计粒度决定生死。我之前见过有人图省事,把所有 key 都写成{share}:xxx:yyy,结果所有 key 都集中到一个槽。表面上没有 CROSSSLOT 了,但那个槽所在的节点 CPU 被打满,其他节点空转。tag 的取值必须足够分散,最好用业务主键 ID。
第二,迁移改造要“先立后破”。先在新代码里按 Cluster 规范写,再一点点把旧代码的重灾区改造掉,而不是等待一个“完美时机”一次性切换。我们最后是花了两周时间把首页、订单、消息三条核心链路的 key 全部改成带 tag 的版本,再推第二步灰度,报错基本为零。
第三,把“单 key 多 field”作为默认选项。不少时候,多 key 操作的本质是在模拟一个对象。既然 Redis 有 Hash 结构,直接用hset order:210345 status 1、hset order:210345 address xxx一个 key 就能表达清楚。改用这种设计之后,跨槽问题直接从数据模型层面消失。我开始做 Redis 开发时也觉得多 key 更直观,经历过 CROSSSLOT 之后才明白,Cluster 时代的 key 设计思路应该整体向“实体化”靠拢。
第四,保留代码仓库里的命令审计工具。我们团队后来写了一个简单的 Python 脚本,可以扫描代码仓库里所有 Redis 调用,自动提取命令和 key 模式,把可疑的多 key 操作列出来。切集群这种事一次就够了,但以后每个新项目都要守住这条线,不能靠人肉记忆。
CROSSSLOT 报错其实不算什么复杂难题,它是一个信号:你的数据模型还没有跟上分布式存储的规则。搞懂哈希槽的机制,认认真真按哈希标签改造 key 设计,这场迁移带来的回报远不止“把报错修掉”这么简单——你的缓存体系从此具备了水平扩展的能力,这才是单机时代给不了你的东西。