2、缓存策略精讲:本地缓存 vs 分布式缓存、缓存淘汰策略(LRU/LFU/TTL)、缓存穿透/击穿/雪崩的应对方案

大家好,我是老赵。今天咱们聊聊缓存策略。

说实话,在在线特征服务里,缓存是命根子。你想想看,一个特征请求从上游打过来,如果每次都去查数据库或者远程服务,延迟根本扛不住。我见过太多团队,上来就堆机器,结果发现瓶颈在数据获取上。缓存,就是那个花小钱办大事的利器。

2.1 本地缓存 vs 分布式缓存:选哪个?

先解决第一个问题:缓存放哪儿?

我个人习惯把缓存分为两类:本地缓存和分布式缓存。它们各有各的脾气,选错了,性能反而会下降。

维度 本地缓存 分布式缓存
存储位置 进程内堆内存 独立缓存服务(如 Redis)
访问延迟 纳秒级(< 1μs) 毫秒级(1-5ms)
容量上限 受限于 JVM 堆大小 可水平扩展,TB 级
数据一致性 各节点独立,容易不一致 中心化,一致性高
典型场景 热点特征、静态配置 全局共享特征、用户画像

我在项目中遇到过这样一个坑:有个团队把所有特征都塞进本地缓存,结果每台机器存了 2GB 数据,GC 频繁触发,服务响应时间从 5ms 飙到 200ms。后来我们拆分了——高频不变的特征用本地缓存,低频变化或全局共享的走 Redis,问题才解决。

我的建议:本地缓存适合「读多写极少」且「单机数据量 < 500MB」的场景。一旦数据量超过 1GB,或者需要跨节点共享,果断上分布式缓存。

2.2 缓存淘汰策略:LRU、LFU、TTL 怎么选?

缓存空间是有限的,满了就得淘汰。选什么策略?这得看你的数据访问模式。

2.2.1 LRU(最近最少使用)

LRU 淘汰的是「最近最久没被访问」的数据。实现上常用 LinkedHashMap 或 ConcurrentLinkedQueue。

说白了,它假设:最近被访问过的数据,未来还可能被访问。这在大多数业务场景下是成立的。

// 一个简单的 LRU 缓存实现(基于 LinkedHashMap)
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxCapacity;

    public LRUCache(int maxCapacity) {
        super(maxCapacity, 0.75f, true); // accessOrder = true
        this.maxCapacity = maxCapacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxCapacity;
    }
}

2.2.2 LFU(最不经常使用)

LFU 淘汰的是「访问频率最低」的数据。它维护一个访问计数器,每次访问加 1,淘汰时选计数最小的。

嗯,这里要注意:LFU 有个问题——历史热点数据会一直占着坑。比如某个特征昨天被刷了 100 万次,今天没人访问了,它还是赖着不走。新来的热点反而进不来。

避坑指南:我曾经在广告推荐系统里用过纯 LFU,结果发现旧特征占满了缓存,新上线的特征根本拿不到缓存。后来改成了 LRU + 衰减因子,每 10 分钟把计数减半,才解决了这个问题。

2.2.3 TTL(过期时间)

TTL 不是淘汰策略,而是数据生命周期管理。每个 key 设置一个过期时间,到期自动删除。

我建议:所有缓存都要设 TTL。哪怕你觉得数据永远不会变,也要设一个合理的过期时间。为什么?防止「缓存污染」——万一数据变了,你还在用旧值,那可就出大事了。

策略 适用场景 缺点
LRU 通用场景,访问模式随时间变化 无法应对突发扫描
LFU 热点集中,访问频率差异大 历史热点残留
TTL 数据有时效性要求 无法控制内存上限

2.3 缓存三大杀手:穿透、击穿、雪崩

这三个问题,是缓存系统的「噩梦三连」。我一个个说。

2.3.1 缓存穿透

什么是穿透?就是查询一个根本不存在的数据。缓存里没有,数据库里也没有,每次请求都直接打到数据库上。

举个例子:用户查询一个不存在的商品 ID,缓存 miss,数据库也查不到,下次同样的请求又来了……数据库扛得住吗?扛不住。

解决方案:

  • 布隆过滤器:在缓存前面加一层过滤器,判断 key 是否存在。不存在直接返回,不用查数据库。
  • 缓存空值:即使数据库查不到,也缓存一个空对象(比如 null),设置较短的 TTL(比如 30 秒)。
我的经验:布隆过滤器适合「key 集合相对固定」的场景。如果 key 是动态生成的(比如用户 ID),布隆过滤器维护成本太高,我一般用「缓存空值 + 短 TTL」方案。

2.3.2 缓存击穿

击穿和穿透不一样。击穿是一个热点 key 过期了,瞬间大量请求打到数据库上。

你想想看:双十一的时候,某个爆款商品的详情页,缓存突然过期了。几千个请求同时来,数据库瞬间被打爆。

解决方案:

  • 互斥锁:只让一个线程去查数据库,其他线程等待。查完再放行。
  • 逻辑过期:缓存永不过期,但存一个逻辑过期时间。后台异步更新缓存。
// 互斥锁方案(伪代码)
public String getFeature(String key) {
    String value = cache.get(key);
    if (value != null) {
        return value;
    }
    // 尝试获取锁
    if (lock.tryLock(key)) {
        try {
            // 双重检查
            value = cache.get(key);
            if (value == null) {
                value = db.query(key);
                cache.set(key, value, 300);
            }
        } finally {
            lock.unlock(key);
        }
    } else {
        // 没拿到锁,等待一会再重试
        Thread.sleep(50);
        return getFeature(key);
    }
    return value;
}

2.3.3 缓存雪崩

雪崩是击穿的升级版——大量 key 同时过期,或者缓存节点宕机,导致所有请求都打到数据库上。

我记得有一次,我们给所有特征缓存统一设置了 1 小时的 TTL。结果整点的时候,数据库连接数瞬间飙满,服务直接挂了。嗯,这就是典型的雪崩。

解决方案:

  • 过期时间加随机值:比如基础 TTL 是 3600 秒,再加一个 0-600 秒的随机偏移。
  • 多级缓存:本地缓存 + 分布式缓存 + 数据库,层层兜底。
  • 缓存高可用:Redis 集群 + 哨兵,避免单点故障。
一个小技巧:我习惯在缓存服务启动时,先「预热」热点数据。比如把最近 1 小时访问量前 100 的特征提前加载到缓存里,避免冷启动时的雪崩。

2.4 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以把它当作一个「缓存决策地图」。

缓存策略知识体系 缓存策略 存储选型 淘汰策略 异常应对 本地缓存 分布式缓存 LRU LFU TTL 穿透 击穿 雪崩 布隆过滤器 / 空值缓存 互斥锁 / 逻辑过期 随机TTL / 多级缓存 核心原则 1. 本地缓存扛热点,分布式缓存保一致 2. 淘汰策略看访问模式,TTL 必须设 3. 穿透靠过滤,击穿靠锁,雪崩靠分散

好了,这一章的内容就到这里。缓存策略说复杂也复杂,说简单也简单——选对存储、定好淘汰、防住异常,这三件事做好了,你的特征获取延迟基本就稳了。