🧠后端面试题:LRU vs LFU,到底谁更聪明?项目里该怎么选?
❝
"面试官问:你知道 LRU 和 LFU 的区别吗?"
我当时心里一紧:这不就是"缓存界的双雄"嘛!结果越答越乱...
------后来我悟了,这俩其实是两种"记仇方式"😂
❞
一、先聊点真实的场景
有没有遇到过这种情况👇
你的接口明明加了缓存,结果访问量一上来,缓存命中率低得可怜;
或者 Redis 内存爆掉后,刚被刷新的热数据居然被踢出缓存了?!
这时候,问题往往不在 Redis 上,而在 「"缓存淘汰策略"」 。
于是,经典二选一:「LRU or LFU?」
二、他们俩到底是谁?
策略 | 全称 | 思想 | 举个例子 |
---|---|---|---|
「LRU」 | Least Recently Used | 最近最少使用 | 像你微信里最久没联系的人,被清理掉 💔 |
「LFU」 | Least Frequently Used | 最少访问次数 | 就像朋友圈互动少的人,会被算法"雪藏"了 😅 |
🕵♂️ 用一句话总结:
❝
「LRU 看"时间",LFU 看"次数"。」
❞
三、他们的底层机制(图解理解更轻松 👇)
🌀 LRU(时间维度)
css
[最近访问] A → B → C → D [最久未访问]
- 每次访问元素,就把它移到"头部"
- 缓存满了,就把"尾部"的淘汰
❝
✅ 优点:实现简单,能应对大多数缓存场景 ❌ 缺点:短时间内被访问多次的"热点"可能被误删
❞
🔢 LFU(次数维度)
scss
访问次数:
A(5次), B(3次), C(1次), D(2次)
- 每次访问次数 +1
- 淘汰时删除访问次数最少的那个
❝
✅ 优点:更能保留真正的"常用数据" ❌ 缺点:计数维护复杂、容易"固化热点"(老数据长期霸占内存)
❞
四、实战代码:手写一个 LRU & LFU 🔧
🧩 Java 版 LRU
其实 Java 自带的 LinkedHashMap
已经帮我们搞定了👇
scala
import java.util.*;
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // true 表示访问顺序
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
测试一下👇
typescript
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "A"); cache.put(2, "B"); cache.put(3, "C");
cache.get(1); // 访问1,让它变"新"
cache.put(4, "D"); // 淘汰最久未访问的2
System.out.println(cache.keySet()); // 输出: [3, 1, 4]
}
🧩 Go 版 LFU
用两个 map + 一个最小堆思想实现👇
go
package main
import "fmt"
type entry struct {
value interface{}
count int
}
type LFUCache struct {
data map[string]*entry
capacity int
}
func NewLFU(cap int) *LFUCache {
return &LFUCache{
data: make(map[string]*entry),
capacity: cap,
}
}
func (c *LFUCache) Get(key string) (interface{}, bool) {
if e, ok := c.data[key]; ok {
e.count++
return e.value, true
}
return nil, false
}
func (c *LFUCache) Put(key string, val interface{}) {
if len(c.data) >= c.capacity {
// 淘汰最少访问的
var minKey string
minCount := int(^uint(0) >> 1)
for k, e := range c.data {
if e.count < minCount {
minCount = e.count
minKey = k
}
}
delete(c.data, minKey)
}
c.data[key] = &entry{val, 1}
}
func main() {
cache := NewLFU(3)
cache.Put("A", 1)
cache.Put("B", 2)
cache.Put("C", 3)
cache.Get("A")
cache.Get("A")
cache.Put("D", 4) // 淘汰最少访问的 B 或 C
fmt.Println(cache.data)
}
🧠 小技巧:
❝
LFU 在高并发系统中要注意计数器的线程安全和性能开销。 实际中 Redis 的
volatile-lfu
就做了「衰减 + 近似计数」优化。❞
五、那项目中我怎么选?
这个问题就像------ 「"要吃炸鸡还是沙拉?"🍗🥗」 得看场景!
场景 | 推荐策略 | 原因 |
---|---|---|
Web 缓存、CDN、接口响应缓存 | ✅ LRU | 热点变化快,最近访问更重要 |
计算结果、配置缓存、AI 模型缓存 | ✅ LFU | 稳定的高频访问数据更关键 |
Redis 缓存 | ✅ volatile-lfu(默认推荐) | 兼顾"时间 + 次数"两种维度 |
六、发散一下:Redis 是怎么做的?
Redis 其实在玩"混合流派"👇
- 「LRU 模式」:按时间踢掉冷数据
- 「LFU 模式」:用 8 bit 计数器 + 衰减机制(count 越大衰减越慢)
- 「近似算法」:随机采样几个 key,对比后淘汰,不会全局扫描 🔥
❝
✅ 性能稳定 ✅ 不容易被"老热点"绑架 ✅ 不需要太多内存统计信息
❞
这就是 Redis 为啥能撑得住高并发读写 💪
七、最后总结 🧩
对比点 | LRU | LFU |
---|---|---|
核心 | 最近最少使用 | 最少访问次数 |
优点 | 实现简单、性能高 | 更聪明地保留热点数据 |
缺点 | 可能误删热点 | 计数维护复杂 |
应用 | Web 缓存、接口缓存 | 模型、配置、冷数据场景 |
Redis 中 | volatile-lru |
volatile-lfu |
🏁结语:写在最后
其实我也走过弯路------ 以前我啥都上 LRU,后来缓存命中率一塌糊涂, 直到被 Redis 的 LFU 教做人 😂
有时候我们以为的"最近",不一定代表"最常用"; 而系统真正的聪明,不在于算法多复杂,而在于------ 「能不能理解数据的"使用习惯"。」
👋 那你呢? 项目里用的是哪种策略? 评论区见,我们一起聊聊那些"被误删"的缓存吧 💬