亿级流量点赞系统 完结撒花

项目github地址https://github.com/xtyooo/ThumbSys

1 基础功能实现

包括 用户模块 点赞模块 博客模块(点赞的载体 可替换为短视频 图片等)

  1. 登录 由于不是本系统的重点 我们只需要用登录区分不同用户即可 不涉及密码
    故 设计登录接口 只需输入 用户的 id 即可登录 将用户 id 作为 session 存入浏览器
  2. 获取当前登录用户 :从 session 中获取
  3. 点赞:参数校验 博客是否存在校验 用户登录校验 用户是否已经点过赞校验 进行数据库操作
  4. 取消点赞:类似于点赞 (取消点赞 即帖子点赞数量 -1 并且 删除点赞表中的对应数据 删除方法有两种 可以根据点赞记录的 id 进行删除 也可以根据 用户 id +帖子 id 进行删除 前者不涉及回表 性能更高 但是需要得到点赞记录的 id 这一点也影响到后续 redis 数据结构的选择)
  5. 获取指定博客:根据博客 id 获取博客详细信息(包括 点赞量 点赞状态 如果用户已登录 还要返回当前用户是否已经将此博客点赞)
  6. 获取博客列表: 批量查询出博客,也要返回当前用户是否已经将某博客点赞

2 优化 1 当前用户是否点赞优化 查数据库改为查缓存

由于查询帖子是需要连带查询出当前用户是否点赞,就会造成多次查询数据库 设想 如果可以将这个查询操作是查缓存 那么就可以缓解数据库压力并加快查询速度

按照流程

  • 首先 当用户进行点赞时 将点赞记录先加入缓存再加入数据库
  • 接着 在查帖子时 判断当前用户是否点过赞就可以查询缓存 如果缓存中没有 就直接认为没有点过赞 不再重查数据库 以达到预期提高性能效果

选择 hash 作为存储的数据结构 key:userid field:帖子 id value:点赞记录的 id

这样就可以查询到点赞记录的 id 进行后续的删除操作

冷热分离 的策略。比如我们认为最近一个月新发的内容是热数据,那么可以让 Redis 中点赞记录的存在时间是帖子的发布时间 + 1 个月,如果点赞时该博客的发布时间不超过一个月,则查 Redis 校验是否已点赞;如果发布时间超过了一个月,则通过 MySQL 校验是否已点赞。还可以引入布隆过滤器,布隆过滤器中存在再进行后续步骤,否则直接返回未点赞。

关于扩展 根据帖子发布时间是否超过一个月来区分热帖进行冷热分离 查缓存 or 查数据库

前提是需要拿到帖子的发布时间 如果不是由前端提前传递 则还是需要多一次查询帖子的数据库操作

所以 可以扩展DoThumbRequest

因为查询帖子后 前端是拥有创建时间的 可以传给后端 避免查数据库获取创建时间

注:考虑 前端传的参数可能不安全 上述方案不采用

问题: 因为Redis 的哈希(Hash)结构本身不能针对单个字段(field)设置过期时间。Redis 的过期时间机制是基于键(key)的,也就是只能为整个键设置过期时间,而不能为哈希中的某个特定字段设置独立的过期时间。

所以以当前的结构 只能根据 userid 进行过期时间的设计 这与冷热帖子就无法对应上

需要考虑更换 key-field-value

java
复制代码
/** * @author xtyooo */ @Service @Slf4j @RequiredArgsConstructor public class ThumbServiceImpl extends ServiceImpl<ThumbMapper, Thumb> implements ThumbService { private final UserService userService; private final BlogService blogService; private final TransactionTemplate transactionTemplate; private final RedisTemplate<String, Object> redisTemplate; @Override public Boolean doThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) { if (doThumbRequest == null || doThumbRequest.getBlogId() == null) { throw new RuntimeException("参数错误"); } User loginUser = userService.getLoginUser(request); if (loginUser == null) { throw new RuntimeException(ErrorCode.NOT_LOGIN_ERROR.getMessage()); } // 加锁 synchronized (loginUser.getId().toString().intern()) { // 编程式事务 return transactionTemplate.execute(status -> { Long blogId = doThumbRequest.getBlogId(); // TODO: 2025/4/19 缓存过期问题 待解决 boolean exists = false; Object o = redisTemplate.opsForHash().get(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogId.toString()); if (o==null){//说明不在缓存中 可能是没点赞 也可能是帖子发布时间超过一个月了缓存被删除了 exists = this.lambdaQuery() .eq(Thumb::getUserId, loginUser.getId()) .eq(Thumb::getBlogId, blogId) .exists(); if (exists){//点过赞 但超过一个月 记录从redis中删除 throw new RuntimeException("用户已点赞"); }else {//没点过赞 执行点赞逻辑 boolean update = blogService.lambdaUpdate() .eq(Blog::getId, blogId) .setSql("thumbCount = thumbCount + 1") .update(); LambdaQueryWrapper<Blog> select = new LambdaQueryWrapper<Blog>().eq(Blog::getId, blogId) .select(Blog::getCreateTime); Blog blog = blogService.getOne(select); Thumb thumb = new Thumb(); thumb.setUserId(loginUser.getId()); thumb.setBlogId(blogId); // 更新成功执行加入缓存操作 boolean isSuccess = update && this.save(thumb); if (isSuccess) { ThumbInfo thumbInfo = new ThumbInfo(); thumbInfo.setThumbId(thumb.getId()); thumbInfo.setExpireTime(blog.getCreateTime().getTime()+ ThumbConstant.THUMB_EXPIRE_TIME); String key = ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId().toString(); redisTemplate.opsForHash().put(key, blogId.toString(), thumbInfo); } return isSuccess; } }else {//在缓存中 点过赞了 但还需要 需要判断是否过期 过期的话要删除缓存 ThumbInfo thumbInfo = (ThumbInfo) o; if (thumbInfo.getExpireTime()<System.currentTimeMillis()){ redisTemplate.opsForHash().delete(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogId.toString()); } throw new RuntimeException("用户已点赞"); } }); } } /** * 取消点赞 * @param doThumbRequest * @param request * @return */ @Override public Boolean undoThumb(DoThumbRequest doThumbRequest, HttpServletRequest request) { if (doThumbRequest == null || doThumbRequest.getBlogId() == null) { throw new RuntimeException("参数错误"); } User loginUser = userService.getLoginUser(request); // 加锁 synchronized (loginUser.getId().toString().intern()) { // 编程式事务 return transactionTemplate.execute(status -> { Long blogId = doThumbRequest.getBlogId(); Object o = redisTemplate.opsForHash().get(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogId.toString()); boolean success = false; if (o==null){//说明不在缓存中 可能是没点赞 也可能是帖子发布时间超过一个月了缓存被删除了 LambdaQueryWrapper<Thumb> lambdaQueryWrapper = new LambdaQueryWrapper<Thumb>() .eq(Thumb::getUserId, loginUser.getId()) .eq(Thumb::getBlogId, blogId); Thumb thumb = this.getOne(lambdaQueryWrapper); if (thumb == null){//没点过赞 throw new RuntimeException("用户未点赞"); } //点过赞 但超过一个月 记录从redis中删除 取消点赞需要从数据库中删除 //博客点赞数-1 UpdateWrapper<Blog> updateWrapper = new UpdateWrapper<Blog>().eq("id", blogId).setSql("thumbCount = thumbCount - 1"); boolean update = blogService.update(updateWrapper); //点赞表删除对应点赞记录 success = update && this.removeById(thumb.getId()); }else {//在缓存中 点过赞了 删除缓存 和 数据库的点赞记录 ThumbInfo thumbInfo = (ThumbInfo) o; //博客点赞数-1 UpdateWrapper<Blog> updateWrapper = new UpdateWrapper<Blog>().eq("id", blogId).setSql("thumbCount = thumbCount - 1"); boolean update = blogService.update(updateWrapper); //点赞表删除对应点赞记录 success = update && this.removeById(thumbInfo.getThumbId()); // 点赞记录从 Redis 删除 if (success) { redisTemplate.opsForHash().delete(ThumbConstant.USER_THUMB_KEY_PREFIX + loginUser.getId(), blogId.toString()); } } return success; }); } } @Override public Boolean hasThumb(Long blogId, Long userId) { String key = ThumbConstant.USER_THUMB_KEY_PREFIX + userId.toString(); return redisTemplate.opsForHash().hasKey(key, blogId.toString()); } }

3 优化 2 《redis 替代 数据库》?

在目前的系统中 当用户每点一次赞 都会直接操作 redis+数据库 进行点赞记录和帖子点赞数增加
那么 在高并发的情况下 会有巨量的数据库操作 不停的插入一条一条的点赞记录 将帖子点赞数一个一个的增加

优化思路:

  • 将巨量频繁数据库操作 变更为 批量操作

  • 当用户点赞后 就不直接操作数据库进行增改 而是 先存起来 等达到一定量 或是 到了一定时间 将这一批用户的点赞操作 整理 批量插入点赞记录表 批量更新帖子的点赞数(原本为+1+1 优化为 +n+n)

  • “存起来” 存到 redis 中

  • “一定时间” 定时任务

java
复制代码
/** * @author xty * @date 2025/4/20 * * 定时任务 同步缓存中的点赞数到数据库中 * 1.同步前10秒钟所有加入到缓存thumb:temp:{time}中的值进入数据库 key 为 userId:blogId value 为点赞数 * 在thumb表中增加对应的点赞记录 * 在blog表中增加对应博客的点赞数 */ @Component @Slf4j public class SyncThumb2DBJob { @Resource private ThumbService thumbService; @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private BlogMapper blogMapper; /** * 例子:[20:41:10,20:41:20) 时间区间的临时数据 在 20:41:24 执行 假如用时4秒执行完毕 那么下次任务执行时间是 20:41:24 + 4 + 10秒 = 20:41:38 * 但是 如果执行时间过长 比如执行8秒才完成 那么下次任务执行时间是 20:41:24 + 8 + 10秒 = 20:41:42 这次任务处理的是[20:41:10,20:41:20)区间的数据 * 下次任务开始执行时间是 20:41:24 + 8 + 10秒 = 20:41:42 处理的是[20:41:30,20:41:40)区间的数据] * 就会造成中间的数据[20:41:20,20:41:40)没被入库和删除 一直存在于redis中 */ // 每十秒执行一次 @Scheduled(initialDelay = 10000, fixedDelay = 10000) @Transactional(rollbackFor = Exception.class) public void run() { log.info("定时任务开始执行"); DateTime nowDate = DateUtil.date(); String timeSlice = DateUtil.format(nowDate, "HH:mm:") + (DateUtil.second(nowDate) / 10 - 1) * 10; process(timeSlice); log.info("定时任务执行结束"); } public void process(String timeSlice) { String tempThumbKey = RedisKeyUtil.getTempThumbKey(timeSlice); AtomicBoolean needRemove = new AtomicBoolean(false); //HMSET "thumb:temp:15:51:10" "2:1" "1" Map<Object, Object> map = redisTemplate.opsForHash().entries(tempThumbKey); if (CollUtil.isEmpty(map)) { return; } HashMap<Long, Long> blogIdToThumbCountMap = new HashMap<>(); ArrayList<Thumb> thumbArrayList = new ArrayList<>(); LambdaQueryWrapper<Thumb> wrapper = new LambdaQueryWrapper<>(); map.forEach((k, v) -> { String[] split = k.toString().split(":"); Long userId = Long.valueOf(split[0]); Long blogId = Long.valueOf(split[1]); Long thumbNumber = Long.valueOf(v.toString());//1表示 点赞,-1表示取消点赞 0表示点赞后取消点赞 if (thumbNumber == 1) { Thumb thumb = new Thumb(); thumb.setUserId(userId); thumb.setBlogId(blogId); thumbArrayList.add(thumb); } else if (thumbNumber == -1) { needRemove.set(true); wrapper.or().eq(Thumb::getUserId, userId).eq(Thumb::getBlogId, blogId); } blogIdToThumbCountMap.put(blogId, blogIdToThumbCountMap.getOrDefault(blogId, 0L) + thumbNumber); }); //在thumb表中批量增加对应的点赞记录 thumbService.saveBatch(thumbArrayList); //批量删除thumb表中对应的点赞记录 if (needRemove.get()){ thumbService.remove(wrapper); } //在blog表中增加对应博客的点赞数 if (!blogIdToThumbCountMap.isEmpty()){ blogMapper.batchUpdateThumbCount(blogIdToThumbCountMap); } //使用虚拟线程异步删除redis中的数据 Thread.startVirtualThread(()->{ redisTemplate.delete(tempThumbKey); }); } }

例子:[20:41:10,20:41:20) 时间区间的临时数据 在 20:41:24 执行 假如用时4秒执行完毕 那么下次任务执行时间是 20:41:24 + 4 + 10秒 = 20:41:38
但是 如果执行时间过长 比如执行8秒才完成 那么下次任务执行时间是 20:41:24 + 8 + 10秒 = 20:41:42 这次任务处理的是[20:41:10,20:41:20)区间的数据

下次任务开始执行时间是 20:41:24 + 8 + 10秒 = 20:41:42 处理的是[20:41:30,20:41:40)区间的数据]
就会造成中间的数据[20:41:20,20:41:40)没被入库和删除 一直存在于redis中

定时将 Redis 中的临时点赞数据同步到数据库的补偿措施 兜底

java
复制代码
@Slf4j @Component public class SyncThumb2DBCompensatoryJob { @Resource RedisTemplate<String, Object> redisTemplate; @Resource SyncThumb2DBJob syncThumb2DBJob; @Scheduled(cron = "0 0 2 * * *") public void run() { try { Set<String> keys = redisTemplate.keys("thumb:temp:*"); if (keys == null || keys.isEmpty()) { return; } keys.forEach(key -> { try { syncThumb2DBJob.process(key.replace("thumb:temp:", "")); } catch (Exception e) { log.error("处理键 {} 时出现异常", key, e); } }); } catch (Exception e) { log.error("获取 Redis 键时出现异常", e); } } }

4 优化 3 接入本地缓存 分担 redis 压力

目前 经过前面的优化 我们实现了 redis 分担 mysql 的压力 但是 redis 也是人啊 也会扛不住

所以 我们再找个帮手 本地缓存(使用 caffeine 框架操作)

但是 问题来了 我们要将所有 redis 中的数据都放给本地缓存么???

显然不行 因为本地内存十分有限 !!

那么 哪些该放哪些不该放呢?

就需要探测 hotKey 了 如果一个信息 会被高频次访问 那就把它放进本地 可以直接在本地查询 不用过问 redis

好的 那该怎么探测呢? 之前我们了解过 JD-HotKey 就可以实现 但是 他太重量级了 有点大炮轰蚊子的感觉了

退而求其次 我们选择自己实现一个探测算法: HeavyKeeper 算法

简单来说,它就是通过多个哈希函数和计数衰减机制,精确识别高频访问的 Key,具有超高的准确性,而且内存占用也较低。通过 HeavyKeeper 算法识别访问频率最高的 topK 后将其缓存到应用本地,同时为这些热点数据设置合理的TTL。

当 Redis 中的数据发生变更时(如用户取消点赞),还要主动刷新对应的本地缓存,确保多级缓存之间的数据一致性。

HeavyKeeper 算法介绍与原理

HeavyKeeper是一种高效的流式TopK检测算法,专为识别大规模数据流中的频繁项(热点Key)而生,它基于Count-Min Sketch算法改进,主要通过以下组件实现:

  1. 二维数组:算法维护一个二维数组,里面有 d 个数组,每个数组里有 w 个桶,桶里记录哈希指纹和计数值。
  2. 计数衰减机制:核心创新点,当发生哈希冲突时,不是简单的覆盖,而是通过概率衰减原有计数。
  3. 堆结构:维护一个大小为 k 的最小堆,用于记录当前观测到的TopK项。

当一个Key到达时:

  1. 对Key应用d个哈希函数,映射到d个数组中的对应桶
  2. 对每个桶:
  • 如果桶为空或已存储的哈希指纹与当前哈希指纹相同,增加计数器
  • 如果发生冲突,以概率P(decay) = 1/(b^C)衰减已有计数,b为衰减因子,C 为计数值
  1. 维护最小堆,保留最大的k个计数项

它的优势很多,比如:d3WkwG9QPUhywMD0eY4ogBhkMlS97W3P7Fm6G0mhz7U=

  1. 高准确性:相比Count-Min Sketch,大幅减少了哈希冲突带来的误差
  2. 内存效率:相比Space-Saving等其他算法用更少的内存实现了更高的精度
  3. 计算高效:处理每个Key的时间复杂度低,适合高吞吐量场景
  4. 抗噪声:衰减机制能有效过滤低频Key的干扰

HeavyKeeper算法特别适合需要实时检测热点Key的场景,能在有限内存下快速准确识别最热门的内容,实现精准的本地缓存策略。

基于这个算法 我们就可以实现一个轻量级的 hotKey 探测机制
当我们想要查询某个 key 的信息时

先查本地缓存

如果存在 就说明 他已经是热的了 访问次数再+1

如果不存在 就去 redis 中查

如果存在 就将该 key 的访问次数+1 并获取到当前的访问次数

如果达到阈值 就把他放到 top 中

如果不存在 就要到 mysql 查中了

。。。

5 优化 4 消息队列实现异步化

引入消息队列 解耦 redis 存入点赞记录和数据库操作

存入 redis 后即返回成功,与后续操作数据库 添加点赞记录 和 增加博客点赞数 解耦异步

流程 :用户点赞或取消赞后 将 userid+blogid+action 存入 redis 并发送至消息队列

消费者 批量取出消息队列中待处理的数据 对数据进行整理构造出可以批量操作数据库的参数 然后进行数据库操作

消息消费失败时要有重试策略,多次重试失败后进入死信队列,进行人工干预,确保消息最终能够被处理

还应该设计定期对账任务,因为在极限情况下如果 Redis 更新成功后系统宕机了,消息还没有发出去,就会导致数据库之间数据不一致。通过对账,可以检查并处理这种情况

对账机制设计

对账定期(比如每天凌晨2点)运行,执行以下步骤:

  1. 扫描 Redis 用户的点赞记录
  2. 从数据库获取对应用户的点赞记录
  3. 比对两者差异
  4. 对差异数据发送补偿事件到消息队列,由消费者重新处理

这样即使在 Redis 写入成功但由于种种原因消息发送失败的异常情况下,也能保证数据的一致性。

选择什么消息队列

在本项目里我们选择 Apache Pulsar 作为消息队列中间件,它与其它主流消息队列的对比可以参考下表:

特性PulsarKafkaRabbitMQRocketMQ
架构设计计算存储分离计算存储耦合经典消息代理类Kafka设计
消息模型队列+流处理流处理为主队列为主队列+发布订阅
多租户支持原生支持有限支持有限支持有限支持
存储策略分层存储单一存储主要依赖内存磁盘+内存
性能表现高(百万级TPS)高(百万级TPS)中(万级TPS)高(十万级TPS)
运维复杂度中等较高较低中等
社区活跃度活跃增长中非常活跃活跃稳定国内活跃

pulsar 踩坑:

配置死信队列 需要将消费者的订阅模式 设置为 share 才可以生效

在本地环境中,所有的服务都在同一台电脑上,没有网络 IO,在真实的生产环境中这个差距不会很明显

消息队列有削峰填谷的作用,能最大程度保证系统的平稳。

引入消息队列后,实现了点赞操作与后续的数据处理(如计数更新、消息通知)的解耦,更利于功能扩展

Redis 和数据库之间的数据同步依赖定时任务,缺乏实时性

6 优化 5 使用分布式数据库 TiDB 替代 传统 Mysql

7 压力测试

使用 Jemter 进行压力测试,批量生成每个用户的 Cookie 使用 csv 保存。

根据 git 提交的版本,压测各个版本的情况,来对比效果。

8 可观测性

  • Prometheus 的指标采集与存储
  • Grafana 的指标可视化

9 高可用方案

  1. 数据库高可用

  2. 通过 多中心部署,在不同地域部署TiDB集群,实现跨区域容灾

  3. 更高级别的保护可采用两地三中心架构,包括主中心、同城灾备中心和异地灾备中心,防范城市级灾难

  4. 还可以在主备架构的基础上实现读写分离和自动故障转移
    当 TiDB 集群不可用时,系统降级为只读模式,禁用写操作,依靠 Redis 中的已有数据尽可能提供查询功能,展示现有点赞数据。

  5. 缓存高可用

  6. 还可以引入布隆过滤器来实现针对缓存的防护机制

  7. 消息队列高可用

  8. 消息队列降级策略

  9. 当 Pulsar 不可用时,将异步点赞处理转为同步处理

  10. 也可以采用我们已经实现过的策略:使用 Redis 暂存点赞信息,然后通过定时任务批量同步到数据库。

  11. 应用层高可用

  12. 限流

  13. 降级

  14. 熔断
    使用开源高可用流控防护组件 Sentinel 实现针对外部依赖的服务调用实现熔断保护

java
复制代码
// 伪代码 @SentinelResource( value = "doThumbResource", fallback = "fallbackThumb", blockHandler = "handleBlock" ) public boolean doThumb(Long blogId, Long userId) { // 正常点赞逻辑 return thumbService.doThumb(blogId, userId); } // 熔断/降级时调用 public String fallbackThumb(Long id, Throwable e) { return "降级处理:服务暂不可用"; } // Sentinel 触发限流时调用 public String handleBlock(Long id, BlockException ex) { return "触发限流:请稍后重试"; }

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