亿级流量点赞系统-第三章

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:点赞时间}

image.png

设计 key 为时间的好处:

  • 数据分片:将点赞操作按时间戳分片存储,即将不同时间段的点赞操作存储在不同的Redis键中。例如,将每小时的点赞操作存储在一个单独的键中,键格式为thumb:temp:{时间}
  • 问题追踪:按时间戳分片存储点赞操作,可以方便地追踪特定时间段的点赞数据。如果出现问题,可以快速定位到具体的分片,进行排查和修复
  • 扩展性:当点赞数据量增加时,可以通过增加Redis实例来扩展系统的处理能力。新增加的实例可以负责新的分片,从而实现系统的水平扩展
  • 容错性:如果某个Redis实例发生故障,只会影响该实例负责的分片,而不会影响整个系统的点赞功能。其他实例仍然可以正常处理自己负责的分片,保证系统的可用性

3.1. 点赞流程

image.png

3.2. 取消点赞流程

image.png

3.3. 定时任务流程

image.png

3.4. Lua 脚本

在实现点赞或者取消点赞时,需要保证 Redis 点赞记录更改 临时记录也需要更改,需要保证一致性,也就是要么都成功要么都失败,而 Lua 脚本在 Redis 中是原子执行的,将多个操作组合起来一起执行,期间 不会执行其他命令。

使用 Lua 脚本的方式:

  1. 直接执行
java
复制代码
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
  1. 加载执行
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 生成):

  1. UPDATE blog
  • **UPDATE blog**:表示要更新的表是 blog
  1. SET thumbCount = thumbCount + CASE id
  • **SET thumbCount = thumbCount + ...**:表示要更新 thumbCount 字段,将其增加一个通过 CASE 语句计算出的值。
  • **CASE id**:表示根据 id 字段的值来决定增加多少点赞数。
  1. <foreach collection="countMap.entrySet()" item="value" index="key">

这是一个 MyBatis 的循环标签,用于遍历 countMap 中的每个键值对。

  • **collection="countMap.entrySet()"**:表示要遍历的集合是 countMapentrySet(),即键值对的集合。countMap 是一个 Map<Long, Long>,存储博客ID和对应的点赞数变化量。
  • **item="value"**:表示每次循环中的 (即点赞数变化量)将被赋给变量 value
  • **index="key"**:表示每次循环中的 (即博客ID)将被赋给变量 key
  1. WHEN #{key} THEN #{value}

这是 CASE 语句的一部分,表示当 id 等于 #{key} 时,thumbCount 应该增加 #{value}

  • **WHEN #{key} THEN #{value}**

  • **#{key}**:表示博客ID,通过 MyBatis 的占位符语法来动态传入。例如,如果 key1L,那么这里会替换为 1

  • **#{value}**:表示点赞数变化量,同样通过 MyBatis 的占位符语法来动态传入。例如,如果 value5L,那么这里会替换为 5

  1. END
  • **END**:表示 CASE 语句的结束。
  1. WHERE id IN
  • **WHERE id IN**:表示更新操作的条件是 id 在指定的集合中。
  1. <foreach collection="countMap.keySet()" item="id" open="(" separator="," close=")">

这是另一个 MyBatis 的循环标签,用于遍历 countMap 中的键(即博客ID),生成一个 IN 子句。

  • **collection="countMap.keySet()"**:表示要遍历的集合是 countMapkeySet(),即博客ID的集合。
  • **item="id"**:表示每次循环中的博客ID将被赋给变量 id
  • **open="("**:表示循环开始时生成的字符串是左括号 (,用于包围 IN 子句中的值。
  • **separator=","**:表示每次循环之间生成的分隔符是逗号 ,,用于分隔多个博客ID。
  • **close=")"**:表示循环结束时生成的字符串是右括号 ),用于结束 IN 子句。
  1. #{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 生成 注意甄别):

  1. 轻量级
  • 虚拟线程的创建和销毁成本极低,与传统的操作系统线程相比,内存占用少。
  • 虚拟线程的初始栈空间需求很小,通常只有几百字节,而传统线程的栈空间通常为1MB。
  1. 高并发
  • 支持数百万级别的并发线程,能够显著提升系统的吞吐量。
  • 虚拟线程的上下文切换开销远低于操作系统级线程,从而提供更高的吞吐量。
  1. 简化编程模型
  • 允许每个任务都拥有自己的线程,从而避免了复杂的回调和Future模式。
  • 虚拟线程支持阻塞式编程模型,开发者可以直接使用同步代码风格,而不需要复杂的异步回调或CompletableFuture
  1. 更高的资源利用率
  • 虚拟线程的栈按需分配,内存利用率更高。
  • 阻塞操作不会占用操作系统线程,释放了更多资源。
  1. 与现有工具兼容
  • 虚拟线程与普通线程共享相同的调试和监控工具(如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. 点赞时间维护

虽然按照教程做完了,但是没有实现我们刚开始的实现思路,我们需要修改几处地方来实现批量点赞时将这个点赞时间也同步上,教程中是有十秒的误差,这里我们维护一下。

  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; }
  1. 取消点赞
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);
  1. 定时任务

我将 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. 点赞

取消点赞

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP