Redis缓存穿透、击穿与雪崩:成因、排查与实战解决方案
1. 项目概述——三个缓存问题的前世今生做后端开发的尤其是跟高并发系统打过交道的对“缓存穿透、缓存击穿、缓存雪崩”这三个词肯定不会陌生。这几乎是Redis缓存场景里最经典的三座大山也是面试官最爱追问的连环炮先让你解释区别再问怎么解决最后还要追问方案的优缺点。我最早在电商大促项目里第一次被线上告警折腾到凌晨三点就是因为缓存穿透把数据库连接池打满了从那以后才真正把这三个问题刻进脑子里。简单说这三个问题都围绕同一个核心矛盾缓存是为了挡在数据库前面扛流量但某些极端情况下流量会绕过缓存直接打到数据库数据库一挂整个服务链就雪崩。它们的区别在于“绕过”的原因各不相同解决思路也就完全不同。这篇内容不光讲理论我会结合自己的实战经历把每个问题的成因、排查手段、解决方案以及各种方案背后的取舍都说清楚。无论你是刚接触Redis的新手还是已经被线上问题虐过几轮的老人都应该能从里面找到点有价值的东西。2. 三者本质区分与核心场景拆解很多人一开始容易把这三个问题搞混其实它们的触发场景差异很明显。先给出一张我平时培训新人用的对比表再逐个拆开讲。对比维度缓存穿透缓存击穿缓存雪崩查的数据缓存和DB都不存在缓存不存在但DB存在缓存大批量失效DB存在流量特征持续恶意或异常请求单点热点key瞬时并发大量key同时过期或Redis宕机影响范围单个不存在key拖垮DB单个热点key拖垮DB大面积key失效拖垮DB核心原因请求绕过缓存key过期瞬间key集中过期 / 故障2.1 缓存穿透——查一个根本不存在的东西缓存穿透的本质是请求的数据在缓存里没有在数据库里也没有导致每次请求都直接穿透到数据库。举个最典型的例子一个商品详情接口用户疯狂查询一个不存在的商品ID比如负数ID、超长随机字符串缓存miss后去查DBDB也查不到于是不会回写缓存。下一次同样的请求来了还是cache miss继续查DB。这种请求只要量大瞬间就能把DB的连接池打穿。我踩过的真实案例某个对外开放的查询接口上线第二天就被别人用脚本刷了几十万个随机ID全部查不到数据数据库CPU直接飙到100%。当时排查的时候看了Redis监控命中率从98%骤降到60%以下才意识到问题出在穿透上。解决穿透的核心思路是让查不到的数据也能被“记住”或者在请求到达DB之前就拦截掉。常见的做法有几个后面我会详细拆解。2.2 缓存击穿——热点key失效的一瞬间缓存击穿和穿透最大的区别是击穿请求的数据在数据库里是真实存在的只是缓存刚好在这一刻过期了。场景是这样某个爆款商品的详情页平时缓存在Redis里QPS可能有几万。突然key到了过期时间Redis删掉了它这时候一瞬间涌来的几万个请求全部miss同时杀向数据库。数据库根本扛不住这种瞬时流量直接打满。等有人把数据重新写回缓存流量才恢复。这个问题的关键点在于“单点热点”和“瞬间并发”跟穿透的“永远查不到”完全两码事。处理思路也截然不同核心是保证在缓存失效的瞬间只有极少数的请求能打到数据库其他请求要么等着要么拿旧数据。2.3 缓存雪崩——一大片key同时倒下雪崩是三个问题里影响面最大的一个指的是大量key在同一时间窗口内集中过期或者Redis实例直接宕机导致一大片请求全部落到数据库。为什么会出现集中过期很多开发习惯把缓存过期时间设成一个固定值比如统一设置60秒。如果某个时间点有一大批数据一起写入缓存那它们就会在同一秒一起过期。下一个瞬间所有请求全部穿透到数据库数据库直接被压垮。更可怕的是数据库挂了之后的连锁反应服务超时-线程池阻塞-请求堆积-服务雪崩整个链路全挂。雪崩的解决思路和击穿有类似之处但更强调“分散”和“兜底”分散过期时间、增加多级缓存、做熔断降级后面逐一展开。3. 缓存穿透的完整解法与细节拆解穿透这个问题我建议按“前置拦截 - 缓存兜底 - 局部增强”三层来设计每一层解决一种情况。3.1 第一层接口层参数校验最基础的防线也是最容易被忽略的。很多穿透事故都是因为接口没有做参数校验导致恶意请求可以随便构造不存在的ID。参数校验要做的几件事校验ID格式负数、0、超长字符串、非数字字符直接拒绝校验ID范围超出合理区间比如订单号长度不对、自增ID超过当前最大值直接拒绝鉴权与限流未登录用户、非白名单IP在网关层就拦截校验这块我习惯直接在Controller入口做或者更前置放在网关的Filter里不要等到Service层才校验——越早拦截消耗越小。3.2 第二层缓存空值兜底参数校验挡不住所有情况比如一个“合法格式但确实不存在”的数据ID。这时候最经典的方案是查不到DB数据时也往缓存里写一个空值并设置一个较短的过期时间。伪代码示例public Product getProduct(String productId) { // 1. 先查缓存 Object cached redis.get(product: productId); // 2. 缓存命中包括空值标记 if (cached ! null) { // 如果缓存的是空值标记直接返回null if (EMPTY_MARK.equals(cached)) { return null; } return (Product) cached; } // 3. 缓存miss查数据库 Product product productMapper.selectById(productId); if (product null) { // 4. 数据不存在缓存空值过期时间设置短一些比如30秒 redis.set(product: productId, EMPTY_MARK, 30); return null; } // 5. 数据存在回写缓存正常过期时间 redis.set(product: productId, product, 600); return product; }这里有几个细节需要注意空值过期时间要短。我一般设置在30到60秒之间。太短起不到拦截效果太长会导致“数据已经入库了但缓存还是空值”的窗口过大用户永远看不到新数据。要区分“空值标记”和真实数据。很多新手直接用null往Redis里塞结果发现取出来的时候根本没法区分到底是“缓存了null”还是“key不存在”。我用的是一个特定的字符串常量比如EMPTY_MARK __EMPTY__取出来先判断是不是这个值。空值也要设置过期时间不能永不过期。否则大量不存在的ID会把Redis内存打满还是一个隐患。3.3 第三层布隆过滤器前置拦截如果穿透请求的量非常大比如每秒几万个恶意请求光靠缓存空值还是会让很多请求打到Redis虽然不会打到DB而且会写入大量无意义的空值key白白浪费内存。这时候就需要布隆过滤器出马了。布隆过滤器是一个很巧妙的数据结构它用多个哈希函数把元素映射到一个位数组里用来判断“某个元素一定不存在”或者“可能存在”。它能做到O(1)级别的查询内存占用极小唯一缺点是判断“可能存在”时有误判率但判断“一定不存在”是100%准确的。所以它的适用场景是在请求到达缓存之前先用布隆过滤器拦截掉那些肯定不存在的KEY。具体做法系统启动时把DB中所有存在的ID加载到布隆过滤器里每个请求来了先问布隆过滤器“这个ID存在吗”如果回答“不存在”直接返回连Redis都不查如果回答“可能存在”继续走缓存 - DB的正常链路布隆过滤器的一个关键参数是误判率。我一般设置expectedInsertions为预估的ID总量fppfalse positive probability设为0.01到0.03。误判率越低位数组越大内存消耗越高需要根据业务量权衡。我用的是Guava的BloomFilter在单机场景下完全够用如果是分布式场景可以用Redis的bitmap自己实现一个或者引入RedisBloom插件。3.4 穿透方案选型心得这三个方案不是互斥的实际生产中我通常组合使用参数校验是必做的成本最低收益最大缓存空值是最通用的兜底手段几乎不需要额外组件布隆过滤器在缓存key基数大、无效请求多的场景下才需要引入不要一上来就上如果问我的建议中小型项目先做参数校验缓存空值等监控数据证明穿透问题依然严重再上布隆过滤器。一上来就搞一套分布式BloomFilter属于过度设计。4. 缓存击穿的解决套路——互斥锁与逻辑过期击穿的核心矛盾是key失效瞬间大量并发同时回源。解决办法就是让并发“串行化”或者干脆让key“永不过期”。4.1 方案一互斥锁Mutex Lock思路很直接当缓存miss后不是让所有请求都去查数据库而是先尝试获取一把分布式锁只有拿到锁的请求才能去查DB并回写缓存其他请求没拿到锁就休眠一小段时间后重试从缓存里拿数据。public Product getProductWithLock(String productId) { Object cached redis.get(product: productId); if (cached ! null) { return (Product) cached; } // 尝试获取分布式锁 String lockKey lock:product: productId; String requestId UUID.randomUUID().toString(); boolean locked redis.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (locked) { try { // 二次检查缓存防止拿到锁之前已经被别人回写了 cached redis.get(product: productId); if (cached ! null) { return (Product) cached; } Product product productMapper.selectById(productId); redis.set(product: productId, product, 600); return product; } finally { // 释放锁注意只能释放自己的锁 if (requestId.equals(redis.get(lockKey))) { redis.delete(lockKey); } } } else { // 没拿到锁睡眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(productId); // 递归重试 } }这个方案有几个坑必须提醒锁的粒度要精确到一个key一个锁。不能搞一个全局锁否则某个热点key失效会把整个缓存系统的查询都串行化。必须设置锁的过期时间。否则拿着锁的线程宕机了锁永远不会释放后面的请求全部卡死。我一般设置8到10秒确保DB慢查询也能在锁超时前完成。释放锁时要校验持有者。用requestId做标识只释放自己持有的锁防止误删了别人后来获取的锁。拿到锁之后要二次查缓存。因为在你等待锁的期间可能已经有别的线程回写了缓存。这个方案的缺点是有锁就有等待有等待就有延迟。极端情况下如果DB你查个500ms那这批请求都会阻塞在这里。所以互斥锁更适合“能容忍少量延迟”的业务。4.2 方案二逻辑过期这是我对“永不过期”方案的进阶版。思路是缓存里的value不存真实数据而是存一个包含了过期时间的包装对象物理上这个key永不过期但逻辑上超过某个时间就认为它“过期”了。public class CacheValueT { private T data; // 真实数据 private long expireAt; // 逻辑过期时间点 }查询时的逻辑变成了从Redis拿到数据解析出expireAt如果当前时间 expireAt说明逻辑上没过期直接返回数据如果当前时间 expireAt说明逻辑过期先返回旧数据然后异步去DB拉取最新数据回写缓存并更新expireAt这个方案的精妙之处在于用户永远能拿到数据最差也是旧数据不存在“查不到”的情况而数据库的更新操作被异步化了不会阻塞主流程。它的缺点是数据一致性弱。在异步更新完成之前用户看到的是旧数据。对一致性要求极高的场景比如库存、价格不适合。我的实践经验是逻辑过期非常适合“读多写少、数据一致性要求不苛刻”的热点数据。比如商品详情页、首页推荐位、配置信息等。拿它跟互斥锁结合——热key用逻辑过期普通key走正常缓存互斥锁兜底——是我在大促链路里的主流打法。4.3 击穿方案对比对比维度互斥锁逻辑过期数据一致性强一致弱一致短暂脏读可用性有短暂阻塞风险始终可用实现复杂度中等要处理锁细节稍高要维护过期标记适用场景库存、价格等强一致业务详情页、列表等读多写少业务从我自己的经验来说互斥锁适合“不能出错”的数据逻辑过期适合“不能没有”的数据。两者是互补关系不是替代关系。5. 缓存雪崩的预防与兜底——批量失效的应对之道雪崩的难点在于“面”太大单点措施都不够需要一套组合拳。5.1 过期时间随机化——最便宜的方案为什么集中过期因为大家都在同一时刻设置了缓存。解决思路就是让过期时间分散开。不要写死固定过期时间而是// 基本过期时间 5 分钟加上 0~60 秒的随机偏移 int baseExpire 300; int randomExpire baseExpire ThreadLocalRandom.current().nextInt(60); redis.set(key, value, randomExpire, TimeUnit.SECONDS);这个方案成本极低但效果却非常明显。它能让缓存失效的时间点错开避免了同一秒几百个key一起过期。我在实际项目里会把随机范围控制在基础过期时间的10%到20%之间。太大可能导致部分数据过早失效太小起不到分散作用。5.2 多级缓存——本地缓存兜底Redis在Redis之上再加一层本地缓存如Caffeine、Guava Cache形成两级缓存架构。请求先查本地缓存本地没有再去查RedisRedis也没有才回源DB。这样即使Redis里的大量key同时失效请求也会被本地缓存接住不会全部跑到DB。本地缓存的过期时间通常设置得比Redis短比如Redis 5分钟本地1分钟——这意味着在Redis失效后的第一分钟内本地缓存还能提供数据大大缓解了并发压力。我参与过一个订单查询服务的改造原来只有Redis一层高峰期Redis抖动导致DB连接数暴涨。加了Caffeine本地缓存后DB的QPS峰值降了60%以上。5.3 熔断、降级与限流——最后一道防线即使做了分散过期和多级缓存仍然存在极端情况Redis宕机、热点数据量超出预期。这时候必须做好最坏的打算。熔断当DB的请求失败率超过阈值比如30%直接熔断后续请求不再打到DB快速返回默认值或错误提示保护DB不被打垮。等DB恢复后熔断器自动半开试探逐步恢复流量。降级某些非核心接口在缓存不可用的时候直接返回兜底数据。比如推荐列表查不到就返回空列表或者预设的静态数据价格查不到就返回上次缓存的价格。限流网关层针对核心接口做限流超出阈值的请求直接丢弃或排队。这是系统自我保护的最后手段。5.4 Redis高可用——从根源上防雪崩如果是Redis宕机导致的雪崩光靠应用层措施还不够需要在部署层面保障Redis高可用主从架构 哨兵主节点挂了自动切换从节点保证缓存服务不中断Redis Cluster多分片集群单分片故障不影响整体持久化策略开启AOF RDB防止重启后缓存全空有一点容易被忽略持久化配置不当可能导致重启后缓存全部丢失。比如你只开了RDB且快照频率很低Redis异常宕机后最近一段时间写入的缓存可能全部丢了。重启后等于一个空缓存所有流量瞬间打到DB这是生产环境最隐蔽的雪崩场景。我自己就吃过这个亏后来强制开启了AOF并设置为everysec才把风险降下来。6. 如何判断当前到底发生了哪种问题——排查实录遇到线上告警第一步不是急着改代码而是先明确“我现在遇到的是穿透、击穿还是雪崩”。三种问题的表象相似DB压力大但排查方向和应对手段完全不同。6.1 看监控指标的四个关键维度我每次排查缓存问题都会先拉四类指标指标穿透特征击穿特征雪崩特征Redis命中率大幅下降单个key短暂下降后恢复整体断崖式下降持续较久DB慢查询大量查不到数据的SQL大量同一个ID的SQL大量不同类型ID的SQL请求分布集中在少数不存在的ID集中在某一个热点ID分散在非常多key上Redis负载正常正常异常或直接连接失败穿透最典型的特征是Redis命中率和DB查询量同时异常且查的都是数据库里没有的数据。击穿则是“查同一个ID”。雪崩往往伴随Redis本身故障或大量key同时失效。6.2 一次真实的排查过程复盘有一次线上告警订单详情接口P99延迟从50ms飙到3秒DB活跃连接数爆满。我当时的排查顺序是第一先看Redis监控发现命中率从99%掉到85%但没有降到断崖级别说明不是大量key失效更像是有部分无效请求在穿透。第二抓DB慢查询日志发现大量select * from order_info where order_id 0123456789abcdef这样的SQL而且order_id格式异常长度超长。第三回看网关访问日志发现这些异常ID请求来自同一个IP段每秒几百次。到这里基本确认是缓存穿透而且是恶意的。后面的处理就很清晰先封掉异常IP再上线空值缓存兜底最后加了参数校验拦截非法格式ID问题当天就解决了。排查的关键是先区分“数据是否存在”如果DB查不到数据就是穿透如果DB能查到但缓存没数据再看是单key还是多key决定是击穿还是雪崩。7. 常见问题速查与独家避坑技巧最后整理一份我在实践中积累的常见问题和避坑清单照着排查能省不少时间。7.1 问题速查表问题现象可能原因解决方案缓存命中率正常但DB偶尔被压垮热点key过期瞬间互斥锁 / 逻辑过期命中率持续下降DB大量查不到数据的SQL缓存穿透空值缓存 / 布隆过滤器 / 参数校验命中率断崖下跌且Redis无异常大量key集中过期过期时间随机化 / 多级缓存Redis重启后DB瞬间被打垮持久化配置缺失开启AOF RDB缓存MISS后回源DB锁一直获取不到锁未释放或锁等待超时锁设置过期时间 / 校验持有者释放布隆过滤器判断存在但实际不存在误判走缓存兜底不要因为误判就报错7.2 几个非主流的实战心得第一回写缓存一定要设置过期时间但不要一刀切用过期时间洗掉所有数据。有些数据适合“永不过期后台异步刷新”的模式这其实是逻辑过期方案的变种。我见过有人因为某key过期时间设太短导致热点数据频繁失效频繁回源DB相当于自己制造了击穿问题。建议过期时间不要低于1分钟。第二监控比解决方案更重要。很多团队把精力放在“怎么解决”上却忽略了“怎么发现”。我建议给Redis命中率、DB QPS、缓存过期key数量都配上告警阈值可以设置在一段时间内的趋势变化上而不是固定值。命中率从98%掉到95%可能不是问题但10分钟内持续下降就要警惕了。第三空值缓存要防止内存被打满。前面提到过大量不存在的key都写空值如果key基数巨大Redis内存会膨胀。我的习惯是空值key的过期时间设置成正常key的十分之一左右并且增加一个独立的Redis逻辑DB或者key前缀方便单独监控和清理。第四布隆过滤器不适合频繁更新的场景。它不支持删除元素。如果业务里的ID会经常失效比如订单关闭后ID不可用布隆过滤器里还留着这些ID它们会一直通过前置校验增加无意义的查询。这种情况下要么定期重建过滤器要么用缓存空值方案替代。第五警惕缓存预热导致的雪崩。系统重启后做缓存预热时如果一次性写入大量key且过期时间相同等于人为制造了雪崩。预热时的过期时间一定要随机化。8. 几个真实案例复盘案例一某电商平台的商品详情页。上线大促当天DB连接池被打爆。排查发现促销活动页刚上线几十个商品同时从预热缓存过期且过期时间完全相同。因祸得福这次事故推动了两个改造过期时间随机化本地缓存兜底。后续大促日均流量翻倍DB压力反而下降了30%。案例二某个内部系统查询一个不存在的外部订单号。因为这个系统是异步回调同步订单状态某些订单号在极端情况下会被外部系统反查但本地没有。开发图省事没做参数校验结果每5分钟就有一批轮询脚本查询不存在的订单号半年里DB的QPS一直被这波垃圾流量占据。后来加了空值缓存DB压力直接下降。案例三某社交APP的Feed流。Redis Cluster中一个分片发生网络分区导致大量key从缓存中消失。因为依赖缓存的Feed接口没有做降级逻辑,所有读取直接打到DB导致DB撑不住。这个事故的教训是高可用架构再完善也不能保证100%不故障应用层一定要有兜底和降级。9. 说在最后缓存三兄弟——穿透、击穿、雪崩——本质上都是“缓存和数据库之间的流量调度问题”。穿透是“查不存在的东西”击穿是“一个人倒下导致拥堵”雪崩是“一群人同时倒下导致崩溃”。搞清楚了这个本质区别很多解决方案都是顺理成章的。我最想传递的一个经验是解决方案没有银弹只有适合业务的选择。强一致的用互斥锁弱一致的用逻辑过期成本敏感的用空值缓存流量大的上布隆过滤器——但永远要记得缓存的终极目标是保护数据库而不是为了缓存而缓存。如果你正在被线上缓存问题折磨我的建议是先把监控做起来。搞清楚了“为什么挂”再谈“怎么修”。这个顺序千万别反。