概要
该方案是一个典型的用空间(Redis缓存)和异步化换取时间(响应速度)和系统稳定性(数据库抗压) 的架构设计。它非常适合点赞这类写多读多、对实时一致性要求稍低的业务场景。
前言
学习java过程中的心得,如有错误请提醒作者纠正,感谢不尽!!!
如果有更好的实现,欢迎分享!!!
当前实现优点
-
高性能与低延迟:
- 写操作快:用户点赞/拉踩请求直接操作内存数据库Redis,响应速度极快,用户体验好。
- 数据库在定时任务触发前压力小:高频的写操作被Redis承接,避免了直接冲击MySQL
-
数据一致性保障:
- 原子性操作:使用Lua脚本在Redis内完成状态切换(点赞->拉踩->无状态),保证了“一个评论同一时刻只能有一种状态”的业务逻辑的原子性,防止并发请求导致的数据错乱。
- 分布式锁:对单个用户的操作加锁(
RLock),防止同一用户极短时间内的重复提交造成缓存数据问题。
-
批量处理效率高:
- 异步落库:定时任务将缓存中的大量变更集中起来,通过批量插入/更新(
ON DUPLICATE KEY UPDATE)和批量更新统计(CASE WHEN)的方式与数据库交互,极大地减少了网络I/O和SQL执行次数,数据库处理效率高。
- 异步落库:定时任务将缓存中的大量变更集中起来,通过批量插入/更新(
当前实现存在问题
-
定时任务引发的数据库峰值:
- 可通过使用消息队列削峰填谷
- 将定时任务从处理全部数据改为处理部分数据,定时任务的周期调低
主要实现细节
Redis + Redis分布式锁 + 原子操作 + 异步落库 + SpringBoot定时任务
流程图
请求方法设计
请求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
- 我们首先要从redis中获取数据,并将数据封装到两个集合中。
- 落库到MySQL
- 根据x_article_comments_votes表,通过SQL语句统计出like_count, dislike_count
- 将统计出的数据更新到x_article_comments
- 清除已在该定时任务处理完的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;
}