2. 本地缓存策略:LRU、LFU、FIFO算法原理与实现,缓存穿透、击穿、雪崩的应对方案

本地缓存,说白了就是每个应用进程自己管自己的那一小块内存。我见过不少团队一上来就上Redis,结果发现业务量根本没到那个级别,反而把架构搞复杂了。其实很多场景下,一个设计得当的本地缓存就能扛住大部分压力。

今天咱们就聊聊三种最经典的淘汰算法,以及那三个让无数人头疼的缓存问题。嗯,都是我在线上实打实踩过的坑。

3.1 三种淘汰算法:原理与实现

先问个问题:缓存空间是有限的,放满了怎么办?你得有个规则把旧数据踢出去。这个规则,就是淘汰算法。

3.1.1 FIFO(先进先出)

FIFO是最朴素的思路——谁先来,谁先走。就像一个排队买奶茶的队伍,先排到的先拿到。

实现方式:用一个队列,新数据从尾部插入,淘汰时从头部移除。

public class FIFOCache<K, V> {
    private final int capacity;
    private final Queue<K> queue = new LinkedList<>();
    private final Map<K, V> map = new HashMap<>();

    public FIFOCache(int capacity) {
        this.capacity = capacity;
    }

    public V get(K key) {
        return map.get(key);
    }

    public void put(K key, V value) {
        if (map.containsKey(key)) {
            map.put(key, value);
            return;
        }
        if (queue.size() >= capacity) {
            K oldest = queue.poll();
            map.remove(oldest);
        }
        queue.offer(key);
        map.put(key, value);
    }
}
注意:FIFO有个致命缺陷——它完全不考虑数据的访问频率。一个被频繁访问的热点数据,仅仅因为来得早就会被淘汰。我在一个报表系统里用过FIFO,结果发现每天凌晨跑批时,所有热点数据都被清空了,缓存命中率直接掉到20%以下。

3.1.2 LRU(最近最少使用)

LRU的核心思想是:如果一个数据最近被访问过,那么它将来被访问的概率也更高。说白了,就是淘汰最久没被用过的那个。

实现方式:哈希表 + 双向链表。每次访问数据时,把它移到链表头部;淘汰时,从链表尾部移除。

public class LRUCache<K, V> {
    private final int capacity;
    private final LinkedHashMap<K, V> cache;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new LinkedHashMap<K, V>(capacity, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacity;
            }
        };
    }

    public V get(K key) {
        return cache.get(key);
    }

    public void put(K key, V value) {
        cache.put(key, value);
    }
}
我的经验:Java的LinkedHashMap自带LRU语义,把accessOrder设为true就行。但要注意,它是线程不安全的。高并发场景下,我习惯用ConcurrentHashMap + 读写锁自己实现一个。

3.1.3 LFU(最不经常使用)

LFU的思路更直接:谁用得最少,谁先走。它记录每个数据的访问次数,次数最低的被淘汰。

实现方式:每个key维护一个计数器,每次访问加1。淘汰时找最小计数的key。

public class LFUCache<K, V> {
    private final int capacity;
    private final Map<K, V> valueMap = new HashMap<>();
    private final Map<K, Integer> countMap = new HashMap<>();

    public LFUCache(int capacity) {
        this.capacity = capacity;
    }

    public V get(K key) {
        if (!valueMap.containsKey(key)) return null;
        countMap.put(key, countMap.get(key) + 1);
        return valueMap.get(key);
    }

    public void put(K key, V value) {
        if (capacity == 0) return;
        if (valueMap.containsKey(key)) {
            valueMap.put(key, value);
            countMap.put(key, countMap.get(key) + 1);
            return;
        }
        if (valueMap.size() >= capacity) {
            K minKey = countMap.entrySet().stream()
                .min(Map.Entry.comparingByValue())
                .get().getKey();
            valueMap.remove(minKey);
            countMap.remove(minKey);
        }
        valueMap.put(key, value);
        countMap.put(key, 1);
    }
}
对比总结:
算法淘汰依据适用场景缺点
FIFO插入时间数据生命周期固定不关心访问频率
LRU最近访问时间热点数据集中偶发批量访问会污染缓存
LFU访问频率访问模式稳定新数据很难进入缓存

3.2 三大缓存问题:穿透、击穿、雪崩

算法选好了,缓存搭起来了,你以为就万事大吉了?我曾经也这么想,直到线上出了事故。这三个问题,每一个都能让你的系统瞬间瘫痪。

3.2.1 缓存穿透

现象:请求的数据在缓存和数据库里都不存在。每次请求都直接打到数据库上。

举个例子:用户查询一个不存在的商品ID,缓存里没有,数据库里也没有。如果恶意攻击者用大量不存在的ID来刷,数据库直接被打爆。

应对方案:

  • 缓存空值:即使数据库查不到,也缓存一个空对象,设置较短的过期时间(比如30秒)。
  • 布隆过滤器:在缓存前面加一层布隆过滤器,判断key是否存在。不存在就直接返回,不用查数据库。
我踩过的坑:布隆过滤器有误判率,虽然很低。我曾经把误判率设得太低,导致过滤器占了几百MB内存。建议控制在1%以内,够用就行。

3.2.2 缓存击穿

现象:一个热点key在缓存过期的一瞬间,大量并发请求同时涌入,全部打到数据库上。

举个例子:双十一秒杀活动,某个爆款商品的库存数据在缓存中过期了。瞬间几万个请求同时去数据库查库存,数据库直接扛不住。

应对方案:

  • 互斥锁:只让一个线程去查数据库并重建缓存,其他线程等待。我用的是Redis的SETNX命令实现分布式锁。
  • 逻辑过期:不给缓存设置物理过期时间,而是在数据里加一个过期标记。后台异步线程去刷新缓存。
// 互斥锁示例
public V getWithLock(K key) {
    V value = cache.get(key);
    if (value != null) return value;

    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
        try {
            value = db.query(key);
            cache.put(key, value, 60, TimeUnit.SECONDS);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 等待其他线程重建缓存
        Thread.sleep(100);
        return cache.get(key);
    }
    return value;
}

3.2.3 缓存雪崩

现象:大量缓存同时过期,或者缓存服务宕机,导致所有请求直接打到数据库上。

举个例子:你给所有缓存都设置了1小时的过期时间。每到整点,所有缓存同时失效,数据库瞬间被压垮。

应对方案:

  • 过期时间加随机值:在基础过期时间上,加一个随机偏移量(比如1-5分钟)。避免大量key同时过期。
  • 缓存高可用:使用Redis哨兵或集群模式,避免单点故障。
  • 限流降级:在数据库层做限流,超出阈值直接返回默认值或错误提示。
重要提醒:缓存雪崩是最难处理的。我经历过一次,Redis主节点宕机,哨兵切换期间所有请求打到MySQL,数据库连接池瞬间耗尽。后来我加了本地缓存作为二级缓存,才把风险降下来。

3.3 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,淘汰算法解决的是缓存空间有限的问题,而三大问题解决的是缓存与数据库之间的数据一致性和可用性问题。

本地缓存策略知识体系 淘汰算法 FIFO LRU LFU 三大缓存问题 穿透 击穿 雪崩 应对方案 缓存空值 布隆过滤器 互斥锁 限流降级 核心原则: 淘汰算法解决空间有限问题,三大方案解决缓存与数据库的可用性问题

好了,这一章的内容就到这里。记住,没有银弹。FIFO简单但粗糙,LRU适合大多数场景,LFU在访问模式稳定的场景下表现更好。至于那三个问题,我的建议是:穿透用布隆过滤器,击穿用互斥锁,雪崩用过期时间加随机值。先把这些基础打牢,后面讲多级缓存架构时,你才能理解为什么需要层层设防。