3、缓存策略设计:Redis缓存架构、缓存穿透/击穿/雪崩解决方案、本地缓存与分布式缓存结合、缓存预热与更新策略
各位同学,咱们今天聊缓存。说实话,在高并发系统里,缓存就是那个「扛把子」的角色。没有缓存,数据库分分钟被冲垮。但缓存也不是银弹,用不好反而会惹出一堆麻烦。
我个人习惯把缓存策略分成四个维度来设计:架构选型、异常防护、多级协同、生命周期管理。咱们一个一个拆开讲。
3.1 Redis缓存架构设计
先说说Redis。很多同学觉得Redis就是「存个key-value」,太肤浅了。在高并发场景下,Redis的架构设计直接决定了系统的天花板。
核心原则:Redis集群模式下,一定要避免「大Key」和「热Key」。我见过一个项目,就因为一个Hash存了上百万个字段,导致集群分片不均,整个系统响应时间从2ms飙升到2s。
我建议的Redis架构方案是这样的:
- 主从架构:适合读多写少的场景。主节点负责写,从节点负责读。注意,从节点挂了不影响写,但读能力会下降。
- 哨兵模式:在主从基础上加了自动故障转移。嗯,这里要注意,哨兵只是解决了高可用,没解决容量问题。
- Redis Cluster:真正的分布式方案。数据自动分片到16384个槽位,每个节点负责一部分槽。我个人比较推荐这个方案,尤其是数据量超过单机内存的场景。
举个例子,我之前负责的一个CRM系统,用户数据量在5000万左右。我们用了Cluster模式,分了12个节点,每个节点负责约400万key。效果还不错,QPS稳定在10万+。
# Redis Cluster 配置示例
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
# 每个节点分配的内存
maxmemory 4gb
maxmemory-policy allkeys-lru
小技巧:配置maxmemory-policy时,我建议用allkeys-lru。别用volatile-lru,万一你忘了设过期时间,内存就爆了。我在项目中踩过这个坑。
3.2 缓存穿透、击穿、雪崩解决方案
这三个问题,说白了就是缓存失效引发的连锁反应。咱们一个一个说。
3.2.1 缓存穿透
什么是穿透?就是请求的数据在缓存和数据库里都不存在。每次请求都直接打到数据库上。你想想看,如果有人恶意构造不存在的ID,数据库瞬间就跪了。
解决方案:
- 布隆过滤器:在缓存前面加一层过滤器。不存在的数据直接拦截掉。我习惯用Guava的BloomFilter,或者Redis自带的布隆模块。
- 缓存空对象:如果数据库查不到,也缓存一个空值,过期时间设短一点,比如30秒。这样至少能挡住重复请求。
// 布隆过滤器示例
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预计数据量
0.01 // 误判率
);
// 查询时先判断
if (!bloomFilter.mightContain(key)) {
return null; // 直接返回,不查数据库
}
避坑指南:我曾经在布隆过滤器的误判率上吃过亏。设成0.1,结果大量无效请求穿透到数据库。建议生产环境设成0.01以下,虽然内存占用多一点,但安全。
3.2.2 缓存击穿
击穿和穿透不一样。击穿是指一个热点key突然过期,大量并发请求同时打到数据库。说白了,就是「一个key扛不住了」。
解决方案:
- 互斥锁:当key过期时,只让一个线程去查数据库,其他线程等待。我常用Redis的SETNX实现。
- 逻辑过期:不设置物理过期时间,而是在value里存一个过期时间戳。后台异步更新缓存。这样永远不会出现key突然消失的情况。
// 互斥锁示例
String lockKey = "lock:" + key;
Boolean lock = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lock)) {
try {
// 查数据库,更新缓存
Object data = queryFromDB(key);
redisTemplate.opsForValue().set(key, data, 1, TimeUnit.HOURS);
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 等待重试
Thread.sleep(100);
return getFromCache(key);
}
3.2.3 缓存雪崩
雪崩比击穿更可怕。大量key在同一时间过期,或者Redis整个挂了,所有请求直接打到数据库。系统瞬间瘫痪。
解决方案:
- 过期时间加随机值:避免大量key同时过期。我习惯在基础过期时间上加一个随机数,比如1小时±10分钟。
- 多级缓存:Redis挂了,还有本地缓存顶着。
- 限流降级:数据库扛不住时,直接返回默认值或错误提示,别硬撑。
重要提醒:缓存雪崩的终极方案是「高可用」。Redis一定要做集群+持久化。我见过一个团队,Redis单节点部署,半夜机器宕机,第二天早上才发现,数据全丢了。那叫一个惨。
3.3 本地缓存与分布式缓存结合
很多同学觉得有了Redis就够了,干嘛还要本地缓存?你想想看,每次请求都走网络,哪怕延迟只有1ms,100万次请求就是1000秒。本地缓存直接走内存,延迟是纳秒级的。
我建议的方案是两级缓存:
- 一级缓存(本地):用Caffeine或Guava Cache。容量小,但速度极快。适合存热点数据。
- 二级缓存(分布式):用Redis。容量大,支持集群。适合存全量数据。
查询流程是这样的:
- 先查本地缓存,命中直接返回。
- 本地没命中,查Redis。
- Redis也没命中,查数据库。
- 查到的数据,先写Redis,再写本地缓存。
// 两级缓存查询示例
public Object getData(String key) {
// 1. 查本地缓存
Object localData = localCache.getIfPresent(key);
if (localData != null) {
return localData;
}
// 2. 查Redis
Object redisData = redisTemplate.opsForValue().get(key);
if (redisData != null) {
// 同步到本地缓存
localCache.put(key, redisData);
return redisData;
}
// 3. 查数据库
Object dbData = queryFromDB(key);
if (dbData != null) {
redisTemplate.opsForValue().set(key, dbData, 1, TimeUnit.HOURS);
localCache.put(key, dbData);
}
return dbData;
}
注意:本地缓存一定要设置上限。我习惯用Caffeine的maximumSize,比如10000条。不然内存会被撑爆。另外,本地缓存的过期时间要比Redis短,比如Redis设1小时,本地设5分钟。这样能保证数据最终一致性。
3.4 缓存预热与更新策略
缓存预热,说白了就是系统上线前,先把热点数据加载到缓存里。别等用户请求来了再慢慢加载,那体验太差了。
预热方案:
- 定时任务预热:系统启动后,跑一个定时任务,把最近7天的活跃用户数据加载到缓存。
- 手动触发预热:运营后台提供一个「刷新缓存」按钮,方便运营人员手动触发。
- 懒加载预热:第一个请求触发加载,后续请求直接命中。适合数据量特别大的场景。
缓存更新策略,我总结了三种:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Cache Aside | 先更新数据库,再删除缓存 | 读多写少,一致性要求不高 |
| Read/Write Through | 缓存层代理数据库操作 | 需要强一致性 |
| Write Behind | 先更新缓存,异步写数据库 | 写性能要求极高,能接受数据丢失 |
我个人最常用的是Cache Aside模式。为什么?因为简单可靠。先更新数据库,再删缓存。下次查询时,缓存没命中,重新从数据库加载。这样能保证数据最终一致性。
避坑指南:我曾经犯过一个错误——先删缓存,再更新数据库。结果在并发场景下,另一个线程读到旧数据写回了缓存,导致缓存和数据库不一致。记住,一定是「先更新数据库,再删缓存」。
好了,关于缓存策略,咱们就聊这么多。总结一下:
- 架构上,用Redis Cluster,避免大Key和热Key。
- 防护上,布隆过滤器防穿透,互斥锁防击穿,过期时间加随机值防雪崩。
- 协同上,本地缓存+Redis两级缓存,速度与容量兼顾。
- 生命周期上,预热加载热点数据,Cache Aside保证一致性。
下一章,咱们聊聊数据库层面的优化。到时候见。