ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis缓存雪崩、穿透、击穿详解:区别、高并发解决方案

Redis缓存雪崩、穿透、击穿详解:区别、高并发解决方案 面试官问“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。如果让我给一个实用建议那就是不要只背概念和方案名一定要动手把代码写一遍。哪怕只是复现一个最简单的空值缓存和互斥锁逻辑踩一遍坑之后面试被追问细节时你自然答得出来。这三个问题和所有缓存方案一样永远没有银弹关键是用对场景、做好取舍再加上一套能兜底的降级策略。祝准备面试的朋友都能把这块啃下来。
返回列表