缓存三大问题:穿透、击穿、雪崩
零、先建立整体观
- 三个问题是三个不同故障场景,可同时发生,防御手段叠加在一套缓存代码里(组合拳)
- 课程拆开讲是教学需要,真实系统是组合用
- 平时它们都在默默工作(挡请求在 Redis 层),只是感觉不到
查缓存
├─ 命中 → 返回 ✅(正常路径,绝大多数请求)
├─ 命中"空值" → 返回"不存在"(防穿透①)
└─ 没命中 →
抢互斥锁 / 逻辑过期(防击穿②,二选一)
查库 → 写缓存 → TTL 加随机值(防雪崩③)
一、缓存穿透(Cache Penetration)
定义
查询一个不存在的 key(比如恶意 id=999999),Redis 没有、数据库也没有,每次都打到数据库。
危害
恶意遍历不存在的 id → 无限打库 → 数据库被无用查询打垮(数据库才是最后防线,打穿就崩)。
解决方案
① 空值缓存(最简单,课程用这个)
查不到也往缓存存个 null(短 TTL),下次同 id 直接命中空值,不再打库。
// 写入空值(CACHE_NULL_TTL = 2L,短 TTL)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 读取端:空串命中 → isNotBlank 为 false → 走查库;但缓存了空值 → 不再打库
- 优点:实现简单
- 缺点:恶意随机 id 会占用大量缓存空间(每个 id 都存一份空值)
② 布隆过滤器(升级方案)
把所有存在的 id 预存进布隆过滤器,查询前先过滤——不存在的 id 直接拒绝,连 Redis 都不查。
- 优点:不占缓存空间
- 缺点:有一定误判率(存在可能误判为不存在,不存在不会漏),需要预热所有 id
二、缓存击穿(Cache Breakdown)
定义
一个热点 key 在过期的那一瞬间,大量请求同时发现缓存没了 → 一起打到数据库。
与穿透的区别
| 穿透 | 击穿 | |
|---|---|---|
| 打的 key | 不存在的 key | 存在但刚过期的热点 key |
| 数据 | 数据库也没有 | 数据库有,缓存刚好过期 |
| 核心 | 查不到 | 查到但缓存瞬间失效 |
危害
热点商品/新闻的 key 一过期,瞬间成千上万的并发冲垮数据库。
解决方案(两个并列方案,二选一)
① 互斥锁(Mutex Lock)——"过期了等新的"
// 抢锁:setIfAbsent 成功才算抢到(Redis 单线程保证原子)
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
if (!isLock) {
Thread.sleep(50); // 没抢到 → 睡一会重试
return queryWithMutex(id); // 递归重试
}
// 抢到 → 双重检查缓存 → 查库 → 写缓存 → finally 释放锁
流程:第一个请求拿锁去重建缓存,其他请求等锁(sleep 重试),缓存好了再读。
- 优点:数据一致性强
- 缺点:重建期间请求在等待,有阻塞;锁有 TTL,处理太久会失效
② 逻辑过期(Logical Expire)——"过期了先给旧的,后台悄悄换" ⭐ 最难
核心:缓存不设 TTL(永远在 Redis 里),而是用 RedisData 包装一个"逻辑过期时间",自己判断。
// RedisData:逻辑过期包装类
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真实数据
}
// 缓存写入(无 TTL!只带逻辑过期时间)
private void saveShop2Redis(Long id, Long expireTime) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
// 查询
private Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson)) {
return null; // 缓存不存在 → 返回 null(逻辑过期不自愈,靠预热)
}
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return shop; // 未过期 → 直接返回 ✅
}
// 已过期 → 抢锁重建
if (tryLock(LOCK_SHOP_KEY + id)) {
// 抢到 → 异步线程重建缓存(不阻塞主线程)
CACHE_REBUILD_EXECUTOR.submit(() -> {
this.saveShop2Redis(id, 20L);
});
// finally 释放锁
}
return shop; // 抢到没抢到都返回旧数据
}
为什么逻辑过期难懂:
时间轴:
T0 Redis 存 RedisData{expireTime=20秒后, data=店铺}(无 TTL,不会自动删)
T20 逻辑过期 → 请求来:
抢到锁 → 异步重建(用户无感,等下次读到新的)
没抢到 → 直接返回旧数据(旧数据先顶着)
关键:过期了也先给旧数据,后台悄悄换新的。 这就是和互斥锁的本质区别。
| 互斥锁 | 逻辑过期 | |
|---|---|---|
| 思想 | 过期了等新的 | 过期了先给旧的 |
| 请求是否阻塞 | 是(sleep 重试) | 否(直接返回旧数据) |
| 数据一致性 | 强 | 弱(有短暂旧数据) |
| 缓存从哪来 | 懒加载(自动建) | 必须预热(不自动建) |
| 实现复杂度 | 简单 | 难(绕) |
选型:实时性要求高 → 互斥锁;读多写少、不想阻塞 → 逻辑过期。
三、缓存雪崩(Cache Avalanche)
定义
大量 key 同时过期(或 Redis 宕机),同一时刻大量请求一起打到数据库。
与击穿的区别
| 击穿 | 雪崩 | |
|---|---|---|
| 范围 | 一个热点 key | 大量 key 同时 |
| 原因 | 单个 key 过期瞬间 | 批量同时过期 / Redis 宕机 |
解决方案
① TTL 加随机值(核心,最简单)
把过期时间打散,避免同一批 key 同时到期。
// 假设基础 TTL 30 分钟,加 0~5 随机分钟
int ttl = 30 + RandomUtil.randomInt(0, 5);
stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.MINUTES);
② 多级缓存(本地 + Redis)
Redis 之上加一层本地缓存(Caffeine/Guava),Redis 挂了本地还能顶一阵。
③ Redis 高可用
主从 + 哨兵 / 集群,避免 Redis 单点宕机导致全崩。
四、三问题对比表(面试速记)
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 场景 | 不存在 key | 热点 key 过期瞬间 | 大量 key 同时过期 |
| 核心 | 查不到 | 查到但缓存瞬间没 | 批量同时失效 |
| 解决 | 空值缓存 / 布隆 | 互斥锁 / 逻辑过期 | TTL 随机 / 多级缓存 / 高可用 |
| 防线 | 挡在缓存前 | 挡在缓存重建时 | 挡在过期策略 |
五、容易踩的坑
- 互斥锁和逻辑过期是二选一,别一起写进一个方法(会打架)
- 逻辑过期不设 TTL,缓存永远在 Redis,靠逻辑时间判断——必须配预热,缓存丢了不会自愈
- 互斥锁的锁 TTL 不能太短,否则重建没完成锁就失效
- 释放锁时要判断是不是自己的锁(防止锁过期后被别人拿到,自己误删)——课程简化版直接 delete,生产要加判断
- 空值缓存要设短 TTL(如 2 分钟),否则长期占空间
一句话速记
穿透 = 查不存在的 key → 空值/布隆挡住;击穿 = 热点 key 过期瞬间 → 互斥锁(等新的)或逻辑过期(先给旧的)二选一;雪崩 = 大批 key 同时过期 → TTL 加随机值打散。三者是叠加关系,生产是一套组合拳。