3. 缓存策略基础:LRU、LFU、FIFO、TTL、W-TinyLFU 算法详解

聊缓存策略之前,我先问个问题:你遇到过缓存把热点数据挤掉的情况吗?

我在早期做电商秒杀系统时,就踩过这个坑。明明 Redis 集群配了 64GB,结果大促刚开始,商品详情页的缓存命中率直接掉到 30%。查了半天,发现是缓存淘汰策略选错了——FIFO 把刚刷新的爆款数据给踢出去了。嗯,从那以后,我对每种淘汰策略的适用场景都格外敏感。

今天咱们就把五种主流策略掰开揉碎讲清楚。你想想看,缓存空间就那么大,数据源源不断往里写,满了怎么办?必须有人「走」,谁走谁留,这就是淘汰策略要解决的问题。

3.1 FIFO(先进先出)—— 最简单的淘汰策略

FIFO 的逻辑一句话就能说清:谁先来,谁先走。它维护一个队列,新数据从尾部插入,淘汰时从头部移除。

核心特点:

  • 实现极其简单,只需要一个队列
  • 不考虑访问频率和访问时间
  • 可能淘汰掉高频访问的「老数据」

我在项目中见过有人用 FIFO 做本地缓存,结果就是「缓存雪崩」的前兆——热点数据被周期性地批量淘汰,数据库压力像过山车一样忽高忽低。我个人习惯,FIFO 只适合两种场景:数据生命周期固定且短(比如验证码),或者你明确知道数据写入顺序就是访问顺序。

避坑指南:我曾经在一个日志采集模块里用了 FIFO,结果因为日志生成速度不均匀,缓存里全是旧日志,新日志根本进不来。后来改成 TTL + 随机淘汰才解决。

3.2 LRU(最近最少使用)—— 最常用的通用策略

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

实现上,LRU 通常用「哈希表 + 双向链表」的组合。哈希表保证 O(1) 查找,双向链表保证 O(1) 移动和删除。每次访问数据,就把它移到链表头部;淘汰时,直接移除链表尾部。

// 伪代码:LRU 核心逻辑
class LRUCache {
    Map<K, Node> map;
    DoubleLinkedList list;
    int capacity;

    V get(K key) {
        if (!map.containsKey(key)) return null;
        Node node = map.get(key);
        list.moveToHead(node);  // 移到头部
        return node.value;
    }

    void put(K key, V value) {
        if (map.containsKey(key)) {
            Node node = map.get(key);
            node.value = value;
            list.moveToHead(node);
        } else {
            if (map.size() >= capacity) {
                Node tail = list.removeTail(); // 淘汰尾部
                map.remove(tail.key);
            }
            Node newNode = new Node(key, value);
            list.addToHead(newNode);
            map.put(key, newNode);
        }
    }
}

我的经验:LRU 在大多数业务场景下表现都不错,但要注意「批量扫描」问题。比如后台跑定时任务,一次性扫描了全量数据,LRU 的链表头部会被这些「一次性数据」占满,真正的热点反而被挤到尾部淘汰掉。我建议在扫描场景下,临时禁用缓存或使用单独的缓存实例。

3.3 LFU(最不经常使用)—— 频率优先

LFU 的思路和 LRU 不同:它看的是访问频率,而不是访问时间。谁被访问的次数最少,谁就先被淘汰。

实现上,LFU 需要维护每个 key 的访问计数,通常用「哈希表 + 最小堆」或「多级链表」来实现。每次访问,计数加一;淘汰时,找计数最小的那个。

特性 LRU LFU
淘汰依据 最近访问时间 访问频率
实现复杂度 中等 较高
内存开销 低 高(需维护计数)
适用场景 通用业务缓存 热点数据稳定、访问模式固定
典型问题 批量扫描污染 频率衰减慢、新数据难进入

LFU 有个明显的缺陷:频率衰减太慢。比如一个数据昨天被访问了 1000 次,今天没人用了,但它的计数还是很高,导致新来的热点数据进不去。我见过一个推荐系统用 LFU,结果用户兴趣变了,缓存里还是旧的热门内容,推荐效果越来越差。

改进思路:给计数加时间衰减因子,或者定期将计数减半。这样既能保留高频特征,又能让新数据有机会「上位」。

3.4 TTL(生存时间)—— 不是淘汰策略,但必须配合使用

严格来说,TTL 不是淘汰策略,而是数据过期机制。它给每个缓存项设置一个过期时间,时间到了就自动失效。

但 TTL 和淘汰策略是「搭档」关系:淘汰策略决定「谁先走」,TTL 决定「谁该走」。两者配合,才能让缓存既高效又新鲜。

我建议的配置原则:

  • 热点数据:TTL 设长一些(比如 1 小时),配合 LRU 淘汰
  • 冷数据:TTL 设短一些(比如 5 分钟),避免占用缓存空间
  • 关键数据:TTL 到期后主动刷新,而不是等请求触发

注意:TTL 不能替代淘汰策略。如果所有数据 TTL 都设得很长,缓存满了之后,淘汰策略才会生效。我曾经见过一个系统只设 TTL 不配淘汰策略,结果缓存满了之后,新数据写入失败,业务直接报错。

3.5 W-TinyLFU —— 现代缓存系统的首选

W-TinyLFU 是 Caffeine 缓存库的核心算法,也是我目前在生产环境最常用的策略。它结合了 LRU 和 LFU 的优点,还解决了各自的缺陷。

它的设计思路很巧妙:

  1. 窗口缓存(Window Cache):用 LRU 维护一个很小的窗口,专门处理「突发热点」
  2. 主缓存(Main Cache):用 SLRU(Segmented LRU)维护大部分数据,兼顾频率和时效
  3. 频率过滤器:用 Count-Min Sketch 概率数据结构记录访问频率,内存开销极低

说白了,W-TinyLFU 是这样工作的:新数据先进入窗口缓存,如果短时间内被频繁访问,就「晋升」到主缓存;如果只是偶尔访问,很快就会被窗口淘汰掉。主缓存内部又分「试用区」和「保护区」,数据先进入试用区,被再次访问后才进入保护区,避免一次扫描就污染缓存。

W-TinyLFU 数据流向图 窗口缓存 LRU 策略 容量: 1% 总空间 频率过滤器 Count-Min Sketch 内存: ~10KB 主缓存 · 试用区 SLRU 策略 · 仅访问一次的数据 容量: 20% 总空间 主缓存 · 保护区 SLRU 策略 · 多次访问的热点 容量: 80% 总空间 淘汰出口 新数据 淘汰时 频率达标 再次访问 淘汰 淘汰

W-TinyLFU 的优势很明显:

  • 抗扫描:一次性数据只进窗口,不会污染主缓存
  • 频率感知:高频数据自动晋升到保护区,不会被轻易淘汰
  • 低内存:Count-Min Sketch 只需要几 KB 到几十 KB
  • 高命中率:在多种 workload 下,命中率接近理论最优

实践建议:如果你用 Java,直接上 Caffeine 缓存库,它内置了 W-TinyLFU。配置方式很简单:Caffeine.newBuilder().maximumSize(10_000).build()。我个人习惯把窗口大小设为总容量的 1%,主缓存试用区占 20%,保护区占 80%,这个比例在大多数场景下表现都不错。

3.6 如何选择?一张表说清楚

场景 推荐策略 理由
通用业务缓存 W-TinyLFU 综合表现最好,抗干扰能力强
数据访问模式稳定 LFU 频率特征明显,命中率高
数据生命周期短 TTL + FIFO 简单高效,无需复杂淘汰
突发流量场景 LRU + 短 TTL 快速响应突发热点
内存极度受限 LRU 实现简单,内存开销低

最后说一句:没有银弹。每种策略都有它的「舒适区」,也有它的「死穴」。我建议你在选型时,先搞清楚你的数据访问模式——是热点集中还是分散?是突发流量还是持续访问?然后拿着这张表去对,基本不会跑偏。