SpringBoot中结合MySQL、Redis,实现异步落库的评论点赞/拉踩功能

0 阅读14分钟

概要

该方案是一个典型的用空间(Redis缓存)和异步化换取时间(响应速度)和系统稳定性(数据库抗压) 的架构设计。它非常适合点赞这类写多读多、对实时一致性要求稍低的业务场景。

前言

学习java过程中的心得,如有错误请提醒作者纠正,感谢不尽!!!

如果有更好的实现,欢迎分享!!!

当前实现优点

  1. 高性能与低延迟

    • 写操作快:用户点赞/拉踩请求直接操作内存数据库Redis,响应速度极快,用户体验好。
    • 数据库在定时任务触发前压力小:高频的写操作被Redis承接,避免了直接冲击MySQL
  2. 数据一致性保障

    • 原子性操作:使用Lua脚本在Redis内完成状态切换(点赞->拉踩->无状态),保证了“一个评论同一时刻只能有一种状态”的业务逻辑的原子性,防止并发请求导致的数据错乱。
    • 分布式锁:对单个用户的操作加锁(RLock),防止同一用户极短时间内的重复提交造成缓存数据问题。
  3. 批量处理效率高

    • 异步落库:定时任务将缓存中的大量变更集中起来,通过批量插入/更新ON DUPLICATE KEY UPDATE)和批量更新统计CASE WHEN)的方式与数据库交互,极大地减少了网络I/O和SQL执行次数,数据库处理效率高。

当前实现存在问题

  1. 定时任务引发的数据库峰值:

    1. 可通过使用消息队列削峰填谷
    2. 将定时任务从处理全部数据改为处理部分数据,定时任务的周期调低

主要实现细节

Redis + Redis分布式锁 + 原子操作 + 异步落库 + SpringBoot定时任务

流程图

点赞拉踩无状态方案2.png

请求方法设计

请求URL/api/article/v1/{commentId}/vote

请求方法:PUT

请求参数:type(Integer);(type=0表示修改成无状态,type=1表示修改成点赞状态,type=-1表示修改成拉踩状态)

URL示例:/api/article/v1/{commentId}/vote?type = 1

Redis表设计

下述三表都用来记录用户对评论的状态(点赞、拉踩、无状态)

articles:comments:likes:users:{userId}

{userId}为动态键名

Redis Set数据结构;

存储的值:{commentId}

articles:comments:dislikes:users:{userId}

{userId}为动态键名

Redis Set数据结构;

存储的值:{commentId}

articles:comments:stateless:users:{userId}

{userId}为动态键名

Redis Set数据结构;

存储的值:{commentId}

MySQL表设计

该博客聚焦实现点赞/拉踩功能,涉及x_article_comments表不多,故不做x_article_comments表的字段介绍

  • x_article_comments_votes

    • 联合主键(comment_id, user_id)
    • vote字段表用户对评论的状态,1代表点赞,-1代表拉踩,0代表无状态,即不处于点赞状态或拉踩状态
-- 文章评论表
CREATE TABLE x_article_comments (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  article_id BIGINT UNSIGNED NOT NULL,            -- 关联的文章
  parent_id BIGINT UNSIGNED NULL,               -- NULL 表示顶级评论;否则是回复
  root_id BIGINT UNSIGNED NULL,                 -- 同一Thread的Root评论 id(便于查询整个Thread)
  user_id BIGINT UNSIGNED NOT NULL,             -- 评论作者
  content TEXT NOT NULL,
  status TINYINT DEFAULT 1,            -- 1=显示,0=已删除/隐藏,2=待审核 等
  like_count INT DEFAULT 0,
  dislike_count INT DEFAULT 0,
  reply_count INT DEFAULT 0, -- 该层孩子的数量
  reply_descendant_count INT DEFAULT 0, -- 该层后代的数量
  version INT DEFAULT 0,               -- 乐观锁(更新时校验)
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
​
  PRIMARY KEY (id)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;
​
-- 点赞/踩表(每个用户对某条评论的动作)
CREATE TABLE x_article_comments_votes (
  comment_id BIGINT NOT NULL,
  user_id BIGINT NOT NULL,
  vote TINYINT NOT NULL, -- 1=like, -1=dislike, 0=none
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (comment_id, user_id)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

后端实现细节

处理用户发起的点赞/拉踩/无状态请求

Controller层

校验参数

先校验当前用户是否登录,userReadApi.getCurrentUserId()是我封装好的方法,用于获取发起当前请求的用户ID

        // 获取当前用户ID
        Long userId = userReadApi.getCurrentUserId();
        if (userId == null) {
            return AjaxResult.error(HttpStatus.UNAUTHORIZED, "用户未登录或获取用户ID失败");
        }
完整代码
    /**
     * 点赞/拉踩/无状态评论
     */
    @PutMapping("/{commentId}/vote")
    public AjaxResult voteComment(
            @PathVariable("commentId") Long commentId,
            @RequestParam("type") Integer type
    ) {
        log.info("进入请求 /api/article/v1/{}/vote -> 点赞/取消点赞评论 type={}", commentId, type);
​
        // 获取当前用户ID
        Long userId = userReadApi.getCurrentUserId();
        if (userId == null) {
            return AjaxResult.error(HttpStatus.UNAUTHORIZED, "用户未登录或获取用户ID失败");
        }
​
​
        articleService.voteComment(userId, commentId, type);
​
        log.info("返回结果 /api/article/v1/{}/vote -> OK", commentId);
        return AjaxResult.success();
    }

Service层

校验参数
        // 1. 参数校验
        if (commentId == null || type == null) {
            throw new ClientException(HttpStatus.BAD_REQUEST, "参数不完整");
        }
        if (type != ArticleCommentVoteConstants.LIKE && type != ArticleCommentVoteConstants.DISLIKE && type != ArticleCommentVoteConstants.STATELESS) {
            throw new ClientException(HttpStatus.BAD_REQUEST, "投票类型非法");
        }
​
        // 2. 校验评论是否存在
        ArticleComment comment = articleCommentMapper.selectArticleCommentById(commentId);
        if (comment == null) {
            throw new ClientException(HttpStatus.NOT_FOUND, "评论不存在");
        }
分布式锁 + lua脚本

使用了redisson实现加锁,并使用了lua脚本保证原子性,从而防止用户频繁发起请求导致在redis缓存的数据出现错误的情况

分布式锁相关代码
        // 5. 尝试获取分布式锁
        RLock lock = redisson.getLock(RedisKeyConstants.ARTICLES_COMMENTS_VOTES_LOCK_KEY + userId);
        boolean isLocked = false;
        try {
            isLocked = lock.tryLock(0, RedisKeyConstants.ARTICLES_COMMENTS_VOTES_LOCK_KEY_EXPIRE_TIME, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "系统繁忙,请稍后再试");
            } else {
                // 获取到锁,执行投票逻辑,此处省略
                // ...
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ClientException(HttpStatus.ERROR, "系统繁忙,请稍后再试");
        } finally {
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("用户 {} 的锁已释放", userId);
            }
        }
lua脚本相关代码

预先准备好redis键和lua脚本

        // 3. 构建 Redis 键
        String likeKey = RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + userId;
        String dislikeKey = RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + userId;
        String statelessKey = RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + userId;
        // 4. 根据投票类型处理逻辑
        DefaultRedisScript<Long> deleteScript = new DefaultRedisScript<>();
        deleteScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RedisLuaConstants.ARTICLE_COMMENTS_VOTE_LUA_SCRIPT_PATH)));
        deleteScript.setResultType(Long.class);
        Long execute = null;

lua脚本文件

lua脚本能够保证原子性,若用户频繁发起请求也能保证以下的要求

三个键对应的set集合里的值应保持互斥,只允许一个值如3只能出现在一个键,例如:共有三个键like:{userId}、dislike:{userId}、stateless:{userId},键like现在持有3,用户对commentId = 3发起了点赞/拉踩/无状态请求,修改为拉踩,此时要去除like:{userId}键和stateless:{userId}键的set集合中值为3的元素,执行完后只有键dislike:{userId}存在值3

-- Lua 脚本:处理评论投票逻辑
local mainKey = KEYS[1]       -- 进行add的键
local srem1Key = KEYS[2]    -- 进行srem的键
local srem2Key = KEYS[3]  -- 进行srem的键
local commentId = ARGV[1]     -- 评论 ID-- 添加到main集合
redis.call('SADD', mainKey, commentId)
​
-- 从两个集合中移除
redis.call('SREM', srem1Key, commentId)
redis.call('SREM', srem2Key, commentId)
​
-- 返回操作结果(可选)
return 1  -- 表示成功执行

获取到锁后,为实现存进redis,执行的操作如下

                switch (type) {
                    case ArticleCommentVoteConstants.LIKE:
                        // 4. 处理点赞逻辑
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(likeKey, dislikeKey, statelessKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "处理点踩逻辑失败");
                        }
                        break;
                    case ArticleCommentVoteConstants.DISLIKE:
                        // 5. 处理点踩逻辑
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(dislikeKey, likeKey, statelessKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "处理点踩逻辑失败");
                        }
                        break;
                    case ArticleCommentVoteConstants.STATELESS:
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(statelessKey, dislikeKey, likeKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.ERROR, "处理无状态逻辑失败");
                        }
                        break;
                    default:
                        throw new ClientException(HttpStatus.BAD_REQUEST, "未知的投票类型");
                }
完整代码
    @Override
    public void voteComment(Long userId, Long commentId, Integer type) {
        // 1. 参数校验
        if (commentId == null || type == null) {
            throw new ClientException(HttpStatus.BAD_REQUEST, "参数不完整");
        }
        if (type != ArticleCommentVoteConstants.LIKE && type != ArticleCommentVoteConstants.DISLIKE && type != ArticleCommentVoteConstants.STATELESS) {
            throw new ClientException(HttpStatus.BAD_REQUEST, "投票类型非法");
        }

        // 2. 校验评论是否存在
        ArticleComment comment = articleCommentMapper.selectArticleCommentById(commentId);
        if (comment == null) {
            throw new ClientException(HttpStatus.NOT_FOUND, "评论不存在");
        }

        // 3. 构建 Redis 键
        String likeKey = RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + userId;
        String dislikeKey = RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + userId;
        String statelessKey = RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + userId;
        // 4. 根据投票类型处理逻辑
        DefaultRedisScript<Long> deleteScript = new DefaultRedisScript<>();
        deleteScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RedisLuaConstants.ARTICLE_COMMENTS_VOTE_LUA_SCRIPT_PATH)));
        deleteScript.setResultType(Long.class);
        Long execute = null;
        // 5. 尝试获取分布式锁
        RLock lock = redisson.getLock(RedisKeyConstants.ARTICLES_COMMENTS_VOTES_LOCK_KEY + userId);
        boolean isLocked = false;
        try {
            isLocked = lock.tryLock(0, RedisKeyConstants.ARTICLES_COMMENTS_VOTES_LOCK_KEY_EXPIRE_TIME, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "系统繁忙,请稍后再试");
            } else {
                // 获取到锁,执行投票逻辑
                switch (type) {
                    case ArticleCommentVoteConstants.LIKE:
                        // 4. 处理点赞逻辑
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(likeKey, dislikeKey, statelessKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "处理点踩逻辑失败");
                        }
                        break;
                    case ArticleCommentVoteConstants.DISLIKE:
                        // 5. 处理点踩逻辑
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(dislikeKey, likeKey, statelessKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.SERVICE_UNAVAILABLE, "处理点踩逻辑失败");
                        }
                        break;
                    case ArticleCommentVoteConstants.STATELESS:
                        execute = stringRedisTemplate.execute(deleteScript, Arrays.asList(statelessKey, dislikeKey, likeKey), commentId.toString());
                        if (execute == null) {
                            throw new ClientException(HttpStatus.ERROR, "处理无状态逻辑失败");
                        }
                        break;
                    default:
                        throw new ClientException(HttpStatus.BAD_REQUEST, "未知的投票类型");
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ClientException(HttpStatus.ERROR, "系统繁忙,请稍后再试");
        } finally {
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("用户 {} 的锁已释放", userId);
            }
        }
    }

定时任务

定时任务要实现落库到MySQL中,并且清空redis对应的缓存

创建定时任务

@Component
@Slf4j
public class VoteSyncScheduler {

    private final ArticleService articleService;
    public VoteSyncScheduler(ArticleService articleService) {
        this.articleService = articleService;
    }

    @Scheduled(fixedRate = 5 * 60 * 1000) // 每五分钟执行一次
    public void syncVote() {
        log.info("开始执行点赞同步任务...");
        Boolean result = articleService.syncVote();
        log.info("点赞同步任务执行完成。");
    }
}

syncVote实现

MySQL涉及操作多张表,所以需要在方法上加上注解@Transactional

参考流程图

  1. 我们首先要从redis中获取数据,并将数据封装到两个集合中。
  2. 落库到MySQL
  3. 根据x_article_comments_votes表,通过SQL语句统计出like_count, dislike_count
  4. 将统计出的数据更新到x_article_comments
  5. 清除已在该定时任务处理完的redis缓存数据
变量说明
  • List votes = new ArrayList<>(); // 保存所有投票数据
  • Set TotalcommentId = new HashSet<>(); // 保存所有评论id
  • likesUserIdToCommentIdsMap、dislikesUserIdToCommentIdsMap、statelessUserIdToCommentIdsMap——存储的值为经过逻辑后键中已被处理的值,在最后一步清除redis缓存发挥作用
从redis中获取数据,并将数据封装到两个集合中
        Set<String> userLikesKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + "*");
        Set<String> userDislikesKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + "*");
        Set<String> userStatelessKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + "*");
        if (userLikesKeys.isEmpty() && userDislikesKeys.isEmpty() && userStatelessKeys.isEmpty()){
            log.info("没有需要同步的数据");
            return true;
        }
        List<ArticleCommentVote> votes = new ArrayList<>();      // 保存所有投票数据
        Set<Long> TotalcommentId = new HashSet<>();     // 保存所有评论id
        Map<Long, Set<String>> likesUserIdToCommentIdsMap = new HashMap<>();
        Map<Long, Set<String>> dislikesUserIdToCommentIdsMap = new HashMap<>();
        Map<Long, Set<String>> statelessUserIdToCommentIdsMap = new HashMap<>();
        for (String userLikesKey : userLikesKeys) {
            String userId = userLikesKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY, "");
            Set<String> commentIds = stringRedisTemplate.opsForSet().members(userLikesKey);
            for (String commentId : commentIds) {
                votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.LIKE, null, null));
                TotalcommentId.add(Long.parseLong(commentId));
                // 更新或新增likesUserIdToCommentIdsMap键值对,值的set集合新增commentId
                likesUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
            }
        }
        for (String userDislikesKey : userDislikesKeys) {
            String userId = userDislikesKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY, "");
            Set<String> commentIds = stringRedisTemplate.opsForSet().members(userDislikesKey);
            for (String commentId : commentIds) {
                votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.DISLIKE, null, null));
                TotalcommentId.add(Long.parseLong(commentId));
                // 更新或新增dislikesUserIdToCommentIdsMap键值对,值的set集合新增commentId
                dislikesUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
            }
        }
        for (String userStatelessKey : userStatelessKeys) {
            String userId = userStatelessKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY, "");
            Set<String> commentIds = stringRedisTemplate.opsForSet().members(userStatelessKey);
            for (String commentId : commentIds) {
                votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.STATELESS, null, null));
                TotalcommentId.add(Long.parseLong(commentId));
                // 添加到statelessUserIdToCommentIdsMap键值对,值的set集合新增commentId
                statelessUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
            }
        }
落库到MySQL
Service层相关代码
        // 根据votes执行落库逻辑(包含插入/更新)
        if (!votes.isEmpty()){
            int i = articleCommentVoteMapper.batchInsertOrUpdate(votes);
            if (i < 0){
                throw new ClientException(HttpStatus.ERROR, "批量插入或更新数据失败");
            }
            log.info("批量插入或更新数据成功,数量为:{}", i);
        } else {
            log.info("没有需要落库的数据");
            return true;
        }
SQL语句相关实现
    <!-- 原有方法 -->
    <insert id="batchInsertOrUpdate" parameterType="java.util.List">
        INSERT INTO x_article_comments_votes (
            comment_id,
            user_id,
            vote,
            create_time,
            update_time
        )
        VALUES
        <foreach collection="list" item="item" separator=",">
            (
                #{item.commentId},
                #{item.userId},
                #{item.vote},
                NOW(),
                NOW()
            )
        </foreach>
        ON DUPLICATE KEY UPDATE
            vote = VALUES(vote),
            update_time = VALUES(update_time)
    </insert>
根据x_article_comments_votes表,通过SQL语句统计出like_count, dislike_count
Service层相关代码
        List<UpdateCommentVoteCountDTO> updateCommentVoteCountDTOList = getCommentLikeCountAndDislikeCountByListOfCommentId(TotalcommentId);
SQL语句相关实现
<!-- 新增方法: 聚合查询点赞/点踩数量 -->
<select id="selectCommentLikeCountAndDislikeCountByListOfCommentId" resultType="com.anon.spaceblogserver.modules.article.POJO.DTO.UpdateCommentVoteCountDTO">
    SELECT
        comment_id AS commentId,
        SUM(CASE WHEN vote = 1 THEN 1 ELSE 0 END) AS likeCount,
        SUM(CASE WHEN vote = -1 THEN 1 ELSE 0 END) AS dislikeCount
    FROM x_article_comments_votes
    WHERE comment_id IN
    <foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
        #{commentId}
    </foreach>
    GROUP BY comment_id
</select>
将统计出的数据批量更新到x_article_comments
Service层相关代码
int updated = batchUpdateCommentLikeCountAndDislikeCount(updateCommentVoteCountDTOList, MySQLBatchSizeConstants.DEFAULT_UPDATE_BATCH_SIZE);
if (updated < 0){
    throw new ClientException(HttpStatus.ERROR, "批量更新数据失败");
}
log.info("批量更新数据成功,更新数量为:{}", updated);
限制单条SQL语句长度,批量更新操作具体实现

因为SQL语句采用了case when减少网络往返,为限制SQL语句长度防止溢出以及影响性能,采用的策略如下

若检测到参数updateCommentVoteCountDTOList超过指定个数,将会拆分成多条SQL语句与MySQL数据库进行交互

/**
 * 批量更新评论的点赞数和点踩数
 * @param updateCommentVoteCountDTOList     需要更新的评论点赞数和点踩数列表
 * @param batchSize                   批次大小,建议根据实际情况调整,过大可能导致单次更新过慢,过小可能导致更新次数过多
 * @return      成功更新的记录数
 */
public int batchUpdateCommentLikeCountAndDislikeCount(List<UpdateCommentVoteCountDTO> updateCommentVoteCountDTOList, Integer batchSize) {
    int totalUpdated = 0;
    for (int i = 0; i < updateCommentVoteCountDTOList.size(); i += batchSize) {
        // 截取当前批次的数据
        List<UpdateCommentVoteCountDTO> batch = updateCommentVoteCountDTOList.subList(
                i,
                Math.min(i + batchSize, updateCommentVoteCountDTOList.size())
        );
        // 执行当前批次的更新
        int updated = articleCommentMapper.batchUpdateCommentLikeCountAndDislikeCount(batch);
        if (updated < 0) {
            log.error("批量更新数据失败,当前批次更新数量: {}", updated);
            return -1;
        }
        totalUpdated += updated;

        log.info("批量更新评论点赞/点踩数,当前批次更新数量: {}, 累计更新数量: {}", updated, totalUpdated);
    }
    return totalUpdated;
}
SQL语句相关实现
<update id="batchUpdateCommentLikeCountAndDislikeCount">
    UPDATE x_article_comments
    SET like_count = CASE id
    <foreach collection="list" item="item">
        WHEN #{item.commentId} THEN #{item.likeCount}
    </foreach>
    END,
    dislike_count = CASE id
    <foreach collection="list" item="item">
        WHEN #{item.commentId} THEN #{item.dislikeCount}
    </foreach>
    END,
    update_time = NOW()
    WHERE id IN
    <foreach collection="list" item="item" open="(" separator="," close=")">
        #{item.commentId}
    </foreach>
</update>
清除已在该定时任务处理完的redis缓存数据

前面提到的变量——likesUserIdToCommentIdsMap、dislikesUserIdToCommentIdsMap、statelessUserIdToCommentIdsMap。存储的值为经过逻辑后键中已被处理的值,在最后一步清除redis缓存发挥作用

上述变量类型形式为Map<Long, Set> ,满足了userId -> 评论id集合,这可以精准且方便的清除已处理后的redis缓存数据

构建上述三个Map集合的过程在第一步操作中经历三个for循环已经构建完毕

该操作同样用到lua脚本保证原子性

lua脚本

该脚本实现安全批量的移除key中要删除的元素列表为什么不直接把键删除的原因 -> 假如在该定时任务执行过程中以及该脚本执行之前用户又发起点赞/拉踩/无状态请求,若与定时任务触发后从redis查到的用户对评论的状态相等,则会移除,这并没有什么问题,若不相等,不会移除新请求中用户设置的点赞/拉踩/无状态,留到下一次定时任务触发后处理

-- 安全批量删除,包含key存在性检查
-- KEYS[1]: Set的key
-- ARGV[1..n]: 要删除的元素列表
--
local key = KEYS[1]
local key_type = redis.call('TYPE', key).ok

-- 检查key是否存在且类型为set
if key_type == 'none' then
    return 0
elseif key_type ~= 'set' then
    return redis.error_reply('WRONGTYPE Operation against a key holding the wrong kind of value')
end

-- 兼容 Lua 5.1 和 Lua 5.2+
local unpack = unpack or table.unpack

local result = 0

-- 如果 ARGV 不为空,则执行批量删除
if #ARGV > 0 then
    result = redis.call('SREM', key, unpack(ARGV))
end

return result
Service层相关代码
// 清空Redis中的数据
DefaultRedisScript<Long> deleteScript = new DefaultRedisScript<>();
deleteScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RedisLuaConstants.BATCH_REMOVE_MEMBERS_FROM_SET_LUA_SCRIPT_PATH)));
deleteScript.setResultType(Long.class);
likesUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
    Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + userId.toString()), commentIdSet.toArray());
    if (result == null || result < 0){
        log.error("批量删除用户 {} 缓存点赞的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
    }
});
dislikesUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
    Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + userId.toString()), commentIdSet.toArray());
    if (result == null || result < 0){
        log.error("批量删除用户 {} 缓存点踩的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
    }
});
statelessUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
    Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + userId.toString()), commentIdSet.toArray());
    if (result == null || result < 0){
        log.error("批量删除用户 {} 缓存无状态的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
    }
});

SyncVote完整代码

@Transactional
@Override
public Boolean syncVote() {
    //TODO 发散思维,该段逻辑似乎在收藏、投币等功能有相似之处,该段之所以有点复杂是因为有三种状态————点赞、点踩、无状态,且三者之间是互斥的,但前面的收藏、投币功能没有这个问题,并且好像只有两个状态,日后可以抽象出一个通用方法来处理这类功能,减少代码重复度
    Set<String> userLikesKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + "*");
    Set<String> userDislikesKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + "*");
    Set<String> userStatelessKeys = stringRedisTemplate.keys(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + "*");
    if (userLikesKeys.isEmpty() && userDislikesKeys.isEmpty() && userStatelessKeys.isEmpty()){
        log.info("没有需要同步的数据");
        return true;
    }
    List<ArticleCommentVote> votes = new ArrayList<>();      // 保存所有投票数据
    Set<Long> TotalcommentId = new HashSet<>();     // 保存所有评论id
    Map<Long, Set<String>> likesUserIdToCommentIdsMap = new HashMap<>();
    Map<Long, Set<String>> dislikesUserIdToCommentIdsMap = new HashMap<>();
    Map<Long, Set<String>> statelessUserIdToCommentIdsMap = new HashMap<>();
    for (String userLikesKey : userLikesKeys) {
        String userId = userLikesKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY, "");
        Set<String> commentIds = stringRedisTemplate.opsForSet().members(userLikesKey);
        for (String commentId : commentIds) {
            votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.LIKE, null, null));
            TotalcommentId.add(Long.parseLong(commentId));
            // 更新或新增likesUserIdToCommentIdsMap键值对,值的set集合新增commentId
            likesUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
        }
    }
    for (String userDislikesKey : userDislikesKeys) {
        String userId = userDislikesKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY, "");
        Set<String> commentIds = stringRedisTemplate.opsForSet().members(userDislikesKey);
        for (String commentId : commentIds) {
            votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.DISLIKE, null, null));
            TotalcommentId.add(Long.parseLong(commentId));
            // 更新或新增dislikesUserIdToCommentIdsMap键值对,值的set集合新增commentId
            dislikesUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
        }
    }
    for (String userStatelessKey : userStatelessKeys) {
        String userId = userStatelessKey.replace(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY, "");
        Set<String> commentIds = stringRedisTemplate.opsForSet().members(userStatelessKey);
        for (String commentId : commentIds) {
            votes.add(new ArticleCommentVote(Long.parseLong(commentId), Long.parseLong(userId), ArticleCommentVoteConstants.STATELESS, null, null));
            TotalcommentId.add(Long.parseLong(commentId));
            // 添加到statelessUserIdToCommentIdsMap键值对,值的set集合新增commentId
            statelessUserIdToCommentIdsMap.computeIfAbsent(Long.parseLong(userId), k -> new HashSet<>()).add(commentId);
        }
    }
    // 根据votes执行落库逻辑(包含插入/更新)
    if (!votes.isEmpty()){
        int i = articleCommentVoteMapper.batchInsertOrUpdate(votes);
        if (i < 0){
            throw new ClientException(HttpStatus.ERROR, "批量插入或更新数据失败");
        }
        log.info("批量插入或更新数据成功,数量为:{}", i);
    } else {
        log.info("没有需要落库的数据");
        return true;
    }
    // 根据TotalcommentId集合中的commentId,利用聚合函数获取对应表中对应评论的likeCount和dislikeCount,封装成List<updateCommentVoteCountDTO> updateCommentVoteCountDTOList
    List<UpdateCommentVoteCountDTO> updateCommentVoteCountDTOList = getCommentLikeCountAndDislikeCountByListOfCommentId(TotalcommentId);
    // 根据updateCommentVoteCountDTOList执行批量更新的逻辑,作用的表为x_article_comments,使用case when then语法更新likeCount和dislikeCount
    int updated = batchUpdateCommentLikeCountAndDislikeCount(updateCommentVoteCountDTOList, MySQLBatchSizeConstants.DEFAULT_UPDATE_BATCH_SIZE);
    if (updated < 0){
        throw new ClientException(HttpStatus.ERROR, "批量更新数据失败");
    }
    log.info("批量更新数据成功,更新数量为:{}", updated);
    // 清空Redis中的数据
    DefaultRedisScript<Long> deleteScript = new DefaultRedisScript<>();
    deleteScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RedisLuaConstants.BATCH_REMOVE_MEMBERS_FROM_SET_LUA_SCRIPT_PATH)));
    deleteScript.setResultType(Long.class);
    likesUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
        Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_LIKES_USERS_KEY + userId.toString()), commentIdSet.toArray());
        if (result == null || result < 0){
            log.error("批量删除用户 {} 缓存点赞的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
        }
    });
    dislikesUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
        Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_DISLIKES_USERS_KEY + userId.toString()), commentIdSet.toArray());
        if (result == null || result < 0){
            log.error("批量删除用户 {} 缓存点踩的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
        }
    });
    statelessUserIdToCommentIdsMap.forEach((userId, commentIdSet) -> {
        Long result = stringRedisTemplate.execute(deleteScript, List.of(RedisKeyConstants.ARTICLES_COMMENTS_STATELESS_USERS_KEY + userId.toString()), commentIdSet.toArray());
        if (result == null || result < 0){
            log.error("批量删除用户 {} 缓存无状态的评论失败,欲删除的commentId -> {}", userId, commentIdSet);
        }
    });
    return true;
}