redis集群模式下,根据key计算槽,从而得到该key位于哪个redis服务器上
批处理是建立一次连接,并批处理所有数据
集群下批处理的数据可能位于不同槽,不同服务器,因此一个连接处理不了,因此报错
解决方案:
1. 方案一:哈希标签(Hash Tags)------ "强行合群"
这是从 数据设计 层面解决问题的最快方法。
- 逻辑原理 :Redis 集群在计算 Key 的槽位时,如果发现 Key 包含
{},则只会对{}里面的内容进行哈希计算。 - 具体做法 :将需要批量操作的 Key 加上相同的标签。
- 原本:
feed:user:1,feed:user:2(随机散落在不同节点)。 - 改造:
feed:{group_A}:user:1,feed:{group_A}:user:2。
- 原本:
- 结果 :所有带
{group_A}的 Key 必定落在 同一个槽(Slot) 并在 同一个物理节点 上。 - 优缺点 :
- 利:Pipeline 效率最高,完全像操作单机 Redis 一样。
- 弊 :容易造成 "数据倾斜" (Data Skew)。如果某个
{group}下的数据量巨大,会导致集群中某个节点被撑爆,而其他节点无所事事。
2. 方案二:客户端分片路由(Client-side Sharding)------ "分治法"
这是在 逻辑控制 层面最通用的做法。
- 逻辑原理:既然一个连接只能去一个节点,那就先把这一批 Key 按"家乡"分好组,分头派发。
- 具体步骤 :
- 分拣(Mapping):遍历你要处理的 1000 个 Key,利用 CRC16 算法计算每个 Key 的 Slot,并根据集群拓扑图(Cluster Nodes Info)找到对应的节点地址。
- 分组(Grouping):将 Key 归类。例如:节点 A 负责 300 个,节点 B 负责 400 个,节点 C 负责 300 个。
- 并发执行(Parallel Execution) :同时开启三个 Pipeline,分别向节点 A、B、C 发送请求。
- 汇总(Aggregation):等待三个 Pipeline 全部返回结果,最后合并给业务层。
- 优缺点 :
- 利:符合集群分布式的初衷,负载均衡,不会产生数据倾斜。
- 弊:逻辑复杂;虽然是并行,但网络往返(RTT)受限于最慢的那个节点。
基于Jedis的方案实现
在 Jedis 环境下,针对 Redis Cluster 的 Pipeline 陷阱,这两个方案的实现思路完全不同。一个是"改数据命名",一个是"改代码逻辑"。
方案一:哈希标签(Hash Tags)
这是逻辑上最简单的做法。通过在 Key 中加入 {},强制让这一批粉丝的收件箱落在同一个 Redis 节点上。
代码逻辑:
java
public void pushWithHashTag(String blogId, List<Long> followerIds) {
// 假设我们要把这批推送任务锁定在同一个槽位(Slot)
// 只要 {} 里的内容相同,Redis 就会计算出相同的 Slot
String slotTag = "{group_1}";
long currentTime = System.currentTimeMillis();
// 在 Jedis 环境下,由于 Key 都在同一个节点,普通的 Pipeline 即可生效
try (Jedis jedis = jedisPool.getResource()) { // 注意:这里必须是连到对应节点的 Jedis
Pipeline pipeline = jedis.pipelined();
for (Long fId : followerIds) {
// Key 变成:feed:{group_1}:user:123
String key = "feed:" + slotTag + ":user:" + fId;
pipeline.zadd(key, (double) currentTime, blogId);
}
pipeline.sync(); // 一次性发送
}
}
方案二:客户端手动分拣分组(Manual Grouping)
由于 JedisCluster 官方对象并不直接提供跨槽位的 pipelined() 方法(因为它无法确定你这一堆 Key 到底要去哪个节点),我们需要手动按照 节点(JedisPool) 进行分组分发。
代码逻辑:
java
import redis.clients.jedis.*;
import redis.clients.jedis.util.JedisClusterCRC16;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class JedisClusterPipelineHelper {
/**
* 获取 JedisCluster 内部维护的 Slot 与 JedisPool 的映射关系
* 这里的逻辑是通过反射进入 JedisCluster 的内部缓存
*/
@SuppressWarnings("unchecked")
public static JedisPool getPoolBySlot(JedisCluster jedisCluster, int slot) {
try {
// 1. 获取 JedisCluster 中的 connectionHandler
// Jedis 3.x 及以上版本字段名为 connectionHandler
Field handlerField = JedisCluster.class.getDeclaredField("connectionHandler");
handlerField.setAccessible(true);
Object handler = handlerField.get(jedisCluster);
// 2. 从 handler 中获取 JedisClusterInfoCache (它存储了槽位分布)
Field cacheField = handler.getClass().getSuperclass().getDeclaredField("cache");
cacheField.setAccessible(true);
Object cache = cacheField.get(handler);
// 3. 从 cache 中获取 slots 映射表
// slots 是一个 Map<Integer, JedisPool> 或者 JedisPool[] 数组
Field slotsField = cache.getClass().getDeclaredField("slots");
slotsField.setAccessible(true);
// Jedis 3.x 以上版本通常返回 JedisPool[] 或 List<JedisPool>
Object slots = slotsField.get(cache);
if (slots instanceof Map) {
return ((Map<Integer, JedisPool>) slots).get(slot);
} else if (slots instanceof List) {
return ((List<JedisPool>) slots).get(slot);
} else if (slots.getClass().isArray()) {
return ((JedisPool[]) slots)[slot];
}
return null;
} catch (Exception e) {
throw new RuntimeException("无法从 JedisCluster 中提取槽位映射信息", e);
}
}
}
java
import redis.clients.jedis.*;
import redis.clients.jedis.util.JedisClusterCRC16;
public void pushWithGrouping(String blogId, List<Long> followerIds) {
// 1. 准备一个 Map,Key 是节点连接池,Value 是属于该节点的粉丝 ID 列表
Map<JedisPool, List<Long>> nodeTasks = new HashMap<>();
// 假设 jedisCluster 是你注入的集群对象
// 获取集群中所有的节点映射关系(Slot -> Pool)
Map<String, JedisPool> clusterNodes = jedisCluster.getClusterNodes();
for (Long fId : followerIds) {
String key = FEED_KEY + fId;
// 计算这个 Key 所在的 Slot
int slot = JedisClusterCRC16.getSlot(key);
// 2. 找到该 Slot 所在的连接池 (这里简化了逻辑,实际需根据 Slot 映射查找)
JedisPool connectionPool = getPoolBySlot(slot);
nodeTasks.computeIfAbsent(connectionPool, k -> new ArrayList<>()).add(fId);
}
// 3. 对每个节点分别开启 Pipeline
nodeTasks.forEach((pool, subList) -> {
try (Jedis jedis = pool.getResource()) {
Pipeline pipeline = jedis.pipelined();
for (Long fId : subList) {
String key = FEED_KEY + fId;
pipeline.zadd(key, (double) System.currentTimeMillis(), blogId);
}
pipeline.sync(); // 针对单台服务器的批量提交
}
});
}
基于Lettuce的方案实现
在 Lettuce 中,你完全不需要 像 Jedis 那样写复杂的反射代码去查 Slot、找节点、分连接池。因为 Lettuce 的底层是基于 Netty 的异步框架,它天生就支持 "自动路由"。
1. Lettuce 的核心逻辑:自动分拣
当你使用 Lettuce 连接 Redis 集群并执行批量操作时,逻辑是这样的:
- 你只管发 :你把 1000 个
zAdd丢给 Lettuce。 - 它帮你拆 :Lettuce 的
RedisAdvancedClusterAsyncCommands会在内存里自动计算每个 Key 的 Slot,并发现它们分属于哪些节点。 - 并发分发:Lettuce 会针对每个涉及到的节点,异步地发起 TCP 请求。
- 结果聚合:等所有节点的响应都回来后,它再把结果拼好还给你。
2. 基于 Spring Data Redis (Lettuce) 的实现
如果你在项目中去掉了 Jedis 的排除逻辑(回到默认的 Lettuce),代码会变得异常简洁。你之前的 executePipelined 代码几乎不需要改动,它在底层就会自动支持集群分发。
java
public void handleInsertWithLettuce(List<Long> followerIds, String blogIdStr) {
long currentTime = System.currentTimeMillis();
// 在 Lettuce 环境下,executePipelined 内部会自动处理集群路由
List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Long fId : followerIds) {
String key = FEED_KEY + fId;
// 这里的 zAdd 虽然看起来是连着写的
// 但 Lettuce 底层会异步地将它们发往不同的集群节点
connection.zAdd(key.getBytes(), (double) currentTime, blogIdStr.getBytes());
}
return null;
});
// results 会包含所有操作的结果
log.info("Lettuce 集群批量推送完成,处理条数:{}", results.size());
}
3. 如果你想用原生 Lettuce API(脱离 Spring)
如果你想绕过 Spring 的封装,直接用原生的 Lettuce 追求极致性能,代码逻辑是 "异步并发":
java
// 1. 获取异步集群命令对象
RedisAdvancedClusterAsyncCommands<String, String> asyncCommands = clusterConnection.async();
// 2. 禁用自动刷新触发(可选,为了让命令更像 Pipeline 一样堆积)
asyncCommands.setAutoFlushCommands(false);
List<RedisFuture<?>> futures = new ArrayList<>();
for (Long fId : followerIds) {
// 发起异步操作,这些操作会被自动路由到对应的节点
futures.add(asyncCommands.zadd(FEED_KEY + fId, System.currentTimeMillis(), blogIdStr));
}
// 3. 一次性将缓冲区命令刷出去
asyncCommands.flushCommands();
// 4. 等待所有异步任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();