高并发点赞的Redis缓存、增量同步、对账补偿

5 阅读4分钟

点赞和取消点赞业务的执行代码

        // 执行点赞Lua 脚本
        long result = redisTemplate.execute(
                RedisLuaScriptConstant.THUMB_SCRIPT,
                Arrays.asList(tempThumbKey, userThumbKey),
                loginUser.getId(),
                blogId
        );
        // 执行取消Lua 脚本
        long result = redisTemplate.execute(
                RedisLuaScriptConstant.UNTHUMB_SCRIPT,
                Arrays.asList(tempThumbKey, userThumbKey),
                loginUser.getId(),
                blogId
        );

原子性执行点赞的Lua脚本


    /**
     * 点赞 Lua 脚本
     * KEYS[1]       -- 临时计数键
     * KEYS[2]       -- 用户点赞状态键
     * KEYS[3]       -- 博客总点赞数ZSET键(新增)
     * ARGV[1]       -- 用户 ID
     * ARGV[2]       -- 博客 ID
     * 返回:
     * -1: 已点赞
     * 1: 操作成功
     */
    public static final RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>(""""
            local tempThumbKey = KEYS[1]       -- 临时计数键(如 thumb:temp:{timeSlice})
            local userThumbKey = KEYS[2]       -- 用户点赞状态键(如 thumb:{userId})
            local blogTotalThumbKey = KEYS[3]  -- 博客总点赞数ZSET键(如 blog:thumb:total)
            local userId = ARGV[1]             -- 用户 ID
            local blogId = ARGV[2]             -- 博客 ID
            
            -- 1. 检查是否已点赞(避免重复操作)
            if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
                return -1  -- 已点赞,返回 -1 表示失败
            end
            
            -- 2. 获取旧值(不存在则默认为 0)
            local hashKey = userId .. ':' .. blogId
            local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
            
            -- 3. 计算新值
            local newNumber = oldNumber + 1
            
            -- 4. 原子性更新:写入临时计数 + 标记用户已点赞 + 增加博客总点赞数
            redis.call('HSET', tempThumbKey, hashKey, newNumber)
            redis.call('HSET', userThumbKey, blogId, 1)
            
            -- **使用 ZINCRBY 原子性增加分数(不存在则默认为0,然后+1)**
            redis.call('ZINCRBY', blogTotalThumbKey, 1, blogId)
            
            return 1  -- 返回 1 表示成功
            """, Long.class);

原子性执行取消点赞的lua脚本

    /**
     * 取消点赞 Lua 脚本
     * KEYS[1]       -- 临时计数键
     * KEYS[2]       -- 用户点赞状态键
     * KEYS[3]       -- 博客总点赞数ZSET键(新增)
     * ARGV[1]       -- 用户 ID
     * ARGV[2]       -- 博客 ID
     * 返回:
     * -1: 未点赞
     * 1: 操作成功
     */
    public static final RedisScript<Long> UNTHUMB_SCRIPT = new DefaultRedisScript<>("""";
            local tempThumbKey = KEYS[1]      -- 临时计数键(如 thumb:temp:{timeSlice})
            local userThumbKey = KEYS[2]      -- 用户点赞状态键(如 thumb:{userId})
            local blogTotalThumbKey = KEYS[3] -- 博客总点赞数ZSET键(如 blog:thumb:total)
            local userId = ARGV[1]            -- 用户 ID
            local blogId = ARGV[2]            -- 博客 ID
            
            -- 1. 检查用户是否已点赞(若未点赞,直接返回失败)
            if redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then
                return -1  -- 未点赞,返回 -1 表示失败
            end
            
            -- 2. 获取当前临时计数(若不存在则默认为 0)
            local hashKey = userId .. ':' .. blogId
            local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
            
            -- 3. 计算新值并更新
            local newNumber = oldNumber - 1
            
            -- 4. 原子性操作:更新临时计数 + 删除用户点赞标记 + 减少博客总点赞数
            redis.call('HSET', tempThumbKey, hashKey, newNumber)
            redis.call('HDEL', userThumbKey, blogId)
            
            -- **使用 ZINCRBY 原子性减少分数(increment 为负数)**
            local newTotal = redis.call('ZINCRBY', blogTotalThumbKey, -1, blogId)
                        
            return 1  -- 返回 1 表示成功
            """, Long.class);

增量同步点赞记录

    @Scheduled(fixedRate = 5000)
    @Transactional(rollbackFor = Exception.class)
    public void run() {
      //  log.info("开始执行");
        DateTime nowDate = DateUtil.date();
        String date = DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10 - 1) * 10;
        syncThumb2DBByDate(date);
       // log.info("临时数据同步完成");
    }

    public void syncThumb2DBByDate(String date) {
        // 获取到临时点赞和取消点赞数据
        // todo 如果数据量过大,可以分批读取数据
        String tempThumbKey = RedisKeyUtil.getTempThumbKey(date);
        Map<Object, Object> allTempThumbMap = redisTemplate.opsForHash().entries(tempThumbKey);
        boolean thumbMapEmpty = CollUtil.isEmpty(allTempThumbMap);


        // 同步 点赞 到数据库
        // 构建插入列表并收集blogId
        Map<Long, Long> blogThumbCountMap = new HashMap<>();
        if (thumbMapEmpty) {
            return;
        }
        ArrayList<Thumb> thumbList = new ArrayList<>();
        LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>();
        boolean needRemove = false;
        for (Object userIdBlogIdObj : allTempThumbMap.keySet()) {
            String userIdBlogId = (String) userIdBlogIdObj;
            String[] userIdAndBlogId = userIdBlogId.split(StrPool.COLON);
            Long userId = Long.valueOf(userIdAndBlogId[0]);
            Long blogId = Long.valueOf(userIdAndBlogId[1]);
            // -1 取消点赞,1 点赞
            Integer thumbType = Integer.valueOf(allTempThumbMap.get(userIdBlogId).toString());
            if (thumbType == ThumbTypeEnum.INCR.getValue()) {
                Thumb thumb = new Thumb();
                thumb.setUserId(userId);
                thumb.setBlogId(blogId);
                thumbList.add(thumb);
            } else if (thumbType == ThumbTypeEnum.DECR.getValue()) {
                // 拼接查询条件,批量删除
                // todo 数据量过大,可以分批操作
                needRemove = true;
                wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId);
            } else {
                if (thumbType != ThumbTypeEnum.NON.getValue()) {
                    log.warn("数据异常:{}", userId + "," + blogId + "," + thumbType);
                }
                continue;
            }
            // 计算点赞增量
            blogThumbCountMap.put(blogId, blogThumbCountMap.getOrDefault(blogId, 0L) + thumbType);

        }
        // 批量插入
        thumbService.saveBatch(thumbList);
        // 批量删除
        if (needRemove) {
            thumbService.remove(wrapper);
        }
        // 批量更新博客点赞量
        if (!blogThumbCountMap.isEmpty()) {
            blogMapper.batchUpdateThumbCount(blogThumbCountMap);
        }

        redisTemplate.delete(tempThumbKey);
    }


对账补偿

    @Resource

    private RedisTemplate<String, Object> redisTemplate;

    @Resource

    private ThumbService thumbService;

    /** 点赞域 Topic 交换机*/
    private static final String EXCHANGE_THUMB_TOPIC = "thumb.topic";
    /** 对账补偿*/
    private static final String ROUTING_KEY_RECONCILE_COMPENSATE = "thumb.reconcile.compensate";

    @Resource
    private RabbitTemplate rabbitTemplate;

    /**

     * 定时任务入口(每天凌晨2点执行)

     */

    @Scheduled(cron = "0 0 2 * * ?")

    public void run() {

        long startTime = System.currentTimeMillis();

        // 1. 获取该分片下的所有用户ID

        Set<Long> userIds = new HashSet<>();

        String pattern = ThumbConstant.USER_THUMB_KEY_PREFIX + "*";

        try (Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(1000).build())) {

            while (cursor.hasNext()) {

                String key = cursor.next();

                Long userId = Long.valueOf(key.replace(ThumbConstant.USER_THUMB_KEY_PREFIX, ""));

                userIds.add(userId);

            }

        }

        // 2. 逐用户比对

        userIds.forEach(userId -> {

            Set<Long> redisBlogIds = redisTemplate.opsForHash().keys(ThumbConstant.USER_THUMB_KEY_PREFIX + userId).stream().map(obj -> Long.valueOf(obj.toString())).collect(Collectors.toSet());

            Set<Long> mysqlBlogIds = Optional.ofNullable(thumbService.lambdaQuery()

                            .eq(Thumb::getUserId, userId)

                            .list()

                    ).orElse(new ArrayList<>())

                    .stream()

                    .map(Thumb::getBlogId)

                    .collect(Collectors.toSet());


            // 3. 计算差异(Redis有但MySQL无)

            Set<Long> diffBlogIds = Sets.difference(redisBlogIds, mysqlBlogIds);


            // 4. 发送补偿消息(RabbitMQ)

            sendCompensationEvents(userId, diffBlogIds);

        });



        log.info("对账任务完成,耗时 {}ms", System.currentTimeMillis() - startTime);

    }

    /**
     * 按用户批量发送一条补偿消息(消息体内为 blogId 列表,减少 MQ 往返次数)
     */
    private void sendCompensationEvents(Long userId, Set<Long> blogIds) {
        if (blogIds == null || blogIds.isEmpty()) {
            return;
        }
        try {
            ThumbReconcileCompensateMessage msg = new ThumbReconcileCompensateMessage(
                    userId, new ArrayList<>(blogIds), LocalDateTime.now());
            rabbitTemplate.convertAndSend(EXCHANGE_THUMB_TOPIC, ROUTING_KEY_RECONCILE_COMPENSATE, msg);
        } catch (Exception ex) {
            log.error("补偿消息发送失败: userId={}, blogIdSize={}", userId, blogIds.size(), ex);
        }
    }


消费者

    @RabbitListener(
            bindings = @QueueBinding(
                    value = @Queue(
                            value = "q.thumb.reconcile.compensate",
                            durable = "true"
                    ),
                    exchange = @Exchange(
                            value = "thumb.topic",
                            type = ExchangeTypes.TOPIC,
                            durable = "true"
                    ),
                    key = "thumb.reconcile.compensate"
            ),
            concurrency = "1-3"
    )
    @Transactional(rollbackFor = Exception.class)
    public void onReconcileCompensate(ThumbReconcileCompensateMessage message) {
        if (message == null || message.getUserId() == null
                || message.getBlogIds() == null || message.getBlogIds().isEmpty()) {
            log.warn("忽略非法补偿消息: {}", message);
            return;
        }
        Long userId = message.getUserId();
        List<Long> blogIds = message.getBlogIds().stream()
                .filter(Objects::nonNull)
                .distinct()
                .toList();
        if (blogIds.isEmpty()) {
            return;
        }
        log.info("收到对账补偿消息(仅增加): userId={}, blogCount={}, time={}",
                userId, blogIds.size(), message.getEventTime());


        List<Thumb> toSave = new ArrayList<>();
        for (Long blogId : blogIds) {
            if (existedBlogIds.contains(blogId)) {
                continue;
            }
            Thumb thumb = new Thumb();
            thumb.setUserId(userId);
            thumb.setBlogId(blogId);
            thumb.setCreateTime(new Date());
            toSave.add(thumb);
        }
        thumbService.saveBatch(toSave);

        Map<Long, Long> countDelta = new HashMap<>();
        for (Thumb t : toSave) {
            countDelta.merge(t.getBlogId(), 1L, Long::sum);
        }
        blogMapper.batchUpdateThumbCount(countDelta);
    }