跳转至

ThumbUP 缓存系统设计

💡 一句话概述

基于 Caffeine + Redis 构建二级缓存,通过 HeavyKeeper 算法实时探测 Top-K 热点 Key,仅将热 Key 提升至本地缓存,避免冷数据污染,配合 Lua 脚本保证点赞原子性。


🔑 核心概念

  1. 二级缓存 — Caffeine 本地缓存(L1)+ Redis 分布式缓存(L2),热 Key 自动提升至 L1
  2. HeavyKeeper 算法 — 基于 Count-Min Sketch 的 Top-K 热点探测,指数衰减淘汰冷 Key
  3. Lua 脚本原子性 — Redis 单线程执行 Lua 脚本,保证"防重检查 + 增量记录 + 状态标记"三步原子操作
  4. 缓存防护 — 互斥锁防击穿、UN_THUMB_CONSTANT 标记防穿透

📝 二级缓存架构

数据流总览

请求 → Caffeine 本地缓存 (L1) → Redis Hash (L2) → MySQL (定时批量同步)
                |
         HeavyKeeper 热点探测
         (仅热 Key 才进入 L1)

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

桶结构

private static class Bucket {
    long fingerprint;  // Key 的哈希指纹(MurmurHash3)
    int count;         // 计数器
}

每个桶只存一个 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 时直接返回 null
  • ThumbServiceImpl.hasThumb() 中,null 被解释为"未点赞"(return false)

缓解因素:

  1. 点赞数据存储在 Redis Hash 中(thumb:{userId}),查询是 Hash 的 HGET 操作,性能较高
  2. 业务层有用户登录校验,限制了 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 命令不会被其他客户端命令打断。因此以下操作是原子的:

  1. 写入临时计数(HSET tempThumbKey)
  2. 标记用户点赞状态(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 序列化使用宽松的子类型验证器,生产环境应替换为白名单验证器,防止反序列化漏洞。


🔗 相关链接