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 的优点,还解决了各自的缺陷。
它的设计思路很巧妙:
- 窗口缓存(Window Cache):用 LRU 维护一个很小的窗口,专门处理「突发热点」
- 主缓存(Main Cache):用 SLRU(Segmented LRU)维护大部分数据,兼顾频率和时效
- 频率过滤器:用 Count-Min Sketch 概率数据结构记录访问频率,内存开销极低
说白了,W-TinyLFU 是这样工作的:新数据先进入窗口缓存,如果短时间内被频繁访问,就「晋升」到主缓存;如果只是偶尔访问,很快就会被窗口淘汰掉。主缓存内部又分「试用区」和「保护区」,数据先进入试用区,被再次访问后才进入保护区,避免一次扫描就污染缓存。
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 | 实现简单,内存开销低 |
最后说一句:没有银弹。每种策略都有它的「舒适区」,也有它的「死穴」。我建议你在选型时,先搞清楚你的数据访问模式——是热点集中还是分散?是突发流量还是持续访问?然后拿着这张表去对,基本不会跑偏。