去年双十一前一周我们有个商品详情页接口 P99 从 40ms 涨到了 3.2s。监控上看QPS 没涨Redis 也没挂CPU 也不高。但数据库连接池被打满了慢查询日志里全是同一条 SQL——查一个已经不存在的 SKU。排查到最后原因很朴素**一个下架很久的商品被某个爬虫反复抓缓存里永远 miss请求全部落到 DB。**而那个 SKU 在 DB 里根本不存在所以每次都是全表扫描级别的代价。那天我学到一件事**缓存的三种“失效”名字听起来像兄弟成因和治法完全不同。**混着讲的人很多但线上出问题的时候你只能选一种方案选错就是二次事故。这篇文章把这三个问题拆开每个都配能跑的代码和明确的适用边界。你可以把它当排查清单也可以当面试前的复习提纲——但我更希望你永远只用上第一部分。一、先把概念钉死它们不是同一件事这是最容易糊的地方。很多人把三个词当同义词用其实它们的答案完全不同。触发条件打的是什么典型特征缓存穿透请求的数据根本不存在缓存没有DB 也没有每次都穿透到 DB流量可能不大但每次都是无效查询命中率长期偏低缓存击穿单个热点 key 过期的瞬间大量并发涌入一瞬间打爆 DB 的某一行/某一张表尖峰型持续时间短秒级日志里是同一个 key缓存雪崩大量 key 同时过期或Redis 实例整体不可用DB 整体被压垮大面积失败伴随超时、熔断、级联故障一句话记法穿透 查不到数据不存在击穿 来不及一个 key 刚过期雪崩 一起过期 / 一起挂了分清这个后面的方案才对得上号。拿防雪崩的方案去治穿透纯属浪费。二、缓存穿透不存在的数据才是最大的漏洞成因请求的 key 在缓存和数据库都不存在。于是每次都是「miss → 查 DB → 回写失败因为没数据可写→ miss」的死循环。如果是恶意构造的不存在 ID等于用最便宜的请求换你最贵的资源。四个解法按性价比排序1. 接口层先拦一层最便宜也最容易被忘if(skuIdnull||skuId0){returnResult.fail(参数非法);}// 更狠一点ID 雪花算法自带趋势性非单调的随机长串直接拒掉很多穿透流量连 DB 都不用见在 Controller 层就该死掉。参数校验、登录态校验、频率限制这三样做好能挡掉一大半。2. 缓存空值最简单但有坑publicSkugetSku(LongskuId){Stringkeysku:skuId;StringjsonredisTemplate.opsForValue().get(key);// 约定一个特殊值表示确实不存在if(NULL.equals(json)){returnnull;}if(json!null){returnJSON.parseObject(json,Sku.class);}SkuskuskuMapper.selectById(skuId);if(skunull){// 关键必须给 TTL且要短redisTemplate.opsForValue().set(key,NULL,60,TimeUnit.SECONDS);returnnull;}redisTemplate.opsForValue().set(key,JSON.toJSONString(sku),30,TimeUnit.MINUTES);returnsku;}**坑在哪**如果攻击者枚举海量不存在的 key比如sku:1到sku:999999你的 Redis 会被一堆NULL占满——这叫缓存污染相当于自己给自己做了个 DoS。所以空值缓存必须配两样东西短 TTL几十秒到几分钟下面第 3 条。3. 布隆过滤器治本但要接受它的性格思路在 Redis 前面再放一个“成员判定”结构。key 进来先问布隆过滤器说不在就直接返回连 Redis 都不用问。// Redisson 的布隆过滤器RBloomFilterLongbloomFilterredissonClient.getBloomFilter(sku:bloom);// 服务启动时初始化预期元素 100w误判率 1%bloomFilter.tryInit(1000000L,0.01);// 全量 SKU 灌进去实际项目建议用脚本或异步任务别在主流程里堵着for(Longid:allSkuIds){bloomFilter.add(id);}调用侧if(!bloomFilter.contains(skuId)){// 一定不存在误判率为 0 的不存在判断returnnull;}用布隆过滤器必须记住三件事它只有假阳性没有假阴性。它说“不在”那就一定不在它说“在”有可能不在误判率由fpp控制。所以它只能用来拦截不能用来确认存在。元素删不掉。标准布隆过滤器不支持删除计数布隆可以但有代价。SKU 上下架、数据变更意味着你要重建整个过滤器。工程上通常的做法是准备两个过滤器交替切换双缓冲重建期间读旧的建完切新的。**初始化要离线做。**别在请求路径里 add也别指望它实时准确。它保护的是“大概率存在的那部分空间”不是精确集合。4. 限流兜底上面三道都漏了还有最后一道单 IP / 单用户 / 单 key 的 QPS 限制。Guava RateLimiter、Sentinel、Redis 滑动窗口都行。限流不是优化是保险丝。三、缓存击穿热点 key 过期的那一秒成因某个极高并发的 key比如首页爆款、秒杀商品、热点新闻在某一时刻过期几千个线程同时 miss同时去打 DB。注意和穿透的区别**数据是存在的只是缓存刚好没了。**所以空值缓存和布隆过滤器对它完全没用——布隆会说“在”然后你照样要去查。三个解法方案 A互斥锁强一致有性能代价只让一个线程去查 DB其他线程等着。publicSkugetHotSku(LongskuId){Stringkeysku:skuId;StringlockKeylock:sku:skuId;StringjsonredisTemplate.opsForValue().get(key);if(json!null){returnJSON.parseObject(json,Sku.class);}// 只有一个线程拿到锁去重建BooleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,1,10,TimeUnit.SECONDS);if(locked!nulllocked){try{// 双重检查等锁的线程醒来可能已经有值了jsonredisTemplate.opsForValue().get(key);if(json!null){returnJSON.parseObject(json,Sku.class);}SkuskuskuMapper.selectById(skuId);redisTemplate.opsForValue().set(key,JSON.toJSONString(sku),30,TimeUnit.MINUTES);returnsku;}finally{// 只删自己的锁别误删别人的redisTemplate.delete(lockKey);}}else{// 没抢到锁自旋重试 or 直接返回旧值如果有Thread.sleep(50);returngetHotSku(skuId);// 生产环境建议改成有限次自旋}}要点setIfAbsent必须带过期时间否则 DB 查询一旦异常锁永不释放 永久击穿。锁的粒度要按 key 分一把大锁会把所有热点串行化。一定要双重检查否则排队的那些线程醒来又会各自查一遍 DB。方案 B逻辑过期高可用优先牺牲一点一致性思路Redis 里的数据永不过期但在 value 里自己塞一个过期时间戳。读取时如果发现“逻辑过期”了不阻塞当前请求而是起一个异步线程去重建本次先返回旧值。DataclassCacheObjectT{privateTdata;privateLocalDateTimeexpireTime;// 逻辑过期时间}publicSkugetWithLogicalExpire(LongskuId){Stringkeysku:skuId;CacheObjectSkucogetCacheObject(key);if(conull){// 第一次同步构建returnbuildAndSet(key,skuId);}if(LocalDateTime.now().isBefore(co.getExpireTime())){returnco.getData();// 没过期直接返回}// 已过期异步重建本次返回旧值REBUILD_POOL.submit(()-buildAndSet(key,skuId));returnco.getData();}优点永远不会出现“所有线程同时等重建”的情况可用性拉满。代价用户可能看到一小段时间的旧数据线程池要隔离否则重建任务堆积会把自己拖垮。方案 C热点数据永不过期 主动预热对于真正的超级热点比如大促主会场最稳的办法是不设过期时间靠发布系统或定时任务主动刷新。配合 CDN / 本地缓存Caffeine做二级缓存DB 基本感知不到流量。选型建议能容忍短暂脏数据 → 方案 B/C不能容忍涉及金额、库存、权限→ 方案 A。四、缓存雪崩大面积失效或者缓存整体没了两种形态治法不同形态一大量 key 同一时刻过期最常见的原因是“批量设置相同 TTL”。比如凌晨 0 点跑批把所有商品缓存刷新成 30 分钟那么 0:30 那一刻就会集体失效。解法在基础 TTL 上加随机抖动。// 基础 30 分钟 0~5 分钟随机抖动longttl30*60ThreadLocalRandom.current().nextInt(300);redisTemplate.opsForValue().set(key,value,ttl,TimeUnit.SECONDS);别小看这个 random。它把“同时失效”变成了“均匀分布”成本是一行代码收益是整晚的安稳。这是全文性价比最高的一行。形态二Redis 节点宕机 / 集群大面积不可用这时候没有任何技巧能救你只能靠架构高可用哨兵模式或 Cluster主节点挂了能自动切。注意主从异步复制下主节点宕机那几秒写入的数据会丢业务上要能接受。多级缓存本地缓存Caffeine扛第一波。Redis 挂了本地缓存还能顶住一部分读给 DB 喘息空间。限流 降级Sentinel / Hystrix 设定好阈值超了就快速失败或返回默认值/静态兜底页。宁可让用户看到“暂时不可用”也不要让 DB 跟着一起死——DB 一旦被打挂恢复时间比 Redis 长一个数量级。持久化兜底重要配置类数据可以落一份到本地文件或 DB启动时预加载。一个容易忽略的点缓存重建风暴。Redis 重启恢复后一开始是空的所有请求瞬间 miss等于人工制造了一次雪崩。所以重启后第一件事不是放开流量而是预热挑核心 key 分批加载或者用灰度方式逐步放量。五、顺带说一句缓存和数据库的一致性聊缓存不提一致性是不完整的。这里只给结论不展开展开又是另一篇文章的长度主流做法先更新数据库再删除缓存Cache Aside Pattern。为什么是删除而不是更新因为更新可能有并发覆盖问题而且有些缓存值是计算出来的。**删除失败了怎么办**重试队列 / binlog 订阅Canal做最终一致性补偿。“延迟双删”有用但不是银弹它能缓解“更新 DB 后、删缓存前有读请求把旧值写回缓存”的窗口问题但第二次删除的时间间隔很难给对且引入了新的复杂度。强一致场景不要赌缓存余额、库存扣减、权限直接走 DB 分布式锁或者用 Redis 做辅助校验但以 DB 为准。六、一份可抄的排查清单线上出现缓存相关慢查询按这个顺序过看是不是同一个 key。日志里全是同一个 key → 击穿全是不同的、且不存在的 key → 穿透大面积、多 key 同时 → 雪崩。看 Redis 命中率和连接数。命中率骤降 连接数正常 → 过期类问题命中率骤降 连接数暴涨/超时 → Redis 本身有问题。看过期时间分布。redis-cli --scan抽样看 TTL如果一批 key 的剩余 TTL 几乎一样就是批量设置没加抖动。看 DB 慢查询。如果慢查询的条件是“查一个不存在的值”基本可以确定是穿透。看有没有新增活动/爬虫/定时任务。很多雪崩是运营活动 定时任务叠加出来的。临时止血三板斧限流 → 降级返回兜底值/静态页→ 手动预热核心 key。顺序别反先保 DB 活着再谈恢复。写在最后回到开头那个事故。我们最后的处理是分三步先在网关层对 SKU 查询加了频率限制五分钟内止血第二天上了空值缓存 短 TTL一周内稳定第三周上了布隆过滤器并改造了重建流程长期方案。事后复盘时有人问“为什么不一步到位直接上布隆”我的回答是**线上问题的解法优先级永远是「先活下来」「再不出事」「最后优雅」。**布隆过滤器要解决初始化、重建、误判、内存占用一堆问题它在事故当晚是给不出来的。这也算是我写这篇文章的一点私心网上讲这三个概念的文章很多大多把它们并列成三道背诵题。但在真实系统里它们从来不是选择题而是分层防御的不同位置——接口层拦住非法的防穿透布隆过滤器挡住不存在的防穿透互斥锁或逻辑过期管住过期的防击穿随机抖动打散集体的防雪崩限流降级托住所有防线都漏掉的兜底每一层都不完美但叠在一起就足够稳了。