缓存问题
问题
什么是缓存穿透、缓存击穿、缓存雪崩?分别如何解决?
答案
三大缓存问题对比
| 问题 | 描述 | 核心原因 |
|---|---|---|
| 缓存穿透 | 请求的数据在缓存和数据库中都不存在 | 恶意请求 / 数据不存在 |
| 缓存击穿 | 热点 key 过期瞬间,大量请求打到数据库 | 热点 key 集中过期 |
| 缓存雪崩 | 大面积缓存同时失效,请求全部打到数据库 | 大量 key 同时过期 / Redis 宕机 |
缓存穿透
请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库。
解决方案
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 数据库查不到时,缓存一个空值(短 TTL) | 简单有效 | 空值占内存 |
| 布隆过滤器 | 请求前先判断数据是否可能存在 | 内存占用小 | 有误判率,不支持删除 |
| 参数校验 | 过滤明显非法参数(如负数 ID) | 零成本 | 只能挡住部分请求 |
缓存空值
public Object getData(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
return "NULL".equals(value) ? null : value; // 识别空值标记
}
// 2. 查数据库
Object dbValue = db.get(key);
if (dbValue != null) {
redis.set(key, dbValue, 3600); // 正常 TTL
} else {
// 缓存空值,短 TTL 防止长期占用
redis.set(key, "NULL", 300); // 5 分钟
}
return dbValue;
}
布隆过滤器
// 初始化时将所有合法 ID 加入布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1000000, 0.01); // 100 万数据,1% 误判率
public Object getData(Long id) {
// 1. 布隆过滤器判断
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,直接返回
}
// 2. 查缓存 → 查数据库(正常流程)
// ...
}
布隆过滤器特点
- 判断不存在一定不存在(100% 准确)
- 判断存在可能不存在(有 1~3% 的误判率)
- Redis 模块
RedisBloom提供了原生布隆过滤器支持:BF.ADD、BF.EXISTS
缓存击穿
某个热点 key 在过期的瞬间,大量并发请求同时查询该 key,全部穿透到数据库。
解决方案
| 方案 | 原理 |
|---|---|
| 互斥锁 | 只允许一个线程重建缓存,其他线程等待 |
| 逻辑过期 | key 永不过期,在 value 中存过期时间,异步更新 |
| 热点预加载 | 提前感知热点 key,在过期前刷新 |
互斥锁方案
public Object getDataWithMutex(String key) {
Object value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);
if (locked) {
try {
// 双重检查:拿到锁后再查一次缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 查数据库,重建缓存
value = db.get(key);
redis.set(key, value, 3600);
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,短暂等待后重试
Thread.sleep(50);
return getDataWithMutex(key); // 递归重试
}
return value;
}
逻辑过期方案
// value 中包含逻辑过期时间
public Object getDataWithLogicalExpire(String key) {
String json = redis.get(key);
if (json == null) {
return null; // 缓存不存在(冷启动需预热)
}
CacheData cacheData = JSON.parseObject(json, CacheData.class);
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
return cacheData.getData(); // 未过期,直接返回
}
// 已逻辑过期,尝试获取锁异步更新
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1")) {
// 开启异步线程更新缓存
executorService.submit(() -> {
try {
Object newData = db.get(key);
CacheData newCache = new CacheData(newData,
LocalDateTime.now().plusHours(1));
redis.set(key, JSON.toJSONString(newCache));
} finally {
redis.del(lockKey);
}
});
}
// 返回旧数据(数据不是最新的,但不会阻塞)
return cacheData.getData();
}
| 对比 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 高(等待最新数据) | 低(短暂返回旧数据) |
| 可用性 | 低(需等待锁) | 高(立即返回) |
| 适用场景 | 数据一致性要求高 | 高可用优先 |
缓存雪崩
大面积缓存 key 同时过期,或 Redis 服务宕机,导致请求全部打到数据库。
解决方案
| 场景 | 方案 |
|---|---|
| key 同时过期 | 过期时间加随机值,分散过期时间 |
| Redis 宕机 | 集群部署 + 哨兵/Cluster |
| DB 保护 | 限流降级 |
| 提前预防 | 多级缓存 |
过期时间加随机值
int baseTTL = 3600; // 基础过期时间 1 小时
int randomTTL = new Random().nextInt(600); // 随机 0~600 秒
redis.set(key, value, baseTTL + randomTTL);
多级缓存
// L1: 本地缓存(Caffeine / Guava Cache)
// L2: Redis
// L3: 数据库
public Object getDataWithMultiLevel(String key) {
// L1: 本地缓存
Object value = localCache.get(key);
if (value != null) return value;
// L2: Redis
value = redis.get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: 数据库
value = db.get(key);
if (value != null) {
redis.set(key, value, 3600 + random(600));
localCache.put(key, value);
}
return value;
}
三大问题解决方案速查
| 问题 | 首选方案 | 备选方案 |
|---|---|---|
| 穿透 | 布隆过滤器 | 缓存空值 + 参数校验 |
| 击穿 | 互斥锁 / 逻辑过期 | 热点 key 永不过期 |
| 雪崩 | TTL 加随机值 + 集群 | 多级缓存 + 限流降级 |
常见面试问题
Q1: 缓存穿透、击穿、雪崩的区别?
答案:
- 穿透:数据根本不存在(缓存和DB都没有),恶意请求可以利用这一点攻击
- 击穿:数据存在但热点 key 刚好过期,大量并发请求同时穿透到 DB
- 雪崩:大面积 key 同时失效或 Redis 宕机,流量全部打到 DB
一句话区分:穿透是"查不到",击穿是"一个热点 key 过期",雪崩是"大量 key 同时过期"。
Q2: 布隆过滤器的原理?
答案:
布隆过滤器使用一个位数组和多个哈希函数:
- 添加元素:用 k 个哈希函数计算 k 个位置,将这些位置设为 1
- 查询元素:用相同的 k 个哈希函数计算位置,如果全部为 1则"可能存在",任一为 0则"一定不存在"
特点:
- 空间效率极高(100 万数据约 1.2MB)
- 说不存在一定不存在,但说存在可能不存在(误判率可控,通常 1~3%)
- 不支持删除(可用 Counting Bloom Filter 支持)
Q3: 互斥锁和逻辑过期哪个好?
答案:
取决于业务对一致性和可用性的优先级:
- 互斥锁:保证数据一致性,但缓存重建期间其他线程需要等待(可能增加延迟)。适合金融、交易等场景。
- 逻辑过期:保证高可用(立即返回旧数据),但短暂时间内数据可能不是最新的。适合社交、内容等场景。
实际中还可以结合使用:热点 key 用逻辑过期 + 异步更新,普通 key 用互斥锁。
Q4: 如何防止缓存雪崩?
答案:
分为预防和兜底两个层面:
预防措施:
- 过期时间加随机值(最简单有效):
TTL = baseTTL + random(0, 600) - Redis 高可用部署:Sentinel 或 Cluster,避免单点故障
- 热点 key 永不过期:使用逻辑过期 + 异步更新
- 多级缓存:本地缓存(Caffeine)→ Redis → 数据库
兜底方案:
- 限流降级:使用 Sentinel 或 Hystrix 对数据库访问限流
- 服务熔断:数据库压力过大时直接返回降级数据
- 请求排队:热点数据重建时使用队列串行化请求