2、缓存策略精讲:本地缓存 vs 分布式缓存、缓存淘汰策略(LRU/LFU/TTL)、缓存穿透/击穿/雪崩的应对方案
大家好,我是老赵。今天咱们聊聊缓存策略。
说实话,在在线特征服务里,缓存是命根子。你想想看,一个特征请求从上游打过来,如果每次都去查数据库或者远程服务,延迟根本扛不住。我见过太多团队,上来就堆机器,结果发现瓶颈在数据获取上。缓存,就是那个花小钱办大事的利器。
2.1 本地缓存 vs 分布式缓存:选哪个?
先解决第一个问题:缓存放哪儿?
我个人习惯把缓存分为两类:本地缓存和分布式缓存。它们各有各的脾气,选错了,性能反而会下降。
| 维度 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 存储位置 | 进程内堆内存 | 独立缓存服务(如 Redis) |
| 访问延迟 | 纳秒级(< 1μs) | 毫秒级(1-5ms) |
| 容量上限 | 受限于 JVM 堆大小 | 可水平扩展,TB 级 |
| 数据一致性 | 各节点独立,容易不一致 | 中心化,一致性高 |
| 典型场景 | 热点特征、静态配置 | 全局共享特征、用户画像 |
我在项目中遇到过这样一个坑:有个团队把所有特征都塞进本地缓存,结果每台机器存了 2GB 数据,GC 频繁触发,服务响应时间从 5ms 飙到 200ms。后来我们拆分了——高频不变的特征用本地缓存,低频变化或全局共享的走 Redis,问题才解决。
2.2 缓存淘汰策略:LRU、LFU、TTL 怎么选?
缓存空间是有限的,满了就得淘汰。选什么策略?这得看你的数据访问模式。
2.2.1 LRU(最近最少使用)
LRU 淘汰的是「最近最久没被访问」的数据。实现上常用 LinkedHashMap 或 ConcurrentLinkedQueue。
说白了,它假设:最近被访问过的数据,未来还可能被访问。这在大多数业务场景下是成立的。
// 一个简单的 LRU 缓存实现(基于 LinkedHashMap)
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int maxCapacity) {
super(maxCapacity, 0.75f, true); // accessOrder = true
this.maxCapacity = maxCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
}
2.2.2 LFU(最不经常使用)
LFU 淘汰的是「访问频率最低」的数据。它维护一个访问计数器,每次访问加 1,淘汰时选计数最小的。
嗯,这里要注意:LFU 有个问题——历史热点数据会一直占着坑。比如某个特征昨天被刷了 100 万次,今天没人访问了,它还是赖着不走。新来的热点反而进不来。
2.2.3 TTL(过期时间)
TTL 不是淘汰策略,而是数据生命周期管理。每个 key 设置一个过期时间,到期自动删除。
我建议:所有缓存都要设 TTL。哪怕你觉得数据永远不会变,也要设一个合理的过期时间。为什么?防止「缓存污染」——万一数据变了,你还在用旧值,那可就出大事了。
| 策略 | 适用场景 | 缺点 |
|---|---|---|
| LRU | 通用场景,访问模式随时间变化 | 无法应对突发扫描 |
| LFU | 热点集中,访问频率差异大 | 历史热点残留 |
| TTL | 数据有时效性要求 | 无法控制内存上限 |
2.3 缓存三大杀手:穿透、击穿、雪崩
这三个问题,是缓存系统的「噩梦三连」。我一个个说。
2.3.1 缓存穿透
什么是穿透?就是查询一个根本不存在的数据。缓存里没有,数据库里也没有,每次请求都直接打到数据库上。
举个例子:用户查询一个不存在的商品 ID,缓存 miss,数据库也查不到,下次同样的请求又来了……数据库扛得住吗?扛不住。
解决方案:
- 布隆过滤器:在缓存前面加一层过滤器,判断 key 是否存在。不存在直接返回,不用查数据库。
- 缓存空值:即使数据库查不到,也缓存一个空对象(比如 null),设置较短的 TTL(比如 30 秒)。
2.3.2 缓存击穿
击穿和穿透不一样。击穿是一个热点 key 过期了,瞬间大量请求打到数据库上。
你想想看:双十一的时候,某个爆款商品的详情页,缓存突然过期了。几千个请求同时来,数据库瞬间被打爆。
解决方案:
- 互斥锁:只让一个线程去查数据库,其他线程等待。查完再放行。
- 逻辑过期:缓存永不过期,但存一个逻辑过期时间。后台异步更新缓存。
// 互斥锁方案(伪代码)
public String getFeature(String key) {
String value = cache.get(key);
if (value != null) {
return value;
}
// 尝试获取锁
if (lock.tryLock(key)) {
try {
// 双重检查
value = cache.get(key);
if (value == null) {
value = db.query(key);
cache.set(key, value, 300);
}
} finally {
lock.unlock(key);
}
} else {
// 没拿到锁,等待一会再重试
Thread.sleep(50);
return getFeature(key);
}
return value;
}
2.3.3 缓存雪崩
雪崩是击穿的升级版——大量 key 同时过期,或者缓存节点宕机,导致所有请求都打到数据库上。
我记得有一次,我们给所有特征缓存统一设置了 1 小时的 TTL。结果整点的时候,数据库连接数瞬间飙满,服务直接挂了。嗯,这就是典型的雪崩。
解决方案:
- 过期时间加随机值:比如基础 TTL 是 3600 秒,再加一个 0-600 秒的随机偏移。
- 多级缓存:本地缓存 + 分布式缓存 + 数据库,层层兜底。
- 缓存高可用:Redis 集群 + 哨兵,避免单点故障。
2.4 知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以把它当作一个「缓存决策地图」。
好了,这一章的内容就到这里。缓存策略说复杂也复杂,说简单也简单——选对存储、定好淘汰、防住异常,这三件事做好了,你的特征获取延迟基本就稳了。