☰
缓存穿透、击穿、雪崩:原理图解、排查路径与解决方案全梳理
2026/9/29 5:54:33 网站建设 项目流程

缓存雪崩、击穿、穿透这三个词,后端干了几年的人基本都听过,但你要真问他仨到底啥区别、线上遇到了怎么快速定位、用哪种方案兜底,很多人就含糊了。更麻烦的是,这三兄弟经常被混在一起讲,网上文章也写得七零八落,有的把击穿当穿透,有的把雪崩说得跟穿透一个样。我这篇直接给你掰开揉碎,从原理、触发条件、排查路径到解决方案全过一遍,文中涉及的代码和配置都是我实际验证过能用的,保证你看完能直接用在项目里。

先说个大概结论,方便你脑子里先有个框架:穿透是查了一个根本不存在的数据,击穿是某个热点key在过期瞬间扛不住并发,雪崩是大量key同时失效或者Redis直接挂了。三者都会把压力打到数据库上,但成因、表现、解法完全不同,不能一概而论。下面一个一个拆。

1. 先搞懂为什么这三个问题总被放在一起说

很多刚接触缓存的朋友会把雪崩、击穿、穿透当成同一种问题的不同叫法,这是因为它们最终的症状都差不多——数据库被打爆、接口超时、服务雪崩。但实际上,这三者的故障链路、排查关键点和解决方案差异很大,搞混了会在定位问题时走弯路。

1.1 从缓存的三层架构看故障源头

为了说清楚这三个问题,先建立一个统一的视图:一个典型的缓存架构是请求先进Redis,Redis没有再去查MySQL,查到了回写缓存并返回,查不到就返回空。这套链路里可能出问题的就三个位置:

  • 请求到了Redis,但Redis里没有这个key——对应穿透和击穿。
  • Redis本身大面积key失效或不可用——对应雪崩。
  • 请求穿透Redis打到MySQL,MySQL扛不住——这是三者的共同恶果。

所以你可以把三兄弟理解成:源头不同、路径各异、结局相似。穿透的根源在"查询了一个不存在的数据",击穿的根源在"热点key的过期时间点",雪崩的根源在"失效范围太大或服务不可用"。

1.2 用生活场景给三兄弟画个像

我一般给团队新人打比方都用一个场景:一家奶茶店,爆款是"生椰拿铁"。

  • 穿透:有个人天天来问"有没有榴莲味生椰拿铁",店里菜单上压根没这个东西。店员查了一次发现没有,但这个人每天都来问,每次都白查一遍,搞得上游供应商也被反复问。
  • 击穿:生椰拿铁卖得特别火,但后厨规定每锅原料2小时换一次。换原料的那1秒钟,所有顾客同时来点single,后厨手忙脚乱,连锁反应就是其他订单也延误了。
  • 雪崩:店里搞活动,全场所有饮品的原料统一在下午3点到期换新。一瞬间所有品类都做不了,顾客全挤到前台,整个店瘫痪。

这个类比基本能把三者的差异点讲明白:穿透是"数据不存在还反复查",击穿是"一个热点数据的单点故障窗口",雪崩是"大面积失效导致的整体性故障"。

2. 缓存穿透:拿一个不存在的数据反复打库

缓存穿透是最容易理解的一个,也是最容易被低估的一个。很多人觉得"查不到就返回空呗",但在高并发场景下,攻击者完全可以用一批不存在的ID把你数据库打到挂。

2.1 穿透请求的完整路径和危害分析

我见过一个真实案例:某电商平台的商品详情接口,Redis的key设计是product:{id},正常情况下商品数据会缓存。有一天业务方反馈数据库CPU飙升,查看慢查询发现大量SQL是SELECT * FROM product WHERE id=xxx,而且这些id在数据库里根本不存在。

这就是典型的穿透攻击。正常用户不会去查一个不存在的商品,但脚本可以随机生成ID去遍历,如果订单号是自增的,攻击者只需要从1开始往上扫,所有不存在的ID都会直接打穿Redis到MySQL。更麻烦的是,因为DB查不到数据,Redis里也不会写缓存,导致每次都穿透。

危害不仅是数据库压力大,还包括:

  • 数据库连接池被无效查询占满,影响正常业务请求。
  • 慢查询日志被刷屏,掩盖了真正需要关注的性能问题。
  • 如果攻击者伪造的ID落在分库分表的某个特定分片上,那个分片会先被打挂,形成"单点热点"。

2.2 布隆过滤器为什么适合拦截穿透请求

解决穿透最有效的方案是布隆过滤器(Bloom Filter),它能在数据不存在时快速返回"肯定不存在",避免查询落到DB。原理不复杂:初始化一个bit数组,写入数据时用多个哈希函数把key映射到数组的多个位置,把对应位设为1。查询时同样计算多个哈希位置,只要有一个位置是0,就说明这个key肯定不存在;如果全是1,只能说可能存在。

因为布隆过滤器只保存"存在性指纹",不保存原始数据,所以它占用的内存极小。比如1亿个key,误判率控制在1%左右,大约只需要几百MB内存。实际使用中可以放本地内存,也可以放Redis(Redisson提供了RBloomFilter的实现)。

要注意布隆过滤器有一个天然缺陷:不支持删除。因为多个key可能映射到同一个bit位,删除某个key时不能把对应位置为0,否则会影响其他key的判断。如果业务中经常有"删除商品"的场景,可以用它的变种Counting Bloom Filter,或者定期重建过滤器。

2.3 空值缓存:一个简单但能快速落地的替代方案

布隆过滤器虽然好,但要维护一份全量key的指纹,很多团队觉得成本高。更简单粗暴的方案是空值缓存:DB查不到数据时,在Redis里写一个空值(null或者特殊标记),并设置一个较短的过期时间,比如2-5分钟。

核心逻辑如下:

public Product getProductById(Long id) { String cacheKey = "product:" + id; Object cacheValue = redis.get(cacheKey); // 缓存为空值标记时直接返回null,不再查DB if (NULL_MARK.equals(cacheValue)) { return null; } if (cacheValue != null) { return (Product) cacheValue; } Product product = productMapper.queryById(id); if (product == null) { // 写空值缓存,过期时间设短 redis.set(cacheKey, NULL_MARK, 300, TimeUnit.SECONDS); return null; } redis.set(cacheKey, product, 3600, TimeUnit.SECONDS); return product; }

这段代码有几点要注意:

  • 空值标记和正常值的过期时间要区分开,空值缓存设短一点,否则数据恢复后用户要等很久才能看到最新数据。
  • 判断空值时必须用NULL_MARK常量对比,不能直接判断字符串"null",防止业务数据里恰好出现同样字符串。
  • 如果空值缓存也没拦住,可以在网关层加参数校验和黑名单机制,把明显异常的ID(比如负数、超长ID、非法字符)直接拦截。

我在实际项目中的习惯是:布隆过滤器做第一层拦截,空值缓存做第二层兜底。布隆过滤器拦截掉大量不存在的key,空值缓存拦住那些"以前存在后来被删了"的数据,两层配合效果比较稳。

3. 缓存击穿:热点key过期的那一瞬间

缓存击穿是三者里最难缠的,因为它的发生窗口非常短,但破坏力极强。很多团队直到线上出现"每整点数据库必崩一次"的诡异现象,才意识到是击穿。

3.1 击穿的本质:单点过期导致的并发冲击

击穿的触发条件很苛刻:

  1. 这个key必须是热点key,缓存中确实有数据,平时大量请求都会命中的那个。
  2. 这个key恰好到了过期时间。
  3. 过期后的极短时间窗口内,大量请求同时来查询,发现缓存没有,全部打到DB。

这三个条件缺一不可。所以它不是常态性问题,而是"时间点+热点"叠加导致的瞬时冲击。

经典场景是秒杀。比如某商品0点开始抢购,库存数据在Redis里的key是stock:1001,过期时间设置的24小时也就是第三天的0点,但业务上线时可能是前一天10点写入的,那第二天0点恰好过期。0点又是抢购高峰,瞬间10万请求同时涌到数据库查库存,数据库直接打垮。

这里有个很多人忽略的细节:击穿不一定要"热点key过期"这一个原因。如果Redis因内存淘汰策略(比如volatile-lru)把热点key淘汰了,效果也是击穿——key在缓存中不存在了,热点请求瞬间打到DB。所以排查问题时要看Redis日志里的淘汰记录,别只盯着过期时间。

3.2 互斥锁方案:保证只有一个请求去查DB

击穿最经典的解决方案是互斥锁。核心思想是:当缓存失效时,只允许一个线程去DB查询并回写缓存,其他线程等待缓存重建完成后直接读取缓存。

我用Redis的SETNX来实现:

public Product getProductById(Long id) throws InterruptedException { String cacheKey = "product:" + id; Object cacheValue = redis.get(cacheKey); if (cacheValue != null) { return (Product) cacheValue; } String lockKey = "lock:product:" + id; String requestId = UUID.randomUUID().toString(); // 尝试获取锁,设置5秒过期防止死锁 boolean locked = redis.setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (locked) { try { // 双重检查:可能其他线程已经重建了缓存 cacheValue = redis.get(cacheKey); if (cacheValue != null) { return (Product) cacheValue; } Product product = productMapper.queryById(id); redis.set(cacheKey, product, 3600, TimeUnit.SECONDS); return product; } finally { // 释放锁时校验requestId,防止误删其他线程的锁 if (requestId.equals(redis.get(lockKey))) { redis.delete(lockKey); } } } // 未获取锁的线程:走降级策略或等待重试 Thread.sleep(50); return getProductById(id); // 递归重试,也可以循环重试 }

这个方案的关键细节:

  • setIfAbsent必须带过期时间,否则获取锁的线程挂了会死锁,其他线程永远等不到。
  • 释放锁前要校验requestId,防止A线程的锁还没释放就过期了,B线程加了新锁,A又把自己的旧锁释放掉,直接导致锁失效。
  • 拿不到锁的线程不能无限等待。建议设置最大重试次数或者等待超时后直接返回空,避免线程堆积。

3.3 逻辑过期方案:热点key常年不过期

互斥锁有一个问题:缓存失效的那段时间里,所有请求都在等锁,用户体验会变差。可以用逻辑过期方案优化。

逻辑过期的思路是:value里不仅存业务数据,还额外存一个过期时间。这个时间不是Redis的TTL,只是业务逻辑里的一个标记。当业务读取数据时,判断逻辑过期时间是否已到,如果到期了就返回旧数据,同时异步去DB拉取新数据回写缓存。

public Product getProductById(Long id) { String cacheKey = "product:" + id; CacheObject cacheObj = redis.get(cacheKey); // 缓存不存在,直接查DB(这个概率已经很低) if (cacheObj == null) { return queryAndCache(id); } // 逻辑时间未过期,直接返回 if (cacheObj.getExpireTime() > System.currentTimeMillis()) { return cacheObj.getProduct(); } // 逻辑时间已过期:先返回旧数据,再异步重建 CompletableFuture.runAsync(() -> queryAndCache(id)); return cacheObj.getProduct(); }

逻辑过期的优势很明显:查询线程永远不会被阻塞,DB的压力靠"一个key只有一个异步重建任务"来控制。但实现上要注意:

  • 异步重建需要有并发控制,否则同一时刻多个线程都发现逻辑过期,都去DB查,还是会打垮DB。可以用Redisson的分布式锁,或者用AtomicBoolean在单机内控制。
  • 旧数据返回的时间窗口可能较长,如果业务要求数据强一致,不能接受短暂返回旧值,逻辑过期方案就不适合。
  • 预热场景下,逻辑过期可以让热点key永不过期,数据更新靠业务主动刷新缓存,而不是等自然过期。

我在秒杀场景中用的是互斥锁+逻辑过期组合方案:库存核心数据用逻辑过期,异步重建;普通商品详情用互斥锁方案。这样既保证了库存查询不等待,又不至于所有查询线程都阻塞。

4. 缓存雪崩:大面积key同时失效

缓存雪崩是三兄弟里"场面"最大的,一旦发生往往是群体性故障,接口大面积超时,数据库连接瞬间打满,甚至整个服务集群雪崩。它的成因比击穿复杂,解决方案也不是一个代码片段能搞定的。

4.1 雪崩的两条触发路径

雪崩通常有两条路径:

路径一:大量key同时过期。很多项目初期图省事,写入缓存时所有key的过期时间都用同一个常量,比如固定3600秒。那每秒种写入的key会在同一时间点过期,如果这个key恰好在高峰期集中写入(比如整点任务批量把数据加载进缓存),那下一个整点就会批量失效。

路径二:Redis实例不可用。可能是Redis宕机,也可能是网络分区导致应用连不上Redis。这时候所有请求首先发现缓存不可用,然后一股脑打到数据库。这种情况比大量key同时过期更严重,因为可用的备份手段会更有限。

判断线上是哪种雪崩,有个小技巧:查看Redis的过期keys监控曲线,如果某个时间点expired_keys突然涨到峰值,就是路径一;如果Redis的QPS直接掉到0,同时应用侧连接数暴涨,就是路径二。

4.2 过期时间加随机值:最简单也最容易被忽略的一步

防止大量key同时过期,教科书级的做法就是给过期时间加随机偏移:

long baseExpireTime = 3600L; long randomExpireTime = baseExpireTime + ThreadLocalRandom.current().nextLong(600L); redis.set(cacheKey, product, randomExpireTime, TimeUnit.SECONDS);

加随机值的范围不用太大,10%-20%的浮动就能破坏"同一时间点失效"的规律性。我见过有的团队直接把随机值加到原时长的50%,结果缓存的有效期严重不一致,冷热数据分布反而乱了,没必要。

但要注意,加了随机值之后,缓存失效时间变成了一个范围,预热和定时任务的执行时间也要相应调整。比如你原来定时任务每小时重建一次缓存,加随机值后有些key可能是1小时20分钟才过期,定时任务重建时间要覆盖最大过期时间。

4.3 多级缓存与降级限流:应对Redis不可用

大量key同时过期靠加随机值能解决,但Redis宕机怎么办?如果用Redis集群模式,单节点挂了会自动切换,但在切换的几秒内应用侧还是可能连不上。

这里我推荐两个组合拳:

  • 多级缓存:应用本地放一层Caffeine(或者Guava Cache),本地缓存未命中再查Redis。即使Redis不可用,只要本地缓存还有数据,大部分请求能被拦截在应用进程内,不会全部打到DB。常见的层级是"本地缓存 -> Redis -> DB",本地缓存过期时间短一点(几十秒),Redis的过期时间长一点。
  • 降级限流:当检测到Redis不可用时,对非核心业务的查询直接返回默认值或空结果,而不是等待DB查询超时。比如商品列表页打不开可以返回"系统繁忙",但库存扣减这种核心链路必须保证。限流可以用Sentinel或Hystrix,QPS阈值根据系统压测结果来定,别拍脑袋。

另外,如果你的Redis是持久化模式,宕机重启后要小心雪上加霜:Redis里大量key的TTL是按"原过期时间"计算的,如果宕机了1小时,重启后内存里有大量已经过期的数据,但不主动清理。当这些key第一次被请求时,发现过期源码删除再查DB,还是相当于穿透雪崩。所以在Redis重启后最好主动做一次缓存预热,或者执行SCAN命令清理已过期的key。

5. 一次线上Cache故障的排查链路复盘

讲完理论,来一次完整的排查复盘。这是我前年处理过的一个真实案例,现象是"每天下午14:00整数据库CPU必有一次尖峰",排查过程花了半天,绕了些弯路,但最终定位思路很典型,值得记录。

5.1 第一波报警:DB连接数飙升

当时监控平台发出告警,数据库的连接数和活跃会话数在13:59开始明显上升,14:00达到顶峰,持续了大约90秒后回落到正常。持续了三天,每天都是差不多的时间点。

第一反应当然是怀疑定时任务。查看了所有分布式定时任务,发现14:00确实有个报表任务,但这个任务跑的库是独立的分析库,和主库不是同一个实例。排除。

继续看慢查询日志,发现有大量SELECT * FROM order_info WHERE user_id=xxx,耗时在1秒到3秒之间。奇怪的是,order_info表是有索引的,user_id查询不应该这么慢。

5.2 定位根因:Redis里的key集中过期了

慢查询日志里SQL的条件全是user_id,看起来像是在查用户维度的订单数据。跟着代码链路查下去,发现了问题所在:订单列表接口的逻辑是先查Redis的order:list:{userId},缓存没有就去查MySQL。而这个key的过期时间设置在项目代码中的一个常量:

private static final long ORDER_LIST_CACHE_TTL = 3600L; // 固定1小时

业务上线初期用户量不大,缓存写入时间分散,这个常量没出问题。后来为了配合运营活动,有一个定时任务在每天13:00批量给近7天活跃的用户的订单列表缓存做刷新,统一重写了这批key,过期时间都从13:00开始算起。结果这些key在14:00集体过期,14:00整之后海量用户请求同时发现缓存失效,一起打到MySQL。

关键证据:Redis的INFO stats里expired_keys字段在14:00那一分钟增量是平常同时段的几百倍,监控图表呈现出一个陡峭的尖峰。

5.3 临时降级、加固与最终修复

定位之后没有急着改代码,先做了三件事止血:

  1. 临时把过期时间常量从3600秒改成3600加随机值,确保后续key不会同时失效。
  2. 手动预热热点用户的缓存,跳过自然过期时间。
  3. 在DB连接池上设置了更保守的最大连接数,防止下次尖峰直接打满。

改完代码后观察了两天,14:00的DB尖峰消失了。但为了避免类似问题换个时间点再冒出来,我还补了两个监控:

  • 对Redis设置expired_keys的分钟级监控告警,阈值设为"正常基线的10倍",防止集中过期再次发生。
  • 对DB慢查询的"缓存穿透特征"做检测——如果某个查询模式的慢SQL数量从个位数突然涨到几千,直接告警到值班群。

这个案例是一个比较纯粹的"缓存雪崩",但排查过程中很容易误判成缓存穿透(因为现象都是大量SQL打到DB),所以区分雪崩和穿透时,关键要看Redis那侧的指标,而不是只看DB表现。

6. 容易被忽略的边界细节与组合坑

三兄弟讲完了,但实战中还有几个容易踩的细节,单独说一下,这些是常规文章很少讲到的。

6.1 三个问题会出现组合攻击

穿透、击穿、雪崩不是互斥的,可能同时发生。比如攻击者先用一批不存在的ID做穿透攻击,把DB拖慢,此时热点key恰好过期,又叠加击穿,DB直接崩。

处理这种情况时,不要只盯着一个方案,要形成一个组合兜底链:参数校验拦截无效请求,布隆过滤器拦截不存在的key,空值缓存兜底已删除数据,互斥锁/逻辑过期防热点击穿,过期时间随机化+多级缓存防大面积雪崩,最后DB层限流兜底。每一层都有自己的职责,不要指望一个方案解决所有问题。

我见过有的团队只加了布隆过滤器就觉得万事大吉,结果热点key击穿时照样出事故。缓存层防护必须是叠加的,缺一环都可能在特定场景下漏风。

6.2 TTL设置为0或负数的奇怪问题

有的开发为了调试方便,会把缓存写入时的TTL设成0或者-1,以为"不设过期时间"。但不同Redis客户端对0和-1的处理不一样:

  • Redis本身SET key value EX 0会立即删除key。
  • 一些客户端库把0当作"不设置过期时间",-1也当作"永不过期",行为不一致。

代码评审时我经常发现这种写法,导致同一个key在测试环境是永不过期,生产环境却一写就失效。统一规范:需要永不过期就明确用SET key value,需要设置过期时间就明确用正数秒数,不要用0或负数表示特殊含义。

6.3 布隆过滤器误判会导致真实数据被拦截

布隆过滤器偶尔会误判,把"可能存在"误判为"肯定不存在"吗?不会,它只会把"不存在"误判为"可能存在",绝不会把"存在"误判为"不存在"。所以布隆过滤器拦截的请求一定是不存在的key,不会误伤真实数据。

但如果布隆过滤器是从数据库全量数据构建的,而数据库里新插入了一条数据,还没来得及同步到布隆过滤器,此时查询这个新数据,布隆过滤器会判断为"不存在"并直接返回空,用户就会看到"数据不存在"。这是布隆过滤器方案最常见的坑。

解决办法:

  • 写入数据库后立即更新布隆过滤器,保证数据同步的时效性。
  • 布隆过滤器同步失败时,要有降级逻辑——如果布隆过滤器本身不可用,直接放行所有请求到缓存层和DB层,宁可穿透也不误拦截。
  • 如果业务上要求极高的数据一致性,布隆过滤器可能不适合,考虑用空值缓存方案。

6.4 分布式锁误删与锁续期问题

互斥锁方案里,最常见的故障是"锁过期但业务还没执行完"。线程A拿到锁后,因为GC停顿或者DB慢查询,业务执行超过了锁的过期时间(比如5秒),锁自动释放了。此时线程B拿到锁也开始查DB,两个线程同时查DB,互斥锁失效。

解决的思路有两个:

  • 锁过期时间设置得比业务预估最大耗时长,比如业务平均耗时100ms,锁过期时间设为5秒甚至10秒。缺点是如果业务确实异常卡住,死锁时间也会变长。
  • 使用看门狗机制,定时给锁续期。Redisson的分布式锁默认有这个机制:锁过期时间是30秒,每隔10秒检查一次,如果业务还在执行就自动续期。但这个机制依赖库的实现,手写SETNX锁没有这能力,需要自己用定时任务实现。

我在高并发核心链路上只用Redisson的锁,手写SETNX只用于非核心业务。别为了省一个依赖,最后在锁过期这个问题上栽跟头。

6.5 预热脚本和缓存的"假过期"

最后说一个冷门细节:Redis主从切换或者持久化恢复后,有些应用会发现响应一下子变慢,但看Redis里的key还都在。这是因为Redis在恢复过程中可能没有加载完所有数据,部分客户端请求走到了DB。这个现象的排查思路不是跟着key走,而是跟着Redis的加载进度走——通过INFO persistence的rdb_bgsave_in_progress和loading状态判断。

如果确认是这种情况,就应该在Redis恢复后、对外开放流量前,主动执行一次热点key预热脚本,把压测标的Top 10000个key重新读取一遍,强制走DB并回写缓存。这样既能避免缓存假过期造成的穿透,也能让Redis加载完数据后再对外服务。

我所见过的线上故障里,很大一部分不是方案不好,而是"方案没搭配好"或者"边界条件没覆盖到"。穿透、击穿、雪崩的解决方案单独拿出来都成熟,但真正考验功力的是把它们组合成一套完整的防御体系,并且监控住各个层的指标。希望这篇能帮你把三兄弟彻底分清,下次线上再蹦出来这类告警,你能在20分钟内就找到根因。

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

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

立即咨询