欢迎光临
我们一直在努力

常见缓存问题

本文: 缓存击穿、缓存雪崩、缓存穿透

一、引入

正常请求路径:

用户请求

缓存(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都没有 空值缓存 / 布隆过滤器
赞(0)
未经允许不得转载:171主机测评 » 常见缓存问题
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址