亿级流量点赞系统第三章

项目地址

GitHub地址💥:https://github.com/tenyon61/likeboom

后端初始化模板💥:https://github.com/tenyon61/springboot3-demo/tree/single

快速开始

✨数据结构改造

之前数据结构如下图,但是还不足以支撑热点数据写的问题,因为每次点赞/取消点赞操作还是操作数据库,高并发场景下事务响应时间长。

2025-04-25-4503cc.webp

现调整 Redis存储的数据结构为

2025-04-25-d5dfa3.webp

然后通过同步以及补偿机制,确保数据一致性,这样也提高点赞系统的响应速度和并发能力。通过改造数据存储,使用 lua 脚本确保操作原子性。最终落到 Redis 中的结果如下

2025-04-25-059314.webp

✨核心流程改造

💥点赞

  • 先看用户有无点赞
  • 无记录就添加点赞状态
  • 写入临时数据,等同步

具体实现如下

  1. 通过hutool工具类,获取当前时间整数秒,拼接 key
java
复制代码
private String getTimeSlice() { DateTime nowDate = DateUtil.date(); // 获取到当前时间前最近的整数秒,比如当前 11:20:23 ,获取到 11:20:20 return DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10) * 10; } String timeSlice = getTimeSlice(); // Redis Key String tempThumbKey = RedisUtils.getTempThumbKey(timeSlice); String userThumbKey = RedisUtils.getUserThumbKey(loginUser.getId());
  1. 编写lua脚本,它会检查用户是否已经点赞,避免了重复点赞的情况。直接给出 lua脚本
lua
复制代码
RedisScript<Long> THUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) local userId = ARGV[1] -- 用户 ID local blogId = ARGV[2] -- 博客 ID -- 1. 检查是否已点赞(避免重复操作) if redis.call('HEXISTS', userThumbKey, blogId) == 1 then return -1 -- 已点赞,返回 -1 表示失败 end -- 2. 获取旧值(不存在则默认为 0) local hashKey = userId .. ':' .. blogId local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0) -- 3. 计算新值 local newNumber = oldNumber + 1 -- 4. 原子性更新:写入临时计数 + 标记用户已点赞 redis.call('HSET', tempThumbKey, hashKey, newNumber) redis.call('HSET', userThumbKey, blogId, 1) return 1 -- 返回 1 表示成功 """, Long.class);
  1. 执行lua脚本
java
复制代码
long result = redisTemplate.execute( RedisConstant.THUMB_SCRIPT, Arrays.asList(tempThumbKey, userThumbKey), loginUser.getId(), blogId );

代码里直接传入

  • tempThumbKey:存储临时点赞计数的键。
  • userThumbKey:存储用户点赞状态的键。
  • userId:执行点赞操作的用户 ID。
  • blogId:被点赞的博客 ID。

通过 Redis 的原子性执行机制,确保点赞操作的原子性,避免了并发问题,操作成功则返回 1。

💥取消点赞

  • 先看用户是否有点赞状态
  • 已有点赞删除点赞状态
  • 写入临时点赞记录,等同步

具体实现如下

  1. 和点赞一样,获取time分片组装temp的key
  2. 编写lua脚本
lua
复制代码
RedisScript<Long> UNTHUMB_SCRIPT = new DefaultRedisScript<>(""" local tempThumbKey = KEYS[1] -- 临时计数键(如 thumb:temp:{timeSlice}) local userThumbKey = KEYS[2] -- 用户点赞状态键(如 thumb:{userId}) local userId = ARGV[1] -- 用户 ID local blogId = ARGV[2] -- 博客 ID -- 1. 检查用户是否已点赞(若未点赞,直接返回失败) if redis.call('HEXISTS', userThumbKey, blogId) ~= 1 then return -1 -- 未点赞,返回 -1 表示失败 end -- 2. 获取当前临时计数(若不存在则默认为 0) local hashKey = userId .. ':' .. blogId local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0) -- 3. 计算新值并更新 local newNumber = oldNumber - 1 -- 4. 原子性操作:更新临时计数 + 删除用户点赞标记 redis.call('HSET', tempThumbKey, hashKey, newNumber) redis.call('HDEL', userThumbKey, blogId) return 1 -- 返回 1 表示成功 """, Long.class);

点赞/取消点赞都是用户不同操作类型,流程和点赞类似

  • 检查用户是否已经对该博客点过赞,如果未点赞则直接返回 -1 表示操作失败
  • 若已点赞,会更新临时点赞计数并删除用户的点赞标记
  • 最后返回 1 表示操作成功。

✨同步机制

  • 周期10s
  • 获取10s内的临时记录
  • 批处理点赞/取消点赞操作
  • 更新count
  • 删除临时记录

SyncThumb2DBJob类实现

  1. run()方法
java
复制代码
@Scheduled(fixedRate = 10000) @Transactional(rollbackFor = Exception.class) public void run() { log.info("开始执行"); DateTime nowDate = DateUtil.date(); // 如果秒数为0~9 则回到上一分钟的50秒 int second = (DateUtil.second(nowDate) / 10 - 1) * 10; if (second == -10) { second = 50; // 回到上一分钟 nowDate = DateUtil.offsetMinute(nowDate, -1); } String date = DateUtil.format(nowDate, "HH:mm:") + second; syncThumb2DBByDate(date); log.info("临时数据同步完成"); }
  • 定义fixedRate为10000ms,10s中执行一次,无论执行成功或者失败都会去执行同步任务
  • Transactional开始事务管理,方法中对于批量处理博客count操作异常需要回滚
  • 获取当前时间,并根据当前时间的秒数进行调整。若秒数在 0 - 9 之间,则将时间调整为上一分钟的 50 秒,执行同步方法
  1. syncThumb2DBByDate(String date)
java
复制代码
public void syncThumb2DBByDate(String date) { // 获取到临时点赞和取消点赞数据 String tempThumbKey = RedisUtils.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); }); }

实现逻辑

  • 获取 Redis临时点赞数据
  • 批处理点赞数据
  • 批量同步数据
  • 开启虚拟线程异步删除 Redis 中的临时数据

最终实现的效果就是,每隔 10 秒执行一次,将 Redis 中特定时间的临时点赞数据同步到数据库中,整个过程通过批量操作提高性能,用事务管理保证数据的一致性。

✨补偿机制

  • 每天一处理
  • 检查是否有临时记录
  • 有记录则执行同步任务

SyncThumb2DBCompensatoryJob定时补偿任务实现

java
复制代码
@Scheduled(cron = "0 0 2 * * *") public void run() { log.info("开始补偿数据"); Set<String> thumbKeys = redisTemplate.keys(RedisUtils.getTempThumbKey("") + "*"); Set<String> needHandleDataSet = new HashSet<>(); thumbKeys.stream().filter(ObjUtil::isNotNull).forEach(thumbKey -> needHandleDataSet.add(thumbKey.replace(RedisConstant.TEMP_THUMB_KEY_PREFIX.formatted(""), ""))); if (CollUtil.isEmpty(needHandleDataSet)) { log.info("没有需要补偿的临时数据"); return; } // 补偿数据 for (String date : needHandleDataSet) { syncThumb2DBJob.syncThumb2DBByDate(date); } log.info("临时数据补偿完成"); }
  1. 指定定时任务每天凌晨2点执行
  2. 获取 Redis 中所有临时点赞数据的键
  3. 处理获取到的键,提取需要处理的日期信息
  4. 执行同步数据

其实就是检查 Redis缓存中还有没有temp数据,如果有那就是同步任务漏掉的数据,需要通过补偿任务实现数据一致。

✨数据验证

  1. 点赞

执行结果

转存失败,建议直接上传图片文件

同步任务执行后异步删除临时数据,保留下点赞状态

转存失败,建议直接上传图片文件

  1. 取消点赞

执行结果

转存失败,建议直接上传图片文件

同时 Redis中的点赞状态和临时数据均清空

本章总结

通过 lua 脚本+同步机制+补偿机制实现Redis强一致性和数据库最终一致性,由原子操作到定期同步再到最终补偿修复减少了数据库压力,提供容错和兜底机制,满足点赞系统高性能+高并发+数据一致性要求。

项目踩坑

待补充

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