缓存雪崩、击穿、穿透这三个词后端干了几年的人基本都听过但你要真问他仨到底啥区别、线上遇到了怎么快速定位、用哪种方案兜底很多人就含糊了。更麻烦的是这三兄弟经常被混在一起讲网上文章也写得七零八落有的把击穿当穿透有的把雪崩说得跟穿透一个样。我这篇直接给你掰开揉碎从原理、触发条件、排查路径到解决方案全过一遍文中涉及的代码和配置都是我实际验证过能用的保证你看完能直接用在项目里。先说个大概结论方便你脑子里先有个框架穿透是查了一个根本不存在的数据击穿是某个热点key在过期瞬间扛不住并发雪崩是大量key同时失效或者Redis直接挂了。三者都会把压力打到数据库上但成因、表现、解法完全不同不能一概而论。下面一个一个拆。1. 先搞懂为什么这三个问题总被放在一起说很多刚接触缓存的朋友会把雪崩、击穿、穿透当成同一种问题的不同叫法这是因为它们最终的症状都差不多——数据库被打爆、接口超时、服务雪崩。但实际上这三者的故障链路、排查关键点和解决方案差异很大搞混了会在定位问题时走弯路。1.1 从缓存的三层架构看故障源头为了说清楚这三个问题先建立一个统一的视图一个典型的缓存架构是请求先进RedisRedis没有再去查MySQL查到了回写缓存并返回查不到就返回空。这套链路里可能出问题的就三个位置请求到了Redis但Redis里没有这个key——对应穿透和击穿。Redis本身大面积key失效或不可用——对应雪崩。请求穿透Redis打到MySQLMySQL扛不住——这是三者的共同恶果。所以你可以把三兄弟理解成源头不同、路径各异、结局相似。穿透的根源在查询了一个不存在的数据击穿的根源在热点key的过期时间点雪崩的根源在失效范围太大或服务不可用。1.2 用生活场景给三兄弟画个像我一般给团队新人打比方都用一个场景一家奶茶店爆款是生椰拿铁。穿透有个人天天来问有没有榴莲味生椰拿铁店里菜单上压根没这个东西。店员查了一次发现没有但这个人每天都来问每次都白查一遍搞得上游供应商也被反复问。击穿生椰拿铁卖得特别火但后厨规定每锅原料2小时换一次。换原料的那1秒钟所有顾客同时来点single后厨手忙脚乱连锁反应就是其他订单也延误了。雪崩店里搞活动全场所有饮品的原料统一在下午3点到期换新。一瞬间所有品类都做不了顾客全挤到前台整个店瘫痪。这个类比基本能把三者的差异点讲明白穿透是数据不存在还反复查击穿是一个热点数据的单点故障窗口雪崩是大面积失效导致的整体性故障。2. 缓存穿透拿一个不存在的数据反复打库缓存穿透是最容易理解的一个也是最容易被低估的一个。很多人觉得查不到就返回空呗但在高并发场景下攻击者完全可以用一批不存在的ID把你数据库打到挂。2.1 穿透请求的完整路径和危害分析我见过一个真实案例某电商平台的商品详情接口Redis的key设计是product:{id}正常情况下商品数据会缓存。有一天业务方反馈数据库CPU飙升查看慢查询发现大量SQL是SELECT * FROM product WHERE idxxx而且这些id在数据库里根本不存在。这就是典型的穿透攻击。正常用户不会去查一个不存在的商品但脚本可以随机生成ID去遍历如果订单号是自增的攻击者只需要从1开始往上扫所有不存在的ID都会直接打穿Redis到MySQL。更麻烦的是因为DB查不到数据Redis里也不会写缓存导致每次都穿透。危害不仅是数据库压力大还包括数据库连接池被无效查询占满影响正常业务请求。慢查询日志被刷屏掩盖了真正需要关注的性能问题。如果攻击者伪造的ID落在分库分表的某个特定分片上那个分片会先被打挂形成单点热点。2.2 布隆过滤器为什么适合拦截穿透请求解决穿透最有效的方案是布隆过滤器Bloom Filter它能在数据不存在时快速返回肯定不存在避免查询落到DB。原理不复杂初始化一个bit数组写入数据时用多个哈希函数把key映射到数组的多个位置把对应位设为1。查询时同样计算多个哈希位置只要有一个位置是0就说明这个key肯定不存在如果全是1只能说可能存在。因为布隆过滤器只保存存在性指纹不保存原始数据所以它占用的内存极小。比如1亿个key误判率控制在1%左右大约只需要几百MB内存。实际使用中可以放本地内存也可以放RedisRedisson提供了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 击穿的本质单点过期导致的并发冲击击穿的触发条件很苛刻这个key必须是热点key缓存中确实有数据平时大量请求都会命中的那个。这个key恰好到了过期时间。过期后的极短时间窗口内大量请求同时来查询发现缓存没有全部打到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或HystrixQPS阈值根据系统压测结果来定别拍脑袋。另外如果你的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_idxxx耗时在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 临时降级、加固与最终修复定位之后没有急着改代码先做了三件事止血临时把过期时间常量从3600秒改成3600加随机值确保后续key不会同时失效。手动预热热点用户的缓存跳过自然过期时间。在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分钟内就找到根因。