
如果你也是刚从单机 Redis 平滑切到 Redis Cluster 的那么第一周大概率会在日志里看到满屏的 CROSSSLOT 报错。我接手这套系统的时候报错日志那个量级已经不是刷屏而是直接把日志文件撑到几个 GB吓得我以为是客户端连接爆了。当时群里所有人第一反应都是Redis 集群是不是有问题结果排查下来问题出在我们自己写的业务代码上。单机时代 Redis 什么事都干得漂亮切到集群之后原来那些一条命令操作多个 key 的写法全被 Cluster 的 slot 一致性校验拦下来了。这篇文章不打算给你复述官方文档我直接把我排查、定位、改代码的完整过程写出来包括为什么单机没问题、哪些命令最容易踩雷、怎么用 hash tag 解决问题、什么情况下必须改业务逻辑最后是我自己踩过的一些坑和预防手段。1. 先弄明白它拦的是什么CROSSSLOT 背后的 slot 校验逻辑1.1 报错文本不是废话每一段都在告诉你答案很多人在日志里看到ERR CROSSSLOT Keys in request dont hash to the same slot就直接关掉了觉得这只是集群不给过的粗鲁提示。其实这句话拆开看信息量非常大。Keys in request指的是同一条命令、同一个请求里出现的多个 key。dont hash to the same slot这些 key 经过 CRC16 计算后没有落在同一个 hash slot 上。完整语义是只要一条命令里涉及多个 keyRedis Cluster 就要求它们必须在同一个 slot 里否则直接拒绝执行。Redis 在集群模式下执行多 key 命令前会先做一次 slot 一致性预检查这一步发生在命令真正执行之前。所以它不是尝试执行然后报错而是一看 key 列表跨 slot直接不执行。这也是为什么集群模式下命令失败得非常干脆几乎没有任何重试的余地。1.2 单机不炸、集群炸根子就在 hash slot 一致性单机 Rediscluster-enabled no或者你只是连了一个 standalone 节点没有 slot 的概念。所有 key 都存放在同一个进程里同一个内存空间。你执行MGET user:1 user:2 user:3服务端直接并行取值没有任何限制性能还很高。切到 Redis Cluster 之后数据被散列到 16384 个 slot 上每个 slot 由某个主节点负责。key 到 slot 的映射规则非常简单粗暴slot CRC16(key) % 16384这里有一个容易忽略的细节参与计算的是 key 字符串本身所以user:1和order:2这两类 key除非运气好到极点否则基本会被散落到完全不同的 slot甚至落到不同节点上。Cluster 为了保证数据一致性和跨节点操作的可行性必须在命令执行前校验如果一条命令涉及两个或多个 key而这些 key 的 slot 不一致那服务端连尝试执行都不会直接抛出 CROSSSLOT。这就是为什么单机切集群后满屏都是 CROSSSLOT不是 Redis 集群本身有问题而是我们原来在单机上写的那批多 key 操作从未考虑过数据分片约束。1.3 一个关键误区同节点不代表同 slot排查过程中我发现一个特别普遍的误解不少同事以为只要这些 key 在同一个节点上mget 就能执行。这个认知是错的。slot 和节点不是一一绑定的固定关系而是一对多的映射。一台 Redis 节点实例会负责多个 slot比如节点 A 可能同时持有 slot 100、101、102 等几百个 slot。但如果一个 mget 里的 key 分散在 slot 100 和 slot 200即使这两个 slot 恰好都归节点 A 管Redis Cluster 依然会报 CROSSSLOT。官方在实现 cluster 命令处理时校验的是所有 key 映射到同一个 slot而不是所有 key 映射到同一个节点。我一开始也想取巧把相关 key 都压到同一台机器上后来发现根本没用。顺便说一句16384 这个 slot 数量和节点数没有关系它是固定的分片粒度集群扩容缩容时只迁移 slot 不迁移节点实例本身。2. 满屏报错的真正来源哪些命令和业务场景最容易中招2.1 MULTI-KEY 命令全家桶都是高危对象我先列一下最容易触发 CROSSSLOT 的命令类型。看到这个名单你应该很有亲切感因为单机时代几乎都这么写过命令场景是否容易跨 slotMGET / MSET批量读、批量写必中DEL 多个 key清理缓存一次删多个看运气EXISTS 多个 key判断多个 key 是否存在看运气SINTER / SUNION / SDIFF集合运算必中SINTERSTORE / SUNIONSTORE集合运算后写目标 key必中ZUNIONSTORE / ZINTERSTORE有序集合聚合计算必中RENAME / RENAMENXkey 改名必中BLPOP / BRPOP 多 key阻塞式列表弹出看运气PFCOUNT 多 key布隆过滤器统计合并必中最典型的就是 MGET。你如果是从 Spring 项目切集群RedisTemplate 的multiGet方法底层就是 MGET只要你的业务代码一次性传进去一坨 id而这些 id 对应的 key 又不在同一个 slot报错几乎是指数级爆发的。2.2 Lua 脚本跨 key 读写是重灾区比 MGET 更隐蔽的是 Lua 脚本。单机 Redis 上 Lua 脚本最大的价值是原子性比如扣库存、加积分、防超卖这些逻辑很多团队都是写一个 Lua 脚本在服务端完成判断加写操作又快又安全。但 Redis Cluster 对 Lua 脚本的约束和 MULTI-KEY 命令一样脚本里所有读写的 key 都必须通过KEYS参数传入并且这些 key 必须落在同一个 slot。什么意思就是你在脚本内部写死 key 名比如local a redis.call(get, stock: .. KEYS[1])这种或者脚本内同时操作user:100和order:100都有可能直接触发 CROSSSLOT。我见过很多团队用 Lua 做秒杀扣库存key 分别是stock:{skuId}和order:{skuId}如果这两个 key 不带 hash tag天然分布在不同 slot脚本一执行就报 CROSSSLOT。而且 Lua 报错的排查比普通命令难得多因为日志里只会告诉你脚本执行失败不会告诉你具体是脚本里的第几行出的问题。你只能把脚本拿出来一个一个 key 检查 slot。2.3 事务与 Pipeline你以为的不相关其实是组合拳MULTI/EXEC 事务在 Cluster 里也有限制事务内所有被操作的 key 必须映射到同一个 slot。很多人事务里喜欢写先取用户、再改用户订单状态这种跨业务域的代码单机 Redis 上跑得飞起切集群后直接整段失败事务回滚都来不及。Pipeline 的情况稍微特殊一点。Pipeline 本身是客户端批处理机制可以连续发多条命令不需要所有命令的 key 都同 slot。但如果你在 Pipeline 里塞了 MGET 这类 MULTI-KEY 命令那这批 pipeline 里面所有命令会以第一条出错命令为界被中断。我遇到过的情况是一个循环里用 pipeline 批量写缓存里面混了几个 MGET结果整个 pipeline 全废了日志刷屏上游接口超时看起来特别像是 Redis 挂了。所以排查 CROSSSLOT 不能只盯单个命令要看调用链里有没有组合拳。3. 从刷屏到溯源我实际使用的定位方法3.1 第一步不是看代码是先给报错做一次聚类遇到满屏 CROSSSLOT最先要做的不是翻代码而是把报错日志按命令 key 列表做一次统计聚类。我当时用了一个很笨但很有效的办法把生产日志按小时切割用脚本把包含CROSSSLOT的日志行抓出来再把 key 部分提取出来排序去重统计出现频次。聚类之后你会发现看起来满屏的报错其实只来自少数几个接口、少数几个工具类。比如我那次80% 的 CROSSSLOT 报错都集中在一个叫UserBatchQueryService的老代码里另一个 15% 来自一个做推荐去重的 Lua 脚本。剩下 5% 是零零散散的散弹枪式调用。先聚类再排查可以节省大量时间。不要看到满屏报错就慌报错的数量和根因的数量通常不是对等的。3.2 用 CLUSTER KEYSLOT 验证 key 的 slot 归属定位到具体 key 之后需要验证它们到底是不是真的跨 slot。最直接的方法是连上集群任意一个节点用CLUSTER KEYSLOT分别查看每个 key 的 slot 值redis-cli -p 6379 CLUSTER KEYSLOT user:100 redis-cli -p 6379 CLUSTER KEYSLOT order:100CLUSTER KEYSLOT 的结果是一个数字。如果两个数字不同那这条命令必然触发 CROSSSLOT如果相同说明这几个 key 天然落在一个 slot 里。这个命令在排查阶段非常好用尤其是你准备给 key 加 hash tag 之前可以用它确认改写之后所有 key 确实归一了。3.3 顺着调用链抓跨 slot 组合的代码位置光知道 key 跨 slot 还不够得知道是哪段代码把它们拼在一起的。对普通命令可以直接在代码仓库里搜 MGET、SINTER、ZUNIONSTORE 这类关键字对 Lua 脚本要全局搜索evalsha、eval把脚本内容和 KEYS 参数列出来。这里有个经验单机时代大家写 key 都特别随意key 前缀往往代表业务含义而不是会被一起操作的集合含义。比如user:info:{id}、order:info:{id}这两个 key单机 Redis 上经常被同时 GET但语义上它们属于两个业务域。切集群后这种语义相关但 key 前缀不同的组合最容易出问题。排查时建议把 key 设计原则整理成一张表哪些 key 是经常被一起操作的这些 key 的前缀能不能统一或者能不能通过 hash tag 绑定。这一步做完后续修复才有依据。3.4 一个真实例子运营后台的批量查询工具类我之前线上最典型的案例是一个运营后台接口调用 Redis 时传入一组用户 id代码大概长这样ListString keys userIds.stream() .map(id - user:profile: id) .collect(Collectors.toList()); ListObject profiles redisTemplate.opsForValue().multiGet(keys);这段代码在单机上编译不过几行线上跑了两年一点问题没有。切集群之后接口直接 100% 报错运营点一次日志里就刷十几条 CROSSSLOT。我用CLUSTER KEYSLOT试了十几个 keyslot 全部不同确认是典型的跨 slot 批量读取。这类工具类代码因为封装得深排查的时候要顺着 service - mapper - cacheUtil 一层层往下翻才能看到真正的 MGET 调用非常容易被忽略。4. 方案一hash tag把相关 key 绑进同一个 slot4.1 写法与绑定粒度hash tag 是 Redis Cluster 内置的 key 路由钩子它允许你在 key 里用花括号指定一小段字符串作为 CRC16 的输入内容。官方规则是如果 key 中包含{...}并且花括号之间有内容那么参与哈希计算的只是花括号里面的字符串。举个例子order:{20240501}:items order:{20240501}:status花括号里都是20240501所以这两个 key 会被计算成同一个 slot。无论 Redis Cluster 怎么分片它们都会落到同一个节点。使用 hash tag 的原则很简单只给会被同一个命令、同一个 Lua 脚本操作的一组 key加同一个 tag。比如秒杀场景里stock:{skuId}和order:{skuId}加同一个{skuId}就可以让 Lua 脚本内的 key 全部落进同一个 slot。4.2 别把所有 key 都塞进一个 tag热点和数据倾斜我见过最夸张的修复方式是有人图省事把所有 key 全部改成了{common}:user:xxx这种形式。这样确实所有 key 都进同一个 slotCROSSSLOT 报错瞬间清零但代价更大——所有读写请求全部路由到同一个 slot也就是同一个节点上Redis Cluster 的分片能力彻底被阉割。一个 slot 最多承载 16384 分之一的数据量理论上它的 CPU、内存、网络带宽是有限的。把所有 key 都绑到一个 tag 里等于把整个集群的巨大流量打到了一个节点上。表现就是集群总 CPU 利用率并不高但某一个 master 节点的 CPU 被打满其他节点全部空闲。这就是典型的数据热点倾斜比 CROSSSLOT 可怕多了因为它不会立刻报错只会让整个集群表现为响应越来越慢。所以 hash tag 的正确做法是绑定粒度尽量细。以业务维度拆分比如按用户维度、按订单维度、按商品维度而不是按全公司维度。4.3 hash tag 在 reshard、热点场景下的连带风险hash tag 还有两个容易被忽略的连带问题。第一集群扩容缩容reshard时slot 是整体迁移的。如果你把大量 key 绑在了同一个 tag 上这些 key 会作为同一个 slot 的一部分被整体迁移到新节点。迁移期间这个 slot 上的读写会被阻塞或延迟等于你迁移了非常多的 key 全部集中在一个 slot 上阻塞影响面会被放大。第二hash tag 会导致 slot 数据量不均。集群的存储均衡是按 slot 个数和 slot 实际规模混着评估的如果一个 slot 里的 key 数量特别多触发迁移后迁移那一个 slot 的数据量可能比其他一百个 slot 加起来还大迁移时间会拖得很长。我后来给团队定的规范是hash tag 里绑定的 key 总量最好控制在某个业务对象下的几十个以内比如一个订单的 redis key 有 items、status、logistics、audit 这几个绑同一个订单号可以但不要把所有订单的数据都往同一个 tag 上堆。5. 方案二改命令和业务逻辑从根本上绕开 CROSSSLOT5.1 MGET/MSET拆成并发单 key 请求对批量读场景最通用的改造是把 MGET 拆成多个 GET用并发的方式发出去。比如在 Java 项目里可以用 CompletableFuture 或者线程池并发提交ListCompletableFutureObject futures userIds.stream() .map(id - CompletableFuture.supplyAsync( () - stringRedisTemplate.opsForValue().get(user:profile: id), executor)) .collect(Collectors.toList()); ListObject profiles futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());单机时代你可能觉得这样效率不如 MGET但在集群模式下单 key 命令可以天然地并行路由到不同节点整体耗时往往比串行 MGET 更快。而且单 key 命令永远不会触发 CROSSSLOT网上很多文章说集群里用并发代替 mget是有道理的。如果你用的是 lettuce它内部有一个智能路由机制并发发送单 key 命令时会自动按 slot 路由到对应节点不需要你手动指定。这一点对业务代码的侵入性极小。批量写 MSET 也是一样拆成多个 SET让客户端自己去并发。如果一定想保留批量语义则必须保证所有 key 都在同一个 slot这时就要配合 hash tag 使用。5.2 Pipeline 和批量命令按 slot 分组分批Pipeline 里的多条命令如果 key 分散在不同 slot严格来说不会报 CROSSSLOT除非里面混了 MULTI-KEY 命令但客户端在集群模式下会按 slot 分组把同一个 slot 的命令聚合到同一连接里。如果你的 pipeline 一次提交了一堆分布很散的 key路由效率会差很多。实际改造时可以按 key 的 slot 做一次分组把同一个 slot 的命令组成一个小 pipeline不同 slot 的 pipeline 再用多线程并发发送。MapInteger, ListRedisCmd slotGroups cmds.stream() .collect(Collectors.groupingBy(cmd - clusterSlot(cmd.getKey()))); // 对每个 slot 分组分别执行 pipeline各组之间并发这个改法对普通 GET/SET 场景效果明显但要注意如果同一批数据天然被一个大 key 操作比如集合运算强绑定分组的意义就不大应该优先考虑 hash tag 或者改存储结构。5.3 Lua 脚本显式声明 KEYS缩小操作范围Lua 脚本是 Cluster 里约束最严格的部分因为它要求脚本中所有被访问的 key 都必须通过KEYS参数显式传入不能写死 key 名而且这些 key 必须落在同一个 slot。改造思路有两种。第一种缩小脚本操作范围。如果一个脚本同时操作了用户维度和订单维度的 key尝试把它拆成两个脚本一个只操作用户维度一个只操作订单维度。拆开之后如果业务上确实需要原子性再用 hash tag 把相关 key 绑在一起。第二种显式声明 KEYS。原脚本如果写死了 key 名要改成通过 KEYS 传入-- 错误写法key 名写死 local stock redis.call(get, stock: .. KEYS[1]) local order redis.call(get, order: .. KEYS[1]) -- 正确写法所有 key 通过 KEYS 传入并且保证 hash 到同一 slot local stock redis.call(get, KEYS[1]) local order redis.call(get, KEYS[2])调用方传 KEYS 时必须保证这两个 key 落在同一个 slot。否则脚本照样 CROSSSLOT。所以这种改法的前提还是 key 设计上要配合 hash tag。5.4 集合运算和事务客户端聚合替代服务端聚合SINTER、SUNION、ZUNIONSTORE 这类集合运算在 Cluster 里是最难处理的因为它们天然就是 multi-key 操作。最彻底的方案是不要在 Redis 服务端做聚合而是把集合数据拉到客户端在应用内存里做运算。以 ZUNIONSTORE 为例一个排行榜需求可能要合并近 7 日和近 30 日的榜单单机上一条命令解决集群上做不到。改造思路是先分别取出两个集合的数据各自用单 key 命令然后在应用内存里做合并排序最后写回一个新的 key。这种做法的缺点是数据量大时网络开销会变大。但如果业务本身对一致性要求不高、排行榜这类数据可以接受秒级延迟完全可行。Redis 官方在 Cluster 模式下对集合运算类命令的推荐做法就是客户端聚合或者提前把数据冗余存储成同一个分片下的多个 key。至于 MULTI/EXEC 事务在 Cluster 里最稳妥的方案是改成 Lua 脚本脚本本身具备原子性或者把事务内操作的 key 通过 hash tag 绑定。如果两者都不行就要接受拆分事务、降低一致性要求。6. 迁移前预防把 CROSSSLOT 挡在上线前6.1 key 设计与 slot 健康度审查这次踩坑之后我们复盘发现CROSSSLOT 报错本质上不是 Redis 的问题而是key 设计从未考虑过集群分片的历史债。所以迁移前最该做的一件事对现有代码里的所有 Redis 操作做一次 key 设计审查重点排查 MGET、Lua、事务、集合运算这几类高危调用评估它们的 key 分布情况。排查基线很简单这个命令涉及几个 key这些 key 是不是固定前缀 单一维度比如都是 user 维度如果是多维度能不能用 hash tag 绑定如果不能用 hash tag能不能改客户端聚合审查结果应该输出一份高风险调用清单在上线前逐条整改。6.2 灰度压测与日志监控迁移不能一把梭。我当时比较后悔的是直接全量切换流量导致刷屏报错直接打爆监控。后来我们改为先灰度 10% 的流量并且把 Redis 客户端错误日志单独接出来按错误类型做告警。CROSSSLOT 报错单独设置一个告警阈值一旦超过每分钟 N 条立刻拉响。压测阶段要特别注意单机 Redis 响应快的接口切集群后即便没有 CROSSSLOT因为路由关系会有额外的网络往返整体 RT 会上升。如果有大量跨节点操作RT 上升更明显。所以压测不仅要看功能是否通过还要对比单机和集群的耗时基线提前暴露性能退化。另外建议在测试环境开启redis-cli --cluster check检查 slot 分布和 key 分布。这是比较容易被忽略的步骤因为大家迁移时只关注数据在不在不关注key 散得均不均匀。6.3 客户端与服务端参数的配合CROSSSLOT 报错还有一个放大器就是客户端配置。如果你用的是 Jedis Cluster 模式它会自动把 multi-key 命令前置检查某些场景下能提前拦截错误避免无效请求打到服务端。lettuce 也是一样开启集群模式后客户端会对 multi-key 命令做 slot 一致性检查如果发现不满足直接本地抛异常不会再白白消耗一次网络请求。所以切集群的第一个动作是把所有客户端连接改成 Cluster 模式而不是继续用单机模式连接。很多团队切了集群但客户端还在用 standalone 连接报错信息全走服务端日志刷屏就特别严重。改成 Cluster 模式后一部分 CROSSSLOT 会在客户端本地被拦下来至少日志压力会小很多。我的经验是CROSSSLOT 这种问题越早暴露越好千万别想着上线之后靠监控兜底。它不像连接超时那样偶尔抖动一下而是你代码里写一次线上就会稳定地报一次并且随着调用量增长线性放大。提前把高危 key 组合排查清楚把 hash tag 规范落到代码评审里比事后救火高效得多。这个项目折腾完给我最深的体会是Redis 单机能干的活太多容易让人养成先写完再说的习惯但集群模式逼着你把数据之间的关联关系想清楚key 的前缀、后缀、是否需要绑定都在下笔之前就要定好。CROSSSLOT 只是表象真正的问题是我们对数据分片缺少敬畏。如果你也在迁移路上被这个报错折磨不要慌按上面的顺序查一遍大概率能在一两个小时内定位到底剩下的就是痛下决心改代码。