资讯详情

缓存穿透、击穿与雪崩:本质、定位与解决方案

📅 2026/10/7 17:27:42 | 华诺云谱 👁 阅读
缓存穿透、击穿与雪崩:本质、定位与解决方案
半夜两点手机开始震动告警群里的消息一瞬间刷了屏数据库CPU冲到99%接口超时率直线上升。你一边披衣服往电脑前跑一边在脑子里快速过了一遍——缓存穿透、缓存雪崩、缓存击穿这三个词几乎所有后端开发都背得滚瓜烂熟但真正到了线上故障那一分钟能迅速判断出当前是哪一个问题、该先上哪个方案的人其实没那么多。这篇文章就是冲着“快速高效复习”去的。不管你是准备面试、正在排查线上问题、还是做系统设计时需要把方案讲清楚都可以用这套思路把三个问题串起来。我会从故障现场出发逐个拆解三兄弟的本质、定位手段和解决方案最后给你一个能直接用的判断框架和复习路线。不是背八股是把链路吃透。1. 从一次线上告警说起三个问题为什么总是结伴出现1.1 一条缓存查询链路三个“故障入口”大多数业务系统的缓存模型都很简单画出来就是一条线请求先到Redis命中了直接返回没命中回源数据库查再把结果写回缓存。换个说法就是 Cache Aside 模式也是目前应用最广泛的读写缓存方式。三个问题就藏在这条链路的不同位置缓存穿透查了一个缓存里没有、数据库里也不存在的数据。因为数据不存在缓存永远写不进去请求每次都穿透到数据库。缓存击穿缓存里本来有数据但某个热点key在某一刻过期失效了正好赶上大量请求同时访问这个key所有请求一起涌向数据库。缓存雪崩一大批key在同一时间段集中失效或者Redis整个不可用导致大量请求绕过缓存数据库被瞬时流量打垮。听起来都懂但一混就乱。我见过不少人面试时把击穿和雪崩讲反更常见的是一紧张就把“穿透”说成“所有请求都打到DB”。其实抓住一个关键点就不容易错——问题出在链路哪一环以及失效范围是单个还是多个。1.2 为什么复习这三个问题不能靠死记硬背背定义是最快的遗忘方式因为你没建立“故障现场感”。我处理过的缓存故障里这三个问题常常是叠加出现的。比如一次秒杀活动中活动开始时大量热点key同时写入并设置了相同过期时间一小时后集体失效——这是雪崩其中有一个爆款商品的key恰好被大量请求访问失效瞬间并发回源——这是击穿同时还有恶意用户批量请求不存在的商品ID——这是穿透。三个问题在同一时间窗口内一起出现如果你只背了各自的定义到现场基本是懵的。所以复习的正确姿势是先建立一条链路心智模型再往里填方案。下面我用三章分别拆解每个问题每一章都按“本质 → 现场定位 → 解决方案 → 方案取舍”的顺序来方便你直接对照。2. 缓存穿透查了一个不存在的数据数据库被你打穿了2.1 穿透的本质问题出在“查询链路里缺失的那一环”缓存穿透的核心是请求的数据在缓存和数据库里都不存在。正常流程里缓存miss之后会回源数据库发现数据库有数据写回缓存下次就能命中。但穿透场景下数据库里压根没有这条记录缓存写不进去于是每次请求都重复走一遍“缓存miss → 查数据库 → 查不到 → 返回空”的流程。这个问题的可怕之处在于它可以被无限放大。攻击者只要摸清你的ID生成规则批量请求一堆不存在的ID比如负数、超大数、随机UUID数据库就会持续被无效查询打满。我碰到过的最典型案例是有人写了脚本循环请求-1、-2、-3一直到-100000的商品ID直接把一个没有做参数校验的服务打挂。正常用户可能一辈子都碰不到一个不存在的ID但恶意请求可以把每秒几万次的无效查询灌给你的数据库。从监控面板上看穿透的典型特征是Redis QPS 很低数据库 QPS 却异常高。因为所有请求都没命中缓存Redis 几乎不干活压力全在数据库上。这时候看数据总QPS并不会下降说明流量根本没被缓存挡住。2.2 方案一缓存空值用一次DB查询换n次缓存命中这是最简单直观的做法既然数据不存在那就把“不存在”这个结果也缓存下来。第一次请求查数据库发现没有数据时往Redis里写入一个空值null、空字符串或者一个特殊标记设置一个较短的过期时间比如 2~5 分钟。后续相同key的请求直接命中这个空值返回一个“查无此数据”的响应不再打到数据库。public Object get(String key) { Object value redis.get(key); if (value ! null) { return value; } // 缓存未命中回源数据库 Object dbValue queryFromDB(key); if (dbValue null) { // 数据不存在缓存空值TTL设置短一些 redis.set(key, EMPTY_MARK, 180); return null; } redis.set(key, dbValue, 3600); return dbValue; }这里面有几个实际运营中的细节很关键空值TTL不要设置太长。一是为了防止大量不存在的key长期占着Redis内存二是如果某天业务方真的往数据库里插入了这条数据缓存里的空值会让新数据在TTL到期前一直“不可见”造成短暂的数据不一致。2-5分钟是一个比较合理的折中区间。要给空值缓存加容量限制。没有限制的话恶意请求可以制造出几百万个不存在的keyRedis内存直接被空值打爆。比较好的做法是设置一个空值缓存的上限比如最多缓存100万个空值key超出后不再写入空值同时配合布隆过滤器一起用。缓存空值的优点是实现成本极低几行代码就搞定缺点是治标不治本——它只是把“打DB”变成了“打Redis”恶意的随机key依然会持续消耗缓存资源。2.3 方案二布隆过滤器在缓存前面加一道“存在性闸门”布隆过滤器的思路是在Redis前面再放一层拦截器把所有可能存在的key预先录入一个bitmap查询时先判断“这个key可能存在吗”如果判断为不存在直接返回连Redis都不用查。这里有一个关键概念要理解布隆过滤器只会误判“存在”不会误判“不存在”。也就是说它可能把一个不存在的key误判为存在放过它继续查Redis和DB但绝不会漏掉一个真正存在的key。这种特性用在防穿透上刚好合适——宁可放过几个恶意请求也绝不能让正常请求被拦掉。# 假设用Redisson实现 RBloomFilterString bloomFilter redisson.getBloomFilter(productBloom); // 初始化预计元素数量100万误判率1% bloomFilter.tryInit(1000000L, 0.01); // 查询前先判断 if (!bloomFilter.contains(productId)) { return null; // 一定不存在直接返回 } // 可能存在继续走缓存查询布隆过滤器的误判率不是拍脑袋定的它由三个参数决定bit数组长度 m、哈希函数个数 k、预计元素数量 n。实际工程中常用这个结论当k (m/n) * ln2时误判率最低。按照经验n100万、误判率控制在1%时m大约需要800万bit约1MB内存哈希函数设5个左右就够了。布隆过滤器最大的坑是不支持删除元素。如果业务里经常有商品下架、用户注销这类“数据消失”的场景被移除的key会一直留在bitmap里误判率会逐渐累积。这时候要么定期重建整个过滤器要么使用升级版的 Counting Bloom Filter让每个bit变成计数器支持删除。我自己的经验是如果数据集合相对稳定布隆过滤器非常香如果数据频繁增删优先考虑缓存空值或者重建过滤器。2.4 穿透方案的取舍与容错空间把参数校验、缓存空值、布隆过滤器排个序我的建议是三层都做但责任分层第一层必做接口参数校验。ID为负数、格式非法、超过合理范围内的请求直接在入口甩掉。这层成本最低但能挡掉大部分无脑攻击。第二层强烈建议布隆过滤器。挡掉“不存在的key”让Redis连查都不用查。第三层兜底缓存空值。即使布隆过滤器误判放过来一批不存在的key空值缓存也能把它们拦在数据库外面。注意三层方案的主要成本是开发量和维护成本。如果你们的系统规模不大、QPS不高只做参数校验缓存空值完全可以布隆过滤器是流量大到DB扛不住的时候才需要上的重武器。面试里追问穿透的时候最常见的一个进阶问题是“布隆过滤器误判了怎么办”。记住一个原则误判方向是“放行”所以威胁依然存在但概率可控真正需要警惕的是数据集合频繁变化导致的误判率恶化而不是布隆过滤器本身失败。3. 缓存击穿单个热点key失效零点一秒冲进来一万个请求3.1 击穿的本质热点key和时间点强绑定缓存击穿在表达上很容易被误解成“穿透”但两者完全不是一回事。穿透是数据压根不存在击穿是数据存在只是缓存刚好失效了。击穿的触发条件是“热点key 集中访问 同一时刻失效”。典型场景是秒杀开始前后一个商品的详情页可能同时被几千上万人刷新它的缓存过期时间到了key从Redis里消失了下一个瞬间所有请求都发现缓存miss于是一起回源数据库。如果你的数据库连接池只有500个连接瞬间来一万个请求结果就是连接池被打满整个服务雪崩。从监控上看击穿非常“陡峭”——数据库QPS在某个时间点突然拔高持续到缓存重建完成之后又恢复正常。这种尖峰形态和雪崩的宽幅爬升有明显区别。3.2 互斥锁让数据库只被一个线程打到应对击穿最经典的方案是互斥锁。核心思想是当发现缓存miss时不所有线程都冲下去查DB而是先抢一把锁只有拿到锁的线程才允许查DB并回填缓存其他线程一直在旁边等待缓存重建完成然后从缓存里拿数据。用Redis实现一个最简单的分布式锁# 尝试获取锁setnx 过期时间原子执行 SET lock:product:10086 1 NX EX 30 # 成功返回OK拿数据失败说明其他线程正在重建缓存配合业务代码大概逻辑是这样public Object queryProduct(Long productId) { Object value redis.get(product: productId); if (value ! null) { return value; } // 尝试获取锁 String lockKey lock:product: productId; boolean locked tryLock(lockKey, 30); if (!locked) { // 没抢到锁说明其他线程正在回源数据库 // 可以短暂sleep后重试或者直接返回兜底数据 Thread.sleep(200); return redis.get(product: productId); } try { // 抢到锁的线程再查一次缓存防止在等待锁期间缓存已被其他线程重建 value redis.get(product: productId); if (value null) { value queryFromDB(productId); redis.set(product: productId, value, 3600); } return value; } finally { // 释放锁 releaseLock(lockKey); } }互斥锁方案有两点容易踩坑锁必须设置过期时间。如果拿到锁的线程在执行DB查询时进程宕机了没走到释放锁那一行这把锁就会变成永久锁。给锁加一个合理的过期时间比如上面代码里的30秒至少保证锁不会永久占用。但过期时间太短也不行——万一DB查询慢到超过了30秒锁提前过期第二个线程又能抢到锁等于锁白设了。这里就需要看门狗续期的思路后台线程定期给锁续期或者把过期时间拉长到一个安全余量。避免自旋等待过深。抢不到锁的线程如果无限循环地sleep重试在高并发下反而会给应用层带来额外压力。实际工程里通常会设置一个最大等待时间比如2000ms超时就返回一个兜底数据比如默认的“系统繁忙请稍后重试”宁可让少量请求失败也不能拖垮整个服务。3.3 逻辑过期用“假数据”兜底换来异步刷新的从容互斥锁虽然简单但有一个硬伤在缓存重建的那段时间里请求要么阻塞等待要么返回兜底。想要用户体验好一点更高级的方案是逻辑过期。逻辑过期的思路完全不一样Redis里的key永不过期但value里存的是“业务数据和它的过期时间”这个复合结构。查询时取出来先看逻辑过期时间没过期直接返回过期了先用一个互斥锁保证只有一个线程去重建缓存重建期间当前请求先返回已经过期的旧数据。伪代码如下// value结构{data: ..., expireTime: 1720000000000} public Object queryProduct(Long productId) { // 命中缓存后拆出逻辑过期时间 CacheItem item redis.get(product: productId); if (item.expireTime System.currentTimeMillis()) { return item.data; // 未过期直接返回 } // 逻辑过期重建缓存 boolean locked tryLock(reload:product: productId); if (!locked) { return item.data; // 抢锁失败先返回旧数据 } // 抢到锁的线程异步重建缓存或者同步重建后更新 CompletableFuture.runAsync(() - { Object freshData queryFromDB(productId); redis.set(product: productId, new CacheItem(freshData, newExpireTime)); }); return item.data; // 本次请求依然返回旧数据 }逻辑过期方案的最大优点是任何时刻缓存都有数据可读用户感知不到缓存重建过程。代价是数据一致性差了——客户端可能读到几分钟前的旧数据而且需要维护一个异步线程池来做缓存重建。它和互斥锁方案的取舍是业务能接受短暂读到旧数据选逻辑过期必须每次读都最新选互斥锁。在我接触过的实际项目里商品详情、资讯列表这类读多写少的场景逻辑过期用的非常多因为用户根本感知不到几百毫秒的数据延迟。3.4 热点预热与“永不过期”策略除了上面两种“挡并发”的方案还有一种思路是“预先补位”——热点预热。在秒杀、抢券这类活动开始前的10分钟写一个定时任务把活动商品、库存数、配置信息提前加载到Redis缓存并设置足够长的过期时间。这样活动真正开始时热点key根本不会失效击穿问题自然就不存在了。很多团队会把“热点key自动发现”做成一个常驻任务通过监控Redis访问频次自动识别访问量Top100的key在过期前提前刷新。所谓“永不过期”并不是真的Redis不设过期时间而是用逻辑过期机制来“假装”不过期。这两个概念经常被混在一起讲面试时如果提到“永不过期”大概率指的就是上面说的逻辑过期方案回答时把value里存过期时间、异步刷新这两点讲清楚就行。注意预热和逻辑过期可以组合使用。预热解决“活动开始前key过期”的问题逻辑过期解决“活动进行中key意外失效”的问题。我在项目里经常是两者都上配合互斥锁做最后兜底。4. 缓存雪崩大面积key同时失效或者Redis整体下线4.1 雪崩的本质从“单点故障”到“集体失效”缓存雪崩的杀伤力比穿透、击穿都大因为它的影响范围是“面”而不是“点”。雪崩有两大来源。来源一大量key集中失效。这是最常见的工程失误。很多系统初始化缓存时会统一设置一个固定过期时间比如所有数据都是3600秒。如果这些key是同一批被写入的那么到期时它们也在同一秒集体失效。假设这批key有10万个失效瞬间全部回源数据库数据库直接被打穿。更隐蔽的是如果业务每个小时整点跑一次定时任务写缓存那么每小时的整点都是一个“定时炸弹”。来源二Redis服务不可用。重启、宕机、网络分区、主从切换任何一个原因导致Redis整体无法服务所有请求都会瞬间绕过缓存直达数据库。这个场景下数据库压力来自全部流量而不是部分key的回源。从监控上看雪崩的典型特征是数据库QPS在某个时间段大幅爬升并持续较久或者干脆就是“Redis掉线期间数据库持续满负荷”。4.2 过期时间随机化让失效不再“整齐划一”针对“集中失效”最基础也是最常用的手段是给TTL加上随机偏移量。如果所有key的过期时间都在7200秒上下浮动0~300秒那原本“同一批key同时过期”就变成了“同一批key在5分钟内陆续过期”。每一秒只有一小部分key回源数据库重建数据库压力就从“一次1万并发”摊薄成“每秒几百并发”。// 基础过期时间 随机偏移 int baseExpire 7200; int randomOffset ThreadLocalRandom.current().nextInt(300); redis.set(key, value, baseExpire randomOffset);随机化几乎零成本却能从根源上避免“整齐划一的失效潮”。这个问题上我见过太多团队用的是“加大基础过期时间”的笨办法——从1小时改成3小时等于问题机率降了三分之一但每逢整点还是有一波尖峰。正确做法永远是把失效时间错开而不是单纯延长。4.3 多级缓存把Redis的失败拦截在业务层当Redis整体不可用时还有一个容易忽略的防线——本地缓存。常见做法是在JVM进程内加一层Caffeine或Guava Cache。查询顺序变成本地缓存 → Redis → 数据库。本地缓存的过期时间可以设置得比Redis长这样即使Redis整个挂了仍然有很大一部分请求能在本地缓存里命中数据库只会收到本地缓存miss的那部分流量。多级缓存的成本在于一致性更难管理如果数据库数据变了你需要同时考虑本地缓存、Redis两层的数据更新问题。实际工程里用得比较多的是“短TTL 主动失效”策略本地缓存TTL设30秒左右Redis TTL设10分钟数据变更时广播一个失效消息到所有实例的本地缓存把不一致的窗口压缩到秒级。4.4 Redis高可用与熔断降级面对雪崩的最后防线场景转换一下如果Redis是因为宕机才雪崩的那随机化TTL就完全帮不上忙。这时候要上的是高可用方案。Redis高可用有两条路线主从哨兵Sentinel能解决单点故障Cluster集群则同时解决容量和故障转移问题。但这里要理解一个现实高可用解决的是“Redis恢复”的问题不是“Redis故障期间数据库安全”的问题。在主从切换的几秒内Redis依然可能短暂不可用这期间流量还是会打到数据库。所以雪崩的最后一道防线是熔断降级。给数据库封装一个保护层当数据库压力超过阈值比如每秒最大允许5000条查询时直接把后续请求拦在入口返回“系统繁忙”或一个默认的兜底数据。这种做法看起来“牺牲”了一些正常请求但至少保证了数据库不被打垮服务不会从“部分异常”恶化成“整体宕机”。我处理过的一个真实案例可以说明这个优先级某流量高峰期间Redis集群因为内存碎片问题触发了主从切换切换那几十秒内数据库收到几百万条查询。如果当时有熔断保护数据库有概率扛过去没有的话服务器直接OOM恢复时间以小时计。高可用方案讲究的是平均保障熔断降级讲究的是极端情况下的底线两者缺一不可。5. 三板斧对比与线上定位两分钟判断当前是哪种故障5.1 一眼区分三兄弟复习到这一步你可以用下面这张表来检验自己是否真正掌握了三者的区别。这也是我面试候选人时最喜欢问的对比问题。问题触发条件影响范围缓存命中率数据库表现最优先方案缓存穿透查询不存在的数据单个请求可能被攻击无限放大明显下降QPS持续偏高查询都是无效记录参数校验、缓存空值、布隆过滤器缓存击穿单个热点key过期单个key的并发访问某个时间点短暂下降出现陡峭尖峰之后恢复互斥锁、逻辑过期、热点预热缓存雪崩大量key集中过期 / Redis不可用大面积key或全站流量大幅下降或直接为0QPS长时间、宽幅爬升TTL随机化、多级缓存、高可用、熔断判断流程可以浓缩成一个决策树先看Redis还活着吗活着继续挂了直接进雪崩分支上高可用预案和熔断。Redis活着但数据库收到的查询都是“查无此记录”穿透。看DB慢查询日志里是不是塞满了不存在的ID。Redis活着缓存命中率也正常但数据库在某个时间点出现细窄尖峰击穿。定位是哪个key在尖峰前刚好过期。Redis活着但很多key的命中率同时掉了数据库呈现宽幅爬升雪崩。查一下是不是一批key设置了相同的过期时间。这个流程的核心是抓住两个维度Redis是否存活数据库QPS的形态细尖峰 vs 宽爬坡。线上排障时把这两个维度的监控打开基本半分钟就能锁定问题方向。5.2 综合诊断场景复盘给你一个模拟场景练手某电商平台的商品详情页突然报警数据库主库CPU从20%暴涨到95%持续了3分钟然后自动降下来了。监控显示Redis正常在线但命中率从95%降到了60%数据库慢查询里出现了大量对同一个商品ID (product:10086) 的查询但也有一些不存在IDproduct:99999的查询。按照上面的决策树一步步走Redis存活 → 排除“Redis宕机型雪崩”。数据库慢查询里既有同一个热点商品的查询又有不存在的商品ID查询 → 这是击穿和穿透叠加。为什么3分钟后自动恢复因为缓存重建完成了并且空值缓存开始生效。真实的结论是这个热点商品因为活动流量暴涨缓存过期后触发击穿与此同时恶意扫描用户在批量请求不存在的商品ID给数据库补了第二刀。处理方式就是各打各的热点key设置逻辑过期预热不存在的ID走布隆过滤器拦截。这种“复合型故障”在线上非常常见单独背任何一个方案的答案都不够用。看问题时要学会把手段拆开按链路的每一环逐层解决。6. 高效复习的正确姿势从“背方案”到“画链路”6.1 用“链路图”代替“定义八股”如果你只有半小时复习这三个问题我建议什么都别背拿张纸画一条请求链路用户请求 → 参数校验 → 布隆过滤器 → Redis → DB → 写回缓存然后把每个问题“挂”到链路上穿透数据不存在卡在“DB前”的那层——用存在性拦截布隆/空值挡住。击穿热点key在“Redis那一环”刚好失效——用并发控制锁/逻辑过期挡住。雪崩Redis那一环“整体失效”——用分散随机TTL 备胎多级缓存/高可用挡住。表面上看每个问题的方案五花八门但内里的逻辑是一致的无论哪种问题最终的目标都是“别让数据库直接面对大流量”。想明白这一点即使面试时遇到一个你没见过的缓存问题也能很快给出合理的解决方向。一句话版本的记忆锚点穿透 “查不到”数据库每次都白干活 → 缓存空值/布隆过滤器。击穿 “个别key的缓存过期撞上高并发” → 互斥锁/逻辑过期。雪崩 “一批key过期/Redis挂了数据库被全量冲击” → 随机TTL/高可用/降级。6.2 面试追问清单不管你是为了面试复习还是纯粹给自己的知识体系查漏补缺下面这几个追问点都能帮你检测掌握程度布隆过滤器误判率可以调到0吗不可以m无限大也只是趋近于0现实中通过增加bit位数降低误判率互斥锁方案里如果锁过期时间设了30秒但DB查询需要40秒会发生什么锁提前释放第二个线程还能抢到锁等于互斥失效要么动态续期要么把锁过期时间拉长到大于最大查询耗时逻辑过期方案读到的旧数据如果对业务有害怎么办有害就别用逻辑过期改用互斥锁两者的本质是“一致性”和“可用性”的权衡为什么要给缓存空值设置TTL一是释放内存防恶意key堆积二是防止数据真插入后被空值挡着读不到随机TTL的随机范围怎么定太小了分散效果差太大了会导致部分数据提前失效一般设置为基础TTL的5%~10%比如7200秒加0~300秒把这些问题用“为什么”串起来之后你就能明显感觉到自己对三个问题的理解是立体而不是平面的。面试官如果追问“你项目里怎么解决缓存问题的”直接用上面第5.2节的综合案例回答远比背方案定义更有说服力。最后再多说一句我在实际项目里的体会——三个问题从来不是孤立存在的。做架构设计的时候把穿透、击穿、雪崩当成三个必须同时考虑的维度而不是三个可以分别绕开的坑。你的防护体系里至少要同时包含“存在性校验防穿透”“热点并发控制防击穿”和“失效分散多级兜底防雪崩”这三类能力。我习惯在项目开发前就把这套链路图画成一张缓存设计checklist贴在wiki里每次上线前对照检查一遍。这么做虽然麻烦了点但线上故障少了之后你就会觉得真值。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑