快速理清缓存穿透、雪崩、击穿的区别与实战方案
最近后台好几个读者来问我同一个问题“缓存穿透、缓存雪崩、缓存击穿这三兄弟到底怎么快速分清楚”坦白讲这三个问题确实是分布式缓存面试的高频考点但绝不只是为了应付面试——我印象里有几次凌晨被电话叫起来处理线上故障根因就是里面某一个典型场景。这篇文章不是教科书复读而是我基于这些年的实际运维和项目踩坑经验把三个问题当作一套复习提纲来梳理。我会把它们的成因链路、相互区别、对应解决方案、以及方案背后的参数计算和坑位全部拆开来讲。不管你是准备面试还是准备排查线上问题按这个结构过一遍基本能覆盖你需要的核心逻辑。1. 先把三个概念彻底分清穿透、雪崩、击穿1.1 三者的触发链路差异很多人在复习时第一个坎就是“名字记混了”。其实从触发链路看三者有非常明显的区分。缓存穿透链路是请求进入 - 缓存Miss - 查询数据库 - 数据库也没有 - 返回空。关键点在于“数据本身不存在于任何一层”所以缓存永远没有机会兜住这个Key每一次请求都会实打实落到数据库。缓存雪崩链路是一批Key同时到期 - 缓存中大面积Miss - 大量请求同时落到数据库 - 数据库压力骤增。它的核心是“Key确实都存在但因为在同一时刻集体失效导致流量像崩堤一样冲过去”。除了Key过期缓存服务整体不可用也会造成同样的效果这个后面单独说。缓存击穿链路是某个热点Key到了过期时间 - 缓存Miss - 该Key恰好正被超高并发访问 - 所有请求同时打到数据库。注意它和雪崩最大的区别是“范围”击穿针对的是某一个单独的Key只是这个Key的流量特别大。我用一个生活化的类比来强化记忆穿透就像一个坏掉的自动售货机你投币买一个根本不存在的商品机器每次都会提示“无货”不会记住这个商品不存在所以每次都得跑一趟雪崩是整条街上所有售货机同时断电击穿则是只因为一台最热门的机器突然死机人群全部涌向唯一的备用窗口。1.2 一张表记住三个问题的核心区别维度缓存穿透缓存雪崩缓存击穿数据是否存在缓存和数据库都不存在存在但大量Key同时失效存在单个热点Key失效失效范围单个Key反复穿透大批量Key集体失效或节点宕机单个热点Key失效瞬间触发条件恶意扫描、非法参数、查询空数据过期时间集中、Redis不可用热点数据过期瞬间高并发影响面持续、可被攻击放大全局性、数据库瞬间崩溃短暂但强度极高核心矛盾缓存永远无法命中可用性/缓存集中失效单点流量过大这张表建议你收藏面试时先用一句话定义再把表格里的“范围”和“触发条件”说清楚面试官基本能判断你确实是理解过的而不是背概念。1.3 最常见的三个认知误区误区一把穿透和击穿当成一回事。我之前带过的同事就犯过这个错他遇到某个接口有大量请求打到数据库第一反应是“这个Key过期了”排查半天才发现请求里全是随机生成的ID数据库里根本没有这些数据属于典型的穿透。判断时先问一句数据库里有这条记录吗没有就是穿透有但是缓存过期就是击穿或雪崩。误区二认为布隆过滤器可以解决击穿。布隆过滤器解决的是“查询不存在数据”的问题它判断的是Key是否在集合内。Key本身存在、只是缓存过期了布隆过滤器当然会返回“可能存在”所以击穿问题还得靠互斥锁或逻辑过期来处理别用错工具。误区三把雪崩仅仅归结为“Key同时过期”。实际上缓存节点宕机、网络分区、大面积内存淘汰导致的缓存不可用同样会引发雪崩。而且这种故障更可怕因为它不是等一段时间Key自动恢复就能解决的需要靠多级缓存或限流降级来保底。2. 缓存穿透空数据导致的高频直达2.1 穿透为什么会发生一次完整请求链路以最常见的查询接口为例标准流程是这样的请求进来先去Redis查Key查到就返回没查到就去数据库查数据库查到后回填Redis并返回。问题就出在“数据库也没查到”这个分支上。很多初学阶段的实现方案里数据库返回空之后什么都不做直接返回空结果。这个逻辑在单个请求上看没有问题但一旦请求的Key是大量不存在的ID比如恶意攻击者伪造递增ID、爬虫随机拼接参数那么每个请求都会走一遍“Redis查不到 - 数据库查不到”的完整路径。数据库连接池是有限的这种无效查询一旦并发上来很快把连接耗尽正常业务请求也会跟着被阻塞。我之前维护过一个活动系统出现过一次诡异的现象数据库CPU突然飙到95%但慢查询日志里全是“SELECT * FROM product WHERE id 123456789”这种语句而表里根本没有这个ID。后来发现是有人写脚本遍历不存在的商品ID单个请求成本很低并发一高就是致命的。2.2 穿透带来的真实代价穿透的代价不仅仅是数据库压力增加还有几个隐蔽问题。第一缓存本身会被“绕过”缓存命中率会直线下降监控面板上看Redis的hit rate会掉得很难看第二恶意穿透配合慢查询可能把数据库的线程池占满拖垮所有正常的读写请求第三如果数据库设计不当比如查询没有走索引穿透查询的成本会被进一步放大。所以处理穿透核心原则是“无论如何也不能让无效请求反复打到底层”。要么让缓存记住“空结果”要么在缓存前面加一道“存在性判断”的过滤层。2.3 方案一缓存空对象及其三个坑最直观的解法就是缓存空对象。数据库查询返回空之后也把空结果写入缓存并设置一个较短的过期时间比如60秒。这样后续相同的请求在TTL内会直接命中缓存不会再打到数据库。但缓存空对象有三个坑我在项目里全踩过。第一个坑是“缓存空间浪费”。如果攻击者使用大量不重复的随机Key每个Key都会在Redis里占一条记录内存可能被撑爆。解法是给穿透Key的缓存设置一个很小的TTL比如30到60秒同时可以额外设置一个阈值比如空缓存key的数量超过总量X%时触发告警。第二个坑是“一致性”。数据库可能后续真的写了这个Key的数据但缓存里的空值还没过期导致读取不到最新数据。解法是在写入业务数据时主动删除对应的空缓存值。第三个坑是“防不住持续变种的穿透”。如果是随机参数的持续攻击每次Key都不同空缓存TTL期间挡得住TTL一过又会穿透。代码实现上很简单查询主流程里加一个分支// 伪代码示例 Object cacheValue redis.get(key); if (cacheValue null) { // 防止并发穿透先尝试获取一个轻量锁也可不加取决于并发量 Object dbValue queryFromDB(key); if (dbValue null) { // 缓存空对象TTL设置为60秒 redis.set(key, , Duration.ofSeconds(60)); return null; } redis.set(key, dbValue, Duration.ofSeconds(600)); return dbValue; }2.4 方案二布隆过滤器怎么算内存和误判率如果说空对象是“事后兜底”布隆过滤器就是“事前拦截”。它通过一个位数组加多个哈希函数判断某个Key“一定不存在”或“可能存在”把不存在的请求挡在缓存查询之前。这里必须说清楚两个参数怎么定预期数据量n和误判率p。位数组长度m的计算公式是m - n * ln(p) / (ln2)^2哈希函数个数k m / n * ln2。举个例子如果预期数据量是100万允许误判率是1%那么ln(0.01) -4.605m 1000000 * 4.605 / 0.48045 ≈ 958万bit换算一下约1.14MBk (9580000 / 1000000) * ln2 ≈ 6.6取整约7个哈希函数也就是说100万条数据只需要大约1.1MB内存就能把误判率压到1%以内。这个内存成本非常低所以布隆过滤器适合数据量大、允许一定误判的场景。在实际落地时如果是Java项目可以直接用Redisson的RBloomFilter初始化时指定expectedInsertions和falseProbability如果是自研系统可以考虑用Guava的BloomFilter做进程内过滤器或者用Lua脚本维护Redis的bitmap。布隆过滤器最大的限制是“不支持删除元素”。数据一旦被加入只能通过定期重建来校准。在商品或用户数据频繁变动的场景里我建议做两层结构一层是高频使用的布隆过滤器定期异步重建另一层是数据库持久化重建时从数据库全量加载。2.5 防穿透的组合拳参数校验与限流除了上面两个主流方案还有一些低成本手段建议大家加上。第一接口参数校验比如ID范围判断、格式校验把明显非法的请求在入口就拒绝掉第二对于未登录或低频用户的查询做限流这个可以用网关层或者Redis计数器实现限制单位时间内的查询次数第三监控空结果比例如果发现接口的空返回率突然异常升高大概率是有人在做遍历或攻击需要及时告警。我的经验是这三种手段并不互斥。真实项目里我习惯这样搭配接口层做参数校验缓存前做布隆过滤器缓存层做短TTL空值兜底数据库层用限流保护。四层一起上基本能把穿透的威胁压到很低。3. 缓存雪崩集体失效与双层防线3.1 雪崩的两种典型形态过期雪崩与宕机雪崩雪崩在我遇到的线上案例里基本可以分成两种形态处理思路完全不一样。第一种形态是“过期雪崩”。最常见的原因是业务方在批量初始化缓存时把所有Key的过期时间设置成了同一个值。比如运营后台导入了一批商品程序里统一执行set key value EX 3600那么一个小时后这批Key集体到期。如果这个时间正好是业务高峰期数据库会瞬间收到大量查询请求慢查询数量马上上涨。第二种形态是“宕机雪崩”。Redis集群中的一个节点挂掉或者发生网络分区导致大量原本应该命中缓存的请求全部落到数据库。更麻烦的是数据库被打挂之后重启Redis时又要做缓存预热期间可能引发新一轮雪崩。这个才是最危险的连锁反应。我参与处理过的一个典型故障就是这样某天凌晨数据团队跑批量任务往Redis里写了几万个Key过期时间统一设成了第二天早上8点。到了8点大量Key同时过期线上查询全部穿透到数据库数据库连接池瞬间被占满服务大面积超时最后只能临时做限流并手动给一批核心Key续期。3.2 第一道防线过期时间随机化解决过期雪崩最直接的办法就是让Key的过期时间不要集中在同一个时刻。具体做法是设置基础TTL之外再加一个随机偏移量。比如原来所有Key都设置3600秒过期可以改成3600 Random.nextInt(300)秒让过期时间分散在3600到3900秒之间。又比如业务要求某个Key的过期时间必须是一天那么可以设成86400 Random.nextInt(600)。这个改动的成本几乎为零但效果非常明显——Key不会在同一时间集体失效了。这里有两个细节要注意。第一随机化的作用是为了打散集中的失效请求所以偏移量不能太小建议至少是基础TTL的5%到10%否则集中失效的窗口仍然可能造成压力第二如果是缓存Key和数据库字段有对应关系的场景过期时间随机化可能会让不同Key的失效顺序变得不可控这时候就要结合业务判断是否能接受。大多数读多写少的场景都是没问题的。3.3 第二道防线本地缓存兜底多级缓存如果担心Redis不可用时雪崩的影响面太大可以考虑引入本地缓存也就是大家常说的多级缓存。请求到来时先查本地缓存Caffeine或Guava查不到再查RedisRedis查不到再查数据库回填时同时写两级缓存。以Caffeine为例一个简单的配置是这样的// 本地缓存最大容量10000条写入5分钟后过期 CacheString, Product localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .build();本地缓存有两个明显优势。一是速度比Redis还快因为它在进程内存里没有网络开销二是当Redis故障或者Key集体失效时本地缓存仍然能兜住一部分热度较高的请求给数据库争取恢复时间。但本地缓存也有个容易翻车的点它分布在各服务实例里数据一致性比Redis更难保证。比如商品价格变更后你更新了Redis但每个实例的本地缓存还在生效用户可能看到旧数据。所以本地缓存一般只适用于“允许短暂不一致”的数据比如商品详情、配置信息而不适合账户余额这类强一致数据。另外本地缓存务必设置最大容量和过期策略否则高并发下每个实例都存大量数据JVM内存会被撑爆。3.4 第三道防线限流、降级与熔断无论怎么优化缓存都必须给数据库留一个“保底开关”。因为雪崩场景下流量是突发性的如果数据库本身承受不住唯一的办法就是主动丢弃一部分请求保护核心链路。我这里说的限流和降级指的是在服务调用数据库的路径上做保护。比如用Sentinel或者Hystrix给数据库查询接口设置一个QPS阈值超过阈值后直接返回降级结果降级结果可以是默认值、旧缓存数据或者友情的提示文案。关键是要明确一个哲学宁可牺牲一部分请求也不能让数据库整体挂掉。举个实际参数配置的例子。假设数据库正常能抗5000 QPS我们给查询接口设置Sentinel的QPS规则为3000超过3000就触发降级直接返回熔断结果。这样数据库永远不会超过它的真实承载能力线上故障的影响范围控制在一部分请求超时而不会是全站不可用。降级时还可以顺便记录被拒绝请求的日志用于事后分析流量来源。3.5 缓存服务本身的高可用配置最后稍微提一下Redis自身的高可用。我们在生产环境基本不用单节点Redis最少是主从加Sentinel模式三个Sentinel实例监控主节点状态挂了自动切换。更大规模的关键业务会用Cluster模式至少3主3从数据分片分布单节点故障时其他分片不受影响。持久化方面我建议AOF和RDB都开启AOF设置everysec刷盘。这样即使Redis进程挂掉重启后也能从持久化文件恢复数据而不是冷启动全空。千万别以为Redis只是缓存就不需要持久化——在雪崩恢复阶段持久化文件就是用来快速回暖和防止二次雪崩的。4. 缓存击穿热点Key的瞬间穿透4.1 击穿的典型场景与识别方法缓存击穿最容易出现在“单点热点数据”上比如秒杀活动的商品详情、明星热搜榜单、爆款文章的阅读量等。这类Key的特点是平时访问量就很大一旦到了过期时间缓存Miss的瞬间会有成千上万个请求同时去查数据库。擦亮眼睛识别击穿有个很实用的方法看监控中Redis的缓存命中率曲线。击穿发生时某个Key所在的时间窗口内Redis命中率会出现一个短暂但极深的“V字”下坠而正常波动不会这么剧烈。再看数据库慢查询如果发现同一条SQL语句在极短时间内出现大量并发那基本可以锁定是热点Key击穿了。击穿和穿透不一样数据本身是存在的只是因为过期瞬间的并发太高突破了缓存的保护。所以解决思路集中在“如何让这个瞬间只有一个请求去查数据库”或者“如何让这个瞬间不查数据库也不返回旧数据”。4.2 互斥锁方案正确写法与易错点互斥锁的核心思想是缓存Miss后不直接放行所有请求去查数据库而是先获取一个锁只有拿到锁的线程才能查数据库并回填缓存其他线程等待后用新的缓存值返回。这里有一个非常经典的易错点很多人用Redis的setnx命令来加锁但setnx默认不支持设置过期时间。如果加了锁之后线程在执行过程中崩了锁永远不会释放别的线程就一直阻塞等待。正确写法是用Redis在2.6.12版本之后提供的原子调用// Spring Data Redis 示例 // setIfAbsent 对应 SET key value NX EX Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lock:hot:1001, current-thread-id, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { // 双重检查防止拿到锁之前缓存已经被其他线程回填 Object cache redis.get(hot:1001); if (cache null) { // 查数据库并回填 Object dbValue queryFromDB(); redis.set(hot:1001, dbValue, Duration.ofSeconds(600)); } } finally { // 释放锁时必须用Lua脚本保证原子性先比对线程ID再删除 stringRedisTemplate.delete(lock:hot:1001); } } else { // 没拿到锁短暂等待后重查缓存如果仍然为空则再尝试获取锁 }上面代码里有两个细节值得好好说。第一个是“双重检查”拿到锁之后还要再查一次缓存因为在你等待获取锁的期间可能已经有别的线程回填了缓存。第二个是“锁的过期时间”要设置成比查询数据库的时间略长比如数据库逻辑需要2秒锁至少设置3到5秒避免锁提前过期导致多个线程同时进入。生产级别还要处理锁误删问题删除锁之前要判断持有者是不是自己用Lua脚本保证判断和删除的原子性这里就不展开写脚本了建议参考Redisson的锁实现。互斥锁的缺点是如果热点Key失效瞬间的并发量巨大大量线程会进入等待可能造成请求响应时间变长和服务线程阻塞。所以它更适合对一致性要求高、但对性能容忍度也较高、并且热点Key数量本身不多的场景。4.3 逻辑过期方案不阻塞请求的异步重建如果你觉得互斥锁的“阻塞等待”有点笨重在实际项目里还有另一种思路逻辑过期。方案的核心是“缓存永不过期”但在缓存Value里额外存一个逻辑过期时间字段。查询时发现逻辑过期了并不直接返回失败而是先返回旧数据同时异步触发一个缓存重建任务。代码思路大概是这样// 缓存Value结构 class CacheValue { Object data; long expireTime; // 逻辑过期时间戳 } // 查询逻辑 CacheValue cache (CacheValue) redis.get(hot:1001); if (cache null) { // 缓存不存在说明可能是冷数据或未初始化 // 加锁重建过程类似互斥锁 } else if (cache.getExpireTime() System.currentTimeMillis()) { // 逻辑过期先返回旧数据同时异步重建 asyncRefreshCache(hot:1001); return cache.getData(); }上面是“逻辑过期”最核心的骨架。注意这里Redis的Key本身没有设置物理TTL所以永远不会被Redis主动删除也就不会出现缓存击穿。重建动作放到线程池里异步执行业务请求不阻塞。这个方案最大的优点是性能好、响应快因为请求永远不等待。但代价是数据的“一致性”变弱了在逻辑过期到重建完成之间的时间窗口里用户读到的是旧数据。你可以通过控制逻辑过期时间的长短来平衡比如热点Key可以设置逻辑过期时间为30秒那最多有30秒的旧数据窗口。在实际项目中逻辑过期方案特别适合秒杀详情这类“商品本身变化不大但访问量极高”的场景。4.4 互斥锁 vs 逻辑过期实际选型对照维度互斥锁逻辑过期请求是否阻塞会需要等待其他线程回填不会立即返回旧数据数据一致性高只会有一个线程查库低重建窗口内返回旧数据实现复杂度中需要处理锁过期和误删中需要处理异步重建线程池适用场景一致性要求高的核心数据读多写少、允许短暂脏读的热点数据热点Key数量少而集中多且分散亦可从我个人的项目经验看如果是电商的库存或者价格类数据我会优先考虑互斥锁因为价格出错会引发资损如果是内容详情、活动页面这类数据逻辑过期方案更合适性能更好。也见过有人把两种方案融合热点Key使用逻辑过期但后台另有一个定时任务定期重建缓存进一步减小脏数据窗口这个思路也不错。5. 面试追问与线上排查实战5.1 高频追问的应对思路复习到这一步光会说“布隆过滤器”“互斥锁”还不够。面试官通常还会围绕细节往下挖我把自己常被追问的几个问题整理一下。追问一布隆过滤器误判之后怎么办误判意味着一个不存在的Key被放行了后续会走到数据库查询但数据库查不到后还会经过空值缓存兜底所以误判不会造成崩溃只是会降低一点防护效果。可以把误判率调低但代价是内存占用增大需要根据数据量和可用内存权衡。追问二缓存空对象和布隆过滤器能同时用吗能。布隆过滤器挡掉大部分不存在的Key空值缓存兜住少部分漏网之鱼两者互补。布隆过滤器放在Redis查询之前空值缓存设置在数据库查询之后这样最坏情况下每个不存在的Key也只有一次机会打到数据库。追问三互斥锁导致大量线程阻塞怎么办如果阻塞严重说明并发量远超预期此时可以考虑换用逻辑过期方案或者给锁加超时时间并设置降级逻辑。阻塞本身就是一种保护但要注意线程池不能因此耗尽。追问四如果雪崩和击穿一起发生呢这种情况说明缓存大面积失效且同时存在热点Key。应对上要先把雪崩的随机化TTL和本地缓存做了再针对热点Key单独加互斥锁或逻辑过期两层防护叠加。5.2 线上故障排查的标准套路如果真的遇到线上告警不要慌按固定步骤来。第一步看Redis的命中率曲线确认是持续下降还是瞬间下坠持续下降往往是穿透或缓存整体失效瞬间下坠大概率是击穿。第二步看数据库慢查询和连接数把请求量最大的那几条SQL拎出来确认是不是同一类Key。第三步用redis-cli登录Redis执行一些基础命令进行判断。# 查看过期Key的整体情况关注 expires 的数量 redis-cli info stats | grep keyspace # 找出大Key帮助判断是否有内存异常或批量写入的Key redis-cli --bigkeys # 在线观察实时请求谨慎使用生产环境会有性能开销 redis-cli -n 0 monitor这里特别提醒一个坑monitor命令在线上环境会输出所有命令会造成额外性能开销绝对不要在高并发时段长时间开。通常我只在问题已经明确、流量相对可控的时候用它观察某一段时间的Key访问模式。区分三个问题可以在日志里加一个标记如果查询的Key在数据库里不存在记作穿透如果大量不同Key同时过期记作雪崩如果单个热点Key过期瞬间出现并发尖峰记作击穿。这个标记在复盘时非常有用。5.3 一份可复用的设计决策速查表最后我把自己在实际设计缓存方案时的决策过程整理成一个速查表。每次接到一个新的接口开发任务我都会对着这个表格过一遍防止漏掉某个防护点。场景特征推荐方案组合接口数据量大、存在大量不存在Key参数校验 布隆过滤器 空值短TTL缓存批量初始化缓存、Key易同时过期TTL随机化 多级缓存单个热点Key访问量极高、允许短暂脏读逻辑过期 异步重建核心数据、一致性要求高互斥锁 双重检查数据库性能有限、需要保底Sentinel限流降级 Redis高可用6. 我的实战体会与几点提醒在真实项目里我见过不少“教科书方案”翻车的案例。有人把缓存空对象的TTL设成永久导致Redis内存暴涨有人所有Key都用同一个固定过期时间结果每天定时雪崩还有人用setnx加锁却不设置过期时间线程一崩就死锁。这些事情听起来低级但不踩一次真的记不住。根据我的经验复习这三个问题的关键不在于背诵方案名称而在于把“流量链路”理解透。你只要问自己三句话请求的数据到底存不存在Key失效的范围是一个还是全部失效瞬间的并发量有多大答案对着这三个问题方案自己就出来了。如果你正在准备面试我建议不要只背结论而是把每个方案的代码写法、参数设置、以及缺点都能讲出来。面试官追问到第三层的时候能站住脚的往往是那些细节和坑位。这个内容后续你还可以继续扩展成两个方向一个是把布隆过滤器的误判率、哈希函数数量与Redis内存占用的关系做成一套公式化的配置工具另一个是结合具体的业务框架写一版完整的防击穿starter。好了这篇复习资料就先写到这里希望对你有用。