ThumbUP 缓存系统设计¶
💡 一句话概述
基于 Caffeine + Redis 构建二级缓存,通过 HeavyKeeper 算法实时探测 Top-K 热点 Key,仅将热 Key 提升至本地缓存,避免冷数据污染,配合 Lua 脚本保证点赞原子性。
🔑 核心概念¶
- 二级缓存 — Caffeine 本地缓存(L1)+ Redis 分布式缓存(L2),热 Key 自动提升至 L1
- HeavyKeeper 算法 — 基于 Count-Min Sketch 的 Top-K 热点探测,指数衰减淘汰冷 Key
- Lua 脚本原子性 — Redis 单线程执行 Lua 脚本,保证"防重检查 + 增量记录 + 状态标记"三步原子操作
- 缓存防护 — 互斥锁防击穿、UN_THUMB_CONSTANT 标记防穿透
📝 二级缓存架构¶
数据流总览¶
CacheManager 核心组件¶
CacheManager 是整个缓存系统的核心协调器,持有三个关键组件:
public class CacheManager {
private final Cache<String, Object> localCache; // Caffeine 本地缓存
private final TopK hotKeyDetector; // HeavyKeeper 热点探测器
private final RedisTemplate<String, Object> redisTemplate; // Redis 操作模板
}
Caffeine 本地缓存配置¶
Caffeine.newBuilder()
.maximumSize(1000) // 最大缓存 1000 个条目
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期
.build();
| 参数 | 值 | 说明 |
|---|---|---|
| 最大容量 | 1000 条 | 防止 OOM |
| 过期策略 | 写入后 5 分钟 | 基于写入时间(write),非访问时间 |
读取流程(get 方法)¶
public Object get(String hashKey, String key) {
String compositeKey = buildCacheKey(hashKey, key); // "thumb:123:456"
// 第一层:查 Caffeine 本地缓存
Object value = localCache.getIfPresent(compositeKey);
if (value != null) {
hotKeyDetector.add(key, 1); // 仍记录访问次数
return value;
}
// 第二层:查 Redis Hash
Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
if (redisValue == null) return null;
// 热点探测:记录访问并判断是否为热 Key
AddResult addResult = hotKeyDetector.add(key, 1);
// 仅热 Key 才写入本地缓存
if (addResult.isHotKey()) {
localCache.put(compositeKey, redisValue);
}
return redisValue;
}
flowchart TD
A[请求查询] --> B{Caffeine 命中?}
B -->|命中| C[记录访问到 HeavyKeeper]
C --> D[返回本地缓存值]
B -->|未命中| E{Redis Hash 命中?}
E -->|未命中| F[返回 null]
E -->|命中| G[HeavyKeeper 热点探测]
G --> H{是热 Key?}
H -->|是| I[写入 Caffeine 本地缓存]
I --> J[返回 Redis 值]
H -->|否| J
关键设计决策:
- 复合 Key 格式:
hashKey:key(如thumb:123:456),保证本地缓存中的 Key 唯一性 - 只缓存热 Key:只有被 HeavyKeeper 判定为"热 Key"的数据才会进入 Caffeine,避免低频 Key 污染本地缓存
- 持续追踪:即使命中本地缓存,仍然调用
hotKeyDetector.add(),持续追踪访问频率
写入流程(putIfPresent 方法)¶
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);
}
"putIfPresent"语义:只更新已经在本地缓存中存在的 Key。这保证了:
- 不会把非热 Key 写入本地缓存
- 点赞/取消点赞操作后,已缓存的热 Key 会同步更新,保持一致性
🔥 HeavyKeeper 算法详解¶
算法概述¶
HeavyKeeper 是一种基于 Count-Min Sketch 思想的 Top-K 频繁项检测算法,核心创新在于**指数衰减机制** — 当桶中存在旧数据时,新数据以指数衰减的概率替换旧数据,使得真正的高频 Key 能够"挤出"低频 Key。
初始化参数¶
new HeavyKeeper(
100, // k: 监控 Top 100 Key
100000, // width: 每层桶的数量(宽度)
5, // depth: 桶数组的层数(深度)
0.92, // decay: 衰减系数
10 // minCount: 最小出现 10 次才算热 Key
);
| 参数 | 值 | 说明 |
|---|---|---|
| k | 100 | 监控 Top 100 热 Key |
| width | 100,000 | 每层桶数量,总桶数 = 5 × 100,000 = 500,000 |
| depth | 5 | 桶数组层数(类似 5 个哈希函数) |
| decay | 0.92 | 衰减系数,count 越大被替换概率越低 |
| minCount | 10 | 最少出现 10 次才认定为热 Key |
桶结构¶
每个桶只存一个 Key 的指纹和计数,整个结构是一个 Bucket[depth][width] 的二维数组。
查找表(预计算指数衰减)¶
private static final int LOOKUP_TABLE_SIZE = 256;
this.lookupTable = new double[LOOKUP_TABLE_SIZE];
for (int i = 0; i < LOOKUP_TABLE_SIZE; i++) {
lookupTable[i] = Math.pow(decay, i); // 0.92^0, 0.92^1, ..., 0.92^255
}
预计算 decay^n 的值,避免运行时重复计算 Math.pow。当桶的 count 为 n 时,被替换的概率为 0.92^n。
指数衰减效果:
| count | 被替换概率 | 含义 |
|---|---|---|
| 1 | 92.0% | 低频 Key 极易被替换 |
| 5 | 65.9% | 中低频 Key 较易被替换 |
| 10 | 43.4% | 中频 Key 有一定抵抗力 |
| 20 | 18.9% | 中高频 Key 较难被替换 |
| 50 | 1.5% | 高频 Key 几乎不可能被替换 |
| 100 | 0.02% | 热 Key 安全 |
add 方法核心逻辑¶
public AddResult add(String key, int increment) {
long itemFingerprint = hash(keyBytes); // MurmurHash3 指纹
int maxCount = 0;
for (int i = 0; i < depth; i++) { // 遍历 5 层
int bucketNumber = Math.abs(hash(keyBytes)) % width;
Bucket bucket = buckets[i][bucketNumber];
synchronized (bucket) {
if (bucket.count == 0) {
// 情况1:空桶,直接写入
bucket.fingerprint = itemFingerprint;
bucket.count = increment;
} else if (bucket.fingerprint == itemFingerprint) {
// 情况2:指纹匹配,累加计数
bucket.count += increment;
} else {
// 情况3:指纹冲突,指数衰减竞争
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) {
// 旧 Key 被完全挤出,新 Key 占据桶
bucket.fingerprint = itemFingerprint;
bucket.count = increment - j;
break;
}
}
}
}
}
}
// ... TopK 堆管理
}
三种情况:
flowchart TD
A[访问 Key] --> B[计算 MurmurHash3 指纹]
B --> C[遍历 5 层桶]
C --> D{桶状态}
D -->|空桶| E[直接写入指纹和计数]
D -->|指纹匹配| F[计数累加]
D -->|指纹冲突| G[指数衰减竞争]
G --> H{随机概率 < decay^count?}
H -->|是| I[旧计数减 1]
I --> J{计数降为 0?}
J -->|是| K[新 Key 占据桶]
J -->|否| I
H -->|否| L[旧 Key 保留]
Top-K 堆管理¶
private final PriorityQueue<Node> minHeap; // 最小堆,维护 Top K
private final BlockingQueue<Item> expelledQueue; // 被挤出的 Key 队列
在 add 方法的后半部分:
synchronized (minHeap) {
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));
} else {
if (minHeap.size() < k || maxCount >= minHeap.peek().count) {
// 堆未满或新 Key 计数 >= 堆顶(最小值)
if (minHeap.size() >= k) {
expelled = minHeap.poll().key; // 挤出最小的
expelledQueue.offer(new Item(expelled, maxCount));
}
minHeap.add(new Node(key, maxCount));
}
}
}
使用最小堆保证:
- 堆中始终保留计数最大的 K 个 Key
- 新 Key 必须计数 >= 堆顶才能进入
- 被挤出的 Key 放入
expelledQueue
衰减机制(fading 方法)¶
public void fading() {
// 桶计数右移 1 位(除以 2)
for (Bucket[] row : buckets) {
for (Bucket bucket : row) {
synchronized (bucket) {
bucket.count = bucket.count >> 1;
}
}
}
// 堆中节点计数也右移 1 位
synchronized (minHeap) {
PriorityQueue<Node> newHeap = new PriorityQueue<>(...);
for (Node node : minHeap) {
newHeap.add(new Node(node.key, node.count >> 1));
}
minHeap.clear();
minHeap.addAll(newHeap);
}
total = total >> 1;
}
由 CacheManager 中的定时任务每 20 秒触发一次:
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void cleanHotKeys() {
hotKeyDetector.fading();
}
半衰期分析:每 20 秒计数减半,如果一个 Key 在 20 秒内没有新的访问:
| 时间 | 计数衰减 | 含义 |
|---|---|---|
| 20 秒 | ½ | 轻微衰减 |
| 40 秒 | ¼ | 明显衰减 |
| 1 分钟 | ⅛ | 接近冷却 |
| 2 分钟 | 1/64 | 基本冷却 |
这确保了"热 Key"的概念是**时效性**的 — 只有持续高频访问的 Key 才能维持热 Key 状态。
🔒 缓存防护策略¶
缓存击穿防护¶
方案二使用 String.intern() 获取字符串常量池中的唯一对象作为锁:
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
return transactionTemplate.execute(status -> {
// 查询 + 更新数据库 + 更新缓存
});
}
- 锁粒度:用户级别(每个用户一把锁),不同用户之间互不影响
intern()保证:同一字符串内容返回同一对象引用- 锁范围:覆盖了"查询 + 更新数据库 + 更新缓存"的完整操作
缓存穿透防护¶
当前实现中**没有**布隆过滤器或空值缓存:
CacheManager.get()在 Redis 返回 null 时直接返回 nullThumbServiceImpl.hasThumb()中,null 被解释为"未点赞"(return false)
缓解因素:
- 点赞数据存储在 Redis Hash 中(
thumb:{userId}),查询是 Hash 的HGET操作,性能较高 - 业务层有用户登录校验,限制了 userId 的随机性
潜在改进:在 Caffeine 层缓存空值(设置较短过期时间,如 1 分钟)或添加布隆过滤器预判。
缓存雪崩防护¶
当前实现中**没有**显式的缓存雪崩防护策略:
- Caffeine 统一设置 5 分钟过期,没有随机过期时间偏移
- 没有 Redis 层面的永不过期 + 后台异步更新机制
缓解因素:
- 只有热 Key 才进入 Caffeine,本地缓存的 Key 数量有限
- Caffeine 过期后,请求回退到 Redis,不会直接打到数据库
- Redis 中的数据是持久化的(Hash 结构),不存在 Redis 缓存过期的问题
🔧 Redis Key 设计¶
| Key 格式 | 类型 | 用途 | 生命周期 |
|---|---|---|---|
thumb:{userId} |
Hash | 用户点赞状态,field=blogId, value=thumbId(方案二)或 1(方案一) | 永久 |
thumb:temp:{HH:mm:ss} |
Hash | 临时点赞计数,field=userId:blogId, value=增量(1/-1) |
10 秒后同步删除 |
spring:session:sessions:{sessionId} |
Hash | Spring Session 分布式会话 | Session 过期时间 |
📊 方案一的 Redis Lua 脚本¶
点赞脚本(THUMB_SCRIPT)¶
local tempThumbKey = KEYS[1] -- 临时计数键(时间片 Hash)
local userThumbKey = KEYS[2] -- 用户点赞状态键
local userId = ARGV[1]
local blogId = ARGV[2]
-- 步骤1:防重检查
if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
return -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
取消点赞脚本(UNTHUMB_SCRIPT)¶
对称逻辑:检查已点赞 → 临时计数 -1 → 删除用户点赞标记。
原子性保证¶
Redis 执行 Lua 脚本时是**单线程串行**的,脚本内的所有 Redis 命令不会被其他客户端命令打断。因此以下操作是原子的:
- 写入临时计数(
HSET tempThumbKey) - 标记用户点赞状态(
HSET/HDEL userThumbKey)
返回值语义¶
| 返回值 | 枚举 | 含义 |
|---|---|---|
1 |
LuaStatusEnum.SUCCESS |
操作成功 |
-1 |
LuaStatusEnum.FAIL |
重复点赞 / 未点赞却取消 |
⚠️ 已知局限与改进方向¶
HeavyKeeper 堆操作性能
minHeap.stream().filter() 是 O(n) 遍历,可额外维护一个 Map<String, Node> 加速查找。
String.intern() 内存风险
大量不同 userId 会导致字符串常量池膨胀,可改用 ConcurrentHashMap<String, Object> 管理锁对象。
Redis Key 永不过期
thumb:{userId} 没有 TTL,长期未活跃用户的点赞数据会永久占用内存,可考虑设置过期时间或定期清理。
LaissezFaireSubTypeValidator
Redis 序列化使用宽松的子类型验证器,生产环境应替换为白名单验证器,防止反序列化漏洞。
🔗 相关链接¶
- 整体架构设计 — 分层架构、数据库设计、两套方案对比
- 并发控制与数据一致性 — 时间片分桶、定时任务、最终一致性保障
- HeavyKeeper 算法详解 — 算法原理与实现细节