Redis集群批处理下的陷阱

redis集群模式下,根据key计算槽,从而得到该key位于哪个redis服务器上

批处理是建立一次连接,并批处理所有数据

集群下批处理的数据可能位于不同槽,不同服务器,因此一个连接处理不了,因此报错

解决方案:


1. 方案一:哈希标签(Hash Tags)------ "强行合群"

这是从 数据设计 层面解决问题的最快方法。

  • 逻辑原理 :Redis 集群在计算 Key 的槽位时,如果发现 Key 包含 {},则只会对 {} 里面的内容进行哈希计算。
  • 具体做法 :将需要批量操作的 Key 加上相同的标签。
    • 原本:feed:user:1feed:user:2(随机散落在不同节点)。
    • 改造:feed:{group_A}:user:1feed:{group_A}:user:2
  • 结果 :所有带 {group_A} 的 Key 必定落在 同一个槽(Slot) 并在 同一个物理节点 上。
  • 优缺点
    • :Pipeline 效率最高,完全像操作单机 Redis 一样。
    • :容易造成 "数据倾斜" (Data Skew)。如果某个 {group} 下的数据量巨大,会导致集群中某个节点被撑爆,而其他节点无所事事。

2. 方案二:客户端分片路由(Client-side Sharding)------ "分治法"

这是在 逻辑控制 层面最通用的做法。

  • 逻辑原理:既然一个连接只能去一个节点,那就先把这一批 Key 按"家乡"分好组,分头派发。
  • 具体步骤
    1. 分拣(Mapping):遍历你要处理的 1000 个 Key,利用 CRC16 算法计算每个 Key 的 Slot,并根据集群拓扑图(Cluster Nodes Info)找到对应的节点地址。
    2. 分组(Grouping):将 Key 归类。例如:节点 A 负责 300 个,节点 B 负责 400 个,节点 C 负责 300 个。
    3. 并发执行(Parallel Execution)同时开启三个 Pipeline,分别向节点 A、B、C 发送请求。
    4. 汇总(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 集群并执行批量操作时,逻辑是这样的:

  1. 你只管发 :你把 1000 个 zAdd 丢给 Lettuce。
  2. 它帮你拆 :Lettuce 的 RedisAdvancedClusterAsyncCommands 会在内存里自动计算每个 Key 的 Slot,并发现它们分属于哪些节点。
  3. 并发分发:Lettuce 会针对每个涉及到的节点,异步地发起 TCP 请求。
  4. 结果聚合:等所有节点的响应都回来后,它再把结果拼好还给你。

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();
相关推荐
悲伤小伞2 小时前
6-MySQL_表的内置函数
数据库·mysql
wei_shuo2 小时前
数据库安全最后一公里:金仓SQL防火墙如何填平开发留下的注入坑
数据库·kingbase·金仓
深念Y2 小时前
Elasticsearch 8.11 + IK 分词器安装踩坑记录
大数据·数据库·elasticsearch·中文分词·jenkins·ki分词器
light blue bird2 小时前
MES/ERP 多维度整周期场景报表
数据库·ai大数据·大数据报表·多功能图表报表
颜颜颜yan_2 小时前
让数据库学会说“不“——金仓 SQL 防火墙深度解析
数据库·后端
人道领域2 小时前
Day | 07 【苍穹外卖:菜品套餐的缓存】
java·开发语言·redis·缓存击穿·springcache
m0_706653232 小时前
数据库与缓存操作策略:数据一致性与并发问题
java·数据库·缓存
独断万古他化2 小时前
【抽奖系统开发实战】Spring Boot 活动模块设计:事务保障、缓存优化与列表展示
java·spring boot·redis·后端·缓存·mvc
JosieBook2 小时前
【数据库】金仓数据库智能SQL防护机制,实现99.99%异常语句精准拦截
数据库·sql