本文是「Redis 缓存实战」系列第 2 篇。上一篇讲了为什么加缓存和 Cache Aside 模式,这篇讲缓存上线后最容易翻车的三个问题。 系列导航:01 为什么加缓存 → 02 穿透·击穿·雪崩(本文)→ 03 布隆过滤器
零、这篇文章讲什么
缓存这层加了之后,你以为万事大吉,其实坑才刚开始。面试必考、压测最容易翻车的三个问题------穿透、击穿、雪崩,本质都是同一件事:
缓存 miss 的那一瞬间,流量涌向数据库------只是 miss 的原因不同,形态不同。
一、一句话分清三坑
| 问题 | miss 的原因 | 一句话 |
|---|---|---|
| 穿透 | 查的 key 本来就不存在(id=99999) | 数据压根没有,回填不了,每次都打库 |
| 击穿 | 一个热点 key 刚好过期 | 过期瞬间,海量请求同时 miss 同一个点 |
| 雪崩 | 一大批 key 同时过期 | 大面积同时失效,流量成片压向 DB |
记住这个区分维度------"miss 是怎么发生的",三坑永不混淆。
二、缓存穿透:查不存在的东西
现象 :你查 id=99999(库里没有),user 是 null,不回填缓存。于是这个 id 每次来都 miss、每次都查库。正常业务无所谓,但恶意脚本可以用海量假 id 把你的缓存"穿透",流量直击数据库。
解法 1:空值缓存------查不到也回填个"空标记",短 TTL:
js
if (user) {
await redis.set(key, JSON.stringify(user), "EX", 60);
} else {
await redis.set(key, "__NULL__", "EX", 30); // 查不到也缓存,30 秒
}
读的时候识别这个标记:
js
if (cached) {
if (cached === "__NULL__") return null; // 命中"空",确认没有
return JSON.parse(cached);
}
空标记别用空串
"",因为if("")是 falsy,会被当成 miss。
解法 2:布隆过滤器------请求进来先判断"这个 id 可能不存在",直接拦截。这个下一篇专门讲。
三、缓存击穿:热点 key 过期的瞬间
现象 :id=2 是爆款,几万人同时刷。EX 60 到点那一刻,几万请求同时 miss、同时查库------一个点被打穿。和穿透的区别:数据真实存在,只是缓存刚好过期。
解法:互斥锁重建 ------miss 后只让一个请求去查库,其余等着:
js
const lockKey = `${key}:lock`;
const gotLock = await redis.set(lockKey, "1", "EX", 5, "NX"); // NX=仅当不存在才设置
if (!gotLock) {
await sleep(50); // 没抢到锁,别人正在回填,等一下
return getUserByIdCached(id); // 重试
}
try {
// 双检:抢到锁后,缓存可能已被前一个请求回填好了
// ... 查库回填 ...
} finally {
await redis.del(lockKey); // 释放锁
}
核心:SET lockKey 1 NX 是原子操作------一万个请求同时来,只有一个能拿到锁去查库,其余 9999 个走重试。数据库从"被一万连发打"变成"被一枪打"。
实际跑 10 个并发的效果(见 src/redis-problems-demo.mjs 场景 3):
🔒 没抢到锁 ×9 ← 9 个请求被锁挡住
⚠️ 打到 PostgreSQL ×1 ← 只有 1 个真的查库
✅ 命中缓存 ×9 ← 其余重试后命中回填好的缓存
四、缓存雪崩:一大批 key 同时过期
现象 :某次批量操作把全表 user 都回填进缓存,TTL 都是 60 秒 → 60 秒后所有 key 一起过期,流量像雪崩一样成片压向数据库。
解法:TTL 加随机抖动------让过期时间错开:
js
const ttl = 60 + Math.floor(Math.random() * 30); // 60~90 秒随机
await redis.set(key, JSON.stringify(user), "EX", ttl);
每个 key 过期时间都不同,就不会在同一秒集体失效,洪峰被摊平。跑起来你会看到每次回填的 TTL 都不一样:84s、74s、85s......
五、三防合一
三个解法的代码互相不冲突,可以叠在一起:
| 问题 | 改动点 | 对应代码 |
|---|---|---|
| 穿透 | 查不到也回填空标记 | else 分支存 __NULL__ |
| 击穿 | miss 后加锁,只让一个查库 | SET ... NX + 重试 + 双检 |
| 雪崩 | 过期时间随机 | ttl = 60 + random*30 |
完整加固版见 src/redis-problems-demo.mjs,里面还叠加了布隆过滤器前置拦截。
小结口诀
穿透查无(空值缓存+布隆挡)、击穿热点(互斥锁只放一个)、雪崩集体过期(TTL 加随机);三坑本质都是 miss 瞬间流量打库。
下一篇预告
下一篇《布隆过滤器:用位图挡住不存在的查询》------穿透解法里那道"更靠外"的门,为什么它敢说"一定不存在"。
关联笔记
- 配套代码
src/redis-problems-demo.mjs、src/bloom-filter.mjs - 项目笔记 psql/readme.md