面试官问“Redis缓存雪崩、穿透、击穿有什么区别,分别怎么解决”的时候,其实是在考察你有没有真实扛过高并发流量。背概念只能拿三分,能把三个问题放在一条链路里讲清楚、给出动手方案、说出取舍,才算真正过关。这套东西我在项目里反复处理过几次,一次是电商大促秒杀,一次是榜单热点数据,还有一次是被人用脚本恶意刷不存在的ID。每次踩坑之后都会回头看这几个基础概念,因为它们不是孤立的知识点,而是缓存架构里最容易出事的三个地方。
这篇文章就把三者彻底讲透:先拆各自的定义和触发场景,再给完整可落地的解决方案,最后从Java实战角度补充代码、参数和面试追问。适合准备面试的Java工程师,也适合刚接手Redis项目、打算系统梳理缓存治理的开发者。直接照着抄作业问题不大,但更重要的是搞清楚方案背后的取舍逻辑。
1. 三个“缓存杀手”为什么会总被一起拿出来考
1.1 它们共同指向同一个风险:数据库被打穿
缓存雪崩、缓存穿透、缓存击穿,名字听着像三胞胎,其实它们的本质都是“本该由缓存扛住的流量,因为缓存没有正确兜底,最终压到了数据库上”。数据库能扛的并发读是有上限的,一旦缓存保护失效,大量请求瞬间打到数据库连接池上,轻则接口变慢、重则整个服务雪崩。面试官问这类题,说白了就是想知道你有没有能力给缓存系统设置“安全防线”。
从触发时机上看,三个问题有明显差异。缓存穿透是“查了不存在的数据”,每一次都落库;缓存击穿是“一个热点key失效的瞬间”,大规模并发同时打到数据库;缓存雪崩是“大量key同一时刻失效”,也可能是Redis整体宕机,导致流量瞬间倾斜到数据库。后面会逐个展开,但你先记住一个底层逻辑:想要解决这类问题,核心思路永远是两条,一是让“打到数据库的请求变少”,二是让“请求即使打到数据库,也不会瞬间压垮系统”。
1.2 面试官真正想听到的回答结构
很多人在面试时容易犯一个错:一上来就背方案,只讲“用互斥锁”“用布隆过滤器”,却不解释为什么。面试官其实更想听到的是“定义-原因-方案-取舍”的完整链路。这背后其实是考察你遇到线上事故时,能不能有条理地排查和决策,而不只是背课本。
我的习惯是先讲“流量经过缓存和数据库的路径”,再指出问题出在哪一层。比如穿透是“缓存这一层根本没拦住”,击穿是“缓存拦住了一万年,偏偏失效那一秒没拦住”,雪崩是“整层缓存同时罢工”。一旦定位到具体环节,方案就是水到渠成的事。这篇文章也会按这个逻辑走,后半部分我会整理一张对比表,方便你面试前快速过一遍。
2. 缓存雪崩:大面积Key同时失效,如何“拆弹”
2.1 雪崩是怎么发生的
缓存雪崩最常见的场景有两个。第一个是大量key的过期时间在同一时刻到期,比如零点定时任务把一批数据写入缓存,统一设置了3600秒过期,第二天同一时刻这批key就会集体失效。第二个场景是Redis实例本身宕机或发生主从切换,整个缓存层不可用,所有请求全部落到数据库。
第一个场景在业务代码里非常隐蔽。我当时接手过一个排行榜项目,运营每天凌晨批量刷新数据,开发者图省事直接给所有key设置了相同TTL,结果每天晚上八点整数据库就被打满。排查半天才发现是缓存集体失效,而不是数据库本身出问题。第二个场景更致命,一旦Redis宕机,数据库通常也撑不过几分钟,因为你根本没有降级方案。
这里有个关键认知:雪崩问题的核心不是“单个key”,而是“规模效应”。单个key失效最多影响一个业务点,大量key同时失效会直接拖垮整个系统的数据库层。
2.2 解决方案:过期时间加随机值,打散“同时失效”
处理缓存雪崩,第一个方案也是最基础的操作,就是给过期时间加一个随机抖动。比如原本统一设置3600秒,改造后设置为3600 + random.nextInt(600),也就是在3600秒到4200秒之间随机选择过期时间。这样即使一批key在同一时间写入,失效时间也会被自然分散开,不会有一瞬间集体过期的风险。
// 原方案:所有key统一3600秒过期 redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS); // 改造后:加上随机抖动,打散失效时间 int baseExpire = 3600; int randomExpire = random.nextInt(600); redisTemplate.opsForValue().set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);这段代码虽然简单,但能解决掉大多数雪崩场景。实际项目中,我会把随机区间控制在基础过期时间的5%到15%之间。比如过期时间是60秒,随机值就在3到9秒之间;过期时间是3600秒,随机值就在180到540秒之间。抖动太大可能导致某些key过短,增加数据库压力;太小又起不到打散效果。
2.3 多级缓存与熔断降级:给数据库铺第二层保护
只靠随机过期时间还不够,因为一旦Redis整体宕机,随机值再分散也没用。这时候需要“多级缓存”和“熔断限流”配合。
多级缓存的意思是,在Redis之上再加一层本地缓存,比如Caffeine或Guava Cache。这样当Redis不可用时,至少本机内存还能扛住一部分请求。我在项目中会用Caffeine做一级缓存,设置几分钟的过期时间;Redis做二级缓存,保存业务数据。查询时先看本地缓存,再看Redis,最后才落库。本地缓存过期时间要比Redis短,保证数据一致性。
熔断限流降级是针对数据库的保护措施。可以用Sentinel或Hystrix给查询数据库的接口配置线程池隔离和熔断规则,一旦数据库调用失败率超过阈值,直接快速失败或返回默认降级数据。比如查商品详情失败时,可以返回一个降级后的默认商品信息,避免用户看到白屏。这套组合拳打下来,即使Redis真的挂了,数据库也不会被瞬间打穿。
2.4 缓存预热:把流量高峰提前“喂饱”
缓存预热是解决雪崩的另一个思路,属于“提前干预”。核心思路是在流量高峰到来之前,先把热点数据主动加载到缓存里,并且保证过期时间能覆盖整个高峰时段。比如电商大促零点开始,那么提前半小时就用定时任务把活动商品预热到Redis,设置过期时间到活动结束以后。
这里的关键点是预热的并发控制。如果预热逻辑在服务启动时执行,并发量可能直接把数据库压垮。我一般会做一个分批加载的机制,比如每批加载100条数据,间隔200毫秒,或者用Redis自身的批量接口减少网络开销。预热完成后要做一次抽查,确认关键key确实存在,而不是预热代码本身出了问题。
提示:雪崩的排查有一个小技巧。数据库压力突然变大时,先看Redis的
keyspace_hits和keyspace_misses指标,再确认有没有大量key在同一秒过期。可以用redis-cli --bigkeys配合扫描过期key分布,快速定位问题。
3. 缓存穿透:查询“查无此物”,要拦在缓存最前面
3.1 穿透的本质:缓存查了也白查
缓存穿透和雪崩、击穿最大的不同在于:它查询的数据根本不存在。比如一个电商系统,用户疯狂请求商品ID为-1或999999999的详情,这个ID在数据库里压根没有。缓存查一次发现没有,于是回源数据库;数据库也没有,返回空。下次再来,还是同样的流程,每次请求都穿透缓存直达数据库。
如果只是偶尔一次,问题不大。但如果有人写脚本并发刷不存在的ID,数据库就会一直收到无效查询。我遇到过最夸张的情况是并发请求量超过每秒两万次,全部打在数据库上,连接池瞬间被打满。这类请求通常是恶意的,也可能是业务代码的bug,比如前端把未删除的旧ID传了过来。
穿透的特点是“每次都是查不到”,所以缓存没法命中。常规缓存策略在这里失效:因为我们不可能把数据库里不存在的所有ID都缓存起来,那样内存会爆掉。
3.2 方案一:参数校验,把明显无效的请求挡在外层
最简单的防御是在接口入口做参数校验。比如商品ID必须是正整数、长度不能超过某个值、UUID必须符合格式规范。一旦参数异常,直接返回参数错误,不进入缓存链路。遇到过有人用-1、0、abc这些值刷接口,参数校验挡住了很大一部分无效流量。
参数校验在Java里可以用@NotNull、@Min、@Pattern这些注解配合Bean Validation实现,也可以在拦截器里统一处理。但要注意,仅靠参数校验是不够的,因为有些非法请求的ID格式完全正常,只不过数据库里确实没有这条记录。所以参数校验只是第一层,还需要后面的空值缓存和布隆过滤器。
3.3 方案二:缓存空值,让“空结果”也有兜底
缓存空值的思路非常直接:如果查询数据库发现数据不存在,也把这个“空结果”缓存起来,只是过期时间设得短一些,避免下次同样的请求再次穿透。这样虽然第一次还是要落库,但后续相同请求会直接命中缓存空值,数据库压力就降下来了。
public Object getProduct(Integer id) { String key = "product:" + id; Object value = redisTemplate.opsForValue().get(key); if (value != null) { // 这里要区分“缓存空值”和“真实业务值” return value; } Object dbValue = queryDb(id); if (dbValue == null) { // 缓存空值,TTL设置短一些,比如300秒 redisTemplate.opsForValue().set(key, "", 300, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, dbValue, 3600, TimeUnit.SECONDS); return dbValue; }这里有一个容易被忽略的坑:如何区分“缓存空值”和“正常业务值”。上面示例中我用空字符串表示空值,但有些业务数据本身可能就是一个空字符串。更稳妥的做法是用一个包装对象,或者在序列化时加上类型标记。我自己习惯的做法是统一用JSON保存,空值时存{"empty":true},正常情况下存具体业务JSON,取出来时先判断empty字段。
空值缓存的TTL设置也很关键。设置太长会导致数据已经新增了,缓存里还是空值,用户查不到;设置太短又起不到拦截作用。我的经验是设置为5到10分钟比较合适。另外还需要考虑内存占用:如果有人恶意刷随机ID,空值缓存会不断增加。所以最好加一层本地限流,或者对相同前缀的空值缓存做数量上限控制。
3.4 方案三:布隆过滤器,从“源头”判断数据是否存在
布隆过滤器是个更高级的解决方案。它的原理是预先将数据库里所有存在的ID,通过多个哈希函数映射到一个很长的位数组上。查询时对传入的ID做同样的哈希计算,只要任何一位为0,就说明这个ID一定不存在,直接返回。如果所有位都是1,说明可能不存在,也可能存在(因为有哈希碰撞)。
把布隆过滤器放在Redis缓存之前,就能挡住大部分“查无此物”的请求。这里的关键权衡是:布隆过滤器可能有误判,但误判只会放行不存在的请求到下一层,不会把存在的请求挡住,所以业务上是可接受的。
Java里实现方式有三种。第一种是Guava的BloomFilter,适合单机场景;第二种是Redisson的RBloomFilter,适合分布式场景;第三种是在Redis里用setbit和getbit自己实现位数组。我用过Redisson的实现,初始化时需要指定预期元素数量和误判率,比如“预计一千万个ID,误判率1%”。
布隆过滤器有两个细节要记住。一是初始化时要把全量数据刷进去,刷的过程可以分批执行,避免阻塞线上服务。二是业务数据删除时,布隆过滤器无法删除对应位,所以在频繁删除数据的场景下要权衡是否适用。这个“无法删除”的问题在面试中经常被追问,答不上来就尴尬了。
4. 缓存击穿:热点Key失效的那几秒,靠锁和过期策略扛住
4.1 击穿是怎么发生的,它和雪崩的区别在哪
缓存击穿针对的不是“一批key”,而是“某一个热点key”。这个key平时有极高的并发访问量,比如微博热搜榜、秒杀商品的详情页。正常情况下命中缓存没有压力,但如果这个key恰好到了过期时间,在重新加载它回填缓存之前,所有请求会同时涌向数据库。
击穿和雪崩的区别在于影响范围不同。雪崩是大面积key同时失效,波及整个系统;击穿是单点key失效,但因为这个key是热点,瞬间流量同样能把数据库打挂。打个比方,雪崩是整栋楼的供水系统坏了,击穿是最高楼层最贵的那间房间水管爆了,影响面小但流量极端集中。
击穿的关键在于“失效的时间窗口”。Redis缓存过期不是立刻删除,而是惰性删除配合定期删除,请求打到已过期key时,Redis会触发删除然后返回空。如果热点key过期和大量并发请求碰巧同时发生,后面的请求就全落到数据库。
4.2 方案一:互斥锁,让“第一个请求”去加载数据
互斥锁的思想是:当多个请求同时发现缓存为空时,只允许其中一个线程去查数据库并回填缓存,其他线程则等待或轮询重试。这样能保证同一时刻只有少数请求直连数据库,其余请求都能在缓存回填后命中。
public Object getProductWithLock(Integer id) { String key = "product:" + id; Object value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } // 获取分布式锁 String lockKey = "lock:product:" + id; String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 180, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 拿到锁后再次查询,防止第一个请求还没回填完成,其余请求重复查询 value = redisTemplate.opsForValue().get(key); if (value == null) { value = queryDb(id); redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS); } return value; } finally { // 释放锁,这里需要注意只能释放自己加的锁 releaseLock(lockKey, requestId); } } // 没拿到锁的线程,短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(id); }这套实现有几个坑。第一个坑是“释放锁时的归属问题”,如果锁过期时间太短,第一个线程还没执行完,锁就自动释放了,第二个线程拿到锁开始执行,第一个线程执行完后把第二个线程的锁释放了,导致锁失效。所以锁的过期时间必须大于查询数据库和回填缓存的耗时,我一般建议至少设置5秒以上。更稳妥的做法是用requestId做标识,释放锁时先判断标识是否一致再删除。
实际项目里我不太建议自己写分布式锁,生产环境直接用Redisson的RLock更省心,因为它内置了看门狗自动续期机制。代码层面过滤掉底层细节后,重点就是理解互斥锁的思路:串行化首次查询,减少数据库并发压力。缺点也很明显,如果热点key大量集中,拿不到锁的请求会延迟几十毫秒,体验下降。所以互斥锁通常配合下面的“逻辑过期”方案一起用。
4.3 方案二:逻辑过期,缓存永不过期但内部“假装过期”
逻辑过期是我在秒杀场景里最常用的方案。它的思路是:缓存本身不设置物理过期时间,key一直存在,但value里额外保存一个逻辑过期时间字段。每次读取时先判断逻辑过期时间是否已到,如果没到,直接返回缓存数据;如果已到,异步触发一个线程去更新缓存,同时先返回旧缓存数据。
public Object getProductWithLogicalExpire(Integer id) { String key = "product:" + id; String value = (String) redisTemplate.opsForValue().get(key); if (value == null) { return queryDbAndSetCache(id); } CacheData cacheData = JSON.parseObject(value, CacheData.class); if (cacheData.getExpireTime() > System.currentTimeMillis()) { // 逻辑未过期,直接返回 return cacheData.getData(); } // 逻辑已过期,先返回旧数据,异步更新缓存 asyncRefreshCache(id); return cacheData.getData(); }这个方案的好处是“即使缓存过期了,用户依然能拿到旧数据”,不会出现缓存穿透到数据库的瞬时高峰。因为更新操作是异步的,数据库的并发压力被平摊到后台线程。缺点是数据一致性变弱,用户可能在短时间内看到旧数据。
实际使用时要注意两个点。一是异步更新时要做并发控制,避免多个线程同时更新同一个key。我一般会配合一个简单的Redis锁,保证只有一个后台线程在更新。二是如果有强一致性的业务场景,逻辑过期方案不适用,需要回退到互斥锁。
4.4 方案三:热点key永不过期,靠主动更新兜底
第三个方案更简单:核心热点key直接不设置过期时间,手动在业务低峰期主动更新数据。比如商品详情配置了永不过期,运营后台修改商品时,主动调用接口删除或更新缓存。
这个方案实现最简单,但没有过期时间意味着Redis内存压力变大,内存需要专门评估。我实际项目里的做法是“永不过期 + 定时更新”,比如每半小时跑一个定时任务,重新加载热点数据到缓存。同时给这个key做一个内存上限告警,防止异常膨胀。面试时提到这个方案,可以强调它是“空间换时间”的思路,同时要注意数据更新的可靠性。
5. 一张表分清三个概念,别再搞混
5.1 核心对比表
整理一张对比表,建议你收藏,面试前快速扫一眼就够了。
| 问题类型 | 触发时机 | 数据状态 | 影响范围 | 解决方案核心 |
|---|---|---|---|---|
| 缓存穿透 | 每次请求 | 数据不存在 | 单点无效请求,恶意攻击时可拖垮数据库 | 参数校验、缓存空值、布隆过滤器 |
| 缓存击穿 | 热点key失效瞬间 | 数据存在但缓存刚好过期 | 单点热点key,但并发极高 | 互斥锁、逻辑过期、永不过期+主动更新 |
| 缓存雪崩 | 大量key同时失效或Redis宕机 | 大量数据同时过期 | 系统级大面积故障 | 过期时间加随机值、多级缓存、熔断限流、缓存预热 |
表里最关键的信息是三者的“数据状态”。穿透是“数据不存在”,击穿是“数据存在但缓存刚失效”,雪崩是“大量数据同时失效”。把数据状态弄明白,定义就永远不会混淆。
5.2 我见过的高频误区
很多人容易把穿透和击穿搞混,因为都涉及“缓存没命中”。区别很简单:穿透是“缓存里压根不会有这个数据”,击穿是“缓存里之前有,刚好这会儿没了”。还有一个误区是认为雪崩只是key过期导致的,忽略了Redis宕机这个可能性。面试时可以主动说出来,会显得你考虑得更全面。
另外,“缓存空值”和“布隆过滤器”经常被混在一起讲。它们解决穿透问题的方式完全不同。布隆过滤器是把“可能存在的数据”提前登记;缓存空值是把“不存在的结果”兜底存下来。前者适合数据量非常大的场景,后者适合数据量可控、空值请求不多的场景。能说出这个区别,面试官会对你刮目相看。
6. 面试高频追问与避坑经验实录
6.1 布隆过滤器误判了怎么办
布隆过滤器最怕被追问“误判了”。回答思路是:误判只会出现在“数据不存在但布隆过滤器判断可能存在”的场景。此时请求会继续走到下一层缓存和数据库,数据库查到不存在,返回空。所以误判带来的额外代价是“一次数据库无效查询”,而不是数据错误。
如果业务上对误判零容忍,可以用“布隆过滤器 + 缓存空值”的组合。布隆过滤器挡住大部分无效请求,零星的漏网之鱼再通过空值缓存兜底。我在项目里就是这么设计的,误判率设置到1%以下时,数据库压力几乎可以忽略。
6.2 分布式锁死锁了怎么办
使用互斥锁解决击穿时,面试官会追问“锁过期了但方法还没执行完怎么办”。这是个经典陷阱。你如果说“调大锁超时时间”,面试官会追问“调到多大合适”;你如果说“设置很长”,面试官又会说“那万一线程挂了锁不是永远不释放”。
最优答案是:不能盲目调大锁的超时时间,而是要让锁支持自动续期。Redisson的看门狗机制会默认每10秒检查一次,如果业务线程还在执行,就自动续期到30秒。这样既不会因为线程挂掉导致锁永不释放,也不会因为业务执行时间超过锁的过期时间导致锁提前失效。如果自己用RedisTemplate实现,可以用定时任务续期,但代码复杂度明显上升,生产环境不推荐。
6.3 空值缓存导致的内存膨胀怎么处理
这个问题针对缓存穿透方案。如果恶意请求构造大量不存在的ID,空值缓存会越来越多。我的处理方式有三种。一是对空值缓存的key做数量上限控制,比如最多缓存一万个空值,超出后淘汰最旧的。二是设置更短的TTL,比如3到5分钟。三是结合布隆过滤器,让空值缓存只作为布隆过滤器的补充,而不是主防线。
另外,在Java层可以加一个基于Caffeine的本地缓存,只存空值结果,容量设置小一些,比如只存最近一千条空值结果。本地缓存不受Redis内存限制,天然适合拦截重复空值请求。
6.4 三个问题叠加出现怎么办
面试的终极大招是问你“如果三种问题同时发生,怎么设计一套完整的缓存治理方案”。我一般的回答思路分四步:
第一步,入口层做参数校验和限流,挡掉明显非法请求。第二步,用布隆过滤器+缓存空值解决穿透问题。第三步,Redis过期时间全链路加随机抖动,从源头防雪崩。第四步,热点key单独做逻辑过期和异步更新,防击穿。最后,再给数据库接口统一配置熔断降级,保证极端情况下系统不整体宕机。
这套组合拳在不同项目里落地时会有调整。比如数据量小的系统,布隆过滤器不一定值得引入;强一致性的业务,逻辑过期方案就要慎重。回答时强调“根据业务场景取舍”,比背一套标准答案要高级得多。
7. 最后分享一点实操体会
这三个问题我在不同阶段都有过切身的“疼感”。最早自己写缓存时只图方便,所有key同一个过期时间,结果线上定时任务跑完半小时后数据库报警,那次让我彻底记住了随机过期时间的重要性。后来被人刷不存在的商品ID,才静下心把布隆过滤器用起来,而不是靠“感觉”写逻辑。再往后做秒杀,才真正体会到热点key失效的威力,开始研究互斥锁和逻辑过期之间的trade-off。
如果让我给一个实用建议,那就是:不要只背概念和方案名,一定要动手把代码写一遍。哪怕只是复现一个最简单的空值缓存和互斥锁逻辑,踩一遍坑之后,面试被追问细节时你自然答得出来。这三个问题和所有缓存方案一样,永远没有银弹,关键是用对场景、做好取舍,再加上一套能兜底的降级策略。祝准备面试的朋友都能把这块啃下来。