系统设计实战:高性能点赞与评论系统设计分享-以租车业务为例
背景
最近我在开发一个租车网站,在车辆详情页面计划实现一个评论区,在实际的实现过程中发现这个评论和点赞系统的设计,还真不那么简单。经过数日的摸索,现在把设计与实现的经验分享给大家
I. 点赞系统
点赞作为一个高频读写的业务,我们尽量在Redis上做读写。具体来说,我们维护2个key:
- 用户点赞的评论列表set,结构为[用户ID:点赞评论ID集合]
- 评论的点赞计数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
点赞/取消点赞的逻辑链路如下:
- 检查有关点赞的2个key是否存在,包括点赞计数和点赞列表,没有的话需要重建缓存,在重建过程中加分布式锁防止缓存击穿。
- 检查点赞列表,对比当前用户id,如果点赞列表中有对应用户id,说明是取消点赞操作,把userId从点赞列表移除,并把点赞计数-1。反之往点赞列表添加userId并把点赞计数+1,达成点赞。
- 最后往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. 评论系统
评论区的评论为双层结构,使用parentId和followCommentId来表示回复和父子关系。顶级评论进行分页加载,回复则默认全部折叠,只显示回复数量,通过手动点击“查看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
逻辑链路:
- 构建评论索引实体并插入,设置状态为未过审,先不显示
- 构建评论详情实体并插入,设置状态为未过审,先不显示
- 发送消息到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
逻辑链路:
- 根据carId分页查询查询顶级索引
- 封装回复条数等,供前端回显
- 异步获取评论内容与点赞情况
- 封装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
总结
🤯本文给出了一种评论和点赞系统的设计思路,设计和实现上可能有可以优化的部分,如有任何问题与建议,欢迎在评论区讨论!