写在前面
Redis作为缓存中间件,是系统架构中不可或缺的一环。但缓存使用不当,反而会带来一系列问题。今天我们来深入探讨缓存三大经典问题:缓存穿透、缓存击穿、缓存雪崩,以及它们的解决方案。

文章目录
-
- 写在前面
- 一、缓存穿透
-
- [1.1 什么是缓存穿透](#1.1 什么是缓存穿透)
- [1.2 缓存穿透的危害](#1.2 缓存穿透的危害)
- [1.3 解决方案一:布隆过滤器](#1.3 解决方案一:布隆过滤器)
- [1.4 解决方案二:空值缓存](#1.4 解决方案二:空值缓存)
- [1.5 两种方案对比](#1.5 两种方案对比)
- 二、缓存击穿
-
- [2.1 什么是缓存击穿](#2.1 什么是缓存击穿)
- [2.2 缓存击穿与穿透的区别](#2.2 缓存击穿与穿透的区别)
- [2.3 解决方案一:互斥锁](#2.3 解决方案一:互斥锁)
- [2.4 解决方案二:热点数据预热](#2.4 解决方案二:热点数据预热)
- [2.5 解决方案三:逻辑过期](#2.5 解决方案三:逻辑过期)
- 三、缓存雪崩
-
- [3.1 什么是缓存雪崩](#3.1 什么是缓存雪崩)
- [3.2 缓存雪崩的原因](#3.2 缓存雪崩的原因)
- [3.3 解决方案一:随机过期时间](#3.3 解决方案一:随机过期时间)
- [3.4 解决方案二:多级缓存](#3.4 解决方案二:多级缓存)
- [3.5 解决方案三:熔断降级](#3.5 解决方案三:熔断降级)
- [3.6 缓存雪崩解决方案对比](#3.6 缓存雪崩解决方案对比)
- 四、缓存更新策略
-
- [4.1 常见更新策略](#4.1 常见更新策略)
- [4.2 Cache Aside模式详解](#4.2 Cache Aside模式详解)
- [4.3 缓存和数据库一致性问题](#4.3 缓存和数据库一致性问题)
- 五、踩坑提醒
-
- [5.1 缓存和数据库一致性陷阱](#5.1 缓存和数据库一致性陷阱)
- [5.2 热点key问题](#5.2 热点key问题)
- [5.3 大key问题](#5.3 大key问题)
- 六、面试高频考点
-
- [6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?](#6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?)
- [6.2 缓存和数据库如何保证一致性?](#6.2 缓存和数据库如何保证一致性?)
- [6.3 为什么删除缓存而不是更新缓存?](#6.3 为什么删除缓存而不是更新缓存?)
- 七、参考资料
- 八、互动话题
一、缓存穿透
1.1 什么是缓存穿透
实际场景:黑客恶意查询不存在的数据,如查询id=-1的商品,导致请求直接穿透缓存打到数据库。
缓存穿透示意图:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 请求 │ → │ 缓存 │ → │ 数据库 │
│(不存在key)│ │ (无数据) │ │ (无数据)│
└─────────┘ └─────────┘ └─────────┘
↑ │
└──────────────────────────────┘
每次都穿透到数据库
1.2 缓存穿透的危害
| 危害 | 说明 |
|---|---|
| 数据库压力 | 大量请求直接打到数据库 |
| 系统崩溃 | 数据库负载过高导致宕机 |
| 资源浪费 | 无效请求消耗系统资源 |
1.3 解决方案一:布隆过滤器
经验之谈:布隆过滤器是一种空间效率很高的数据结构,可以快速判断元素是否存在于集合中。
布隆过滤器原理:
元素 → 多个哈希函数 → 位数组中多个位置设为1
查询时:所有位置都是1 → 可能存在
有位置是0 → 一定不存在
Redis实现布隆过滤器:
shell
# 使用RedisBloom模块
# 添加元素
BF.ADD users user1
BF.ADD users user2
# 判断元素是否存在
BF.EXISTS users user1
# 返回1表示可能存在
BF.EXISTS users user999
# 返回0表示一定不存在
Java代码示例:
java
// 使用Guava布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预期元素数量
0.01 // 误判率
);
// 添加所有有效key
for (String key : allValidKeys) {
bloomFilter.put(key);
}
// 查询前先判断
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}
1.4 解决方案二:空值缓存
踩坑提醒:空值缓存会占用内存,需要设置较短的过期时间,避免内存浪费。
java
public Object getValue(String key) {
// 1. 查询缓存
Object value = redisTemplate.opsForValue().get(key);
// 2. 缓存命中
if (value != null) {
// 空值标记
if ("NULL".equals(value)) {
return null;
}
return value;
}
// 3. 查询数据库
value = database.query(key);
// 4. 写入缓存
if (value == null) {
// 空值缓存,过期时间较短
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
}
return value;
}
1.5 两种方案对比
| 对比项 | 布隆过滤器 | 空值缓存 |
|---|---|---|
| 空间占用 | 小 | 较大 |
| 精确度 | 有误判率 | 精确 |
| 实现复杂度 | 较高 | 简单 |
| 适用场景 | 数据量大、固定集合 | 数据量小、动态变化 |
| 维护成本 | 需要重建过滤器 | 自动过期 |
二、缓存击穿
2.1 什么是缓存击穿
实际场景:某热点商品缓存过期瞬间,大量并发请求同时查询该商品,全部穿透到数据库。
缓存击穿示意图:
┌─────────┐
│ 请求1 │
│ 请求2 │ ┌─────────┐ ┌─────────┐
│ 请求3 │ → │ 缓存 │ → │ 数据库 │
│ ... │ │ (过期) │ │ (压力) │
│ 请求N │ └─────────┘ └─────────┘
└─────────┘
热点key过期瞬间大量请求
2.2 缓存击穿与穿透的区别
| 对比项 | 缓存穿透 | 缓存击穿 |
|---|---|---|
| 数据是否存在 | 不存在 | 存在但过期了 |
| 请求特点 | 恶意请求不存在的key | 热点key过期瞬间大量请求 |
| 影响范围 | 持续影响 | 瞬间影响 |
| 解决方案 | 布隆过滤器、空值缓存 | 互斥锁、热点预热 |
2.3 解决方案一:互斥锁
经验之谈:使用分布式锁保证只有一个线程去查询数据库并更新缓存,其他线程等待或返回旧数据。
java
public Object getValueWithLock(String key) {
// 1. 查询缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 2. 获取分布式锁
String lockKey = "lock:" + key;
try {
// 尝试获取锁,等待时间3秒,锁过期时间10秒
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,查询数据库
value = database.query(key);
// 写入缓存
if (value != null) {
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(100);
return getValueWithLock(key); // 递归重试
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
return value;
}
2.4 解决方案二:热点数据预热
实际场景:双十一大促前,提前将热点商品数据加载到缓存,并设置较长的过期时间。
java
@Component
public class CacheWarmUp {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ProductService productService;
// 系统启动时预热
@PostConstruct
public void warmUp() {
// 获取热点商品列表
List<Long> hotProductIds = productService.getHotProductIds();
for (Long id : hotProductIds) {
Product product = productService.getById(id);
if (product != null) {
// 预热缓存,设置较长过期时间
String key = "product:" + id;
redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
}
}
}
}
2.5 解决方案三:逻辑过期
经验之谈:不设置TTL,而是在value中存储过期时间,后台异步更新缓存。
java
@Data
public class CacheData<T> {
private T data;
private Long expireTime; // 逻辑过期时间
}
public Object getValueWithLogicalExpire(String key) {
// 1. 查询缓存
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
return null; // 直接返回,不查数据库
}
// 2. 解析数据
CacheData cacheData = JSON.parseObject(json, CacheData.class);
// 3. 判断是否过期
if (cacheData.getExpireTime() > System.currentTimeMillis()) {
return cacheData.getData(); // 未过期
}
// 4. 过期了,异步更新
CompletableFuture.runAsync(() -> {
// 获取锁
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 查询数据库
Object newData = database.query(key);
// 更新缓存
CacheData newCacheData = new CacheData();
newCacheData.setData(newData);
newCacheData.setExpireTime(System.currentTimeMillis() + 3600000);
redisTemplate.opsForValue().set(key, JSON.toJSONString(newCacheData));
} finally {
redisTemplate.delete(lockKey);
}
}
});
// 5. 返回旧数据
return cacheData.getData();
}
三、缓存雪崩
3.1 什么是缓存雪崩
实际场景:凌晨2点,大量缓存同时过期,瞬间大量请求打到数据库,导致数据库崩溃。
缓存雪崩示意图:
时间轴:
├──────┼──────┼──────┼──────┤
0:00 1:00 2:00 3:00 4:00
↑ ↑ ↑
key1 key2 key3
过期 过期 过期
↓ ↓ ↓
└──────┴──────┘
同时大量请求打到数据库
3.2 缓存雪崩的原因
| 原因 | 说明 |
|---|---|
| 同时过期 | 大量key设置了相同的过期时间 |
| Redis宕机 | 缓存服务不可用 |
| 网络问题 | 缓存服务网络故障 |
3.3 解决方案一:随机过期时间
经验之谈:在基础过期时间上增加随机值,避免大量key同时过期。
java
public void setCacheWithRandomExpire(String key, Object value) {
// 基础过期时间:1小时
long baseExpire = 3600;
// 随机过期时间:0-600秒
long randomExpire = new Random().nextInt(600);
// 总过期时间
long totalExpire = baseExpire + randomExpire;
redisTemplate.opsForValue().set(key, value, totalExpire, TimeUnit.SECONDS);
}
3.4 解决方案二:多级缓存
实际场景:使用本地缓存+Redis缓存的多级缓存架构,即使Redis不可用,本地缓存还能扛一阵。
请求 → 本地缓存(Caffeine) → Redis缓存 → 数据库
(一级缓存) (二级缓存) (数据源)
多级缓存实现:
java
@Component
public class MultiLevelCache {
@Autowired
private RedisTemplate redisTemplate;
// 本地缓存
private Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Object get(String key) {
// 1. 先查本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 再查Redis缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 写入本地缓存
localCache.put(key, value);
return value;
}
// 3. 查询数据库
value = database.query(key);
if (value != null) {
// 写入两级缓存
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
localCache.put(key, value);
}
return value;
}
}
3.5 解决方案三:熔断降级
踩坑提醒:熔断降级是最后的防线,当缓存和数据库都扛不住时,通过限流保护系统。
java
@Component
public class CacheService {
// 熔断器
private CircuitBreaker circuitBreaker = CircuitBreaker.create(
"cacheBreaker",
CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率50%触发熔断
.waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断30秒
.build()
);
public Object getWithCircuitBreaker(String key) {
return circuitBreaker.executeSupplier(() -> {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
value = database.query(key);
if (value != null) {
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
}
}
return value;
}, () -> {
// 降级逻辑:返回默认值
return getDefaultvalue(key);
});
}
}
3.6 缓存雪崩解决方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 随机过期时间 | 简单易实现 | 不能完全避免 | 常规场景 |
| 多级缓存 | 性能高、容错强 | 数据一致性复杂 | 高并发场景 |
| 熔断降级 | 保护系统 | 影响用户体验 | 极端情况 |
四、缓存更新策略
4.1 常见更新策略
实际场景:缓存和数据库数据一致性是分布式系统的经典难题,需要根据业务场景选择合适的策略。
| 策略 | 描述 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先更新DB,再删除缓存 | 较好 | 较好 | 读多写少 |
| Read/Write Through | 由缓存代理更新DB | 好 | 好 | 读写均衡 |
| Write Behind | 只更新缓存,异步更新DB | 差 | 最好 | 写多读少 |
4.2 Cache Aside模式详解
面试高频考点:为什么是删除缓存而不是更新缓存?
删除 vs 更新:
| 对比项 | 删除缓存 | 更新缓存 |
|---|---|---|
| 复杂度 | 低 | 高 |
| 数据一致性 | 较好 | 可能不一致 |
| 性能 | 高(懒加载) | 低(每次写都更新) |
| 并发问题 | 较少 | 较多 |
Cache Aside实现:
java
public void updateData(String key, Object value) {
// 1. 先更新数据库
database.update(key, value);
// 2. 再删除缓存
redisTemplate.delete(key);
}
4.3 缓存和数据库一致性问题
踩坑提醒:在高并发场景下,即使先更新DB再删除缓存,也可能出现不一致。
问题场景:
线程A: 更新DB → 删除缓存
线程B: 读缓存miss → 查DB(旧数据) → 写缓存
如果线程B在线程A删除缓存前写入,缓存就是旧数据
解决方案:延迟双删
java
public void updateData(String key, Object value) {
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
database.update(key, value);
// 3. 延迟后再次删除缓存
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延迟500ms
redisTemplate.delete(key);
} catch (InterruptedException e) {
log.error("延迟双删失败", e);
}
});
}
五、踩坑提醒
5.1 缓存和数据库一致性陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 先删缓存再更新DB | 并发时可能读到旧数据写入缓存 | 使用延迟双删 |
| 缓存删除失败 | 数据库更新成功但缓存删除失败 | 使用消息队列重试 |
| 并发写问题 | 多线程同时写导致数据错乱 | 使用分布式锁 |
5.2 热点key问题
踩坑提醒:热点key会导致单个Redis节点压力过大,需要特殊处理。
解决方案:
java
// 方案1:热点key分散
String[] keys = {"hot:1", "hot:2", "hot:3"};
int index = new Random().nextInt(keys.length);
Object value = redisTemplate.opsForValue().get(keys[index]);
// 方案2:本地缓存
// 使用Caffeine等本地缓存框架
5.3 大key问题
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 内存占用大 | 单个key占用过多内存 | 拆分大key |
| 网络阻塞 | 传输大key阻塞网络 | 压缩或分片 |
| 过期阻塞 | 删除大key阻塞主线程 | 异步删除 |
六、面试高频考点
6.1 如何解决缓存三兄弟(穿透、击穿、雪崩)?
答案:
缓存穿透:
- 布隆过滤器:过滤不存在的key
- 空值缓存:缓存空值,设置短过期时间
- 参数校验:在入口处过滤非法请求
缓存击穿:
- 互斥锁:只允许一个线程查询数据库
- 热点预热:提前加载热点数据
- 逻辑过期:不设置TTL,后台异步更新
缓存雪崩:
- 随机过期时间:避免同时过期
- 多级缓存:本地缓存+Redis缓存
- 熔断降级:保护系统不被压垮
6.2 缓存和数据库如何保证一致性?
答案:
-
Cache Aside模式:先更新数据库,再删除缓存
-
延迟双删:删除缓存 → 更新DB → 延迟后再删除缓存
-
消息队列重试:删除缓存失败时,通过MQ重试
-
Binlog订阅:通过Canal订阅MySQL binlog,异步更新缓存
-
强一致性场景:使用分布式锁或直接查数据库
6.3 为什么删除缓存而不是更新缓存?
答案:
-
并发安全:删除操作是幂等的,更新可能被覆盖
-
性能考虑:很多场景下缓存可能根本不会被读取,更新是浪费
-
数据一致性:更新缓存可能失败,导致数据不一致
-
懒加载:删除后下次读取时再加载,数据更新鲜
七、参考资料
八、互动话题
- 你的项目中遇到过缓存穿透、击穿、雪崩吗?是如何解决的?
- 对于强一致性要求的业务,你会如何设计缓存策略?
- 多级缓存的方案在实际应用中有什么坑?
欢迎在评论区分享你的实战经验!
下期预告:Day13我们将学习Redis分布式锁,深入理解SETNX、Redisson和Redlock算法。