缓存三大坑:穿透·击穿·雪崩一次讲透

本文是「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
相关推荐
行百里er1 小时前
Redis 核心数据结构(四)——Set 与 Sorted Set,去重与排名神器
redis·后端
仍然.1 小时前
Redis---事务
数据库·redis
MacroZheng1 小时前
Redis 已正式接入 AI !
人工智能·redis·后端
我叫黑大帅1 小时前
为了研究Redis 三大守护神,我做了一个本地模拟!
redis·后端·面试
晚安日记wanna1 小时前
订单30分钟未支付自动取消:定时任务为什么被面试官嫌弃
redis·后端·面试
行百里er1 小时前
Redis 核心数据结构(二)——List 与消息队列
redis·后端
行百里er1 小时前
Redis 核心数据结构(三)——Hash,把一堆字段塞进一个 Key
redis·后端
光影少年1 小时前
Redis + Node 如何支撑百万级并发
redis·后端·node.js
行百里er1 小时前
Redis 核心数据结构(一)—— String 与数字
redis·后端