又学了两节
3 优化 2 《redis 替代 数据库》?
项目github地址:https://github.com/xtyooo/ThumbSys(已更新)
在目前的系统中 当用户每点一次赞 都会直接操作 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算法改进,主要通过以下组件实现:
- 二维数组:算法维护一个二维数组,里面有 d 个数组,每个数组里有 w 个桶,桶里记录哈希指纹和计数值。
- 计数衰减机制:核心创新点,当发生哈希冲突时,不是简单的覆盖,而是通过概率衰减原有计数。
- 堆结构:维护一个大小为 k 的最小堆,用于记录当前观测到的TopK项。
当一个Key到达时:
- 对Key应用d个哈希函数,映射到d个数组中的对应桶
- 对每个桶:
- 如果桶为空或已存储的哈希指纹与当前哈希指纹相同,增加计数器
- 如果发生冲突,以概率
P(decay) = 1/(b^C)衰减已有计数,b为衰减因子,C 为计数值
- 维护最小堆,保留最大的k个计数项
它的优势很多,比如:
- 高准确性:相比Count-Min Sketch,大幅减少了哈希冲突带来的误差
- 内存效率:相比Space-Saving等其他算法用更少的内存实现了更高的精度
- 计算高效:处理每个Key的时间复杂度低,适合高吞吐量场景
- 抗噪声:衰减机制能有效过滤低频Key的干扰
HeavyKeeper算法特别适合需要实时检测热点Key的场景,能在有限内存下快速准确识别最热门的内容,实现精准的本地缓存策略。
基于这个算法 我们就可以实现一个轻量级的 hotKey 探测机制
当我们想要查询某个 key 的信息时
先查本地缓存
如果存在 就说明 他已经是热的了 访问次数再+1
如果不存在 就去 redis 中查
如果存在 就将该 key 的访问次数+1 并获取到当前的访问次数
如果达到阈值 就把他放到 top 中
如果不存在 就要到 mysql 查中了
。。。

