亿级流量点赞系统-第三章
1. 上期回顾
项目源码地址:https://github.com/ruogu-coder/ruogu-like
在项目的第二期点赞优化中,优化了查询点赞的功能,从 Mysql 查询修改为了 Redis 查询,使用 satoken 实现了分布式登录。
2. 本期目标
优化点赞功能的写操作,针对点赞和取消点赞功能的高并发场景下出现的性能瓶颈。
3. 实现思路
上期我们设计的用户点赞的数据结构为:
- key 为博客 id,field 为用户 id,value 是点赞记录 Id
本期所有操作都放在 redis 即点赞时不插入数据库(定时任务时才插入),所有 value 需要修改为 boolean 类型表示是否点赞
Redis 缓存 + 定时任务,将点赞数据缓存到 Redis 中,定时批量的插入到数据库中。
这个临时点赞记录表存储还是使用 Hash 结构,具体为:
Key:thumb:temp:{时间}
Field:{userId}:{bolgId}
Value: {"type":(1-点赞,-1-取消点赞 0-未变化),time:点赞时间}
设计 key 为时间的好处:
- 数据分片:将点赞操作按时间戳分片存储,即将不同时间段的点赞操作存储在不同的Redis键中。例如,将每小时的点赞操作存储在一个单独的键中,键格式为
thumb:temp:{时间} - 问题追踪:按时间戳分片存储点赞操作,可以方便地追踪特定时间段的点赞数据。如果出现问题,可以快速定位到具体的分片,进行排查和修复
- 扩展性:当点赞数据量增加时,可以通过增加Redis实例来扩展系统的处理能力。新增加的实例可以负责新的分片,从而实现系统的水平扩展
- 容错性:如果某个Redis实例发生故障,只会影响该实例负责的分片,而不会影响整个系统的点赞功能。其他实例仍然可以正常处理自己负责的分片,保证系统的可用性
3.1. 点赞流程
3.2. 取消点赞流程
3.3. 定时任务流程
3.4. Lua 脚本
在实现点赞或者取消点赞时,需要保证 Redis 点赞记录更改 临时记录也需要更改,需要保证一致性,也就是要么都成功要么都失败,而 Lua 脚本在 Redis 中是原子执行的,将多个操作组合起来一起执行,期间 不会执行其他命令。
使用 Lua 脚本的方式:
- 直接执行
▼java复制代码EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
- 加载执行
▼java复制代码-- 加载脚本 SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])" -- 返回脚本的 SHA1 校验和,如:"232fd51614574cf0867b83d384a5e898cfd24e5a" -- 使用 SHA1 校验和执行脚本 EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 1 mykey myvalue
tips:实际场景使用方式二 比较频繁,可以避免发送重复内容,只需要发送 SHA1 校验即可。
4. 开发实现
4.1.1. 配置常量类
在 constant 包下的 ThumbConstant 新增缓存键
▼java复制代码String TEMP_THUMB_KEY_PREFIX = "thumb:temp:%s";
在 util 包下的 RedisKeyUtil 添加临时键获取方法
▼java复制代码/** * 获取 临时点赞记录 key * @param time 时间 * @return 缓存key */ public static String getTempThumbKey(String time) { return ThumbConstant.TEMP_THUMB_KEY_PREFIX.formatted(time); }
点赞类型枚举
在 model.enums 中创建 ThumbTypeEnum
▼java复制代码package com.ruogu.thumb.model.enums; import lombok.Getter; /** * @Author code_zhang * @Date 2025/4/21 22:49 */ @Getter public enum ThumbTypeEnum { // 点赞 INCR(1), // 取消点赞 DECR(-1), // 不发生改变 NON(0), ; private final int value; ThumbTypeEnum(int value) { this.value = value; } }
定义表示 Lua 脚本执行状态的枚举,在 model.enums 中创建LuaStatusEnum
▼java复制代码package com.ruogu.thumb.model.enums; import lombok.Getter; /** * @Author code_zhang * @Date 2025/4/21 22:51 */ @Getter public enum LuaStatusEnum { // 成功 SUCCESS(1L), // 失败 FAIL(-1L), ; private final long value; LuaStatusEnum(long value) { this.value = value; } }
Lua 脚本常量
在 constant 包下新建 RedisLuaScriptConstant
▼java复制代码package com.ruogu.thumb.constant; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; /** * @Author code_zhang * @Date 2025/4/21 22:56 */ public class RedisLuaScriptConstant { /** * 点赞 Lua 脚本 * KEYS[1] -- 临时计数键 * KEYS[2] -- 用户点赞状态键 * ARGV[1] -- 用户 ID * ARGV[2] -- 博客 ID * 返回: * -1: 已点赞 * 1: 操作成功 */ public static final RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) \s local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) \s local userId = ARGV[1] -- 用户 ID \s local blogId = ARGV[2] -- 博客 ID \s \s -- 1. 检查是否已点赞(避免重复操作) \s if redis.call('HEXISTS', userThumbKey, blogId) == 1 then \s return -1 -- 已点赞,返回 -1 表示失败 \s end \s \s -- 2. 获取旧值(不存在则默认为 0) .. 拼接字符串 local hashKey = userId .. ':' .. blogId \s local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0) \s \s -- 3. 计算新值 \s local newNumber = oldNumber + 1 \s \s -- 4. 原子性更新:写入临时计数 + 标记用户已点赞 \s redis.call('HSET', tempThumbKey, hashKey, newNumber) \s redis.call('HSET', userThumbKey, blogId, 1) \s \s return 1 -- 返回 1 表示成功 \s \s""", Long.class); /** * 取消点赞 Lua 脚本 * 参数同上 * 返回: * -1: 未点赞 * 1: 操作成功 */ public static final RedisScript<Long> UNTHUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) \s local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) \s local userId = ARGV[1] -- 用户 ID \s local blogId = ARGV[2] -- 博客 ID \s \s -- 1. 检查用户是否已点赞(若未点赞,直接返回失败) \s if redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then \s return -1 -- 未点赞,返回 -1 表示失败 \s end \s \s -- 2. 获取当前临时计数(若不存在则默认为 0) \s local hashKey = userId .. ':' .. blogId \s local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0) \s \s -- 3. 计算新值并更新 \s local newNumber = oldNumber - 1 \s \s -- 4. 原子性操作:更新临时计数 + 删除用户点赞标记 \s redis.call('HSET', tempThumbKey, hashKey, newNumber) \s redis.call('HDEL', userThumbKey, blogId) \s \s return 1 -- 返回 1 表示成功 \s \s""", Long.class); }
这里为啥使用 oldNumber -1 呢?这里其实仔细想想就能明白
-
用户未点赞
-
未点赞在上面就会 return
-
用户已点赞(定时任务未更新)
-
定时任务未更新 所以这条记录目前状态 1 使用 1-1 那么就是得到 0 到时候定时任务不处理即可
-
用户已点赞(定时任务已更新)
-
已更新的话就会删除这条记录 默认去获取时 获取不到默认赋值为 0 然后使用 0-1 那么就是取消点赞的情况
点赞服务实现
复制ThumbServiceImpl并改名为ThumbeServicerRedisImpl
▼java复制代码package com.ruogu.thumb.service.impl; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruogu.thumb.constant.RedisLuaScriptConstant; import com.ruogu.thumb.mapper.ThumbMapper; import com.ruogu.thumb.model.dto.thumb.ThumbLikeOrUnLikeDTO; import com.ruogu.thumb.model.entity.Thumb; import com.ruogu.thumb.model.entity.User; import com.ruogu.thumb.model.enums.LuaStatusEnum; import com.ruogu.thumb.service.BlogService; import com.ruogu.thumb.service.ThumbService; import com.ruogu.thumb.service.UserService; import com.ruogu.thumb.util.RedisKeyUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.Arrays; import java.util.Optional; import static com.ruogu.thumb.common.exception.enums.GlobalErrorCodeConstants.*; import static com.ruogu.thumb.common.exception.util.ServiceExceptionUtil.exception; /** * @author ruogu * @description 针对表【thumb(点赞记录表)】的数据库操作Service实现 * @createDate 2025-04-18 13:36:53 */ @Service("thumbService") @Slf4j @RequiredArgsConstructor public class ThumbRedisServiceImpl extends ServiceImpl<ThumbMapper, Thumb> implements ThumbService { private final UserService userService; private final BlogService blogService; private final RedisTemplate<String, Object> redisTemplate; private String getTimeSlice() { DateTime nowDate = DateUtil.date(); int second = (DateUtil.second(nowDate) / 10) * 10; return DateUtil.format(nowDate, "HH:mm:") + (second == 0 ? "00" : second); } private User validateRequestAndGetUser(ThumbLikeOrUnLikeDTO dto) { if (dto == null || dto.getBlogId() == null) { throw exception(BAD_REQUEST); } return Optional.ofNullable(userService.getLoginUser()) .orElseThrow(() -> exception(UNAUTHORIZED)); } @Override public Boolean doThumb(ThumbLikeOrUnLikeDTO thumbLikeOrUnLikeDTO) { User loginUser = validateRequestAndGetUser(thumbLikeOrUnLikeDTO); Long blogId = thumbLikeOrUnLikeDTO.getBlogId(); // 获取时间 String timeSlice = getTimeSlice(); // 获取Redis的key String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId()); String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice); // 执行Lua脚本 Long result = redisTemplate.execute( RedisLuaScriptConstant.THUMB_SCRIPT, Arrays.asList(tempThumbKey, userThumbKey), loginUser.getId(), blogId ); if (null == result || LuaStatusEnum.FAIL.getValue() == result) { throw exception(USER_LIKE_ERROR); } return LuaStatusEnum.SUCCESS.getValue() == result; } @Override public Boolean undoThumb(ThumbLikeOrUnLikeDTO thumbLikeOrUnLikeDTO) { User loginUser = validateRequestAndGetUser(thumbLikeOrUnLikeDTO); Long blogId = thumbLikeOrUnLikeDTO.getBlogId(); // 获取时间 String timeSlice = getTimeSlice(); // 获取Redis的key String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId()); String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice); // 执行Lua脚本 Long result = redisTemplate.execute( RedisLuaScriptConstant.UNTHUMB_SCRIPT, Arrays.asList(tempThumbKey, userThumbKey), loginUser.getId(), blogId ); if (null == result || LuaStatusEnum.FAIL.getValue() == result) { throw exception(USER_UNLIKE_ERROR); } return LuaStatusEnum.SUCCESS.getValue() == result; } @Override public Boolean isThumb(Long userId, Long blogId) { return redisTemplate.opsForHash().hasKey(RedisKeyUtil.getUserThumbKey(userId), blogId.toString()); } }
修改原来的 ThumbeServiceImpl的@Service注解为:@Service("thumbServiceDB")
和原来的逻辑比较,删除了事务和锁,使用 Lua 脚本来保证原子性,提升了性能。
小坑记录:
redisTemplate.execute 时不需要调用 toString()方法 否则会多一层""
批量更新博客点赞数量
▼java复制代码public interface BlogMapper extends BaseMapperPlus<Blog> { /** * 批量更新博客点赞数量 * @param countMap 博客ID -> 点赞数 */ void batchUpdateThumbCount(@Param("countMap") Map<Long, Long> countMap); /** * 分页查询博客信息 * * @param blogPageReqDTO 请求参数 * @return 博客信息 */ default PageResult<Blog> getBlogPage(BlogPageReqDTO blogPageReqDTO) { return selectPage(blogPageReqDTO, new LambdaQueryWrapper<Blog>() .like(Objects.nonNull(blogPageReqDTO.getBlogName()), Blog::getTitle, blogPageReqDTO.getBlogName())); } }
▼java复制代码<update id="batchUpdateThumbCount"> UPDATE blog SET thumb_count = thumb_count + CASE id <foreach collection="countMap.entrySet()" item="value" index="key"> WHEN #{key} THEN #{value} </foreach> END WHERE id IN <foreach collection="countMap.keySet()" item="id" open="(" separator="," close=")"> #{id} </foreach> </update>
这里对batchUpdateThumbCount给出解释(AI 生成):
UPDATE blog
**UPDATE blog**:表示要更新的表是blog。
SET thumbCount = thumbCount + CASE id
**SET thumbCount = thumbCount + ...**:表示要更新thumbCount字段,将其增加一个通过CASE语句计算出的值。**CASE id**:表示根据id字段的值来决定增加多少点赞数。
<foreach collection="countMap.entrySet()" item="value" index="key">
这是一个 MyBatis 的循环标签,用于遍历 countMap 中的每个键值对。
**collection="countMap.entrySet()"**:表示要遍历的集合是countMap的entrySet(),即键值对的集合。countMap是一个Map<Long, Long>,存储博客ID和对应的点赞数变化量。**item="value"**:表示每次循环中的 值(即点赞数变化量)将被赋给变量value。**index="key"**:表示每次循环中的 键(即博客ID)将被赋给变量key。
WHEN #{key} THEN #{value}
这是 CASE 语句的一部分,表示当 id 等于 #{key} 时,thumbCount 应该增加 #{value}。
-
**WHEN #{key} THEN #{value}**: -
**#{key}**:表示博客ID,通过 MyBatis 的占位符语法来动态传入。例如,如果key是1L,那么这里会替换为1。 -
**#{value}**:表示点赞数变化量,同样通过 MyBatis 的占位符语法来动态传入。例如,如果value是5L,那么这里会替换为5。
END
**END**:表示CASE语句的结束。
WHERE id IN
**WHERE id IN**:表示更新操作的条件是id在指定的集合中。
<foreach collection="countMap.keySet()" item="id" open="(" separator="," close=")">
这是另一个 MyBatis 的循环标签,用于遍历 countMap 中的键(即博客ID),生成一个 IN 子句。
**collection="countMap.keySet()"**:表示要遍历的集合是countMap的keySet(),即博客ID的集合。**item="id"**:表示每次循环中的博客ID将被赋给变量id。**open="("**:表示循环开始时生成的字符串是左括号(,用于包围IN子句中的值。**separator=","**:表示每次循环之间生成的分隔符是逗号,,用于分隔多个博客ID。**close=")"**:表示循环结束时生成的字符串是右括号),用于结束IN子句。
#{id}
**#{id}**:表示博客ID,通过 MyBatis 的占位符语法来动态传入。
假设 countMap 的内容如下:
▼java复制代码Map<Long, Long> countMap = new HashMap<>(); countMap.put(1L, 5L); // 博客ID为1,点赞数增加5 countMap.put(2L, 3L); // 博客ID为2,点赞数增加3
那么生成的 SQL 语句将类似于:
▼java复制代码UPDATE blog SET thumbCount = thumbCount + CASE id WHEN 1 THEN 5 WHEN 2 THEN 3 END WHERE id IN (1, 2)
解释
- 对于博客ID为1的记录:
thumbCount将增加5。 - 对于博客ID为2的记录:
thumbCount将增加3。 **WHERE id IN (1, 2)**:确保只更新ID为1和2的博客记录。
总结
通过使用 MyBatis 的 **<foreach>** 标签 和 **CASE** 语句,我们可以动态生成 SQL 语句,批量更新多个博客的点赞数。这种方式:
- 高效:避免了多次单独的更新操作,减少了数据库交互次数。
- 灵活:可以根据传入的
countMap动态生成更新条件和值。 - 简洁:通过 MyBatis 的动态 SQL 功能,可以在 XML 中简洁地表达复杂的更新逻辑。
定时任务同步
创建 job 包并新建SyncThumbtoDBJob类
▼java复制代码/** * @Author ruogu * @Date 2025/4/20 11:51 * 定时将 Redis 中的临时点赞数据同步到数据库 */ @Component @Slf4j public class SyncThumb2DBJob { @Resource private ThumbService thumbService; @Resource private BlogMapper blogMapper; @Resource private RedisTemplate<String, Object> redisTemplate; @Scheduled(fixedRate = 10000) @Transactional(rollbackFor = Exception.class) public void run() { log.info("开始执行"); DateTime nowDate = DateUtil.date(); String date = DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10 - 1) * 10; syncThumb2DBByDate(date); log.info("临时数据同步完成"); } public void syncThumb2DBByDate(String date) { // 获取到临时点赞和取消点赞数据 String tempThumbKey = RedisKeyUtil.getTempThumbKey(date); Map<Object, Object> allTempThumbMap = redisTemplate.opsForHash().entries(tempThumbKey); boolean thumbMapEmpty = CollUtil.isEmpty(allTempThumbMap); // 同步 点赞 到数据库 // 构建插入列表并收集blogId Map<Long, Long> blogThumbCountMap = new HashMap<>(); if (thumbMapEmpty) { return; } ArrayList<Thumb> thumbList = new ArrayList<>(); LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>(); boolean needRemove = false; for (Object userIdBlogIdObj : allTempThumbMap.keySet()) { String userIdBlogId = (String) userIdBlogIdObj; String[] userIdAndBlogId = userIdBlogId.split(StrPool.COLON); Long userId = Long.valueOf(userIdAndBlogId[0]); Long blogId = Long.valueOf(userIdAndBlogId[1]); // -1 取消点赞,1 点赞 Integer thumbType = Integer.valueOf(allTempThumbMap.get(userIdBlogId).toString()); if (thumbType == ThumbTypeEnum.INCR.getValue()) { Thumb thumb = new Thumb(); thumb.setUserId(userId); thumb.setBlogId(blogId); thumbList.add(thumb); } else if (thumbType == ThumbTypeEnum.DECR.getValue()) { // 拼接查询条件,批量删除 needRemove = true; wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId); } else { if (thumbType != ThumbTypeEnum.NON.getValue()) { log.warn("数据异常:{}", userId + "," + blogId + "," + thumbType); } continue; } // 计算点赞增量 blogThumbCountMap.put(blogId, blogThumbCountMap.getOrDefault(blogId, 0L) + thumbType); } // 批量插入 thumbService.saveBatch(thumbList); // 批量删除 if (needRemove) { thumbService.remove(wrapper); } // 批量更新博客点赞量 if (!blogThumbCountMap.isEmpty()) { blogMapper.batchUpdateThumbCount(blogThumbCountMap); } // 异步删除 Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey)); } }
JDK21 虚拟线程:
▼java复制代码// 异步删除 Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey));
**Thread.startVirtualThread()**:用于创建和启动一个虚拟线程。**redisTemplate.delete(tempThumbKey)**:这是虚拟线程中执行的操作,这里是删除Redis中的一个键。
虚拟线程的优点(AI 生成 注意甄别):
- 轻量级:
- 虚拟线程的创建和销毁成本极低,与传统的操作系统线程相比,内存占用少。
- 虚拟线程的初始栈空间需求很小,通常只有几百字节,而传统线程的栈空间通常为1MB。
- 高并发:
- 支持数百万级别的并发线程,能够显著提升系统的吞吐量。
- 虚拟线程的上下文切换开销远低于操作系统级线程,从而提供更高的吞吐量。
- 简化编程模型:
- 允许每个任务都拥有自己的线程,从而避免了复杂的回调和Future模式。
- 虚拟线程支持阻塞式编程模型,开发者可以直接使用同步代码风格,而不需要复杂的异步回调或
CompletableFuture。
- 更高的资源利用率:
- 虚拟线程的栈按需分配,内存利用率更高。
- 阻塞操作不会占用操作系统线程,释放了更多资源。
- 与现有工具兼容:
- 虚拟线程与普通线程共享相同的调试和监控工具(如JStack、JVisualVM)
虚拟线程非常适合以下场景:
- I/O密集型任务:如网络请求、数据库操作、文件读写等,因为这些操作通常涉及大量的阻塞等待时间,而虚拟线程在阻塞时会自动挂起,释放资源给其他任务。
- 高并发短生命周期的任务:如Web服务器中的请求处理,每个请求都可以分配一个虚拟线程,从而提高系统的并发处理能力
补偿任务实现
在 job 包下创建SyncThumb2DBCompensatoryJob类
▼java复制代码/** * @Author code_zhang * @Date 2025/4/21 23:53 * 定时将 Redis 中的临时点赞数据同步到数据库的补偿措施 */ @Component @Slf4j public class SyncThumb2DBCompensatoryJob { @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private SyncThumb2DBJob syncThumb2DBJob; @Scheduled(cron = "0 0 2 * * *") public void run() { log.info("开始补偿数据"); Set<String> thumbKeys = redisTemplate.keys(RedisKeyUtil.getTempThumbKey("") + "*"); if(null==thumbKeys||CollUtil.isEmpty(thumbKeys)){ return; } Set<String> needHandleDataSet = new HashSet<>(); thumbKeys.stream().filter(ObjUtil::isNotNull).forEach(thumbKey -> needHandleDataSet.add(thumbKey.replace(ThumbConstant.TEMP_THUMB_KEY_PREFIX.formatted(""), ""))); if (CollUtil.isEmpty(needHandleDataSet)) { log.info("没有需要补偿的临时数据"); return; } // 补偿数据 for (String date : needHandleDataSet) { syncThumb2DBJob.syncThumb2DBByDate(date); } log.info("临时数据补偿完成"); } }
最后记得要在主启动类添加注解:@EnableScheduling
5. 扩展功能
5.1.1. 点赞时间维护
虽然按照教程做完了,但是没有实现我们刚开始的实现思路,我们需要修改几处地方来实现批量点赞时将这个点赞时间也同步上,教程中是有十秒的误差,这里我们维护一下。
- 点赞
在点赞时,按照第三步的实现步骤,我们需要将 value 字段设计为 json 字符串,示例:
▼java复制代码{ "type":1, "time":"2025-04-22 09:33:21" }
需要先修改点赞操作,我们已经修改为使用 Lua 脚本实现,所以需要修改 Lua 脚本:
新增加了一个额外参数,点赞时间,通过调用时传入,拼接成 JSON 字符串
▼java复制代码/** * 点赞 Lua 脚本 * KEYS[1] -- 临时计数键 * KEYS[2] -- 用户点赞状态键 * ARGV[1] -- 用户 ID * ARGV[2] -- 博客 ID * ARGV[3] -- 点赞时间 * 返回: * -1: 已点赞 * 1: 操作成功 */ public static final RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) \s local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) \s local userId = ARGV[1] -- 用户 ID \s local blogId = ARGV[2] -- 博客 ID \s local currentTime = ARGV[3] -- 点赞时间 \s -- 1. 检查是否已点赞(避免重复操作) \s if redis.call('HEXISTS', userThumbKey, blogId) == 1 then \s return -1 -- 已点赞,返回 -1 表示失败 \s end \s \s -- 2. 获取旧值(不存在则默认为 0) .. 拼接字符串 local hashKey = userId .. ':' .. blogId \s local oldValue = redis.call('HGET', tempThumbKey, hashKey) if oldValue == false then oldValue = '{"type":0,"time":""}' end -- 3. 解析旧值 local oldData = cjson.decode(oldValue) local oldType = oldData.type local oldTime = oldData.time -- 4. 计算新值 local newType = 1 local newValue = '{"type":' .. newType .. ',"time":' .. currentTime .. '}' -- 5. 原子性更新 redis.call('HSET', tempThumbKey, hashKey, newValue) redis.call('HSET', userThumbKey, blogId, 1) -- 6. 返回成功结果 return 1 \s""", Long.class);
修改点赞实现的 doThumb 方法:
▼java复制代码public Boolean doThumb(ThumbLikeOrUnLikeDTO thumbLikeOrUnLikeDTO) { User loginUser = validateRequestAndGetUser(thumbLikeOrUnLikeDTO); Long blogId = thumbLikeOrUnLikeDTO.getBlogId(); // 获取时间 String timeSlice = getTimeSlice(); // 获取Redis的key String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId()); String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice); // 执行Lua脚本 long result = redisTemplate.execute( RedisLuaScriptConstant.THUMB_SCRIPT, Arrays.asList(tempThumbKey, userThumbKey), loginUser.getId(), blogId, DateUtil.format(DateUtil.date(), "yyyy-MM-dd HH:mm:ss") ); if (LuaStatusEnum.FAIL.getValue() == result) { throw exception(USER_LIKE_ERROR); } return LuaStatusEnum.SUCCESS.getValue() == result; }
- 取消点赞
▼java复制代码@Override public Boolean undoThumb(ThumbLikeOrUnLikeDTO thumbLikeOrUnLikeDTO) { User loginUser = validateRequestAndGetUser(thumbLikeOrUnLikeDTO); Long blogId = thumbLikeOrUnLikeDTO.getBlogId(); // 获取时间 String timeSlice = getTimeSlice(); // 获取Redis的key String userThumbKey = RedisKeyUtil.getUserThumbKey(loginUser.getId()); String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice); // 执行Lua脚本 long result = redisTemplate.execute( RedisLuaScriptConstant.UNTHUMB_SCRIPT, Arrays.asList(tempThumbKey, userThumbKey), loginUser.getId(), blogId, DateUtil.format(DateUtil.date(), "yyyy-MM-dd HH:mm:ss") ); if (LuaStatusEnum.FAIL.getValue() == result) { throw exception(USER_UNLIKE_ERROR); } return LuaStatusEnum.SUCCESS.getValue() == result; }
▼java复制代码/** * 取消点赞 Lua 脚本 * 参数同上 * 返回: * -1: 未点赞 * 1: 操作成功 */ public static final RedisScript<Long> UNTHUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) \s local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) \s local userId = ARGV[1] -- 用户 ID \s local blogId = ARGV[2] -- 博客 ID \s local currentTime = ARGV[3] -- 点赞时间 \s -- 1. 检查用户是否已点赞(若未点赞,直接返回失败) \s if redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then \s return -1 -- 未点赞,返回 -1 表示失败 \s end \s \s -- 2. 获取当前临时计数(若不存在则默认为 0) \s local hashKey = userId .. ':' .. blogId \s local oldValue = redis.call('HGET', tempThumbKey, hashKey) if oldValue == false then oldValue = '{"type":0,"time":""}' end -- 3. 解析旧值 local oldData = cjson.decode(oldValue) local oldType = oldData.type local oldTime = oldData.time local oldNumber = oldType \s \s -- 3. 计算新值并更新 \s local newNumber = oldNumber - 1 \s local newValue = '{"type":' .. newNumber .. ',"time":'.. currentTime ..'}' \s -- 4. 原子性操作:更新临时计数 + 删除用户点赞标记 \s redis.call('HSET', tempThumbKey, hashKey, newValue) \s redis.call('HDEL', userThumbKey, blogId) \s \s return 1 -- 返回 1 表示成功 \s \s""", Long.class);
- 定时任务
我将 value 字段设置为了 json 对象,而读取定时任务时报错,如下:

排查问题根源在于 手动构建的 JSON 格式与 Jackson 的序列化规则不兼容:
这种手动生成的 JSON 不包含 Jackson 要求的类型信息(如 ["com.example.ThumbTempCacheDTO", {...}]),导致反序列化失败。
解决办法:
禁用 Jackson 的类型信息
直接关闭 ObjectMapper 的默认类型处理,让所有对象以普通 JSON 格式存储
▼java复制代码@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // 使用 Jackson2JsonRedisSerializer 序列化值 ObjectMapper objectMapper = new ObjectMapper(); // objectMapper.activateDefaultTyping( // LaissezFaireSubTypeValidator.instance, // ObjectMapper.DefaultTyping.NON_FINAL); Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class); // Key 使用 String 序列化 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }
定时任务
▼java复制代码@Component @Slf4j public class SyncThumb2DBJob { @Resource private ThumbService thumbService; @Resource private BlogMapper blogMapper; public static final int SECOND_BUG = -10; @Resource private RedisTemplate<String, Object> redisTemplate; @Scheduled(fixedRate = 10000) @Transactional(rollbackFor = Exception.class) public void run() { log.info("定时任务:将 Redis中的临时点赞数据同步到数据库"); DateTime nowDate = DateUtil.date(); // 如果秒数为0~9 则回到上一分钟的50秒 int second = (DateUtil.second(nowDate) / 10 - 1) * 10; if (second == SECOND_BUG) { second = 50; // 回到上一分钟 nowDate = DateUtil.offsetMinute(nowDate, -1); } String timeSlice = DateUtil.format(nowDate, "HH:mm:") + (second < 10 ? "0" + second : second); syncThumb2DBByDate(timeSlice); log.info("同步完成,当前时间片:{}", timeSlice); } public void syncThumb2DBByDate(String date) { // 获取到临时点赞和取消点赞数据 String tempThumbKey = RedisKeyUtil.getTempThumbKey(date); Map<Object, Object> allTempThumbMap = redisTemplate.opsForHash().entries(tempThumbKey); boolean thumbMapEmpty = CollUtil.isEmpty(allTempThumbMap); // 同步 点赞 到数据库 // 构建插入列表并收集blogId Map<Long, Long> blogThumbCountMap = new HashMap<>(); if (thumbMapEmpty) { return; } ArrayList<Thumb> thumbList = new ArrayList<>(); LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>(); boolean needRemove = false; for (Object userIdBlogIdObj : allTempThumbMap.keySet()) { String userIdBlogId = (String) userIdBlogIdObj; String[] userIdAndBlogId = userIdBlogId.split(StrPool.COLON); Long userId = Long.valueOf(userIdAndBlogId[0]); Long blogId = Long.valueOf(userIdAndBlogId[1]); // {"type":1,time:'2025-01-01 00:00:00'} -1 取消点赞,1 点赞 Object value = allTempThumbMap.get(userIdBlogId); ThumbTempCacheDTO thumbTemp = BeanUtil.toBean(value, ThumbTempCacheDTO.class); if (thumbTemp == null) { continue; } Integer thumbType = Optional.ofNullable(thumbTemp.getType()).orElse(0); if (thumbType == ThumbTypeEnum.INCR.getValue()) { Thumb thumb = new Thumb(); thumb.setUserId(userId); thumb.setBlogId(blogId); thumb.setCreateTime(DateUtil.parse(thumbTemp.getTime())); thumbList.add(thumb); } else if (thumbType == ThumbTypeEnum.DECR.getValue()) { // 拼接查询条件,批量删除 needRemove = true; wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId); } else { if (thumbType != ThumbTypeEnum.NON.getValue()) { log.warn("数据异常:{}", userId + "," + blogId + "," + thumbType); } continue; } // 计算点赞增量 blogThumbCountMap.put(blogId, blogThumbCountMap.getOrDefault(blogId, 0L) + thumbType); } // 批量插入 thumbService.saveBatch(thumbList); // 批量删除 if (needRemove) { thumbService.remove(wrapper); } // 批量更新博客点赞量 if (!blogThumbCountMap.isEmpty()) { blogMapper.batchUpdateThumbCount(blogThumbCountMap); } // 异步删除 Thread.startVirtualThread(() -> redisTemplate.delete(tempThumbKey)); } }
6. 测试验证
6.1.1. 点赞

取消点赞

