feat(comment): 引入本地缓存优化评论查询性能

- 添加 Caffeine 依赖以支持本地缓存
- 实现评论详情的本地缓存机制,减少 Redis 查询压力
-优化分页查询逻辑,优先从本地缓存获取评论数据
- 异步同步评论详情至本地缓存,提升响应速度
- 简化 Redis 操作代码,提高可读性和维护性
This commit is contained in:
2025-11-08 15:34:27 +08:00
parent 6fbe8eed25
commit 85e6bab079
2 changed files with 143 additions and 65 deletions

View File

@@ -130,6 +130,12 @@
<artifactId>han-note-user-api</artifactId> <artifactId>han-note-user-api</artifactId>
</dependency> </dependency>
<!-- Caffeine 本地缓存 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies> </dependencies>

View File

@@ -3,6 +3,8 @@ package com.hanserwei.hannote.comment.biz.service.impl;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
@@ -34,7 +36,11 @@ import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.*; import org.apache.logging.log4j.util.Strings;
import org.jspecify.annotations.NonNull;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -64,6 +70,15 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
@Resource(name = "taskExecutor") @Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor; private ThreadPoolTaskExecutor threadPoolTaskExecutor;
/**
* 评论详情本地缓存
*/
private static final Cache<Long, String> LOCAL_CACHE = Caffeine.newBuilder()
.initialCapacity(10000) // 设置初始容量为 10000 个条目
.maximumSize(10000) // 设置缓存的最大容量为 10000 个条目
.expireAfterWrite(1, TimeUnit.HOURS) // 设置缓存条目在写入后 1 小时过期
.build();
@Override @Override
public Response<?> publishComment(PublishCommentReqVO publishCommentReqVO) { public Response<?> publishComment(PublishCommentReqVO publishCommentReqVO) {
// 评论正文 // 评论正文
@@ -167,47 +182,83 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
} }
// 分页返回参数 // 分页返回参数
List<FindCommentItemRspVO> commentRspVOS = null; List<FindCommentItemRspVO> commentRspVOS;
// 若评论总数大于0 commentRspVOS = Lists.newArrayList();
if (count > 0) { // 计算分页查询的offset
commentRspVOS = Lists.newArrayList(); long offset = PageResponse.getOffset(pageNo, pageSize);
// 计算分页查询的offset // 评论分页缓存使用 ZSET + STRING 实现
long offset = PageResponse.getOffset(pageNo, pageSize); // 构建评论 ZSET Key
// 评论分页缓存使用 ZSET + STRING 实现 String commentZSetKey = RedisKeyConstants.buildCommentListKey(noteId);
// 构建评论 ZSET Key // 先判断 ZSET 是否存在
String commentZSetKey = RedisKeyConstants.buildCommentListKey(noteId); boolean hasKey = redisTemplate.hasKey(commentZSetKey);
// 先判断 ZSET 是否存在
boolean hasKey = redisTemplate.hasKey(commentZSetKey);
// 若不存在 // 若不存在
if (!hasKey) { if (!hasKey) {
// 异步将热点评论同步到 redis 中(最多同步 500 条) // 异步将热点评论同步到 redis 中(最多同步 500 条)
threadPoolTaskExecutor.execute(() -> threadPoolTaskExecutor.execute(() ->
syncHeatComments2Redis(commentZSetKey, noteId)); syncHeatComments2Redis(commentZSetKey, noteId));
} }
// 若 ZSET 缓存存在, 并且查询的是前 50 页的评论 // 若 ZSET 缓存存在, 并且查询的是前 50 页的评论
if (hasKey && offset < 500) { if (hasKey && offset < 500) {
// 使用 ZRevRange 获取某篇笔记下,按热度降序排序的一级评论 ID // 使用 ZRevRange 获取某篇笔记下,按热度降序排序的一级评论 ID
Set<Object> commentIds = redisTemplate.opsForZSet() Set<Object> commentIds = redisTemplate.opsForZSet()
.reverseRangeByScore(commentZSetKey, -Double.MAX_VALUE, Double.MAX_VALUE, offset, pageSize); .reverseRangeByScore(commentZSetKey, -Double.MAX_VALUE, Double.MAX_VALUE, offset, pageSize);
// 若结果不为空 // 若结果不为空
if (CollUtil.isNotEmpty(commentIds)) { if (CollUtil.isNotEmpty(commentIds)) {
// Set 转 List // Set 转 List
List<Object> commentIdList = Lists.newArrayList(commentIds); List<Object> commentIdList = Lists.newArrayList(commentIds);
// 构建 MGET 批量查询评论详情的 Key 集合 // 先查询本地缓存
List<String> commentIdKeys = commentIdList.stream() // 新建一个集合用于存储本地缓存中不存在的评论ID
.map(RedisKeyConstants::buildCommentDetailKey) List<Long> localeCacheExpiredCommentIds = Lists.newArrayList();
.toList();
// MGET 批量获取评论数据 // 构建本地缓存的key集合
List<Object> commentsJsonList = redisTemplate.opsForValue().multiGet(commentIdKeys); List<Long> localCacheKeys = commentIdList.stream()
.map(e -> Long.valueOf(e.toString()))
.toList();
// 可能存在部分评论不在缓存中,已经过期被删除,这些评论 ID 需要提取出来,等会查数据库 // 批量查询本地缓存
List<Long> expiredCommentIds = Lists.newArrayList(); Map<Long, @NonNull String> commentIdAndDetailJsonMap = LOCAL_CACHE.getAll(localCacheKeys, missingKeys -> {
// 对应本地缓存缺失的Key返回空字符串
Map<Long, String> missingData = Maps.newHashMap();
missingKeys.forEach(key -> {
// 记录缓存中不存在的ID
localeCacheExpiredCommentIds.add(key);
// 不存在的评论详情对其Value设置为空字符串
missingData.put(key, Strings.EMPTY);
});
return missingData;
});
// 如果localCacheExpiredCommentIds的大小不等于commentIdList的大小说明本地缓存中有数据
if (CollUtil.size(localeCacheExpiredCommentIds) != commentIdList.size()) {
// 将本地缓存中的评论详情Json转为实体类添加到VO返参集合中
for (String value : commentIdAndDetailJsonMap.values()) {
if (StringUtils.isBlank(value)) continue;
FindCommentItemRspVO commentRspVO = JsonUtils.parseObject(value, FindCommentItemRspVO.class);
commentRspVOS.add(commentRspVO);
}
}
// 如果localCacheExpiredCommentIds大小为0说明评论详情全在本地缓存中直接响应返参
if (CollUtil.size(localeCacheExpiredCommentIds) == 0) {
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
}
// 构建 MGET 批量查询评论详情的 Key 集合
List<String> commentIdKeys = localeCacheExpiredCommentIds.stream()
.map(RedisKeyConstants::buildCommentDetailKey)
.toList();
// MGET 批量获取评论数据
List<Object> commentsJsonList = redisTemplate.opsForValue().multiGet(commentIdKeys);
// 可能存在部分评论不在缓存中,已经过期被删除,这些评论 ID 需要提取出来,等会查数据库
List<Long> expiredCommentIds = Lists.newArrayList();
if (commentsJsonList != null) {
for (int i = 0; i < commentsJsonList.size(); i++) { for (int i = 0; i < commentsJsonList.size(); i++) {
String commentJson = (String) commentsJsonList.get(i); String commentJson = (String) commentsJsonList.get(i);
if (Objects.nonNull(commentJson)) { if (Objects.nonNull(commentJson)) {
@@ -219,29 +270,56 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
expiredCommentIds.add(Long.valueOf(commentIdList.get(i).toString())); expiredCommentIds.add(Long.valueOf(commentIdList.get(i).toString()));
} }
} }
// 对于不存在的一级评论,需要批量从数据库中查询,并添加到 commentRspVOS 中
if (CollUtil.isNotEmpty(expiredCommentIds)) {
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(expiredCommentIds);
getCommentDataAndSync2Redis(commentDOS, noteId, commentRspVOS);
}
} }
// 按热度值进行降序排列 // 对于不存在的一级评论,需要批量从数据库中查询,并添加到 commentRspVOS 中
commentRspVOS = commentRspVOS.stream() if (CollUtil.isNotEmpty(expiredCommentIds)) {
.sorted(Comparator.comparing(FindCommentItemRspVO::getHeat).reversed()) List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(expiredCommentIds);
.collect(Collectors.toList()); getCommentDataAndSync2Redis(commentDOS, noteId, commentRspVOS);
}
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
} }
// 缓存中没有,则查询数据库
//查询一级评论 // 按热度值进行降序排列
List<CommentDO> oneLevelCommentIds = commentDOMapper.selectPageList(noteId, offset, pageSize); commentRspVOS = commentRspVOS.stream()
getCommentDataAndSync2Redis(oneLevelCommentIds, noteId, commentRspVOS); .sorted(Comparator.comparing(FindCommentItemRspVO::getHeat).reversed())
.collect(Collectors.toList());
// 异步将评论详情,同步到本地缓存
syncCommentDetail2LocalCache(commentRspVOS);
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
} }
// 缓存中没有,则查询数据库
//查询一级评论
List<CommentDO> oneLevelCommentIds = commentDOMapper.selectPageList(noteId, offset, pageSize);
getCommentDataAndSync2Redis(oneLevelCommentIds, noteId, commentRspVOS);
// 异步将评论详情,同步到本地缓存
syncCommentDetail2LocalCache(commentRspVOS);
return PageResponse.success(commentRspVOS, pageNo, count, pageSize); return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
} }
/**
* 同步评论详情到本地缓存中
*
* @param commentRspVOS 评论VO列表
*/
private void syncCommentDetail2LocalCache(List<FindCommentItemRspVO> commentRspVOS) {
// 开启一个异步线程
threadPoolTaskExecutor.execute(() -> {
// 构建缓存所需的键值
Map<Long, String> localCacheData = Maps.newHashMap();
commentRspVOS.forEach(commentRspVO -> {
Long commentId = commentRspVO.getCommentId();
localCacheData.put(commentId, JsonUtils.toJsonString(commentRspVO));
});
// 批量写入本地缓存
LOCAL_CACHE.putAll(localCacheData);
});
}
/** /**
* 获取一级评论数据,并同步到 Redis 中 * 获取一级评论数据,并同步到 Redis 中
* *
@@ -378,10 +456,10 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
// 批量写入并设置过期时间 // 批量写入并设置过期时间
connection.setEx( connection.stringCommands().setEx(
redisTemplate.getStringSerializer().serialize(entry.getKey()), Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(entry.getKey())),
randomExpire, randomExpire,
redisTemplate.getStringSerializer().serialize(jsonStr) Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(jsonStr))
); );
} }
return null; return null;
@@ -396,18 +474,12 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
* @param dbCount 数据库查询到的笔记评论总数 * @param dbCount 数据库查询到的笔记评论总数
*/ */
private void syncNoteCommentTotal2Redis(String noteCommentTotalKey, Long dbCount) { private void syncNoteCommentTotal2Redis(String noteCommentTotalKey, Long dbCount) {
redisTemplate.executePipelined(new SessionCallback<>() { // 同步 hash 数据
@Override redisTemplate.opsForHash().put(noteCommentTotalKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, dbCount);
public Object execute(RedisOperations operations) {
// 同步 hash 数据
operations.opsForHash().put(noteCommentTotalKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, dbCount);
// 随机过期时间 (保底1小时 + 随机时间),单位:秒 // 随机过期时间 (保底1小时 + 随机时间),单位:秒
long expireTime = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60); long expireTime = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60);
operations.expire(noteCommentTotalKey, expireTime, TimeUnit.SECONDS); redisTemplate.expire(noteCommentTotalKey, expireTime, TimeUnit.SECONDS);
return null;
}
});
} }
/** /**