☰
缓存穿透、击穿、雪崩:高并发系统三大缓存故障的防御实战
2026/9/30 7:42:41 网站建设 项目流程

说到高并发缓存,你早晚会撞上三个坑:穿透、击穿、雪崩。这三个词在分布式缓存领域几乎无人不知,但真到了线上出了问题,很多人却分不清自己遇到的到底是哪一个,更别提怎么对症下药了。我早期维护一个电商订单系统时,就曾因为一个热点商品详情页缓存失效,数据库瞬间被几千个并发请求打满,页面直接超时,那会儿才真正意识到,光会写Redis读写还远远不够,你得懂缓存失效背后的机制,才知道该用什么手段去挡。

这篇文章就把这三个问题彻底讲透:缓存穿透怎么用布隆过滤器拦截,缓存击穿怎么用互斥锁和逻辑过期扛住,缓存雪崩怎么靠随机过期、多级缓存和限流降级来防御。适合后端开发、系统架构师,以及自己搭过缓存服务但没经历过极端场景的人看。我会把原理、参数怎么算、代码怎么落、线上会踩什么坑全部分享出来,争取你看完就能直接拿去排查和改造。

1. 三大故障的本质:穿透、击穿、雪崩到底差在哪

1.1 三个故障的核心特征

先给三个问题画个像。

缓存穿透,指的是请求的数据在缓存和数据库里压根就不存在。比如用户查一个不存在的商品ID,或者爬虫故意构造一堆无效参数打你接口。缓存里没有,数据库里也没有,于是每次请求都穿透到数据库层,最坏情况下直接把DB压垮。它的特征是“查了个不存在的东西”,而且和并发量无关,哪怕只有一个请求反复来,也会持续打库。

缓存击穿,指的是某一个热点key在缓存过期的瞬间,恰好有大量请求同时打进来。这个key平时是热点,但它在那一刻失效了,所有请求同时发现缓存里没数据,于是全部涌向数据库。它的特征是“某一个key的失效瞬间”引发高并发穿透。它和穿透的区别在于,数据本身是存在的,只是缓存暂时空了。

缓存雪崩,指的是大量key在同一时间段集中过期,或者缓存节点整体宕机,导致大量请求同时落到数据库。它和击穿的区别是,击穿是“单点失效”,雪崩是“面状失效”。雪崩一旦发生,数据库QPS瞬间暴涨,往往连带着服务熔断、连接池耗尽,引发更大范围的故障。

1.2 为什么一定要区分这三者

很多人觉得,反正都是缓存没命中,加个缓存不就完了?其实这三个问题的应对思路完全相反:穿透的关键是“别让无效请求到DB”,击穿的关键是“热点key过期时别让并发重建”,雪崩的关键是“让失效时间错开、让冲击被缓冲”。

如果搞混了场景,就容易用错方案。比如用互斥锁去防穿透,你会发现锁根本防不住,因为穿透请求压根不存在数据可回填,锁重建了还是空;用布隆过滤器去防击穿,你会发现热点key明明存在却被过滤器误判挡住,或者根本没进过滤器名单,照样打穿。所以先定位清楚是哪一个问题,再选方案,这是最基本的思路。

我建议你在排查线上故障时,先看监控面板上的两个指标:缓存命中率和数据库QPS曲线。穿透的特征是数据库QPS很高,但缓存命中率长时间处于低位,且请求的key五花八门;击穿的特征是缓存命中率在某个时间点突然断崖式下跌,然后迅速恢复;雪崩的特征是命中率整体性下跌,数据库QPS呈陡峭的尖峰,持续时间相对较长。这三条观察经验,比看任何日志都直观。

2. 缓存穿透:用布隆过滤器把无效请求挡在门外

2.1 穿透是怎么发生的

我见过最典型的穿透场景有两种。第一种是业务逻辑漏洞,比如前端传了一个不存在的商品ID,后端没校验就直接查缓存和数据库;第二种是恶意攻击,攻击者摸清了你的ID规则,批量构造不存在的ID来刷接口。无论哪种,结果都一样:每次请求都绕过缓存、直击数据库。如果数据库每秒能扛1000 QPS,攻击者只要每秒打2000个请求,库就很容易被拖垮。

要防穿透,核心思路只有一个:在缓存层和数据库层之间,加一道“挡板”,让不存在的key根本走不到数据库。常见的挡板有两种:布隆过滤器,以及空值缓存。空值缓存很简单,查不到数据时,在缓存里存一个空值并设置短TTL,比如2到5分钟,这样同样的key在TTL内不会再打库。但这个方案有两个问题:一是占用缓存空间,如果恶意key非常多,空值缓存会越积越多;二是攻击者换个key就能继续打。所以空值缓存更适合“偶发穿透”的场景,严重点的还是得上布隆过滤器。

2.2 布隆过滤器的原理与参数计算

布隆过滤器本质上是一个超大的位数组(bitmap)加若干个哈希函数。当你把一个key塞进过滤器时,会用k个哈希函数算出k个位置,把这些位都置为1。判断一个key是否存在时,同样算k个位置,如果这些位全是1,说明key“可能存在”;只要有任何一个位是0,说明key“一定不存在”。注意这个“可能存在”意味着它允许误判,但绝不允许漏判,也就是不存在的数据有可能被误认为存在,但存在的数据绝不会被误判为不存在。

这里就得说一下布隆过滤器的两个关键参数:预期元素数量n和允许误判率p。有了这两个值,就可以算出位数组大小m和哈希函数个数k。公式是:

m = -(n * ln p) / (ln2)^2

k = (m / n) * ln2

我举个实际例子。假设你的商品表有100万个有效ID,你希望误判率控制在1%,那么ln p = ln(0.01) ≈ -4.605,ln2 ≈ 0.693,(ln2)^2 ≈ 0.480。代入公式,m = -(1000000 * -4.605) / 0.480 ≈ 9,594,000 bit,折合下来大约1.14 MB。k = (9,594,000 / 1,000,000) * 0.693 ≈ 6.64,取整后就是7个哈希函数。也就是说,一个100万数据量的过滤器,只需要占用1.14MB内存,加7次哈希计算,就能换来1%误判率的拦截能力,性价比极高。

实际使用时,我建议把误判率设置成0.1%到1%之间。1%的误判率意味着每100个不存在的key,大约有1个会穿透过滤器,穿透之后还能靠缓存空值或者数据库查询兜底。没必要追求0.01%,因为误判率每降一个数量级,位数组大小就要增加差不多一倍,内存成本涨得很快。

2.3 布隆过滤器的落地实践与取舍

在Java生态里,我常用两种方式。一种是Google Guava的BloomFilter,适合单机场景;另一种是Redisson的RBloomFilter,适合分布式场景,可以直接存在Redis里,多个服务实例共享一份过滤器数据。

Redisson的用法很直接,先初始化一个RBloomFilter,指定expectedInsertions和falseProbability,然后调用tryInit,再往里面塞数据。这里有个细节:tryInit一旦执行,过滤器已经从空开始初始化了,如果之后发现数据量估算少了,不能动态扩容,只能重建一个新的过滤器。所以初始化时,expectedInsertions宁可多估一点,也别少估,否则误判率会急剧上升。

布隆过滤器还有一个容易被忽略的坑:它需要和“数据写入”保持同步。商品新增时,要把新ID塞进过滤器;数据删除时,布隆过滤器不支持删除元素(因为多个元素可能共用一个bit位置,清了会影响别的元素)。所以实践中通常不是实时删除,而是定期重建过滤器,或者接受“已删除ID也能查到但库里查不到”的情况,让空值缓存去兜底。

我还需要提醒一点:布隆过滤器是用来防穿透的,不是用来做权限校验的。假设一个合法用户查询一个不存在的订单,被过滤器误判挡住了,对用户来说就是“明明数据不存在,你却说有”,体验上问题不大。但如果过滤器直接返回“存在”然后去查库,相当于放行了一部分无效请求,所以它拦的是“一定不存在的请求”,拦不住的全部放行到下一层。这才是正确使用姿势。

3. 缓存击穿:互斥锁与逻辑过期的实战选择

3.1 击穿场景为何比穿透更难防

击穿的麻烦在于,它只发生在一个热点key上,但这个key在那一瞬间承载的流量非常惊人。典型例子是电商大促时的秒杀商品详情页,平时这个key的QPS可能有几千,结果缓存过期后,几千个请求同时发现缓存没值,齐刷刷地闯到数据库。数据库能扛几百并发就算不错了,这一瞬间直接就跪。

为什么布隆过滤器防不了击穿?因为击穿请求的key是真实存在的,布隆过滤器只会把它放行,甚至过滤器里存的bit全都为1,判断结果也是“存在”。所以击穿的应对重点,不是拦截key,而是控制“重建缓存”的过程,保证只有一个线程能在并发中查库回填,其他人要么等待,要么用旧数据兜底。

3.2 互斥锁方案:实现与细节

互斥锁的核心逻辑很简单:缓存过期后,请求先尝试获取分布式锁,拿到锁的线程去查数据库并回填缓存,其他线程拿不到锁,就等待锁释放后再去缓存里取。听起来不难,但代码里全都是细节。

我写个最直白的伪代码版本:

String value = cache.get(key); if (value == null) { if (redis.lock(key)) { try { value = cache.get(key); // double check,防止等锁期间已被其他线程回填 if (value == null) { value = database.query(key); cache.set(key, value, ttl); } } finally { redis.unlock(key); } } else { Thread.sleep(50); value = cache.get(key); // 等锁线程直接再查一次缓存 } }

这个方案有几个关键点。第一,拿到锁之后必须二次检查缓存,因为在你等锁的这段时间里,抢到锁的线程可能已经把缓存回填好了,如果你不检查直接查库,锁就白加了。第二,锁的超时时间必须大于数据库查询耗时,否则查询还没完成锁就过期了,其他线程拿到锁又开始重复查库,锁形同虚设。第三,不能用简单的SETNX裸锁,因为SETNX没有原子性的过期时间设置,进程挂掉锁就永远不释放。我建议直接用Redisson的RLock,它自带看门狗续期机制,能避免锁超时误杀。

你可能会问,为什么不直接用本地锁?因为线上服务通常是多实例部署,本地锁只能锁住当前进程,其他实例照样能查到数据库。分布式场景必须用分布式锁,Redis没部署Sentinel的话,至少也得是集群模式,否则Redis单点挂掉的时候,锁本身就成了新的单点故障。

3.3 逻辑过期方案:无锁也能扛住

互斥锁最大的问题是,当热点key过期瞬间那几百毫秒内,拿不到锁的请求都在等待,极端情况下会在接口层堆积,拖慢整个服务的吞吐量。还有一种更丝滑的思路,叫逻辑过期:缓存里不设置Redis的TTL过期时间,而是把过期时间直接写在value里。

查询时先拿到这个value,判断逻辑过期时间有没有到。如果没到,直接返回;如果到了,就返回旧值,同时启动一个线程去异步更新缓存。更新缓存时加上互斥锁,防止多个异步线程重复查库。这样所有请求都能立刻拿到响应,虽然拿到的可能是几百毫秒前的旧数据,但保证了接口不阻塞、数据库压力可控。

这种方案适合对实时性要求不那么苛刻的场景,比如商品详情页、文章内容、配置信息。只要下一秒刷新时能拿到新数据,用户几乎感知不到差异。我实际做过一次活动页的缓存改造,用逻辑过期之后,接口的P99从158ms降到了23ms,数据库QPS高峰期反而比改造前更平稳,因为永远不会出现“缓存空窗期全量打库”的情况。

3.4 互斥锁与逻辑过期的选型对比

选哪个不是拍脑袋,我给一张对比表:

维度互斥锁方案逻辑过期方案
数据一致性强一致,拿到的一定是最新数据弱一致,过期瞬间返回旧数据
接口耗时最坏情况有等待阻塞永不阻塞,返回旧值
实现复杂度中等,需要锁和二次检查稍高,需要异步刷新线程
适用场景金额、库存、对一致性要求高的场景详情页、推荐列表等可容忍轻微延迟的场景
数据库压力只有一个线程重建,但等待线程仍会重试只有异步线程重建,压力更稳

我个人更倾向于把两种方案混用:一致性要求高的业务数据,用互斥锁;读多写少、可短暂不一致的热点数据,用逻辑过期。别试图拿一个方案解决所有问题,那才会出问题。

4. 缓存雪崩:从过期时间到多级缓存的体系化防御

4.1 雪崩的成因范围

雪崩的触发条件比前两个更广。最常见的原因是大量key的过期时间点相同,比如你写了个定时任务,把一批商品数据统一设置成明天凌晨0点过期,结果0点一到,几千个key同时失效,流量瞬间全压到数据库。另一种是缓存节点宕机,比如Redis主节点挂了,没有及时切换,或者网络分区导致客户端拿不到数据,所有请求直接绕过缓存打DB。

雪崩的杀伤力在于,它不是某一个key的事,而是整个缓存的防护能力在某个时间点归零。你的数据库原本只承受20%的直连流量,还剩80%被缓存挡着,雪崩一来,直接变成100%,而且往往是量的几倍几十倍,连接池先爆,紧接着是线程池排队,最后服务雪崩式超时。

4.2 让过期时间“错峰”

对付过期型雪崩,最简单有效的操作是给TTL加随机偏移量。比如原本统一设置3600秒过期,现在改成3600加一个0到300之间的随机数:

ttl = 3600 + new Random().nextInt(300); value = database.query(key); redis.set(key, value, ttl);

这个改动成本极低,但效果非常明显。它把原本“同一秒集体过期”变成了“5分钟内分散过期”,数据库每秒多承受的请求量就被一个数量级地降下来。只要不是那个区间内所有key都一起失效,数据库就能匀速处理回填请求。

这里我特别想强调一个细节:随机偏移量不能设得太小。如果base TTL是120秒,随机范围只有0到5秒,那和没加几乎一样;随机范围至少得是TTL的10%到20%才有效果。而且加了随机之后,缓存命中率的曲线波动会更平滑,不会出现“每到整点就抖一下”的月经式故障。

4.3 多级缓存:本地缓存兜底

光靠随机过期还不够,因为雪崩还有可能来自Redis节点宕机。这时就需要多级缓存,最经典的组合是“本地缓存 + Redis + 数据库”三层结构。本地缓存指的是Java进程内的Caffeine或者ConcurrentHashMap,它的特点是访问极快,QPS能顶到几十万,但容量有限,每个服务实例存的数据量不能太大。

我建议把最热门的几百个key放进本地缓存,比如商品详情页里访问量最高的前500个商品。查询顺序变成:先查本地缓存,没命中再查Redis,再没命中才查数据库。当Redis宕机时,本地缓存还在,它能挡掉一大半的读取流量。等到Redis恢复,再按正常流程回填。这样做还有个额外好处:即使Redis压力很大,本地缓存也分担了很大一部分读流量,Redis的负载能降下来。

本地缓存的大小设置要注意,Caffeine可以配置maximumSize和expireAfterWrite,我通常设置最大条目5000条,过期时间60秒。这个配置能让本地缓存保持足够新鲜,又不会占用太多堆内存。如果你把过期时间设成几小时,那用户可能会看到很久之前的旧数据,体验反而受影响。

4.4 限流、熔断与预热:兜底三件套

多级缓存能把大部分流量挡在外层,但数据库仍然需要最后一道保险。限流的作用是,当数据库QPS超过它能承受的阈值时,直接拒绝一部分请求,宁可让少量用户看到“稍后重试”,也不能让数据库整个垮掉。业界常用Sentinel或Hystrix做限流,但你如果不想引入新组件,也可以在网关层写一个简单的计数器限流。需要注意的是,限流阈值要结合数据库压测结果来定,而不是随便填个数字。

熔断是限流的进阶。当我们发现数据库错误率超过阈值,比如连续30秒内错误率超过20%,就打开熔断器,后续请求直接返回降级结果。降级结果可以是默认文案、静态页面,或者是上一次缓存下来的兜底数据。这样数据库就有了喘息的机会,等它恢复后熔断器再半开试探,逐渐放量。

缓存预热同样重要。比如大促开始前,线上还没有真实流量时,就先把热点商品的缓存数据加载好,避免活动一开始就触发大面积缓存重建。预热的实现方式很多,可以直接写个定时任务扫描热点ID列表,批量查询后回填缓存,也可以在应用启动时用项目中的接口主动刷一遍。我踩过的一个坑是,预热的回填TTL没有加随机,结果所有预热数据在同一秒过期,又一次触发雪崩。所以记住,预热也务必设置随机过期时间。

5. 实战中的坑与排查清单

5.1 布隆过滤器的误判问题怎么收场

布隆过滤器误判本身是设计的一部分,但误判带来的业务影响需要提前想好后果。我之前做过一个订单号拦截的场景,布隆过滤器里的误判导致极少数合法订单被当成“不存在”处理,用户查询订单时偶尔查不到,客服那边收到好几个工单。后来我调整了方案:布隆过滤器只用于主动拦截“判断为不存在”的高频恶意请求,而一旦过滤器判断为“可能存在”,查询链路照常走缓存+数据库。这样误判最多是放行一些无效请求,不会误伤合法数据。

如果你连1%的误判都不能接受,那就需要换一种完全精确的数据结构,比如Redis的Set或者数据库索引表。但这也意味着存储成本和判断耗时都会上升。我给你的建议是:布隆过滤器是拦截器,不是裁判员,它负责挡掉七成以上的无效流量,剩下的交给下游兜底,这才是性价比最高的用法。

5.2 锁失效与线程阻塞

击穿场景里互斥锁经常出问题。第一个坑是锁没有设置过期时间,线程查到一半突然抛出异常,finally里释放锁的代码又被return漏掉了,锁永远不被释放,后面所有请求都卡在等待上。这个问题最好的解法是加锁时强制设置leaseTime,比如5秒,锁到期自动释放,配合Redisson的看门狗续期来避免业务没执行完锁就过期。

第二个坑是等待锁的线程只用Sleep + 重试,这种写法在低并发下没事,在高并发下会造成大量线程占用,甚至拖垮线程池。更好的做法是设置一个最大等待时间,比如200毫秒,超时后直接返回一个降级结果,不要死等。同时,重试获取锁的次数要限制住,否则就成了另类慢查询。

5.3 缓存与数据库一致性是那个绕不开的话题

雪崩和击穿解决的是“缓存没失效时怎么挡流量”的问题,但缓存与数据库的数据一致性问题,其实贯穿所有防御方案。经典的Cache Aside模式是:先更新数据库,再删除缓存。这个顺序是有讲究的,因为删缓存失败的概率远低于改缓存失败,一旦缓存没删掉,下次读到旧数据的概率更大。

我还会配合一个延迟双删的变体:更新数据库后先删一次缓存,等几百毫秒再删一次。这个“等几百毫秒”是为了处理并发更新时,另一个线程把旧数据写回缓存的竞态窗口。注意,延迟双删不是银弹,它有极低概率在两个删除之间被旧值回填。如果你的系统对一致性要求极高,我建议直接从架构上绕开——直接读数据库,或者用Canal监听binlog再异步更新缓存,不要在应用层死磕。

5.4 一套趁手的排查流程和速查表

最后分享一套我自己在用的排查流程,遇到缓存事故时可以照着走:

  1. 先看监控大盘:缓存命中率、数据库QPS、平均响应时间三个曲线一起看,确认故障区间和持续时间。
  2. 再定位key特征:按key前缀统计访问量排行,找出高并发key。
  3. 确认是否存在不存在数据:抽样几个高并发key,直接查库验证数据库里有没有。
  4. 看key的过期时间分布:用Redis的scan命令把大key的TTL分布拉出来,看是否集中在同一时间段。
  5. 结合以上信息判定问题类型,再采用对应方案。

我把常见的定位思路整理成一张速查表:

现象大概率问题核心排查点首选防御手段
大量不存在的key反复打库缓存穿透高并发key在库中是否存在布隆过滤器 + 空值缓存
单个热点key过期瞬间数据库打爆缓存击穿是否集中在特定热点key互斥锁 或 逻辑过期
大量key同时过期或Redis宕机后打库缓存雪崩TTL分布是否有尖峰,Redis节点状态随机TTL + 多级缓存 + 限流熔断

这套流程其实并不复杂,关键是你愿意在故障发生前就做好监控和预案。缓存这层防护做得越完善,数据库就越少面对极限情况。而数据库一旦崩了,恢复通常要按小时算,这个代价比事先加一个布隆过滤器要高得多。

我个人这些年最深的体会是:处理缓存故障,千万别只盯着某一种技术方案。布隆过滤器、互斥锁、随机过期、多级缓存,这些东西单独拎出来都只是工具,真正值钱的是你能看清流量特征、判断出故障类型、再把对应工具组合起来。每次线上缓存抖动,都是一次免费的压力测试机会,事后把参数调优记录写下来,下次再遇到就会从容很多。

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

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

立即咨询