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);
}
}
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);
}
}
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是否存在。不存在就直接返回,不用查数据库。
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哨兵或集群模式,避免单点故障。
- 限流降级:在数据库层做限流,超出阈值直接返回默认值或错误提示。
3.3 知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以看到,淘汰算法解决的是缓存空间有限的问题,而三大问题解决的是缓存与数据库之间的数据一致性和可用性问题。
好了,这一章的内容就到这里。记住,没有银弹。FIFO简单但粗糙,LRU适合大多数场景,LFU在访问模式稳定的场景下表现更好。至于那三个问题,我的建议是:穿透用布隆过滤器,击穿用互斥锁,雪崩用过期时间加随机值。先把这些基础打牢,后面讲多级缓存架构时,你才能理解为什么需要层层设防。