亿级流量点赞系统第四章
项目地址
GitHub地址💥:https://github.com/tenyon61/likeboom
后端初始化模板💥:https://github.com/tenyon61/springboot3-demo/tree/single
快速开始
✨多级缓存设计
系统正式上线后,难免有恶意用户刷接口,导致某篇博客对应点赞状态所对应的hash中的某个key成为超级热点,严重影响正 常用户的请求响应,尤其是在流量高峰期,热点文章响应慢,影响正常用户使用。
引入本地缓存
▼xml复制代码<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>${caffeine.version}</version> </dependency>
设计多层缓存架构如下:
- L1:本地缓存Caffine,访问最高频、响应速度最快,存放本系统超级热点数据
- L2:Redis分布式缓存,存储热点数据,支持高并发访问,并提供较快的读写速度,有效减少数据库压力。
- L3:MySQL数据库,最终存储层,数据持久化存储。
在本地缓存中存储超级热点需要考虑以下几个问题:
- 内存空间限制
- 数据时效性
- 如何确保本地缓存的数据是超级热点
✨热Key检测
鉴于上面几个问题,就需要通过某种策略淘汰次热点数据,释放内存,提高缓存命中率。本系统使用HeavyKeeper算法实现高频键检测,将超级热点键保留在本地缓存中,减少L2 Redis缓存压力。
💥HeavyKeeper算法
HeavyKeeper是一种高效的流式TopK检测算法,可以在高并发场景下高效检测高频访问的键。核心原理是通过多层哈希桶(Bucket) 和 概率衰减机制,在有限内存条件,以较小的误差识别出访问频率最高的键Top-K。我们点赞系统就是判断哪些thumb:userId+blogId复合key属于高频访问,将其存入本地缓存。
实现如下:
TopK.java
▼java复制代码public interface TopK { AddResult add(String key, int increment); List<Item> list(); BlockingQueue<Item> expelled(); void fading(); long total(); }
Item.java
▼java复制代码public record Item(String key, int count) { }
HeavyKeeper.java
▼java复制代码public class HeavyKeeper implements TopK { private static final int LOOKUP_TABLE_SIZE = 256; private final int k; private final int width; private final int depth; private final double[] lookupTable; private final Bucket[][] buckets; private final PriorityQueue<Node> minHeap; private final BlockingQueue<Item> expelledQueue; private final Random random; private long total; private final int minCount; public HeavyKeeper(int k, int width, int depth, double decay, int minCount) { this.k = k; this.width = width; this.depth = depth; this.minCount = minCount; this.lookupTable = new double[LOOKUP_TABLE_SIZE]; for (int i = 0; i < LOOKUP_TABLE_SIZE; i++) { lookupTable[i] = Math.pow(decay, i); } this.buckets = new Bucket[depth][width]; for (int i = 0; i < depth; i++) { for (int j = 0; j < width; j++) { buckets[i][j] = new Bucket(); } } this.minHeap = new PriorityQueue<>(Comparator.comparingInt(n -> n.count)); this.expelledQueue = new LinkedBlockingQueue<>(); this.random = new Random(); this.total = 0; } @Override public AddResult add(String key, int increment) { byte[] keyBytes = key.getBytes(); long itemFingerprint = hash(keyBytes); int maxCount = 0; for (int i = 0; i < depth; i++) { int bucketNumber = Math.abs(hash(keyBytes)) % width; Bucket bucket = buckets[i][bucketNumber]; synchronized (bucket) { if (bucket.count == 0) { bucket.fingerprint = itemFingerprint; bucket.count = increment; maxCount = Math.max(maxCount, increment); } else if (bucket.fingerprint == itemFingerprint) { bucket.count += increment; maxCount = Math.max(maxCount, bucket.count); } else { for (int j = 0; j < increment; j++) { double decay = bucket.count < LOOKUP_TABLE_SIZE ? lookupTable[bucket.count] : lookupTable[LOOKUP_TABLE_SIZE - 1]; if (random.nextDouble() < decay) { bucket.count--; if (bucket.count == 0) { bucket.fingerprint = itemFingerprint; bucket.count = increment - j; maxCount = Math.max(maxCount, bucket.count); break; } } } } } } total += increment; if (maxCount < minCount) { return new AddResult(null, false, null); } synchronized (minHeap) { boolean isHot = false; String expelled = null; Optional<Node> existing = minHeap.stream() .filter(n -> n.key.equals(key)) .findFirst(); if (existing.isPresent()) { minHeap.remove(existing.get()); minHeap.add(new Node(key, maxCount)); isHot = true; } else { if (minHeap.size() < k || maxCount >= Objects.requireNonNull(minHeap.peek()).count) { Node newNode = new Node(key, maxCount); if (minHeap.size() >= k) { expelled = minHeap.poll().key; expelledQueue.offer(new Item(expelled, maxCount)); } minHeap.add(newNode); isHot = true; } } return new AddResult(expelled, isHot, key); } } @Override public List<Item> list() { synchronized (minHeap) { List<Item> result = new ArrayList<>(minHeap.size()); for (Node node : minHeap) { result.add(new Item(node.key, node.count)); } result.sort((a, b) -> Integer.compare(b.count(), a.count())); return result; } } @Override public BlockingQueue<Item> expelled() { return expelledQueue; } @Override public void fading() { for (Bucket[] row : buckets) { for (Bucket bucket : row) { synchronized (bucket) { bucket.count = bucket.count >> 1; } } } synchronized (minHeap) { PriorityQueue<Node> newHeap = new PriorityQueue<>(Comparator.comparingInt(n -> n.count)); for (Node node : minHeap) { newHeap.add(new Node(node.key, node.count >> 1)); } minHeap.clear(); minHeap.addAll(newHeap); } total = total >> 1; } @Override public long total() { return total; } private static class Bucket { long fingerprint; int count; } private static class Node { final String key; final int count; Node(String key, int count) { this.key = key; this.count = count; } } private static int hash(byte[] data) { return HashUtil.murmur32(data); } } // 新增返回结果类 @Data class AddResult { // 被挤出的 key private final String expelledKey; // 当前 key 是否进入 TopK private final boolean isHotKey; // 当前操作的 key private final String currentKey; public AddResult(String expelledKey, boolean isHotKey, String currentKey) { this.expelledKey = expelledKey; this.isHotKey = isHotKey; this.currentKey = currentKey; } }
💥缓存操作
CacheManager.java
▼java复制代码@Component @Slf4j public class CacheManager { private TopK hotKeyDetector; private Cache<String, Object> localCache; @Resource private RedisTemplate<String, Object> redisTemplate; @Bean public TopK getHotKeyDetector() { hotKeyDetector = new HeavyKeeper( // 监控 Top 100 Key 100, // 哈希表宽度 100000, // 哈希表深度 5, // 衰减系数 0.92, // 最小出现 10 次才记录 10 ); return hotKeyDetector; } @Bean public Cache<String, Object> localCache() { return localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); } // 辅助方法:构造复合 key private String buildCacheKey(String hashKey, String key) { return hashKey + ":" + key; } public Object get(String hashKey, String key) { // 构造唯一的 composite key String compositeKey = buildCacheKey(hashKey, key); // 1. 先查本地缓存 Object value = localCache.getIfPresent(compositeKey); if (value != null) { log.info("本地缓存获取到数据 {} = {}", compositeKey, value); // 记录访问次数(每次访问计数 +1) hotKeyDetector.add(key, 1); return value; } // 2. 本地缓存未命中,查询 Redis Object redisValue = redisTemplate.opsForHash().get(hashKey, key); if (redisValue == null) { return null; } // 3. 记录访问(计数 +1) AddResult addResult = hotKeyDetector.add(key, 1); // 4. 如果是热 Key 且不在本地缓存,则缓存数据 if (addResult.isHotKey()) { localCache.put(compositeKey, redisValue); } return redisValue; } public void putIfPresent(String hashKey, String key, Object value) { String compositeKey = buildCacheKey(hashKey, key); Object object = localCache.getIfPresent(compositeKey); if (object == null) { return; } localCache.put(compositeKey, value); } // 定时清理过期的热 Key 检测数据 @Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS) public void cleanHotKeys() { hotKeyDetector.fading(); } }
对于缓存操作时序图可以总结如下:
▼tex复制代码用户调用 get(hashKey, key) │ ├─ 构造复合键 compositeKey │ ├─ 查询本地缓存(Caffeine) │ ├─ 命中 → 返回数据,hotKeyDetector.add(key, 1) → 流程结束 │ └─ 未命中 → 查询 Redis(获取 redisValue) │ ├─ redisValue 不为 null │ ├─ hotKeyDetector.add(key, 1) → 得到 addResult │ ├─ 若 addResult.isHotKey() → 本地缓存存入 compositeKey:redisValue │ └─ 返回 redisValue │ └─ redisValue 为 null → 返回 null 定时任务(每 20 秒): hotKeyDetector.fading() → 所有桶和堆的计数减半,淘汰过时高频键
✨功能验证
当一直调用获取博客接口,模拟小黑子攻击。当超过minCount时候就会主键变为超级热点key,此时响应时间是非常快。

超级热点缓存到了本地,随着攻击结束,fading方法会剔除失效的热点,知道重新成为超级热点。

本章总结
使用Caffine+Redis+MySQL实现多级缓存策略,应用HeavyKeeper算法实现流量削峰,通过衰减机制,避免热点过期问题。
项目踩坑
待补充
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
