☰
缓存穿透、缓存击穿、缓存雪崩:一张表讲清区别和各自解法
2026/11/3 2:51:56 网站建设 项目流程

名字像,其实是三件事

先说清楚最大的混乱来源:这三个词长得太像,很多人以为它们是同一个问题的不同说法,于是答案永远答不全。

它们其实是三个完全不同层级的问题:

穿透击穿雪崩
出问题的数据根本不存在的数据某一个热点数据一大片数据
直接原因缓存和数据库里都没有热点 key 恰好过期大量 key 同时失效
谁来搞事攻击者 / 错误参数高并发碰巧撞上批量预热 / 定时器设置不当
后果每次请求都打到数据库单 key 的请求全部落库数据库整体压力陡增
核心防线布隆过滤器、空值缓存互斥锁、逻辑不过期过期时间加随机值

一句话区分:穿透是查不到,击穿是凑巧没了,雪崩是集体没了。

为什么会出现"查不到"这回事?因为正常的缓存流程是:查缓存,没有就查数据库,查到了写进缓存。但如果数据库里也没有,缓存永远不会被写入。于是不管来多少请求,都会老老实实打到数据库上——缓存完全失效。

一次真实的雪崩

有一年做活动,运营要求在零点整把 20 万条商品的新价格生效。

做法是:定时任务在 00:00 批量把旧缓存删掉,让请求触发重新加载。逻辑上没错,问题出在这些 key 写入缓存的时间原本就集中在同一个批次,删除之后重建也就集中了。

但真正压垮系统的不是重建本身。重建那批请求同时打到数据库,数据库连接池瞬间被打满,连那些本不需要重建的请求也一起卡住了。

结果零点前 30 秒到后 2 分钟,整个商品详情页不可用。事后复盘,最核心的一条教训是:雪崩可怕的不是"缓存没了",而是"数据库被压住之后,连自救的机会都没有"。

后来改了三处:过期时间加了随机抖动、加了少量永久不过期的兜底 key,以及最重要的一条,数据库连接池做了隔离,给重建流量单开一个池。第三条才是真正起作用的。

穿透:先判断"这个 id 到底存不存在"

解决穿透的思路只有一个方向:在请求到达数据库之前,想办法知道这个数据是不是空的。

方案一:布隆过滤器

一个 bit 数组加几个哈希函数。写入时把 key 映射进数组的几个位置置 1;查询时如果这几个位置不全为 1,那这个 key一定没来过,直接返回空。

代价要说清楚:

  • 有误判率。说"不存在"是百分百准的,说"可能存在"有可能其实是错的
  • 不支持删除(标准版),要删除得上计数布隆布过滤器
  • 有误判就要有兜底:布隆过滤器说"可能存在",还是要去查缓存和数据库

误判率怎么估:

误判率 p ≈ (1 - e^(-k*n/m)) ^ k n = 已插入元素数量 m = bit 数组长度 k = 哈希函数个数

经验值:让m/n = 10(每个元素约 10 bit),k = 7,误判率大约 1% 左右。对这个场景完全够用,1% 的误判意味着 99% 的非法请求被挡在数据库之外。

方案二:缓存空值

更简单,也更常用:查不到的时候,把一个空值写进缓存,过期时间设短一点(60 秒到 5 分钟)。

// 伪代码,仅为表达意图 Object val = redis.get(key); if (val != null) { return val; } Object db = mapper.selectById(id); if (db == null) { redis.setex(key, 120, NULL_PLACEHOLDER); // 关键这一行 return null; } redis.setex(key, 3600, db); return db;

它的问题是内存占用。如果攻击者用大量不同的随机 id 刷,每个都存个空值,内存会先于数据库被打爆。所以生产上一般两个方案一起用:布隆过滤器挡大批量,空值缓存兜少量漏网的。

击穿:别让几万个人同时去重建同一个 key

一个热点 key 过期了,同一瞬间几万个请求全部 miss,全部去查数据库重建。它们明明只需要一次重建。

解法一:互斥锁
Object val = redis.get(key); if (val != null) return val; String lockKey = "lock:" + key; boolean got = redis.set(lockKey, "1", "NX", "PX", 10000); if (got) { try { Object db = mapper.selectById(id); redis.setex(key, 3600, db); return db; } finally { redis.del(lockKey); // 注意:这里有并发坑,见下方说明 } } else { Thread.sleep(50); return queryWithMutex(key); // 重试 }

这段伪代码有个经典隐患必须说:如果业务执行时间超过了锁的过期时间,锁会被别人抢走,然后被你删掉别人的锁。正解是用 Lua 脚本做原子删除(校验 value 再删),或者直接用 Redisson。

解法二:逻辑过期(推荐给真正的高热点)

不让 key 真的过期,而是把过期时间放进 value 里:

value = { data: {...}, expireAt: 1730000000 }

请求进来,检查 expireAt,发现过期了:先返回旧数据,同时提交一个异步线程去重建。用户永远不会被阻塞。

代价是可能短暂读到旧数据。但对热点商品、榜单这类场景,读到 200ms 前的旧数据几乎无害,比让所有人卡住强得多。

这也是我在生产上更倾向的方案,理由很简单:互斥锁让失败请求等待,逻辑过期让失败请求拿到略旧的数据,后者的用户体验好得多。

雪崩:核心是让失效时间分散

最直接的做法,一句话:

实际过期时间 = 基础过期时间 + random(0, 300)

给过期时间加随机抖动,避免集体阵亡。

但如果只答这一句,是不够的。真正完整的防护有五层:

  1. 过期时间加随机值:最基础的一层,上面已经说过
  2. 多级缓存:本地缓存(Caffeine)+ Redis,Redis 挂了本地缓存还能顶一阵
  3. 熔断降级:数据库压力上来之后,主动返回兜底内容,而不是让所有请求一起超时
  4. 资源隔离:这是我那个 case 里真正救命的一条,给不同的数据源分配独立的连接池,避免一个池被打满拖垮所有请求
  5. 提前预热:大促之前主动把热点 key 灌进缓存,而不是等它自然加载

第五条容易被忽略,但它是唯一一条"让雪崩不发生"而不是"发生后怎么救"的措施。

面试官在等的那句

如果只回答上面那些,答得已经不错了。但真正让面试官抬眼的,是接上这么一句:

这三个问题看起来不同,根因其实是同一件事:缓存的职责边界不清楚。

我们默认缓存必须命中,一旦 miss 就认为系统出问题了。但实际上,缓存的职责应该是"扛住大部分流量",而不是"保证所有请求都能拿到"。

想清楚这一点,解法就自然出来了:穿透是缓存扛了不该扛的无效请求,击穿是缓存退化的瞬间没人补位,雪崩是缓存整体不可用之后没有兜底措施。相应地加过滤器、加互斥重建、加降级隔离,都是在补这个边界。

这段的价值不在技术细节,而在于它表明你从设计原则出发看问题,而不是背了三套方案。

常见错误答案

错误一:把三个问题混成一个最致命。一说缓存问题就把三种解法全倒出来,面试官一听就知道你没分清。

错误二:布隆过滤器只说"能防穿透",不说误判率百分之百会被追问"那误判了怎么办"。标准答案是"误判的请求会漏到数据库,但我们限制了这个比例,数据库扛得住"。

错误三:互斥锁答到 setnx 就停会被追问"删锁的时候删到别人的怎么办"。答案是用 Lua 校验 value 再删,或者用 Redisson 的 watchdog。

错误四:雪崩只答随机过期时间太单薄了。至少再补上熔断降级和资源隔离。

这张表值得存下来

问题首道防线兜底我倾向的最优解
穿透布隆过滤器缓存空值、参数校验布隆过滤器 + 空值缓存
击穿互斥锁逻辑过期、续期逻辑过期(热点极高时)
雪崩过期时间加随机多级缓存、熔断、隔离资源隔离 + 提前预热

关于本文

缓存部分还有几道题没写:缓存一致性到底先改哪个、Redis 分布式锁的五个坑、热 Key 和大 Key 怎么发现。这几道才是线上真正会咬人的。

我会接着更新。感兴趣的话可以留个关注,方便下次找。

另外,完整的十二个模块(Java / C++ / Go / JVM / MySQL / Redis / MQ / 分布式 / 事务 / 网络 / 系统设计 / 项目深挖)我整理成了一份手册,每题都标了考察点和加分答法。【草丛后端架构】,持续分享后端架构与底层原理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询