JAVA通过Redis实现Key分区分片聚合点赞、收藏等计数同步数据库,并且通过布隆过滤器防重复点赞

在处理高并发的点赞、关注等社交行为时,使用 Redis 作为缓存系统能够显著提升性能,减少对数据库的访问压力。然而,面对大规模数据时,如何高效存储、分片处理和确保数据一致性成为了设计中的关键问题。本方案通过 Redis 哈希表分片存储布隆过滤器防止重复操作定时任务同步数据库 等方式,构建了一个高效且可扩展的点赞与计数系统。

系统设计与核心组件:

  1. Redis Hash计数与分片存储:

    • 使用 Redis 哈希表 (Hash)存储点赞数、关注数等数据。每个哈希表存储一个时间维度的数据(例如按小时分片,yyyyMMddHH 格式),避免了过大的单个 Redis 键。

    • 时间分片:数据按小时(或者其他时间维度)分片存储,减少单个哈希表存储的数据量,避免性能瓶颈。

  2. RedisCursor 批量遍历与数据管理:

    • 批量操作 :通过 Redis 游标(Cursor)实现批量扫描和操作,从 Redis 中批量获取或删除数据。每次扫描数据的批次大小可控,有效避免大规模数据加载引发的内存溢出和性能问题。

    • 删除过期数据:定期清理历史无用数据,确保 Redis 中的数据保持最新且精简。

  3. 布隆过滤器防止重复点赞:

    • 使用 Redis 的 布隆过滤器(Bloom Filter)高效检测用户是否已经对某个内容进行过点赞。

    • 布隆过滤器原理 :通过多个哈希函数将用户的 userId 映射到位图中,查询时检查该位是否为 1,如果是,表示该用户可能已经点赞过,否则表示没有点赞。

  4. 定时任务同步 Redis 到数据库:

    • 通过定时任务,定期将 Redis 中的点赞数、关注数等计数同步到数据库中,确保数据的最终一致性。

    • 任务执行:每小时或者指定时间范围内,扫描 Redis 中的计数数据,更新到数据库中,并删除已同步的数据。

1、布隆过滤器业务枚举类

java 复制代码
/**
 * 布隆过滤器类型
 *
 * @author Lucas
 * date 2026/3/21 18:11
 */
@Getter
@AllArgsConstructor
public enum BloomFilterType {
    /**
     * 帖子点赞
     */
    UpBlog(100000000, 0.01, 16),
    /**
     * 帖子收藏
     */
    CollectBlog(80000000, 0.01, 4),
    /**
     * 新闻点赞
     */
    UpNews(100000000, 0.01, 16),
    /**
     * 新闻收藏
     */
    CollectNews(80000000, 0.01, 4),
    /**
     * 消息点赞
     */
    UpMessage(100000000, 0.01, 64);

    /**
     * 每个分片预计插入数量(数量超了只会影响误判率)
     * 总容量 = perShardInsertions * shardsNumber
     */
    private final long perShardInsertions;

    /**
     * 误判率(0~1)
     */
    private final double falseProbability;

    /**
     * 分片数量
     */
    private final int shardsNumber;
}

2、布隆过滤器工具类

java 复制代码
/**
 * Redis 布隆过滤器工具类
 *
 * @author Lucas
 * date 2026/3/21 17:53
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisBloomFilterUtil {

    private final RedissonClient redissonClient;
    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 为所有枚举类型及其分片初始化布隆过滤器
     */
    public void init() {
        for (BloomFilterType type : BloomFilterType.values()) {
            String typeName = type.name();
            int shards = type.getShardsNumber();
            log.info("开始初始化布隆过滤器,类型={}, 分片数={}", typeName, shards);
            for (int shard = 0; shard < shards; shard++) {
                String filterName = getFilterName(type, shard);
                RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(filterName);
                try {
                    // 尝试初始化,如果已存在且参数一致则返回 false
                    boolean initialized = bloomFilter.tryInit(type.getPerShardInsertions(), type.getFalseProbability());
                    if (initialized) {
                        log.info("布隆过滤器初始化成功,key={}, 预计插入数={}, 误判率={}", filterName, type.getPerShardInsertions(), type.getFalseProbability());
                    } else {
                        log.error("布隆过滤器已存在且配置一致,跳过初始化,key={}", filterName);
                    }
                } catch (Exception e) {
                    log.error("布隆过滤器初始化失败,key={}, 类型={}, 分片={}, 可能原因:Redis中已存在但参数不一致,请检查配置", filterName, typeName, shard, e);
                }
            }
        }
    }

    /**
     * 构建布隆过滤器的 Redis Key
     *
     * @param type       业务类型
     * @param shardIndex 分片索引(0 ~ shardsNumber-1)
     * @return Redis key
     */
    private String getFilterName(BloomFilterType type, int shardIndex) {
        return RedisKey.USER_BLOOM_FILTER + type.name().toLowerCase() + ":" + shardIndex;
    }

    /**
     * 根据业务标识计算分片索引
     *
     * @param type  业务类型
     * @param value 业务唯一标识(如 userId:operateId)
     * @return 分片索引
     */
    private int getShardIndex(BloomFilterType type, String value) {
        int hash = HashUtil.murmur32(value.getBytes(StandardCharsets.UTF_8));
        return Math.floorMod(hash, type.getShardsNumber());
    }

    /**
     * 获取对应 value 所属的布隆过滤器实例
     *
     * @param type  业务类型
     * @param value 业务唯一标识
     * @return RBloomFilter 实例
     */
    private RBloomFilter<String> getBloomFilter(BloomFilterType type, String value) {
        int shardIndex = getShardIndex(type, value);
        return redissonClient.getBloomFilter(getFilterName(type, shardIndex));
    }

    /**
     * 构建存储在布隆过滤器中的唯一标识
     *
     * @param userId    用户ID
     * @param operateId 操作对象ID
     * @return 唯一标识字符串
     */
    private String buildValue(String userId, String operateId) {
        return userId + ":" + operateId;
    }

    /**
     * 添加用户操作记录(点赞、收藏等)
     *
     * @param type      业务类型
     * @param userId    用户ID
     * @param operateId 操作对象ID(如帖子ID、新闻ID等)
     * @return true 表示添加成功,false 表示添加失败(如 Redis 异常)
     */
    public boolean addAction(BloomFilterType type, String userId, String operateId) {
        String value = buildValue(userId, operateId);
        try {
            RBloomFilter<String> bloomFilter = getBloomFilter(type, value);
            boolean add = bloomFilter.add(value);
            if (add) {
                stringRedisTemplate.opsForValue().set(getCacheKey(type, userId, operateId), String.valueOf(true), 10, TimeUnit.MINUTES);
            }
            return add;
        } catch (Exception e) {
            log.error("布隆过滤器添加元素失败, type={}, userId={}, operateId={}", type, userId, operateId, e);
            return false;
        }
    }

    /**
     * 检查用户是否已执行过操作(点赞、收藏等)
     *
     * @param type      业务类型
     * @param userId    用户ID
     * @param operateId 操作对象ID
     * @return true 表示可能存在,false 表示一定不存在
     */
    public boolean existsAction(BloomFilterType type, String userId, String operateId) {
        String value = buildValue(userId, operateId);
        try {
            RBloomFilter<String> bloomFilter = getBloomFilter(type, value);
            return bloomFilter.contains(value);
        } catch (Exception e) {
            log.error("布隆过滤器查询元素失败,type={}, userId={}, operateId={}", type, userId, operateId, e);
            // 发生异常时,保守返回 true 认为可能操作,再进行兜底查询确定
            return true;
        }
    }

    /**
     * 拼接用户操作KEY
     *
     * @param type      业务类型
     * @param userId    用户ID
     * @param operateId 操作对象ID
     * @return 用户操作KEY
     */
    private String getCacheKey(BloomFilterType type, String userId, String operateId) {
        return RedisKey.USER_OPERATE_CACHE + type.name().toLowerCase() + ":" + userId + "-" + operateId;
    }

    /**
     * 取消用户操作时调用
     *
     * @param type      业务类型
     * @param userId    用户ID
     * @param operateId 操作对象ID
     * @return true 操作成功 false 操作失败
     */
    private boolean removeUserCacheKey(BloomFilterType type, String userId, String operateId) {
        return stringRedisTemplate.unlink(getCacheKey(type, userId, operateId));
    }

    /**
     * 检查用户是否已执行过某个操作(点赞、收藏等)
     * 使用布隆过滤器快速过滤,再查缓存/数据库,避免频繁穿透
     *
     * @param type      业务类型
     * @param userId    用户ID
     * @param operateId 操作对象ID
     * @param dbQuery   数据库查询回调,返回 true 表示已操作,false 表示未操作
     * @return true 表示已操作,false 表示未操作
     */
    public boolean isUserOperated(BloomFilterType type, String userId, String operateId, Supplier<Boolean> dbQuery) {
        // 1. 布隆过滤器快速判断
        boolean bloomResult = existsAction(type, userId, operateId);
        if (!bloomResult) {
            // 布隆过滤器确定不存在 → 一定未操作
            return false;
        }
        // 2. 布隆过滤器认为可能存在,查询Redis缓存
        String cacheKey = getCacheKey(type, userId, operateId);
        Boolean cachedExists = stringRedisTemplate.hasKey(cacheKey);
        if (Boolean.TRUE.equals(cachedExists)) {
            // 缓存命中,表示已操作
            return true;
        }
        // 3. 缓存未命中,最终查数据库(兜底操作)
        boolean dbResult = dbQuery.get();
        if (dbResult) {
            // 数据库确认已操作,写入缓存(有效期 10 分钟,可根据业务调整)
            stringRedisTemplate.opsForValue().set(cacheKey, String.valueOf(true), 10, TimeUnit.MINUTES);
        }
        return dbResult;
    }
}

3、Redis Cursor 批量遍历迭代器工具

java 复制代码
/**
 * Redis Cursor 批量遍历迭代器工具
 *
 * @author Lucas
 * date 2026/3/14 11:47
 */
@Slf4j
public final class RedisBatchIterable implements Iterable<List<Map.Entry<String, Long>>>, AutoCloseable {

    private final Cursor<Map.Entry<String, Long>> cursor;
    private final int batchSize;

    private boolean closed = false;
    private boolean iterated = false;

    public RedisBatchIterable(Cursor<Map.Entry<String, Long>> cursor, int batchSize) {
        if (batchSize <= 0) {
            throw new IllegalArgumentException("batchSize must be > 0");
        }
        this.cursor = Objects.requireNonNull(cursor, "cursor");
        this.batchSize = batchSize;
    }

    @NotNull
    @Override
    public Iterator<List<Map.Entry<String, Long>>> iterator() {
        ensureOpen();
        if (iterated) {
            throw new IllegalStateException("This iterable can only be used once");
        }
        iterated = true;
        return new AbstractBatchIterator<>(cursor, batchSize, Function.identity(), this::close);
    }

    private void ensureOpen() {
        if (closed) {
            throw new IllegalStateException("Already closed");
        }
    }

    @Override
    public void close() {
        if (closed) {
            return;
        }
        closed = true;
        try {
            cursor.close();
        } catch (Exception e) {
            log.warn("Failed to close Redis cursor", e);
        }
    }

    static final class AbstractBatchIterator<T> implements Iterator<T> {

        private final Cursor<Map.Entry<String, Long>> cursor;
        private final int batchSize;
        private final Function<List<Map.Entry<String, Long>>, T> converter;
        private final Runnable onClose;

        private final List<Map.Entry<String, Long>> buffer;

        private boolean finished = false;
        private boolean prefetched = false;

        AbstractBatchIterator(
            Cursor<Map.Entry<String, Long>> cursor,
            int batchSize,
            Function<List<Map.Entry<String, Long>>, T> converter,
            Runnable onClose
        ) {
            this.cursor = cursor;
            this.batchSize = batchSize;
            this.converter = converter;
            this.onClose = onClose;
            this.buffer = new ArrayList<>(batchSize);
        }

        private void fetch() {
            if (finished) {
                return;
            }

            buffer.clear();
            int count = 0;

            try {
                while (cursor.hasNext() && count < batchSize) {
                    buffer.add(cursor.next());
                    count++;
                }
                if (buffer.isEmpty()) {
                    finished = true;
                    onClose.run();
                }
            } catch (Exception e) {
                finished = true;
                onClose.run();
                throw new RuntimeException("Redis scan failed", e);
            }
        }

        @Override
        public boolean hasNext() {
            if (!prefetched && !finished) {
                fetch();
                prefetched = true;
            }
            return !buffer.isEmpty();
        }

        @Override
        public T next() {
            if (!hasNext()) {
                throw new NoSuchElementException();
            }
            prefetched = false;
            List<Map.Entry<String, Long>> snapshot = List.copyOf(buffer);
            buffer.clear();
            return converter.apply(snapshot);
        }
    }
}

4、Redis Hash计数工具类

java 复制代码
/**
 * Redis Hash计数工具类(维度小时分片,数据量大KEY可根据分片参数获取Hash值再次细化分片)
 *
 * @author Lucas
 * date 2026/3/13 17:02
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisHashIncrementUtil {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
    private final StringRedisTemplate redisTemplate;

    /// 获取当前往前推多少个小时
    static String getCurrentSlot(Integer hourOffset) {
        // 获取当前时间并减去 hourOffset 小时
        LocalDateTime date = DateTimeUtil.getDateTime(Instant.now()).minusHours(hourOffset);
        return date.format(FORMATTER);
    }

    /**
     * +1
     */
    public void incrFollowCount(String key, BigInteger tribuneId) {
        redisTemplate.opsForHash().increment(key + getCurrentSlot(0), tribuneId.toString(), 1);
    }


    /**
     * -1
     */
    public void decrFollowCount(String key, BigInteger tribuneId) {
        redisTemplate.opsForHash().increment(key + getCurrentSlot(0), tribuneId.toString(), -1);
    }

    /**
     * 删除使用过的key
     */
    public void deleteFollowCountKey(String key, Integer hourOffset) {
        redisTemplate.unlink(key + getCurrentSlot(hourOffset));
    }

    /**
     * 分批获取 上一个时间段 Hash 的值,每批 batchSize 条(建议batchSize数量不超过1000)
     */
    public RedisBatchIterable fetchInBatches(String key, int batchSize, Integer hourOffset) {
        String hashKey = key + getCurrentSlot(hourOffset);
        HashOperations<String, String, Long> hashOps = redisTemplate.opsForHash();
        Cursor<Map.Entry<String, Long>> cursor = hashOps.scan(hashKey, ScanOptions.scanOptions().count(batchSize).build());
        return new RedisBatchIterable(cursor, batchSize);
    }

    /**
     * 删除指定HashKey
     */
    public void deleteInBatches(String key, List<String> hashKeys, Integer hourOffset) {
        String hashKey = key + getCurrentSlot(hourOffset);
        HashOperations<String, String, Long> hashOps = redisTemplate.opsForHash();
        CollUtil.split(hashKeys, 200).forEach(batch -> {
            try {
                hashOps.delete(hashKey, batch);
            } catch (Exception e) {
                log.error("删除 Redis hash 批次失败: {}", batch, e);
            }
        });
    }

    /**
     * 批量获取当前时间段对应的值(建议tribuneIds数量不超过500)
     */
    public Map<BigInteger, Integer> getFollowCounts(String key, List<BigInteger> tribuneIds) {
        HashOperations<String, String, String> hashOps = redisTemplate.opsForHash();
        String hashKey = key + getCurrentSlot(0);
        List<String> fields = tribuneIds.stream().map(BigInteger::toString).toList();
        List<String> values = hashOps.multiGet(hashKey, fields);
        return IntStream.range(0, tribuneIds.size()).boxed().collect(Collectors.toMap(tribuneIds::get, i -> {
            String v = values.get(i);
            return v == null ? 0 : Integer.parseInt(v);
        }, (a, b) -> b, LinkedHashMap::new));
    }
}

5、定时任务通过点赞数据

java 复制代码
public Response<String> oneHourTimedAttentionTask(String key, Boolean isAttention) {
        String typeText = isAttention ? "关注" : "帖子";
        // 循环处理前 N 小时的数据(补偿 + 前一小时)
        // 例如检查前 2 小时
        int maxHoursToCheck = 2;
        for (int hourOffset = maxHoursToCheck; hourOffset >= 1; hourOffset--) {
            try (RedisBatchIterable batches = redisHashIncrementUtil.fetchInBatches(key, 400, hourOffset)) {
                for (List<Map.Entry<String, Long>> entries : batches) {
                    try {
                        tribuneManager.batchUpdateFollowCount(entries, isAttention);
                        redisHashIncrementUtil.deleteInBatches(key, entries.stream().map(Map.Entry::getKey).toList(), hourOffset);
                        // 轻微间隔,防止瞬间冲击 Redis
                        Thread.sleep(ThreadLocalRandom.current().nextInt(5, 20));
                    } catch (Exception e) {
                        log.error("定时任务同步帖子{}数量异常", typeText, e);
                    }
                }
            } catch (Exception e) {
                log.error("定时任务同步帖子{}数量,批量迭代器执行异常", typeText, e);
            }
            // 处理完成后删除 Redis key
            redisHashIncrementUtil.deleteFollowCountKey(key, hourOffset);
            log.info("定时检查同步帖子{}数量统计成功,执行完成时间:{},执行前几个小时的:{}", typeText, DateUtil.now(), hourOffset);
        }
        return responseFactory.success();
    }
相关推荐
华科易迅2 小时前
Spring装配对象方法-注解
java·后端·spring
掘根2 小时前
【微服务即时通讯】消息转发子服务
数据库·oracle
喜欢喝果茶.2 小时前
SQL 预处理
数据库·sql
庄周的大鱼3 小时前
分析@TransactionalEventListener注解失效
java·spring·springboot·事务监听器·spring 事件机制·事务注解失效解决
史蒂芬_丁3 小时前
C++深度拷贝例子
java·开发语言·c++
云烟成雨TD3 小时前
Spring AI Alibaba 1.x 系列【4】ReAct 范式与 ReactAgent 核心设计
java·人工智能·spring
数据科学小丫4 小时前
Python 数据存储操作_数据存储、补充知识点:Python 与 MySQL交互
数据库·python·mysql
Knight_AL4 小时前
Nacos 启动问题 Failed to create database ’D:\nacos\nacos\data\derby-data’
开发语言·数据库·python
「QT(C++)开发工程师」4 小时前
C++11三大核心特性深度解析:类型特征、时间库与原子操作
java·c++·算法