Redis 缓存穿透、击穿、雪崩
提出问题
缓存穿透、缓存击穿、缓存雪崩——这三个词面试出现频率极高,但很多人只背了"布隆过滤器防穿透、互斥锁防击穿、随机过期防雪崩"就以为过关了。面试官换个问法:"设计一个高可用缓存层,怎么防止缓存失效导致 DB 被打崩?"大多数人的回答就露馅了。
这三个问题本质上是缓存与数据库之间的数据一致性 + 可用性博弈:什么时候该查 DB?什么时候该拒绝?什么时候该降级?每个方案都有代价,能说出代价的人才算懂。
分析问题
先厘清三个概念,不是单纯背定义,而是看它们各自在什么场景下发生。
缓存穿透:查询不存在的数据
发生条件:请求查询一个缓存和数据库中都不存在的数据。比如查一个不存在的用户 ID,缓存没有,DB 也没有,但每次请求都穿透缓存打进 DB。
为什么危险:如果恶意攻击者刻意构造大量不存在的 key,DB 的连接池和 IO 会瞬间被打满。因为缓存层没有任何拦截,攻击流量直接打到数据库。
生产案例:某电商做周年庆活动,被黑产扫了一波不存在的优惠券码——构造了 100 万个不存在的 code 来请求,Redis 缓存命中率为 0,MySQL 5 分钟内 CPU 冲到 100%,慢查询 2000 条/秒,最终导致整条链路的优惠券查询接口 502。
缓存击穿:热点 key 过期
发生条件:一个被高并发访问的热点 key 突然过期,大量请求同时打到 DB 去重建缓存。
为什么危险:不是所有 key 过期都有问题——只有热点 key 在过期瞬间,大量请求同时发现缓存为空,全部涌入 DB。DB 峰值 QPS 可能瞬间飙到正常值的 10 倍以上。
生产案例:某社交媒体首页热榜,缓存 key 为 hot:list:page:1,TTL=3600s。某个整点所有用户的刷新请求都打到了这个 key,过期后 3 秒内 5000+ 请求直接打到 MySQL,DB 连接池打满,接口超时从 30ms 飙到 8s。
缓存雪崩:大量 key 同时过期或 Redis 宕机
发生条件:要么是大量 key 设了相同过期时间(比如业务代码里 EXPIRE 3600 写死了一个整数),要么是 Redis 实例宕机,导致所有请求都落到 DB。
生产案例:某在线教育平台课程列表缓存,所有 key 过期时间统一设为 EXPIRE 86400(24h),业务上线时恰好是早上 8:00 整。第二天 8:00,所有 key 同时过期,8000+ QPS 瞬间打到 MySQL 从库,主从延迟从 200ms 变成 30s,大量读到脏数据,线上课程价格显示异常,持续 10 分钟才恢复。
解决方案
1. 缓存穿透:布隆过滤器 + 空值缓存
方案一:布隆过滤器(Bloom Filter)
在缓存层之前加一层布隆过滤器,把所有已知的 key 预加载到位数组里。查询时先判断 key 是否存在,不存在直接返回,存在才查缓存。
原理简述:布隆过滤器由 bitmap 和 k 个哈希函数组成。添加元素时,用 k 个哈希函数计算出 k 个位位置,全部置为 1。查询时,k 个位置只要有一个是 0,则元素一定不存在;全部为 1,则可能存在(有误判率)。
流程:
请求到达 → 布隆过滤器查 key 是否存在
├─ 不存在 → 直接返回 null(拦截成功)
└─ 可能存在 → 查 Redis → 查 DB → 回填缓存参数选择:BF.RESERVE {key} {error_rate} {capacity}
error_rate:误判率,推荐 0.01(1%),每降低一个数量级,每个元素多占约 1.44 倍内存capacity:预估元素数量,设低了会大幅提高误判率- 内存估算公式:
n * ln(p) / (ln(2)^2)bit,其中 n=预估容量,p=误判率- 100 万元素 + 1% 误判率 ≈ 1.13 MB
- 100 万元素 + 0.1% 误判率 ≈ 1.7 MB
// 初始化布隆过滤器(误判率 1%,预估容量 100 万)
BF.RESERVE cache:bloom 0.01 1000000
// 添加已知 key
BF.ADD cache:bloom "user:1001"
// 查询时先判断
Boolean exists = jedis.bfExists("cache:bloom", key);
if (!exists) {
return null; // 一定不存在,直接返回
}
// 存在才查缓存代价:
- 误判率 1% 意味着 100 个不存在 key 里有 1 个会穿透到 DB,不是 100% 拦住
- key 新增时需要同步更新布隆过滤器,如果 key 频繁新增(比如注册用户),维护成本高
- 布隆过滤器不支持删除,如果 key 过期或被删除,不能从布隆过滤器中移除(除非用计数布隆过滤器,但会多占 3-4 倍内存)
实际落地:把布隆过滤器放在 Redis 自身,用 BF.RESERVE 和 BF.ADD 命令。别自作聪明用 Java 自建本地布隆过滤器——jmh 压测显示,本地布隆过滤器 + Redis 缓存的双次 IO 对延迟敏感业务(要求 < 10ms)不友好,且多实例间数据不同步。
方案二:缓存空值
如果布隆过滤器不好维护(比如 key 频繁新增),可以退而求其次:查到 DB 的 null 结果也缓存起来,设置一个短 TTL(1-5 分钟)。
Object value = cache.get(key);
if (value == null) {
value = db.query(key);
if (value == null) {
// 缓存空值,防穿透
cache.set(key, "NULL", 60); // 1 分钟过期
} else {
cache.set(key, value, 3600);
}
}代价:
- 空值缓存会占用内存。假设攻击者每秒构造 1 万个不同 key,1 分钟内缓存会多出 60 万个空值条目,按 50 字节/key 算,约 30 MB——对 Redis 来说不算大,但如果持续攻击 1 小时,就是 1.8 GB
- 需要配合 key 前缀白名单或者空值缓存数量上限使用(比如 SyneRedis cache null.maxKeys=10000,超限后不再缓存空值)
- 数据真正写入后,必须手动删除空值缓存,否则要等 TTL 过期
方案对比
| 方案 | 防穿透率 | 内存成本 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| 布隆过滤器 | 99%(1% 误判) | 低(1M 元素 ≈ 1MB) | 中(需维护 key 同步) | key 总量可控,变化不频繁 |
| 空值缓存 | 100% | 高(随攻击量线性增长) | 低(无需额外组件) | key 频繁变化,短期防御 |
2. 缓存击穿:互斥锁 + 永不过期
方案一:互斥锁(SETNX)
当缓存过期时,不是所有请求都去重建缓存,而是只让一个请求拿到锁去查 DB,其他请求等待或返回旧缓存。
流程:
请求读取缓存
├─ 缓存命中 → 返回
└─ 缓存过期 → 尝试获取分布式锁(SETNX)
├─ 成功 → 查 DB → 重建缓存 → 释放锁
└─ 失败 → 短暂等待(50ms)→ 重试读缓存String value = cache.get(key);
if (value == null) {
// 尝试获取分布式锁,超时 3 秒
String lockKey = "lock:" + key;
Boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:可能在等待锁的期间,其他线程已经重建了缓存
value = cache.get(key);
if (value == null) {
value = db.query(key);
cache.set(key, value, 3600);
}
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁的请求:等待 50ms 后重试,或返回旧缓存
Thread.sleep(50);
return cache.get(key); // 此时可能已经重建了
}
}关键细节:
- 用 Redisson 的
tryLock(waitTime, leaseTime)代替原始SETNX,避免死锁 leaseTime设置一个合理的锁超时(比如 10 秒),确保锁不因程序异常而永久持有- 注意双重检查:拿到锁后读一次缓存,可能等待锁期间缓存已经被其他线程重建了
- 没拿到锁的请求不要一直阻塞——设置
waitTime上限(比如 500ms),超时直接返回降级数据
代价:锁等待会增加响应延迟。正常缓存命中 1ms,加锁场景下最差情况可能到 20-50ms,对读密集型接口影响明显。
方案二:永不过期 + 异步更新
真正的热点 key,干脆不设过期时间,而是后台异步线程定时刷新。
流程:
物理不设过期时间,value 里嵌入逻辑过期时间戳
请求读缓存
├─ value 中 expireTime > now → 直接返回(新鲜数据)
└─ value 中 expireTime <= now → 返回旧数据 + 触发异步刷新
└─ 后台线程异步查 DB → 更新缓存 → 写入新过期时间戳// 缓存永不过期,但 value 里带上过期时间戳
String data = cache.get(key);
if (data != null) {
long expireTime = parseExpireTime(data);
if (expireTime < System.currentTimeMillis()) {
// 逻辑过期,但不阻塞当前请求,返回旧数据
asyncRefresh(key); // 异步线程去 DB 拉新数据
}
return parseData(data);
}代价:数据会有短暂的不一致(旧数据最多服务到下一次刷新完成)。如果数据一致性要求极高(比如支付金额),这个方案不适用。
方案对比
| 方案 | 响应延迟 | 数据一致性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 增加 20-50ms | 查询时重建,强一致 | 中(注意死锁) | 一致性要求高,并发量适中 |
| 永不过期+异步 | 无增加 | 最多窗口期不一致 | 低(需额外线程池) | 读多写少的热点数据 |
3. 缓存雪崩:过期时间随机化 + 高可用 + 本地缓存兜底
方案一:过期时间加随机偏移
这是成本最低、效果最明显的方案,能避免大量 key 同时过期。
// 基础过期时间 1 小时,加上随机 0-600 秒偏移
int baseTtl = 3600;
int randomOffset = new Random().nextInt(600);
cache.set(key, value, baseTtl + randomOffset);实际效果:假设 10000 个 key 基础 TTL 都是 3600s,不带随机偏移时全部在同一秒过期。加上 0-600s 随机偏移后,过期时间分布在 3600-4200s 之间,同一秒过期的 key 从 10000 个降到约 16 个,DB 压力降了 99.8%。
方案二:Redis 高可用
- 哨兵模式:主节点挂了自动切换,秒级恢复
- Redis Cluster:数据分片 + 副本,单节点宕机不影响整体
注意:哨兵切换期间会有 10-30s 的不可用窗口,期间所有请求穿透到 DB。如果业务不能容忍这个窗口,需要配合方案三。
方案三:本地缓存兜底 + 熔断降级
当 Redis 缓存全空时,不能直接让请求打到 DB。需要在业务侧加一层本地缓存(Caffeine/Guava Cache),即使 Redis 数据全丢了,本地缓存还能扛 1-2 分钟。
完整查询链路:
客户端 → 本地缓存(Caffeine,1分钟过期)
├─ 命中 → 返回
└─ 未命中 → 布隆过滤器
├─ 不存在 → 返回 null
└─ 可能存在 → Redis Cluster
├─ 命中 → 返回
└─ 未命中 → DB(熔断保护)
├─ 正常 → 回填各层缓存
└─ 超时/错误 → 熔断返回降级数据// 本地缓存,1 分钟过期
Cache<String, String> localCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
// 查询链路:本地缓存 → Redis → DB
String value = localCache.get(key);
if (value == null) {
value = redis.get(key);
if (value == null) {
// 熔断检查:如果 DB 已熔断,直接返回降级数据
if (circuitBreaker.isOpen()) {
return fallbackValue;
}
value = db.query(key);
redis.set(key, value, 3600 + randomOffset);
}
localCache.put(key, value);
}熔断参数(Sentinel 推荐配置):
- 最小请求数:5(采样窗口内至少 5 个请求才做熔断判断)
- 慢调用阈值:500ms(超过该时长的请求视为慢调用)
- 错误比例阈值:50%(慢调用和错误占所有请求的 50% 以上时熔断)
- 熔断时长:10s(熔断后多久尝试半开恢复)
- 统计窗口:10000ms(滑动窗口统计时间范围)
本地缓存容量估算:maximumSize(10000) 意味着如果热点 key 有 10000 个,每个 key 的 value 平均 1KB,本地缓存约 10MB。JVM 堆外内存不受限制,但要考虑 GC 压力——Caffeine 使用 WeakReference 存储,Full GC 频繁时可能导致缓存被提前回收。
综合架构设计
面试官期望的"设计一个高可用缓存层"答案,应该是一张完整的分层架构图:
┌─────────────────────────────────┐
│ 客户端(App/Web) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 本地缓存(Caffeine) │
│ 容量:10,000 条,TTL:60s │
│ 作用:扛 Redis 宕机场景 │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 布隆过滤器(Redis BF) │
│ 误判率:1%,容量:100 万 │
│ 作用:拦截穿透查询 │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ Redis Cluster(3 主 3 从) │
│ 过期策略:随机化 ±10% TTL │
│ 热点 key:永不过期 + 异步刷新 │
│ 作用:缓存层 │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ Sentinel 熔断降级 │
│ 慢调用阈值:500ms │
│ 错误比例阈值:50% │
│ 熔断时长:10s │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ 数据库(MySQL) │
│ 连接池:HikariCP(最大 50 个) │
│ 读超时:5s │
└─────────────────────────────────┘每一层各司其职:
- 本地缓存扛 Redis 整个宕机(1 分钟内无压力)
- 布隆过滤器扛穿透攻击(99% 拦截率)
- 随机化 TTL 扛雪崩(99.8% 减少同时过期)
- 互斥锁/异步刷新扛热点 key 击穿
- 熔断器兜底 DB 极限情况
生产落地建议
先做监控,再做治理
没有以下数据之前,不要急着改策略:
- 缓存命中率:
INFO stats中keyspace_hits/keyspace_misses。如果命中率低于 90%,说明穿透问题严重 - 慢查询:
SLOWLOG GET 10,看 Redis 侧的慢命令 - DB 流量:监控 MySQL 的 QPS 和连接数,按业务接口维度拆分
- 延迟毛刺:
redis-cli --latency -h <host> -p <port>,看 Redis 端到端延迟
分层方案优先级
| 防御层 | 优先级 | 实施成本 | 防什么 |
|---|---|---|---|
| 过期时间随机化 | P0 必做 | 极低(改一行代码) | 雪崩 |
| 空值缓存 | P0 必做 | 低(加几行代码) | 穿透 |
| 互斥锁 | P1 推荐 | 中(需把控死锁风险) | 击穿 |
| 本地缓存兜底 | P1 推荐 | 中(需引入 Caffeine) | 雪崩+宕机 |
| 布隆过滤器 | P2 可选 | 高(需维护 key 同步) | 穿透 |
| 熔断降级 | P2 可选 | 高(需全套 Sentinel 部署) | 所有异常 |
面试进阶:这三个问题可以串成一个完整的系统设计题——"设计一个高可用缓存层"。如果面试中能说出"布隆过滤器 1% 误判率下每个元素 10 bit"、"本地缓存 1 分钟过期 + 异步刷新保证最终一致"、"熔断器 50% 错误率阈值 10 秒内打开"这些具体参数,就和背八股拉开差距了。再提一句"先看监控再下手,没有指标别做架构决策",面试官会知道你真有生产经验。
生产提醒:数据是架构决策的锚点,不是感觉。先跑一周监控,拿到缓存命中率、DB QPS 峰值、慢查询分布,再决定哪一层加固到哪一步。不要上来就上布隆过滤器和熔断器——一个 1% 误判率的布隆过滤器 + 一个 500ms 阈值的熔断器,可能是你 80% 的收益,但只占 20% 的代码量。