高性能点赞与评论系统设计实战-以租车业务为例

21 阅读17分钟

系统设计实战:高性能点赞与评论系统设计分享-以租车业务为例

背景

最近我在开发一个租车网站,在车辆详情页面计划实现一个评论区,在实际的实现过程中发现这个评论和点赞系统的设计,还真不那么简单。经过数日的摸索,现在把设计与实现的经验分享给大家

代码仓库:GitHub - l0sgAi/car-rental-backend at new_comment


I. 点赞系统

点赞作为一个高频读写的业务,我们尽量在Redis上做读写。具体来说,我们维护2个key:

  1. 用户点赞的评论列表set,结构为[用户ID:点赞评论ID集合]
  2. 评论的点赞计数count,结构为[评论ID:点赞总数]

然而,这样会带来一个问题:用户点赞的评论列表set,我们为了防止大key问题,必须要对set的大小做限制,这会导致如果用户的总点赞数超过某一个上限,在之后的点赞列表同步的时候,会把最老的一批点赞记录淘汰,导致有的点赞可以被重复触发。

这个问题目前我还没找到很好的解决方法,如果有人了解的话欢迎提出宝贵意见。

一般来说,点赞作为弱一致性业务,这个误差也不是不能接受,比如设置一个容量5000的set,对于大部分用户,一天的点赞数量不超过10个,即用户要将近2年才能填满点赞列表。而对一个2年前的点赞,允许重新记录也不是不能接受。

1. 表设计

点赞记录表

点赞表通过唯一索引,尽量减少表中的数据量,提升业务的逻辑清晰度。

-- 点赞表
CREATE TABLE `like`
(
    `id`          bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `user_id`     bigint unsigned NOT NULL COMMENT '对应用户ID',
    `comment_id`  bigint unsigned NOT NULL COMMENT '对应评论ID',
    `create_time` datetime        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `is_dislike` tinyint         NOT NULL DEFAULT '0' COMMENT '标志操作类型,是否为取消点赞,类似逻辑删除:0=点赞,1=已取消点赞',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci COMMENT ='点赞信息表';

-- 添加唯一索引
ALTER TABLE `like`
    ADD UNIQUE KEY uk_user_comment (user_id, comment_id); 

2. 点赞/取消点赞的实现

入参:评论id与用户id

点赞/取消点赞的逻辑链路如下:

  1. 检查有关点赞的2个key是否存在,包括点赞计数和点赞列表,没有的话需要重建缓存,在重建过程中加分布式锁防止缓存击穿。
  2. 检查点赞列表,对比当前用户id,如果点赞列表中有对应用户id,说明是取消点赞操作,把userId从点赞列表移除,并把点赞计数-1。反之往点赞列表添加userId并把点赞计数+1,达成点赞。
  3. 最后往MQ里面发一条消息,消费者根据时间维度(比如3秒),对一段时间内的点赞消息进行聚合,批量入库。
2.1 点赞实现参考

这里可以使用lua脚本实现点赞操作,提升性能,保证操作原子性。

@Override
@Description("点赞/取消点赞 (Lua原子版)")
public ResultCodeEnum like(Long commentId, Long userId) {
    if (commentId == null || userId == null) {
        return ResultCodeEnum.DATA_ERROR;
    }

    String userSetKey = USER_LIKE_KEY_PREFIX + userId;
    String countKey = LIKE_COUN_KEY_PREFIX + commentId;

    // 1. 尝试执行 Lua 脚本
    // 参数说明:Keys列表, ARGV列表
    long result = executeLikeScript(userSetKey, countKey, commentId);

    // 2. 如果返回 -1,说明缓存缺失,需要重建
    if (result == -1) {
        log.info("缓存缺失,执行重建逻辑... uid:{}, cid:{}", userId, commentId);

        // 重建两个缓存
        rebuildUserLikedCache(userId);
        rebuildCommentCountCache(commentId);

        // 再次执行 Lua 脚本
        result = executeLikeScript(userSetKey, countKey, commentId);

        // 如果还是 -1,说明重建失败或者系统异常,做个兜底
        if (result == -1) {
            return ResultCodeEnum.SYSTEM_ERROR;
        }
    }

    // 3. 根据结果返回
    // 或者返回特定枚举 UNLIKED
    if (result == 1) {
        log.info("点赞成功");
    } else {
        log.info("取消点赞成功");
    }
    // 消息队列发送点赞记录入库和点赞数量更新请求
    Like like = new Like();
    like.setUserId(userId);
    like.setCommentId(commentId);
    like.setIsFallback(result == 1 ? 0 : 1);
    like.setCreateTime(Date.from(Instant.now()));
    sender.sendLikeSync(RabbitMQMessageConfig.EXCHANGE_NAME, RabbitMQMessageConfig.ROUTING_KEY_LIKE, like);
    return ResultCodeEnum.SUCCESS;
}
2.2 执行脚本的方法
private Long executeLikeScript(String userSetKey, String countKey, Long commentId) {
    return redisTemplate.execute(
            likeScript,
            Arrays.asList(userSetKey, countKey), // KEYS
            commentId, // ARGV[1]
            7 * 24 * 60 * 60, // ARGV[2] UserSet过期时间 7天
            24 * 60 * 60 // ARGV[3] Count过期时间 1天
    );
}
2.3 重建缓存的方法
/**
 * 重建评论点赞数缓存 (只负责 Count)
 * 调用时机:GET article:count:{id} 返回 null 时
 */
@Description("根据commentId重建点赞数缓存")
private void rebuildCommentCountCache(Long commentId) {
    String countKey = LIKE_COUNT_KEY_PREFIX + commentId;
    // 1. 第一层检查
    if (redisTemplate.hasKey(countKey)) {
        return;
    }

    RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX_COMMENT_LIKE + commentId);
    try {
        // 尝试加锁
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            try {
                // 2. 双重检查
                if (redisTemplate.hasKey(countKey)) {
                    return;
                }

                log.info("重建评论点赞数缓存,id: {}", commentId);

                // 3. 查 DB
                Integer dbCount = likeMapper.countByCommentId(commentId);
                dbCount = dbCount == null ? 0 : dbCount;

                // 4. 写 Redis
                redisTemplate.opsForValue().set(countKey, dbCount, 24, TimeUnit.HOURS);

            } finally {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        log.error("Lock interrupted during comment count rebuild", e);
    }
}

/**
 * 重建用户点赞历史缓存 (只负责 User Set)
 * 调用时机:SISMEMBER user:likes:{uid} 之前的检查发现 Key 不存在时
 */
@Description("根据userId重建用户点赞历史缓存")
private void rebuildUserLikedCache(Long userId) {
    String userSetKey = USER_LIKE_KEY_PREFIX + userId;
    // 1. 第一层检查
    if (redisTemplate.hasKey(userSetKey)) {
        return;
    }

    RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX_USER_LIKE + userId);
    try {
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            try {
                // 2. 双重检查
                if (redisTemplate.hasKey(userSetKey)) {
                    return;
                }

                log.info("重建用户点赞历史缓存,uid: {}", userId);

                // 3. 查 DB 这里查的是 commentId 列表
                List<Like> recentLikes = likeMapper.selectLatestLikes(userId, LIKE_LIMIT);

                if (CollUtil.isNotEmpty(recentLikes)) {
                    // 提取 CommentId
                    Object[] commentIds = recentLikes.stream()
                            .map(Like::getCommentId)
                            .toArray(); // <--- 关键修正:转为数组

                    // 4. 批量写入 Set
                    redisTemplate.opsForSet().add(userSetKey, commentIds);
                } else {
                    // 5. 空值占位防穿透
                    redisTemplate.opsForSet().add(userSetKey, -1L);
                }
                // 统一设置过期时间,用户历史可以久一点
                redisTemplate.expire(userSetKey, 7, TimeUnit.DAYS);

            } finally {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        log.error("Lock interrupted during user history rebuild", e);
    }
}
2.4 lua脚本参考
-- KEYS[1]: 用户点赞列表 Key (user:likes:{uid})
-- KEYS[2]: 文章/评论点赞计数 Key (comment:count:{cid})
-- ARGV[1]: 评论 ID (commentId)
-- ARGV[2]: 用户列表过期时间 (秒)
-- ARGV[3]: 计数缓存过期时间 (秒)

-- 1. 检查缓存是否存在 (如果不存在,返回特殊标记让 Java 层去重建)
if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('EXISTS', KEYS[2]) == 0 then
    return -1 -- 返回 -1 代表缓存失效,需要重建
end

-- 2. 判断用户是否已点赞
local isLiked = redis.call('SISMEMBER', KEYS[1], ARGV[1])

if isLiked == 1 then
    -- === 逻辑分支:取消点赞 ===
    redis.call('SREM', KEYS[1], ARGV[1])
    redis.call('DECR', KEYS[2])

    -- 续期
    redis.call('EXPIRE', KEYS[1], ARGV[2])
    redis.call('EXPIRE', KEYS[2], ARGV[3])

    return 0 -- 返回 0 代表取消点赞成功
else
    -- === 逻辑分支:执行点赞 ===
    redis.call('SADD', KEYS[1], ARGV[1])
    redis.call('INCR', KEYS[2])

    -- 续期
    redis.call('EXPIRE', KEYS[1], ARGV[2])
    redis.call('EXPIRE', KEYS[2], ARGV[3])

    return 1 -- 返回 1 代表点赞成功
end
2.5 MQ消息聚合消费实现参考(JDK21+):
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeConsumer {

    private final LikeMapper likeMapper;

    private final CommentIndexMapper commentIndexMapper;

    private final RedisTemplate<String, String> stringRedisTemplate;

    // 内存缓冲队列
    private final BlockingQueue<Like> bufferQueue = new LinkedBlockingQueue<>();

    private final TransactionTemplate transactionTemplate;

    // 参数配置
    private static final int BATCH_SIZE = 900;
    private static final int FLUSH_INTERVAL_SECONDS = 3;

    // JDK 21: 虚拟线程执行器 (专门用于执行 IO 操作)
    private final ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();

    // 聚合线程 (单线程即可,因为它只负责分发任务,不负责执行)
    private final ExecutorService aggregatorExecutor = Executors.newSingleThreadExecutor();

    @PostConstruct
    public void init() {
        // 启动聚合调度器
        aggregatorExecutor.submit(this::aggregationLoop);
    }

    @RabbitListener(queues = RabbitMQMessageConfig.QUEUE_NAME_LIKE, concurrency = "3-10")
    @Description("按时间聚合,同步点赞记录至数据库")
    public void handleMessage(Like message) {
        // 生产者极快,不阻塞
        bufferQueue.offer(message);
    }

    /**
     * 聚合循环 (长期运行)
     */
    private void aggregationLoop() {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                List<Like> batch = new ArrayList<>();

                // 1. 带超时的获取 (实现时间窗口聚合)
                Like first = bufferQueue.poll(FLUSH_INTERVAL_SECONDS, TimeUnit.SECONDS);

                if (first != null) {
                    batch.add(first);
                    // 2. 尽可能多捞,直到满了
                    bufferQueue.drainTo(batch, BATCH_SIZE - 1);
                }

                // 3. 如果有数据,提交给虚拟线程去执行 DB 操作
                if (!batch.isEmpty()) {
                    // 关键点:这里不再同步等待 processBatch 完成
                    // 而是“发后即忘”,让虚拟线程去扛 IO
                    // 注意:需要拷贝一份 list,因为 batch 会被清空重用
                    List<Like> taskList = new ArrayList<>(batch);

                    ioExecutor.submit(() -> processBatch(taskList));
                }

            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                log.error("聚合循环异常", e);
            }
        }
    }

    /**
     * 耗时的 IO 操作
     */
    private void processBatch(List<Like> messages) {
        try {
            // 1. 聚合点赞列表
            Map<String, Like> uniqueMap = new HashMap<>();
            for (Like item : messages) {
                String key = item.getUserId() + "_" + item.getCommentId();
                uniqueMap.put(key, item);
            }
            List<Like> optimizedList = new ArrayList<>(uniqueMap.values());

            log.debug("虚拟线程 {} 开始执行批量写入,条数: {}", Thread.currentThread(), optimizedList.size());
            long start = System.currentTimeMillis();

            // ================= Refactored Start =================

            // 2. 提取不重复的 commentId 列表
            // 原因:多个用户可能点赞同一个评论,DB批量更新时同一个ID只需更新一次
            List<Long> distinctCommentIds = optimizedList.stream()
                    .map(Like::getCommentId)
                    .distinct()
                    .toList();

            List<CommentHeatDto> heatDtoList = new ArrayList<>();

            if (!distinctCommentIds.isEmpty()) {
                // 3. 使用 Pipeline 批量查询 Redis
                List<Object> pipelineResults = stringRedisTemplate.executePipelined(new SessionCallback<>() {
                    @Override
                    public <K, V> Object execute(@NotNull RedisOperations<K, V> operations) throws DataAccessException {
                        for (Long commentId : distinctCommentIds) {
                            String redisKey = LIKE_COUNT_KEY_PREFIX + commentId;
                            // 仅进入队列,不立即执行
                            operations.opsForValue().get(redisKey);
                        }
                        // 必须返回 null
                        return null;
                    }
                });

                // 4. 处理结果 (Pipeline返回的列表顺序与请求顺序严格一致)
                for (int i = 0; i < distinctCommentIds.size(); i++) {
                    Object result = pipelineResults.get(i);
                    // 如果 result 不为 null,说明 Redis 中有数据 (没有Key就不更新)
                    if (result != null) {
                        Long commentId = distinctCommentIds.get(i);
                        // StringRedisTemplate 返回的一般是 String
                        Integer heat = Integer.valueOf(result.toString());

                        // 构建 DTO
                        heatDtoList.add(new CommentHeatDto(commentId,heat));
                    }
                }
            }
            // ================= Refactored End =================

            // 5. 开启事务执行数据库操作
            if (!optimizedList.isEmpty()) {
                transactionTemplate.execute(status -> {
                    // 批量插入点赞记录
                    if (!optimizedList.isEmpty()) {
                        likeMapper.batchInsert(optimizedList);
                    }
                    // 批量更新热度 (仅更新 Redis 中存在的)
                    if (!heatDtoList.isEmpty()) {
                        commentIndexMapper.batchUpdateHeat(heatDtoList);
                    }
                    return null;
                });
            }

            log.debug("写入完成,耗时: {}ms,点赞落库: {} 条,热度更新: {} 条",
                    System.currentTimeMillis() - start, optimizedList.size(), heatDtoList.size());

        } catch (Exception e) {
            log.error("批量写入失败", e);
        }
    }

    @PreDestroy
    public void destroy() {
        // 优雅关闭
        aggregatorExecutor.shutdownNow();
        ioExecutor.close(); // JDK 21 新增的 close 方法,会等待任务完成
    }
}

⚠️上面的实现仅供参考,未经严格测试,请根据自己的需求修改。

完整代码请移步代码仓库:GitHub - l0sgAi/car-rental-backend at new_comment


II. 评论系统

评论区的评论为双层结构,使用parentIdfollowCommentId来表示回复和父子关系。顶级评论进行分页加载,回复则默认全部折叠,只显示回复数量,通过手动点击“查看n条回复”来手动分页获取。

这里采用评论索引和评论内容分离的表设计,以更好的设计缓存。

1.表设计

数据库表目前采用MySQL,评论相关表结构如下:

主题表 (这里是车辆信息)

评论主题表为评论区的主题聚合信息,即评论对应的实体。

-- 车辆信息表  
CREATE TABLE `car`  
(  
    `id`               bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',    `brand_id`         bigint unsigned NOT NULL COMMENT '品牌ID',  
    `name`             varchar(50)     NOT NULL COMMENT '车辆名',  
    `number`           varchar(50)     NOT NULL COMMENT '车牌号',  
    `min_rental_days`  int             NOT NULL COMMENT '最小租赁天数',  
    `daily_rent`       decimal(10, 2)  NOT NULL COMMENT '日租金(人民币元)',  

    `car_type`         varchar(50)              DEFAULT NULL COMMENT '车型',  
    `power_type`       varchar(50)              DEFAULT NULL COMMENT '动力类型',  
    `purchase_time`    date                     DEFAULT NULL COMMENT '车辆购买日期',  
    `horsepower`       int                      DEFAULT NULL COMMENT '马力',  
    `torque`           int                      DEFAULT NULL COMMENT '最大扭矩',  
    `fuel_consumption` int                      DEFAULT NULL COMMENT '百公里油耗(L/100km)',  
    `endurance`        int                      DEFAULT NULL COMMENT '理论续航km',  
    `description`      varchar(1536)            DEFAULT NULL COMMENT '描述',  
    `size`             varchar(50)              DEFAULT NULL COMMENT '尺寸,长×宽×高',  
    `seat`             int                      DEFAULT NULL COMMENT '座位数',  
    `weight`           int                      DEFAULT NULL COMMENT '车重(kg)',  
    `volume`           int                      DEFAULT NULL COMMENT '储物容积(L)',  
    `acceleration`     decimal(4, 1)            DEFAULT NULL COMMENT '百公里加速(s)',  
    `images`           varchar(1536)            DEFAULT NULL COMMENT '图片url列表,逗号分隔,最多9张图片',  

    `status`           tinyint         NOT NULL DEFAULT '0' COMMENT '车辆状态:0=正常,1=不可租',  
    `hot_score`        int             NOT NULL DEFAULT '0' COMMENT '热度评分',  
    `avg_score`        int             NOT NULL DEFAULT '0' COMMENT '车辆用户平均评分',  

    `create_time`      datetime        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',  
    `update_time`      datetime        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',  
    `deleted`          tinyint         NOT NULL DEFAULT '0' COMMENT '逻辑删除:0=正常,1=已删除',  
    PRIMARY KEY (`id`)) ENGINE = InnoDB  
  AUTO_INCREMENT = 1  DEFAULT CHARSET = utf8mb4  COLLATE = utf8mb4_0900_ai_ci COMMENT ='车辆信息表';  

评论索引表

评论索引表只构建评论的结构索引,不储存实际的评论细节。

-- 评论索引表  
CREATE TABLE `comment_index`  
(  
    `id`                bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',    `user_id`           bigint unsigned NOT NULL COMMENT '对应用户ID',  
    `car_id`            bigint unsigned NOT NULL COMMENT '对应车辆ID',  
    `parent_comment_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '父级评论id,默认0即为顶级评论',  
    `follow_comment_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '回复评论id,默认0即非回复评论',  
    `hot_score`         int unsigned NOT NULL DEFAULT '0' COMMENT '热度-目前只用点赞数实现',  

    `create_time`       datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',  
    `update_time`       datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',  
    `deleted`           tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除:0=正常,1=已删除',  
    PRIMARY KEY (`id`)) ENGINE = InnoDB  
  AUTO_INCREMENT = 1  DEFAULT CHARSET = utf8mb4  COLLATE = utf8mb4_0900_ai_ci COMMENT ='评论索引表';  

-- 推荐索引,必须包含 car_id 用于分片路由,hot_score 用于排序,create_time排序补充
ALTER TABLE `comment_index` ADD INDEX `idx_car_hot` (`car_id`, `parent_comment_id`, `hot_score` DESC, `create_time` DESC);

评论详情表

评论详情再储存具体的评论数据,实现了评论结构和评论内容的解耦,这样的话,可以很好的防止在缓存过程中出现大key的问题。

-- 评论详情表  
CREATE TABLE `comment_detail`  
(  
    `id`                bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',    `index_id`          bigint unsigned NOT NULL COMMENT '对应索引ID',  
    `content`           varchar(1024) NOT NULL COMMENT '评论内容',  
    `score`             int unsigned DEFAULT NULL COMMENT '近期订单评分',  
    `extra_images`      JSON DEFAULT NULL COMMENT '评论图片URL列表(JSON数组)',  
    `create_time`       datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',  
    `update_time`       datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',  
    `deleted`           tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除:0=正常,1=已删除',  
    PRIMARY KEY (`id`)) ENGINE = InnoDB  
  AUTO_INCREMENT = 1  DEFAULT CHARSET = utf8mb4  COLLATE = utf8mb4_0900_ai_ci COMMENT ='评论详情表';  

外部关联表

引入一个额外的业务背景:如果用户对于某个车辆,有完成且打分的订单,用户评论会额外显示最近一次的订单的打分。

具体的业务实现如下:

用户发送评论时,查询数据库,如果评论的车辆之前有订单评分分数,把最近的一次订单评分插入到评论详情的score字段中 (create_time desc,limit 1)。即一个当时评分的快照。

-- 订单表  
CREATE TABLE `rental_order`  
(  
 `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', `user_id` bigint unsigned NOT NULL COMMENT '对应用户ID',  
 `car_id` bigint unsigned NOT NULL COMMENT '对应车辆ID',  
 `trade_no` varchar(255) NOT NULL COMMENT '支付宝订单编号',   
 `start_rental_time` date NOT NULL COMMENT '车辆起租日期',  
 `end_rental_time` date NOT NULL COMMENT '车辆还车日期',  
 `price` decimal(10, 2) NOT NULL COMMENT '订单总额(人民币元)',  
 `address` varchar(255) NOT NULL COMMENT '取还车地址',   
 `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0=新建/待支付,1=已支付,2=租赁中,3=已完成,4=已取消 5=待退款 6=已退款',  
 `score` int DEFAULT NULL COMMENT '订单评分0-10,计入车辆均分',   
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',  
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',  
 `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除:0=正常,1=已删除',  
 PRIMARY KEY (`id`)) ENGINE = InnoDB  
 AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT ='订单信息表';  

2. 新增评论的实现-带审核流程

入参:评论信息Dto/用户ID

逻辑链路:

  1. 构建评论索引实体并插入,设置状态为未过审,先不显示
  2. 构建评论详情实体并插入,设置状态为未过审,先不显示
  3. 发送消息到MQ让LLM执行审核,审核通过就更新状态
2.1 服务层参考实现
@Override
@Transactional
public ResultCodeEnum add(CommentDto comment, Long userId) {
    // userId后端检查
    comment.setUserId(userId);
    comment.setCreateTime(Date.from(Instant.now()));
    comment.setUpdateTime(Date.from(Instant.now()));
    // 构建索引
    CommentIndex commentIndex = new CommentIndex();
    commentIndex.setUserId(userId);
    commentIndex.setCarId(comment.getCarId());
    commentIndex.setParentCommentId(comment.getParentCommentId());
    commentIndex.setFollowCommentId(comment.getFollowCommentId());
    commentIndex.setHotScore(0);
    commentIndex.setCreateTime(comment.getCreateTime());
    commentIndex.setUpdateTime(comment.getUpdateTime());
    // 审核结束前都置为1已经删除,不做展示
    commentIndex.setDeleted(1);
    // 执行插入
    commentIndexMapper.insert(commentIndex);
    // 构建详情
    CommentDetail commentDetail = new CommentDetail();
    commentDetail.setIndexId(commentIndex.getId());
    commentDetail.setContent(comment.getContent());
    // score从用户订单中查询
    Integer score = rentalOrderMapper.getScoreByUserIdAndCarId(userId, comment.getCarId());
    commentDetail.setScore(score);
    commentDetail.setExtraImages(comment.getExtraImages());
    commentDetail.setCreateTime(comment.getCreateTime());
    commentDetail.setUpdateTime(comment.getUpdateTime());
    // 审核结束前都置为1已经删除,不做展示
    commentDetail.setDeleted(1);
    commentDetailMapper.insert(commentDetail);
    // 发给消息队列执行审核
    sender.sendCarReview(RabbitMQMessageConfig.EXCHANGE_NAME,
            RabbitMQMessageConfig.ROUTING_KEY_COMMENT_CENSOR,
            new ReviewDto(commentIndex.getId(), commentDetail.getId(), commentDetail.getContent()));
    return ResultCodeEnum.SUCCESS;
}
2.2 审核更新流程参考实现
/**
 * AI审核,默认使用SpringAI,异步请求3-10个线程处理
 */
@RabbitListener(queues = RabbitMQMessageConfig.QUEUE_NAME_COMMENT_CENSOR, concurrency = "3-10")
@Description("审核评论")
@Transactional
public void receiveCommentCensor(ReviewDto reviewDto) {
    log.info("[MQ]消费者收到消息:{}", reviewDto);

    AiConfig aiConfig = commentService.getDefaultConfig();

    // 调用大模型
    String s = modelBuilder.buildModelWithoutMemo(
            aiConfig,
            SYS_CENSOR_PROMPT,
            reviewDto.getContent());

    if ("0".equals(s)) {
        try {
            // 审核通过,更新评论显示状态
            CommentIndex commentIndex = new CommentIndex();
            commentIndex.setId(reviewDto.getIndexId());
            commentIndex.setDeleted(0);
            commentIndexMapper.updateByPrimaryKeySelective(commentIndex);
            CommentDetail commentDetail = new CommentDetail();
            commentDetail.setId(reviewDto.getDetailId());
            commentDetail.setDeleted(0);
            commentDetailMapper.updateByPrimaryKeySelective(commentDetail);
        } catch (Exception e) {
            log.error("[MQ:用户发送评论] 插入数据库失败", e);
        }
    }
}

3. 分页查询评论的实现

入参:carId/parentCommentId

逻辑链路

  1. 根据carId分页查询查询顶级索引
  2. 封装回复条数等,供前端回显
  3. 异步获取评论内容与点赞情况
  4. 封装vo并返回
3.1 获取评论列表的方法
/**
 * 评论列表异步编排组装
 */
@Override
public List<CommentVo> queryByCarId(Long carId) {
    // 查询1级评论索引 limit10,带回复数量
    List<CommentIndexDto> indexes = commentIndexMapper.queryByCarIdWithLimit(carId, 10);

    if (indexes.isEmpty()) {
        return Collections.emptyList();
    }
    Long curUserId = StpUtil.getLoginIdAsLong();

    // 提取所有涉及的 commentId,用于批量查询
    List<Long> commentIds = indexes.stream().map(CommentIndexDto::getId).collect(Collectors.toList());

    // ================== 异步任务编排开始 ==================

    // 2. 异步任务 A:查询评论内容(带多级缓存策略)
    CompletableFuture<Map<Long, CommentDetail>> contentFuture = CompletableFuture
            .supplyAsync(() -> getCommentContentMap(commentIds), vtExecutor);

    // 3. 异步任务 B:查询点赞数据(点赞数 + 当前用户是否点赞)
    CompletableFuture<Map<Long, LikeInfoVo>> likeFuture = CompletableFuture
            .supplyAsync(() -> getCommentLikeMap(commentIds, curUserId), vtExecutor);

    CompletableFuture.allOf(contentFuture, likeFuture).join();

    // ================== 数据组装 ==================

    Map<Long, CommentDetail> contentMap = contentFuture.getNow(Collections.emptyMap());
    Map<Long, LikeInfoVo> likeMap = likeFuture.getNow(Collections.emptyMap());

    // 5. 分组与封装 (父子评论归位)
    // 找出所有一级评论 (假设 parentId 为 0 或 null 代表一级)
    // 寻找该一级评论下的二级评论 (Reply)
    // 假设有rootParentId指向顶级

    return indexes.stream()
            .map(index -> convertToVo(index, contentMap, likeMap))
            .collect(Collectors.toList());
}
3.2 加载回复的方法
@Override
@Description("分页加载回复")
public List<CommentVo> loadReplyByCommentId(Long parentCommentId) {
    // 查询1级评论索引 limit20,带回复数量,覆盖前10条
    List<CommentIndexDto> indexes = commentIndexMapper.queryReplyWithLimit(parentCommentId);

    if (indexes.isEmpty()) {
        return Collections.emptyList();
    }
    Long curUserId = StpUtil.getLoginIdAsLong();

    // 提取所有涉及的 commentId,用于批量查询
    List<Long> commentIds = indexes.stream().map(CommentIndexDto::getId).collect(Collectors.toList());

    // ================== 异步任务编排开始 ==================

    // 2. 异步任务 A:查询评论内容(带多级缓存策略)
    CompletableFuture<Map<Long, CommentDetail>> contentFuture = CompletableFuture
            .supplyAsync(() -> getCommentContentMap(commentIds), vtExecutor);

    // 3. 异步任务 B:查询点赞数据(点赞数 + 当前用户是否点赞)
    CompletableFuture<Map<Long, LikeInfoVo>> likeFuture = CompletableFuture
            .supplyAsync(() -> getCommentLikeMap(commentIds, curUserId), vtExecutor);

    CompletableFuture.allOf(contentFuture, likeFuture).join();

    // ================== 数据组装 ==================

    Map<Long, CommentDetail> contentMap = contentFuture.getNow(Collections.emptyMap());
    Map<Long, LikeInfoVo> likeMap = likeFuture.getNow(Collections.emptyMap());

    // 5. 分组与封装 (父子评论归位)
    // 找出所有一级评论 (假设 parentId 为 0 或 null 代表一级)
    // 寻找该一级评论下的二级评论 (Reply)
    // 假设有rootParentId指向顶级

    return indexes.stream()
            .map(index -> convertToVo(index, contentMap, likeMap))
            .collect(Collectors.toList());
}
3.3 获取评论内容的封装函数
/**
 * 获取评论内容 Map
 * 优化策略: MultiGet(Redis) -> BatchSelect(DB) -> Pipeline SetEx(Redis)
 */
private Map<Long, CommentDetail> getCommentContentMap(List<Long> commentIds) {
    Map<Long, CommentDetail> resultMap = new HashMap<>();

    // 1. 构造 Redis Keys
    List<String> keys = commentIds.stream()
            .map(id -> COMMENT_CACHE_KEY_PREFIX + id)
            .collect(Collectors.toList());

    // 2. 批量查询 Redis
    List<Object> cacheResults = redisTemplate.opsForValue().multiGet(keys);

    List<Long> missingIds = new ArrayList<>();

    // 3. 分离命中与未命中
    for (int i = 0; i < commentIds.size(); i++) {
        Long id = commentIds.get(i);
        Object value = (cacheResults != null && cacheResults.size() > i) ? cacheResults.get(i) : null;
        if (value instanceof CommentDetail) {
            resultMap.put(id, (CommentDetail) value);
        } else {
            missingIds.add(id);
        }
    }

    // 4. 处理未命中:查库 + Pipeline 回填
    if (!missingIds.isEmpty()) {
        List<CommentDetail> dbDetails = commentDetailMapper.selectBatchIds(missingIds);

        // 准备回填的数据
        Map<String, CommentDetail> cacheUploadMap = new HashMap<>();
        for (CommentDetail detail : dbDetails) {
            resultMap.put(detail.getId(), detail);
            cacheUploadMap.put(COMMENT_CACHE_KEY_PREFIX + detail.getIndexId(), detail);
        }

        if (!cacheUploadMap.isEmpty()) {
            // 使用 Pipeline + SetEx 替代 multiSet,支持过期时间
            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                @SuppressWarnings("unchecked")
                RedisSerializer<String> keySerializer = (RedisSerializer<String>) redisTemplate.getKeySerializer();
                @SuppressWarnings("unchecked")
                RedisSerializer<Object> valueSerializer = (RedisSerializer<Object>) redisTemplate.getValueSerializer();

                for (Map.Entry<String, CommentDetail> entry : cacheUploadMap.entrySet()) {
                    byte[] keyBytes = keySerializer.serialize(entry.getKey());
                    byte[] valBytes = valueSerializer.serialize(entry.getValue());

                    // 生成随机过期时间:基础时间 + 0~3600秒随机值,防止雪崩
                    long ttl = CACHE_TTL_SECONDS + ThreadLocalRandom.current().nextInt(3600);

                    if (keyBytes != null && valBytes != null) {
                        connection.stringCommands().setEx(keyBytes, ttl, valBytes);
                    }
                }
                return null;
            });
        }
    }

    return resultMap;
}
3.4 获取点赞情况的函数
/**
 * 获取点赞信息 Map
 * 优化策略: Pipeline Get(Redis) -> BatchSelect(DB) -> Pipeline SetEx(Redis)
 */
private Map<Long, LikeInfoVo> getCommentLikeMap(List<Long> commentIds, Long curUserId) {
    Map<Long, LikeInfoVo> resultMap = new HashMap<>();

    // 0. 预处理用户点赞集合
    rebuildUserLikedCache(curUserId);
    String userLikeKey = USER_LIKE_KEY_PREFIX + curUserId;

    // 1. 使用 Pipeline 批量获取 (Count + IsLiked)
    List<Object> pipelineResults = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (Long id : commentIds) {
            String likeCountKey = LIKE_COUNT_KEY_PREFIX + id;

            // 命令 A: 获取点赞数
            connection.stringCommands().get(likeCountKey.getBytes());

            // 命令 B: 获取是否点赞 (仅当已登录时查询)
            connection.setCommands().sIsMember(userLikeKey.getBytes(), String.valueOf(id).getBytes());
        }
        return null;
    });

    // 2. 解析结果
    // 步长
    int step = 2;

    List<Long> missingCountIds = new ArrayList<>();

    for (int i = 0; i < commentIds.size(); i++) {
        Long id = commentIds.get(i);
        LikeInfoVo info = new LikeInfoVo();

        // --- 解析点赞数 ---
        Object countObj = pipelineResults.get(i * step);
        if (countObj != null) {
            info.setCount(Integer.parseInt((String) countObj));
        } else {
            missingCountIds.add(id);
            info.setCount(0);
        }

        // --- 解析是否点赞 ---
        // pipelineResults可能会包含null,如果命令执行失败
        Object isMemberObj = pipelineResults.get(i * step + 1);
        info.setLiked(isMemberObj instanceof Boolean && (Boolean) isMemberObj);

        resultMap.put(id, info);
    }

    // 3. 批量查库回填点赞数
    if (!missingCountIds.isEmpty()) {
        List<CommentLikeCountDto> dtos = likeMapper.selectCountsBatch(missingCountIds);

        Map<Long, Integer> dbCounts = dtos.stream()
                .collect(Collectors.toMap(CommentLikeCountDto::getCommentId, CommentLikeCountDto::getCount));

        Map<String, String> cacheUpdateMap = new HashMap<>();

        for (Long missingId : missingCountIds) {
            Integer dbCount = dbCounts.getOrDefault(missingId, 0);

            // 更新返回结果
            resultMap.get(missingId).setCount(dbCount);

            // 准备缓存数据
            cacheUpdateMap.put(LIKE_COUNT_KEY_PREFIX + missingId, String.valueOf(dbCount));
        }

        if (!cacheUpdateMap.isEmpty()) {
            // 【关键优化】使用 Pipeline + SetEx 回填点赞数
            stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                for (Map.Entry<String, String> entry : cacheUpdateMap.entrySet()) {
                    byte[] keyBytes = entry.getKey().getBytes();
                    byte[] valBytes = entry.getValue().getBytes();

                    // 点赞数属于热点数据,过期时间可以稍短,并加随机
                    long ttl = CACHE_TTL_SECONDS + ThreadLocalRandom.current().nextInt(3600);

                    connection.stringCommands().setEx(keyBytes, ttl, valBytes);
                }
                return null;
            });
        }
    }

    return resultMap;
}

⚠️上面的实现仅供参考,未经严格测试,请根据自己的需求修改。

完整代码请移步代码仓库:GitHub - l0sgAi/car-rental-backend at new_comment


总结

🤯本文给出了一种评论和点赞系统的设计思路,设计和实现上可能有可以优化的部分,如有任何问题与建议,欢迎在评论区讨论!