Redis Cluster 批量操作限制与 Jedis/Lettuce 适配
从主从迁移到 Cluster,单 key 命令几乎无感,真正会出问题的是所有「一次操作多个 key」的能力:MGET、Pipeline、事务、Lua 脚本。它们在 Cluster 下都受同槽约束,绕过方式又因客户端而异。本文基于 Redis 7,把限制的边界、服务端行为和 Jedis、Lettuce 两个主流客户端的适配差异一次讲清。
一、CROSSSLOT:哪些命令会踩坑
Cluster 的规则只有一条:多 key 命令的所有 key 必须落在同一槽。MGET、MSET、DEL、EXISTS 直接报错:
(error) CROSSSLOT Keys in request don't hash to the same slot
这条规则的来源是服务端的执行模型。Cluster 中每个节点只持有部分槽,一条操作多个 key 的命令如果跨越了两个节点的数据,就没有任何单节点能完整执行它。Redis 不会替客户端做拆分,也不会像某些分布式数据库那样做两阶段提交,它直接拒绝。判断发生在命令分发之前:节点先算每个 key 的槽,只要有一个槽不在自己名下就返回错误,因此 CROSSSLOT 的响应时间极短,日志里看不到明显耗时,容易被误认为"命令没执行"。
哪些命令受约束,可以按是否声明了多个 key 来判断。常见的多 key 命令包括:MGET、MSET、DEL、EXISTS、UNLINK、SINTER、SUNION、SDIFF、PFCOUNT、PFMERGE、ZUNIONSTORE、ZINTERSTORE、BITOP、SMOVE、RENAME、RPOPLPUSH。其中 MGET、DEL 这类最容易在迁移时被遗漏,因为它们在主从架构下完全无害。
事务(MULTI/EXEC)与 Lua 脚本(EVAL 的 KEYS 数组)同样受此约束。此外还有一类更隐蔽的错误发生在迁槽期间:槽处于 MIGRATING 状态时,多 key 命令如果部分 key 已迁走、部分还在源节点,服务端返回 TRYAGAIN,客户端要等迁移完成或分批重试。自研客户端和低版本 SDK 通常只处理了 MOVED,漏掉 TRYAGAIN 的重试,表现为「平时没事,一迁槽就报错」。
二、批量读取的替代方案
方案一是 Hash Tag,把确实需要原子性的 key 规划到同一 tag(见槽位机制一文)。方案二是业务层按槽分组:先算每个 key 的槽,把同槽 key 归成一批,每批一次请求,最后按原始顺序合并结果。
// Java 17,Jedis 5.x:按槽分组再逐批请求
Map<Integer, List<String>> bySlot = new HashMap<>();
for (String key : keys) {
int slot = JedisClusterCRC16.getSlot(key);
bySlot.computeIfAbsent(slot, k -> new ArrayList<>()).add(key);
}
方案三是选一个自动拆分的客户端。Lettuce 的 RedisAdvancedClusterCommands 会把跨槽的 MGET/MSET 自动拆成按节点的子请求并行执行,再按入参顺序合并结果,业务代码不需要感知槽位:
// Lettuce 6.x:跨槽 mget 自动拆分、按序合并
StatefulRedisClusterConnection<String, String> conn = client.connect();
List<KeyValue<String, String>> kvs = conn.sync().mget(keys);
自动拆分看起来解决了问题,但代价必须算清。假设一次 MGET 涉及 1000 个 key,均匀分布在 3 个主节点上,实际发生的是 3 次往返而不是 1 次;如果集群有 30 个分片,理论上最多可能变成 30 次往返。串行执行时总耗时是各分片耗时之和,并行执行时则由最慢的那个分片决定。这就是为什么"自动拆分"在小集群上几乎无感,在大集群上可能比手工分组还慢——它把一次网络成本换成了 N 次,只是把复杂度藏进了客户端。
真正需要优化的场景是"一次取几百个 key"。此时更有效的做法是从设计上避免:把需要一起读的数据放到同一 tag,或者用一次 HGETALL 取回同一个小 Hash,而不是几百个独立的 String key。批量读的本质问题是"数据的物理布局与访问模式不匹配",客户端拆分只是在掩盖它。
Pipeline 在 Cluster 下同理:命令要按目标节点分发。Lettuce 的异步 API 天然支持跨节点批量发送;Jedis 5.x 起提供 pipelined() 返回 ClusterPipeline,跨节点批次会用线程池并行执行,5.x 之前的 JedisCluster 则没有 pipeline 能力,需要按节点取连接自行封装。
三、事务与 Lua 的替代思路
事务和 Lua 没有客户端层面的拆分方案——原子性无法跨节点伪造。可行的替代按优先级排列:第一,用 Hash Tag 把事务涉及的 key 绑进同一槽,这是最直接的;第二,把「跨多 key 的原子操作」拆成多个单槽 Lua 脚本加业务补偿,用幂等性换原子性;第三,重新审视设计,把强一致的部分移回数据库,Redis 只做加速。Lua 脚本本身还要遵守 KEYS 声明:脚本内只允许访问 KEYS 数组传入的 key,动态拼 key 在 Cluster 下要么 CROSSSLOT、要么行为不可控。
另外,Redis 7 引入的分片订阅(Sharded Pub/Sub)值得留意:SSUBSCRIBE/SPUBLISH 按频道名的槽路由,消息只发往负责该槽的节点,替代了 PUBLISH 在 Cluster 下的全节点广播,是 Cluster 下做事件通知的首选。
四、Jedis 与 Lettuce 的集群适配差异
| 能力 | Jedis 5.x | Lettuce 6.x |
| --- | --- | --- |
| 跨槽 mget/mset | 按槽分组多请求合并(早期版本要求同槽) | 自动拆分并按序合并 |
| Pipeline | 5.x 起提供 ClusterPipeline,旧版无 | 异步 API 原生支持 |
| 事务 | 不支持,直接抛异常 | 仅同槽可用,无跨节点封装 |
| 拓扑刷新 | 以 MOVED 触发为主 | 周期刷新加自适应触发 |
| 读写分离 | 需自行封装 | 内置 ReadFrom 策略 |
Lettuce 的自适应刷新能响应 MOVED、ASK 等事件主动更新槽位表,在频繁扩缩容的集群里优势明显;Jedis 的刷新被动依赖重定向,变更后首批请求会多一次往返。Lettuce 对同槽事务保持原生支持,JedisCluster 上 MULTI/EXEC 则直接抛异常——两家的 API 在事务上都不能跨节点。另外,新版 Jedis 正把集群能力迁移到 RedisClusterClient,升级依赖时注意 API 变化。
常见坑 / 最佳实践
- 不要滥用自动拆分掩盖性能问题。 Lettuce 一次 MGET 一千个跨槽 key,底层就是上百次网络往返,P99 反而劣化。控制单次批量在百级,配合业务侧分组。
- Lua 脚本只从 KEYS 数组取 key。 动态拼 key 违反声明式原则,迁槽期会返回 CROSSSLOT 或 TRYAGAIN,排查成本极高。
- 迁移窗口要验证 TRYAGAIN 重试。 上线 reshard 前在测试环境跑一轮,确认所用客户端版本能正确重试,必要时在业务层加退避重试。
- 只有真正需要原子性的 key 才进同一 tag。 用 Hash Tag 「支持」大规模 MGET 会造成数据倾斜,批量读交给客户端拆分更合适。
- 升级客户端版本前回归集群相关代码。 Jedis 事务抛异常、ClusterPipeline 的引入、RedisClusterClient 的迁移都是版本敏感点,集成测试要覆盖扩缩容场景。
小结
- Cluster 的铁律是多 key 命令同槽,覆盖 MGET、事务与 Lua;迁槽期还会出现 TRYAGAIN。
- 批量读的三条出路:Hash Tag、业务按槽分组、支持自动拆分的客户端。
- 原子性无法跨节点伪造,事务与 Lua 靠同槽 Hash Tag 或拆分加业务补偿解决。
- Lettuce 在自动拆分、pipeline 与拓扑刷新上适配更完整;Jedis 5.x 补齐了 ClusterPipeline,事务仍不可用。
- 客户端能力再强也只是分发层,key 设计是否合理才是 Cluster 批量性能的决定因素。
- 点赞
- 收藏
- 关注作者
评论(0)