本文: 缓存击穿、缓存雪崩、缓存穿透
一、引入
正常请求路径:
用户请求
↓
缓存(Redis)← 命中:直接返回,不查 DB
↓ 未命中
数据库(MySQL)← 查询并回填缓存
缓存存在的意义是挡住大部分流量,让 DB 只承受少量请求。
一旦缓存"失效",流量就会穿透到 DB。DB 的并发能力远不如 Redis,大量请求涌入极易崩溃。
二、缓存击穿(Cache Breakdown)
2.1 概念
一个热点 Key 在高并发访问的瞬间过期,大量并发请求同时涌入 DB。
时间轴:
t=0s:热点 Key "feed:public:1" 过期
↓
1000 个请求同时发现缓存 miss
↓
1000 个线程同时查 DB:SELECT * FROM know_post LIMIT 10
↓
DB 连接池耗尽 → 响应超时 → 服务雪崩
注意"热点"二字:访问量低的 Key 过期不是问题,因为同时发起的请求数量少。
击穿特指那些访问量极高、一旦失效就有大量请求同时穿透的 Key。
2.2 方案一:SingleFlight(单飞)
核心思想:同一个 Key,只让一个线程去查 DB,其他线程等结果。
┌── 线程1 ──→ DB(查)── 回填缓存
├── 线程2 ──→ 等待线程1的结果
Key 过期 ──→ 1000个请求├── 线程3 ──→ 等待线程1的结果
├── …
└── 线程1000→ 等待线程1的结果
DB 只承受 1 次查询
项目实现
// KnowPostFeedServiceImpl.java
private final ConcurrentHashMap<String, Object> singleFlight = new ConcurrentHashMap<>();
// 同一个 idsKey 对应同一把锁对象
Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
synchronized (lock) {
// ① 双重检查:刚拿到锁,先再查一次缓存
// 前一个线程可能已经回填了,直接用结果就行
FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey, …);
if (again != null) {
singleFlight.remove(idsKey);
return again; // 直接返回,不查 DB
}
// ② 真正回源 DB
List<KnowPostFeedRow> rows = mapper.listFeedPublic(safeSize + 1, offset);
// ③ 写入缓存,释放锁
writeCaches(…);
singleFlight.remove(idsKey);
return result;
}
双重检查的必要性:
线程A、B、C 同时发现缓存 miss,都在锁外等待
线程A 拿到锁 → 查 DB → 写缓存 → 释放锁
线程B 拿到锁 → 双重检查 → 缓存已有!→ 直接返回(不查 DB)
线程C 拿到锁 → 双重检查 → 缓存已有!→ 直接返回(不查 DB)
只有线程A 真正查了一次 DB
锁的粒度:
// 以 idsKey 为锁粒度(精确到"具体哪一页")
Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
// idsKey 示例:
// "feed:public:ids:10:1234567:1" → 第1页
// "feed:public:ids:10:1234567:2" → 第2页
// 第1页和第2页用不同的锁,互不阻塞
// 如果用全局一把锁,第2页请求会被第1页的查询无辜阻塞
2.3 方案二:热点 Key 永不过期
// 设置很长的 TTL(比如 24 小时)
// 后台异步线程定期刷新,不让它真正过期
redis.expire(key, Duration.ofHours(24));
// 定时任务每小时主动刷新热点 Key
@Scheduled(fixedDelay = 3600_000)
public void refreshHotKeys() {
for (String hotKey : hotKeyDetector.getTopKeys(100)) {
rebuildCache(hotKey);
}
}
适合极度热点(如首页第1页)的场景
动态地根据热度延长过期时间的策略:
// 命中缓存时检测是否热点,热点则延长 TTL
private void maybeExtendTtlMine(String key) {
int target = hotKey.ttlForMine(30, key); // 热点Key返回更长的TTL
Long currentTtl = redis.getExpire(key);
if (currentTtl < target) {
redis.expire(key, Duration.ofSeconds(target)); // 续命
}
}
三、缓存雪崩(Cache Avalanche)
3.1 概念
大量 Key 在同一时刻集中过期,导致大批请求同时穿透到 DB。
t=0s(整点):100个页面缓存同时过期(因为都设了固定 60s TTL)
↓
所有页面的请求同时 miss
↓
DB 收到 100 倍的查询压力
↓
响应变慢 → 请求堆积 → 服务崩溃
与击穿的区别:
| 失效范围 | 单个热点 Key | 大量 Key |
| 原因 | 热点 Key 在高并发时过期 | 大批 Key TTL 相同,集中过期 |
| 冲击 | 一个 DB 查询被放大 | 整个 DB 被大量查询淹没 |
3.2 原因:使用同样的 TTL
// 错误写法:所有 Key 用同样的 TTL
redis.opsForValue().set(key, value, Duration.ofSeconds(60));
// 假设 t=0s 时写入了 100 个页面缓存
// → t=60s 时这 100 个缓存同时过期
// → 雪崩发生
3.3 方案一:TTL 加随机抖动
核心思想:把 Key 的过期时间错开,避免集中过期。
int baseTtl = 60;
int jitter = ThreadLocalRandom.current().nextInt(30); // 随机 0~29
Duration frTtl = Duration.ofSeconds(baseTtl + jitter); // 60~89s 随机
writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, frTtl);
效果:
Key A → TTL = 60 + 17 = 77s → t=77s 过期
Key B → TTL = 60 + 3 = 63s → t=63s 过期
Key C → TTL = 60 + 29 = 89s → t=89s 过期
Key D → TTL = 60 + 11 = 71s → t=71s 过期
过期时间均匀分布在 60~89s 之间
DB 每次只承受少量请求,不会被瞬间淹没
ThreadLocalRandom vs Random:
// Random 是全局共享的,多线程竞争内部锁,高并发下性能差
Math.random()
// ThreadLocalRandom 每个线程独立,无锁竞争,高并发首选
ThreadLocalRandom.current().nextInt(30)
3.4 方案二:hourSlot 时间分片
根据时间分片来设计key
// 按小时分片的片段缓存键
long hourSlot = System.currentTimeMillis() / 3600000L;
String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
工作原理:
23:00 写入缓存:Key = "feed:public:ids:10:N:1"(hourSlot=N)
TTL = 60~89s
23:00:63 缓存A过期 → 重建时 hourSlot 还是 N → 写入新 Key A
23:00:77 缓存B过期 → 重建时 hourSlot 还是 N → 写入新 Key B
00:00:00 跨小时:hourSlot 变成 N+1
→ 所有请求用新 Key "feed:public:ids:10:N+1:1"
→ 旧 Key 不再被访问,自然过期
→ 新 Key 是全新的,第一次访问才回源 DB
→ 跨小时时流量是逐渐迁移的,不是瞬间全部失效
3.5 方案三:多级缓存兜底
即使 Redis 缓存失效,可以使用 L1缓存 Caffeine 兜底:
// L1 Caffeine(本地内存,纳秒级)
FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
if (local != null) {
return local; // Redis 失效了,Caffeine 还在
}
// L2 Redis 片段缓存
FeedPageResponse fromCache = assembleFromCache(…);
if (fromCache != null) {
feedPublicCache.put(localPageKey, fromCache); // 回填 L1
return fromCache;
}
// L3 DB(最后兜底)
雪崩场景下 Redis 大批 Key 失效:
→ L1 Caffeine 仍然有数据(本地内存不受影响)
→ 大部分请求被 L1 拦截,不会透到 DB
→ 只有 Caffeine 也失效的情况才到 DB(概率更低)
三级缓存把"雪崩"变成了"小波动"
四、缓存穿透(Cache Penetration)
4.1 概念
查询一个根本不存在的数据,缓存里没有,DB 里也没有,每次都穿透到 DB。
场景:恶意请求 id=-1、id=999999999
→ Redis 没有(因为 DB 也没有,从没缓存过)
→ 每次都查 DB
→ DB 被无效查询拖垮
4.2 方案一:空值缓存
若查询到DB中没有,则缓存一个空值
Object result = db.findById(id);
if (result == null) {
// DB 也没有,缓存一个空值,TTL 短一点
redis.opsForValue().set(key, "NULL", Duration.ofMinutes(5));
return null;
}
4.3 方案二:布隆过滤器
空值缓存的问题:
攻击者不停换ID:-1, -2, -3, … -100000
→ 每个ID都要查一次 DB
→ Redis 里塞了10万个 "NULL"
→ 内存浪费,DB 仍然被打
布隆过滤器在查 Redis/DB 之前,先判断这个ID存不存在。它用一个位数组(bit array) + 多个哈希函数实现:
位数组,全部初始化为 0:
下标: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
值: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
写入元素
用3个哈希函数计算出3个下标,把这3个位置标为 1:
hash1(101) = 3
hash2(101) = 7
hash3(101) = 12
下标: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
值: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0]
↑ ↑ ↑
再写入 ID=202:
hash1(202) = 1
hash2(202) = 7 ← 和101共用了这个位!
hash3(202) = 14
下标: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
值: [0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0]
↑ ↑ ↑ ↑ ↑
查询元素
查 ID=101 是否存在:
hash1(101) = 3 → 位数组[3] = 1 ✓
hash2(101) = 7 → 位数组[7] = 1 ✓
hash3(101) = 12 → 位数组[12] = 1 ✓
三个位置都是1 → "可能在"(实际确实在)✓
查 ID=999(不存在):
hash1(999) = 5 → 位数组[5] = 0 ✗
有一个0就能立刻断定:"一定不在!"
直接拒绝,不用查 Redis 或 DB
查 ID=500(不存在,但遇到误判):
hash1(500) = 1 → 位数组[1] = 1 ✓(被202"污染"了)
hash2(500) = 7 → 位数组[7] = 1 ✓(被101/202"污染"了)
hash3(500) = 14 → 位数组[14] = 1 ✓(被202"污染"了)
三个位置碰巧都是1 → "可能在"(实际不在!)← 误判
误判是布隆过滤器的固有特性,无法完全消除:
"一定不在" → 100% 正确,绝不误判
"可能在" → 有小概率是误判(实际不存在)
误判率由两个参数控制:
位数组越大 → 冲突越少 → 误判率越低(但内存占用越大)
哈希函数越多 → 判断越严格 → 误判率越低(但写入/查询越慢)
实际项目通常把误判率控制在 0.1%~1% 之间
误判了怎么办?
布隆说"可能在" → 去 Redis/DB 查 → 发现真的没有 → 返回空
最坏情况:多查了一次 Redis/DB(0.1%~1% 的请求)
但正常攻击流量(随机乱打ID)被拦截了 99%+
五、总结
| 缓存击穿 | 热点Key过期 | 单Key,高并发 | SingleFlight / 永不过期 |
| 缓存雪崩 | 大批Key集中过期 | 多Key,大范围 | 随机TTL / 时间分片 / 多级缓存 |
| 缓存穿透 | 查不存在的数据 | 缓存和DB都没有 | 空值缓存 / 布隆过滤器 |




