4、缓存策略:Redis缓存架构、缓存穿透/击穿/雪崩解决方案、本地缓存与分布式缓存
说到性能优化,缓存绝对是绕不开的核心话题。我做了这么多年系统,见过太多因为缓存没用好导致线上事故的案例。说白了,缓存就是拿空间换时间,但怎么换、换多少、换完怎么保证不出问题,这里面的门道可不少。
这一章,咱们就聊聊缓存策略。我会从Redis缓存架构讲起,然后重点分析三个经典问题——缓存穿透、击穿、雪崩,最后再对比一下本地缓存和分布式缓存的使用场景。
4.1 Redis缓存架构设计
Redis是目前最主流的分布式缓存方案。我个人习惯把它当作系统的「第二层内存」来用。为什么这么说?因为应用服务器的本地内存是第一层,Redis是独立部署的第二层,两者配合才能发挥最大效果。
核心架构要点:
- 读写分离:主节点负责写,从节点负责读。我在项目中遇到过,如果不做读写分离,单节点QPS到10万左右就开始抖动了。
- 哨兵模式:自动故障转移。嗯,这里要注意,哨兵只是解决了高可用问题,不解决容量问题。
- 集群模式:数据分片存储。每个节点只存一部分数据,总容量可以横向扩展。
下面这张图是我总结的Redis缓存架构核心逻辑,你看一眼就明白了:
我的经验:Redis集群的节点数建议控制在100以内。超过这个数,节点间的通信开销会明显上升。我曾经帮一个客户优化,他们搞了200多个节点,结果一半的带宽都用在gossip协议上了。
4.2 缓存穿透、击穿、雪崩解决方案
这三个问题,是缓存面试的必考题,也是线上最容易踩的坑。咱们一个一个说。
4.2.1 缓存穿透
缓存穿透,说白了就是「查了个不存在的数据」。请求直接绕过缓存打到数据库,如果并发高,数据库瞬间就跪了。
解决方案:
- 缓存空对象:查询结果为空时,也缓存一个空值,设置较短的过期时间(比如30秒)。
- 布隆过滤器:在缓存前面加一层过滤器,判断数据是否存在。不存在直接返回,不用查数据库。
我个人更推荐布隆过滤器。为什么?因为缓存空对象有个问题——如果恶意攻击用随机key来打,你的缓存里会塞满空对象,内存扛不住。
// 布隆过滤器示例(Java)
public class BloomFilterDemo {
private BloomFilter<String> bloomFilter;
public BloomFilterDemo() {
// 预计插入100万条数据,误判率0.01
this.bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1_000_000,
0.01
);
}
public boolean mightContain(String key) {
return bloomFilter.mightContain(key);
}
public void put(String key) {
bloomFilter.put(key);
}
}
注意:布隆过滤器有误判率,它会说「某个key可能存在」,但不会说「一定不存在」。误判率一般控制在1%以内就够用了。我曾经见过有人把误判率设成0.0001%,结果布隆过滤器占了几百MB内存,得不偿失。
4.2.2 缓存击穿
缓存击穿,指的是某个热点key突然过期,大量请求同时打到数据库。你想想看,双十一的时候某个爆款商品的缓存过期了,那场景...
解决方案:
- 互斥锁:只让一个线程去查数据库重建缓存,其他线程等待。
- 逻辑过期:缓存永不过期,但数据里带一个过期时间字段。后台异步更新。
我建议用互斥锁方案,实现简单,效果直接。逻辑过期虽然能避免锁竞争,但实现复杂,容易出bug。
// 互斥锁重建缓存
public String getWithMutex(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取锁
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10)) {
try {
// 查数据库
value = db.query(key);
redis.set(key, value, 3600);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁,等待后重试
Thread.sleep(50);
return getWithMutex(key);
}
}
4.2.3 缓存雪崩
缓存雪崩,就是大量key同时过期,或者Redis整个挂了。这时候所有请求直接打到数据库,系统瞬间瘫痪。
解决方案:
- 过期时间加随机值:避免大量key同时过期。比如基础过期时间1小时,再加0-10分钟的随机值。
- 多级缓存:Redis挂了,还有本地缓存顶着。
- 限流降级:数据库层面做限流,保护核心业务。
我曾经踩过的坑:有一次上线新功能,把所有缓存key的过期时间都设成了整点过期。结果凌晨12点一到,数据库CPU直接飙到100%。从那以后,我每次设过期时间都会加随机值,成了肌肉记忆。
4.3 本地缓存与分布式缓存
很多人在选型时会纠结:到底用本地缓存还是分布式缓存?我的看法是:两者不是二选一,而是配合使用。
| 对比维度 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 访问速度 | 纳秒级(内存直接访问) | 毫秒级(网络IO) |
| 数据一致性 | 各节点不一致 | 全局一致 |
| 容量 | 受限于单机内存 | 可横向扩展 |
| 典型实现 | Caffeine、Guava Cache | Redis、Memcached |
| 适用场景 | 高频访问、变化不频繁的数据 | 共享数据、跨服务数据 |
我一般这样设计:
- 第一层:本地缓存(Caffeine),存最热的数据。比如用户信息、配置信息。
- 第二层:Redis,存所有缓存数据。本地缓存没命中时,查Redis。
- 第三层:数据库,兜底。
这样做的好处是:本地缓存扛住了绝大部分请求,Redis的压力很小。即使Redis挂了,本地缓存还能撑一会儿,给运维留出恢复时间。
一个小技巧:本地缓存的过期时间要比Redis短。比如Redis设1小时,本地缓存设10分钟。这样即使本地缓存数据过期了,还能从Redis拿到最新数据,不会直接打到数据库。
嗯,缓存这块内容不少,但核心就三点:架构设计要分层、三个穿透问题要防住、本地和分布式要配合。你把这些想明白了,缓存这块基本就稳了。
公众号:蓝海资料掘金营,微信deep3321