在 Redis 缓存设计里,「穿透、击穿、雪崩」是三个最容易踩的坑,名字相似但成因和后果完全不同。先把它们分辨清楚,再谈解决方案。
一、三个问题一次说清
| 问题 | 成因 | 后果 |
|---|---|---|
| 穿透 | 查询的数据根本不存在,缓存永远不命中 | 恶意请求直接打到数据库 |
| 击穿 | 某个热点 key 恰好过期,大量请求同时回源 | 单个 key 打崩数据库 |
| 雪崩 | 大量 key 在同一时间过期 | 数据库被整体压垮 |
二、缓存穿透:缓存空值 / 布隆过滤器
穿透的本质是「查了不存在的数据」。两种主流解法:
缓存空值:查询结果为空时,也往缓存里写一个短 TTL 的空值,后续相同请求直接命中:
Object val = cache.get(key);
if (val == null) {
val = db.query(key);
if (val == null) {
cache.set(key, NULL_PLACEHOLDER, 60); // 60 秒空值
} else {
cache.set(key, val, 300);
}
}
布隆过滤器:在请求入口用布隆过滤器判断「该 key 是否存在」,不存在的直接拦截。布隆过滤器存在误判(可能误报存在),但不会漏报,适合 key 集合稳定的场景。
三、缓存击穿:互斥锁 / 逻辑过期
击穿是单个热点 key 过期瞬间,并发请求同时回源。解法是「让回源只有一个请求」:
互斥锁:发现缓存过期时,先抢锁,只有一个请求能回源并回写缓存,其余请求等待后从缓存读取:
val = cache.get(key);
if (val == null) {
if (lock.tryLock(key)) {
try {
val = cache.get(key); // 双重检查
if (val == null) {
val = db.query(key);
cache.set(key, val, 300);
}
} finally {
lock.unlock(key);
}
} else {
Thread.sleep(50);
val = cache.get(key);
}
}
逻辑过期:缓存永不过期,但值里带一个逻辑过期时间;读取时发现逻辑过期就异步重建,先返回旧值。适合对一致性要求不高的场景。
四、缓存雪崩:过期时间加随机值
雪崩是大量 key 同时过期。最简单的解法是让过期时间「错开」:
cache.set(key, val, 300 + ThreadLocalRandom.current().nextInt(300));
给每个 key 的基础 TTL 加上一个随机偏移,避免集体过期的波峰。更进一步,热点数据用「热点记录 + 手动续期」的方式,不让它自然过期。
五、请求回源兜底
无论哪种方案,都建议在数据库层加一层保护:
- 限流:对回源数据库的查询做限流,超限直接降级。
- 熔断:数据库出现异常时快速失败,而不是继续堆积请求。
- 降级:缓存不可用时返回默认数据或空列表,保证服务可用。
小结
穿透要「拦」(空值/布隆),击穿要「锁」(互斥/逻辑过期),雪崩要「散」(随机 TTL)。三层防护配合限流熔断,缓存层才能稳定扛住高并发。