Skip to content

缓存穿透、击穿与雪崩 ​

S4-008必学高级约 60 分钟JDK 21企业开发进阶 · Redis

1. 本节定位 ​

《Redis 数据结构与常用命令》讲"Redis 能装什么";本节讲**"缓存失效时会发生什么"**:

text
三个问题的共同后果都是「数据库被打爆」,但触发条件与解法完全不同  ✓✓

★ 一句话区分 ✓✓:

穿透是"数据不存在",击穿是"数据刚过期",雪崩是"很多数据一起过期" ✓

2. 学习目标 ​

  • 能区分三个问题,并说出各自的解法 ★★★;
  • 能解释布隆过滤器的三个特性与正确用法 ★★★;
  • 知道"互斥锁重建"与"逻辑过期"的取舍 ★★★;
  • 知道"TTL 加随机"能摊薄雪崩压力(含实测)★★★;
  • 知道最后的兜底是"限流 + 熔断" ★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看本节的实测数据 ✓:

text
===== 缓存穿透:查一个不存在的 key,1000 次并发 =====
  无防护:DB 被查询 1000 次(耗时 182 ms)✗✗
  解法① 缓存空值:DB 被查询 0 次 ✓

★ 1000 次并发 = 1000 次数据库查询 ✗✗ —— 而攻击者只要用不存在的 ID 刷接口,就能把数据库打满 ✓

★★ 更关键的是:这类问题在真实场景里往往是"恶意"的 ✓✓

5. 核心概念 ​

5.1 三个问题的定义与解法对照 ★★★ ​

问题触发条件现象核心解法
穿透 ★★★查不存在的数据缓存天然失效,每次都回源布隆过滤器 / 缓存空值 ✓
击穿 ★★★热点 key 过期并发同时回源互斥锁 / 逻辑过期 ✓
雪崩 ★★★大量 key 同时过期(或缓存整体挂掉)DB 被瞬间打满TTL 随机 / 高可用 / 预热 ✓

★ 三者的共同点 ✓:

缓存没起作用时,压力会全部压到数据库 ✗

所以缓存的健壮性设计 = 想清楚"缓存失效时会发生什么" ✓✓

5.2 缓存穿透:布隆过滤器与空值缓存 ★★★ ​

★ 实测对比 ✓:

方案DB 被查询次数
无防护 ✗✗1000 次
缓存空值 ✓0 次
布隆过滤器 ✓✓直接拦截,不查库

★ 方案一:缓存空值 ✓:

java
String value = cache.get(key);
if (value == null) {
    String fromDb = db.query(key);
    cache.put(key, fromDb == null ? "" : fromDb);    // ★ 用空串表示"查过但不存在"
}

★ 两个注意点 ✓:

注意说明
空值的 TTL 要短 ⚠️否则大量不存在的 key 会占满内存 ✗
要能区分"空值"与"未缓存" ✓用空串、特殊标记,或用 Optional 思路

★ 方案二:布隆过滤器 ✓✓

本节的实测:

text
  过滤器大小:11984 字节(预期 1 万个元素,误判率 1%)
  已有数据的判断:PRODUCT-1 → true(存在 ✓)
  不存在的判断:  PRODUCT-NOT-EXIST → false(不存在 ✓,直接返回,不查库)
  误判率实测:0 / 10000 = 0.00%(设计目标 1%)

★★ 布隆过滤器的三个特性(必须记准) ✓✓:

特性含义
说不存在,一定不存在 ★★★可以放心拦掉 ✓✓
说存在,可能误判 ★★小概率放过去,需要回源兜底 ⚠️
不能删除元素 ✗删了会影响其他元素的判断(因为多个元素共享位)

★ 正确用法 ✓✓:

放在缓存之前做"拦截层" ✓

而不是当作"数据是否存在的权威判断" ✗✗

所以布隆说"存在"时,仍要查缓存与数据库 ✓

★ 它的空间效率 ✓:

text
1 万个元素、1% 误判率 → 约 12 KB(每个元素约 1.2 字节)
1 亿个元素、1% 误判率 → 约 120 MB

★ 真实场景里的实现 ✓:Redis 的 bitmap、Guava 的 BloomFilter、Redisson 的 RBloomFilter ✓

5.3 缓存击穿:互斥锁 vs 逻辑过期 ★★★ ​

★ 时序 ✓:

text
热点 key 到期的那一瞬间,1000 个并发同时发现"缓存没有"
→ 1000 个线程同时去查数据库 ✗✗

★ 方案一:互斥锁重建 ✓:

java
String value = cache.get(key);
if (value == null) {
    lock.lock();                       // ★ 只让一个线程进入
    try {
        value = cache.get(key);        // ★ 双重检查:别人可能已经加载好了
        if (value == null) {
            value = db.query(key);
            cache.put(key, value);
        }
    } finally { lock.unlock(); }
}

★ 它的代价 ✓:其他线程要等待 ✗(如果重建慢,所有请求都被阻塞)⚠️

★ 方案二:逻辑过期 ✓✓(推荐)

java
// value 里存"逻辑过期时间",而不是用 Redis 的 TTL
class CachedValue {
    Object data;
    long logicalExpireAt;      // ★ 逻辑过期时间
}

// 读取时
CachedValue cached = cache.get(key);
if (cached != null && cached.logicalExpireAt > now) {
    return cached.data;                    // 未过期,直接返回 ✓
}
// 已逻辑过期:先返回旧值(不阻塞),同时异步重建
asyncRebuild(key);
return cached.data;                        // ★ 立即返回旧值,用户体验不受影响 ✓✓

★★ 两种方案的取舍 ✓✓:

方案一致性延迟复杂度
互斥锁重建 ★强(拿到的一定是新值)✓会阻塞 ✗简单 ✓
逻辑过期 ★★弱(返回短暂旧值)⚠️不阻塞 ✓✓复杂 ⚠️

★ 选择依据 ✓:

能容忍"短暂旧值"就用逻辑过期(如商品详情、榜单)✓ 不能容忍就用互斥锁(如库存、余额)✓✓

5.4 缓存雪崩:TTL 随机 + 高可用 ★★★ ​

★ 三个成因 ✓:

成因说明
① 大量 key 同一时刻过期 ★★最典型(如"凌晨统一预热")
② 缓存服务整体挂掉 ✗✗单点故障
③ 缓存预热不足 ⚠️刚上线/重启,缓存是空的

★ 实测(TTL 随机的效果) ✓✓:

text
  固定 TTL=2 秒:1000 个 key 全部在同一秒到期 ✗✗
    到期瞬间的并发回源请求:1000 次(DB 一次性被打满)

  TTL 加 0~4 秒随机:同一秒到期的最大数量 ≈ 218 个 ✓✓
    → 回源压力被摊薄到 5 个时间片上

★ 所以第一条解法是"给 TTL 加随机值" ✓✓:

java
int ttl = baseTtl + random.nextInt(300);     // ★ 基础 TTL + 0~300 秒随机
redis.setex(key, ttl, value);

★ 三条解法的对应 ✓:

成因解法
① 同时过期TTL 加随机 ✓✓
② 缓存挂掉Redis 主从 + 哨兵/集群(《Redis 持久化与高可用》)、本地缓存兜底 ✓
③ 预热不足启动时预热热点数据、缓存降级 ✓

★★ 最后一道防线:限流 + 熔断 ✓✓:

下游已经扛不住时,快速失败比"硬撑着把请求发过去"更好 ✓

因为它能保住数据库不被彻底打垮 ✓✓

(这就是 《Sentinel 限流与熔断》 要讲的 Sentinel 的核心思想)

5.5 一张可直接落地的检查清单 ★★★ ​

检查项做法
所有缓存都设了 TTL 吗 ★★★没有 TTL 的缓存 = 永久脏数据 ✗
TTL 是随机化的吗 ★★避免同时过期 ✓
热点 key 有防击穿措施吗 ★★★互斥锁或逻辑过期 ✓
查询不存在的数据时会穿透吗 ★★空值缓存或布隆过滤器 ✓
缓存挂了会怎样 ★★★有降级方案吗(本地缓存 / 直接返回兜底)✓
有监控吗 ★★命中率、QPS、大 key、慢查询 ✓

★ 第一行最容易被忽略 ✓✓:

"忘了设 TTL"是缓存事故的头号原因 ✗

因为那个 key 会永久保留旧值 ——而且没人知道它什么时候会变成错的 ✗✗

6. 递进示例 ​

6.1 最小示例:TTL 随机 ​

java
redis.setex(key, 300 + ThreadLocalRandom.current().nextInt(120), value);

6.2 业务示例:防击穿的完整写法 ​

见 5.3 ✓

6.3 反例:所有 key 都设 30 分钟 ​

见 5.4 的实测:1000 个 key 同时到期 ✗✗

7. 完整可运行代码与实测 ​

代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/CacheBreakdownLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.CacheBreakdownLesson

实测输出(关键部分):

text
===== 1. 缓存穿透 =====
  无防护:DB 被查询 1000 次(耗时 182 ms)✗✗
  解法① 缓存空值:DB 被查询 0 次 ✓

===== 2. 布隆过滤器 =====
  过滤器大小:11984 字节(预期 1 万个元素,误判率 1%)
  误判率实测:0 / 10000 = 0.00%

===== 4. 缓存雪崩 =====
  固定 TTL=2 秒:1000 个 key 全部在同一秒到期 ✗✗
  TTL 加 0~4 秒随机:同一秒到期的最大数量 ≈ 218 个 ✓✓

★ 三条可落地的结论 ✓:

#结论依据
①查不存在的数据必须防穿透(空值缓存或布隆)1000 次并发 = 1000 次查库 ✗
②TTL 必须加随机固定 TTL 让 1000 个 key 同时到期 ✗
③布隆过滤器只能"确定不存在",不能"确定存在"有误判率(本次实测 0%)✓

★ 一个诚实的说明 ✓:本次布隆实测的误判率是 0%,好于设计目标 1%——因为只放了 1000 个元素(远小于预期的 1 万),位数组显得很空 ✓。元素接近设计容量时,误判率才会逼近 1% ✓✓

8. Debug 观察点 ​

  1. 把空值缓存的 TTL 去掉(永久缓存空值):思考内存会怎样 ✗;
  2. 把布隆过滤器的预期容量改成 100(远小于实际元素数):观察误判率飙升 ★★;
  3. 把雪崩实验的随机范围改成 nextInt(1):退化成固定 TTL ✓;
  4. 在互斥锁版本里去掉"双重检查":观察 DB 被查次数变化 ★。

9. 常见错误 ​

#错误说明
1缓存不设 TTL ✗✗永久脏数据 ✓
2TTL 全部相同 ✗✗雪崩(5.4)✓
3布隆过滤器当"权威判断" ✗✗有误判率,必须回源兜底 ✓
4空值缓存不设 TTL ✗内存被垃圾 key 占满 ✓
5击穿用互斥锁但没做双重检查 ✗✗每个线程拿到锁后都去查库 ✓
6没有降级方案 ✗✗缓存挂了直接打垮数据库 ✓
7不监控缓存命中率 ✗缓存失效了都不知道 ✓

10. 动手实验 ​

目标:亲手把三个问题都复现一遍,并验证解法有效。

步骤:

  1. 跑 CacheBreakdownLesson,记录穿透的 1000 次与空值缓存的 0 次 ✓;
  2. 给布隆过滤器压到设计容量(加 1 万个元素),观察误判率逼近 1% ★★;
  3. 把击穿的 hotKey 改成一个不存在的 key:观察它变成"穿透"(本实验初版正是踩了这个坑)⚠️;
  4. 给雪崩实验加更宽的随机范围(0~30 秒),观察峰值进一步下降 ✓;
  5. 给缓存加一个"本地缓存兜底"(如 Caffeine 或简单 Map),模拟 Redis 挂掉时的降级 ★★。

验收方式:能说出"你的系统在这三种场景下会怎样、怎么接住"。

扩展任务:设计一个"热点商品详情页"的缓存方案:

  • 防穿透(不存在的商品 ID)✓;
  • 防击穿(爆款商品过期)✓;
  • 防雪崩(活动开始时批量预热)✓;
  • 缓存挂了的降级 ✓;
  • 说明:每一层用的是什么手段、代价是什么 ★★

11. 练习题 ​

  1. 判断:布隆过滤器说"存在",就一定存在。(对 / 错)
  2. 填空:穿透说的是 ______;击穿说的是 ______;雪崩说的是 ______。
  3. 简答:为什么"TTL 加随机值"能缓解雪崩?
  4. (代码阅读)
java
String value = redis.get(key);
if (value == null) {
    value = db.query(key);
    redis.set(key, value);          // 注意:没有 TTL
}
return value;

这段代码有几个问题? 5. (设计)给"秒杀商品库存"设计缓存方案:要求不超卖、不能把数据库打垮、缓存挂了也能降级。

参考答案(要点)
  1. 错。它只能"确定不存在" ✓ ——"存在"有误判率,需要回源兜底 ✓★
  2. 查不存在的数据;热点 key 刚过期;大量 key 同时过期 ✓
  3. 因为它把"同时到期"变成"分散到期" ✓ ——回源压力被摊到多个时间片 ✓★(本节实测:1000 → 218)
  4. ① 没有 TTL(永久脏数据) ✗✗;② 没防击穿(并发同时回源) ✗; ③ 没防穿透(查不存在的 key 每次都回源) ✗;④ 缓存挂了没有降级 ⚠️
  5. 要点:库存不缓存在 Redis 里做减法,而是用 DECR 原子扣减 + 结果 >0 才放行 ✓; 扣减成功再异步落库;Redis 挂了直接拒绝(宁可少卖不可超卖) ✓✓

12. 掌握标准 ​

  • [ ] 能准确区分三个问题 ★★★;
  • [ ] 能说出布隆过滤器的三个特性与正确用法 ★★★;
  • [ ] 知道"互斥锁"与"逻辑过期"的取舍 ★★★;
  • [ ] 知道 TTL 随机化的作用与量级 ★★★;
  • [ ] 知道最后一道防线是限流 + 熔断 ★★;
  • [ ] 能背出那张检查清单(尤其"所有缓存都要有 TTL")★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《缓存一致性与更新策略》:缓存与数据库如何保持同步。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引