
sn: 16batch: 4round: 9topic: 缓存穿透、击穿、雪崩应对方案去年 618 预热那天晚上我们一个上线 8 个月的商品详情服务突然把 MySQL 打挂了。有意思的是那天没有任何流量高峰QPS 只有平时的 60%。DBA 拉出慢查询一看全是select * from sku where id ?而且 id 千奇百怪明显不是正常用户行为。后来查日志才发现某竞对的爬虫换了策略不再抓页面直接拿递增 id 加随机偏移量轰我们的详情接口。缓存里查不到全部落到数据库。这个服务上线时我信心满满地写了一句注释已加 Redis 缓存结果 8 个月后它用一次 P3 故障告诉我加缓存不等于缓存设计。那之后我重新整理了缓存三大故障的防御体系也把哪种病用哪种药彻底想清楚了。这篇就把我的分治思路和 3 层防御清单摊开讲。先分清三种病症状相似病因完全不同很多文章把穿透、击穿、雪崩混在一起讲结果读者背了一堆方案却不知道什么时候用哪个。我的理解是看数据库收到的流量形态。维度穿透击穿雪崩数据特征缓存和 DB 都不存在缓存过期DB 存在大量 key 同时失效或 Redis 整体挂流量形态持续平缓、覆盖大量不同 key瞬时尖峰、集中在个别热 key瞬时尖峰、覆盖面广典型元凶恶意爬虫、业务漏洞秒杀品、热搜词缓存到期统一 TTL、Redis 宕机防御核心拦在缓存层之前保护重建过程打散失效时间穿透是持续性的慢性病击穿是急性心梗雪崩是系统性中风。药方当然不一样。穿透的治法把查不到也缓存住穿透的本质是DB 查不到 → 缓存永远不写入 → 下次还打 DB这个死循环。破局点就是让不存在这个事实本身可以被缓存。第一种做法是缓存空值。逻辑很简单但有个参数必须想清楚public SkuVO getSku(long skuId) { String key sku: skuId; String cache redis.get(key); // 1. 命中且非空值标记直接返回 if (redis.exists(key)) { // 2. 空值标记约定为字符串 NIL反序列化前先判断 return NIL.equals(cache) ? null : JSON.parseObject(cache, SkuVO.class); } SkuVO vo skuMapper.selectById(skuId); if (vo null) { // 3. 不存在也写入TTL 给短一点60 秒足够挡住重复攻击 redis.setex(key, 60, NIL); return null; } // 4. 正常数据 TTL 长一些并加随机抖动防雪崩 redis.setex(key, 3600 ThreadLocalRandom.current().nextInt(600), JSON.toJSONString(vo)); return vo; }逐行看几个关键决策第 1 行用exists而不是判空字符串是为了区分没缓存过和缓存了空值两种状态这个区别是整个方案的命门第 3 行空值 TTL 我建议 60 秒左右——太长会让新上架商品短时间查不到DB 有了但缓存还标 NIL太短挡不住持续攻击第 4 行的随机抖动是给后面雪崩防御埋的伏笔。空值缓存的问题是有上限的攻击者如果每次换不同的 id你的 Redis 会被垃圾 key 塞满。所以对付职业选手得上第二层——布隆过滤器。// 系统启动时加载全量存在的 id 到布隆过滤器Guava 版本 31.x private BloomFilterLong skuBloom BloomFilter.create( Funnels.longFunnel(), // 1. 元素类型long 型 skuId 50_000_000, // 2. 预期插入量 5000 万按量级上浮 20% 规划 0.01); // 3. 误判率 1% public SkuVO getSkuV2(long skuId) { // 4. 布隆说不存在那 100% 不存在直接返回连 Redis 都不用碰 if (!skuBloom.mightContain(skuId)) { return null; } // 5. 布隆说可能存在走正常缓存流程1% 误判漏到 DB 可接受 return getSku(skuId); }逐行说第 2 行的预期插入量一定要按业务峰值预留布隆过滤器不支持扩容重建会很难受第 3 行误判率我选 1% 而不是 0.1%因为每降一个数量级内存占用大约翻倍而 1% 的漏网流量配合空值缓存完全兜得住——两层方案是配合用的不是二选一。第 4 行是精髓绝大多数恶意 id 在这一行就被拦掉了成本是一次布隆查询微秒级。但布隆过滤器有个死穴删除商品后过滤器不会自动收缩误判率随删除累积缓慢上升。商品量稳定或只增的场景用它很舒服频繁下架的场景要评估重建策略。击穿的治法保护重建过程而不是保护数据击穿的场景很具体一个热 key比如秒杀主商品缓存过期的瞬间成百上千个并发同时发现缓存没了全部冲向 DB。注意这里的关键不是数据不在而是重建过程没有任何保护。我的方案是分布式互斥重建 逻辑过期双轨。先看互斥重建public SkuVO getHotSku(long skuId) { String key sku:hot: skuId; String cache redis.get(key); if (cache ! null) { return JSON.parseObject(cache, SkuVO.class); } String lockKey lock:rebuild: skuId; // 1. 尝试拿重建锁setnx 过期时间一步到位保证原子性 boolean locked redis.set(lockKey, 1, SetParams.setParams().nx().ex(10)); if (locked) { try { // 2. 双重检查拿到锁后可能别人已经重建完了 cache redis.get(key); if (cache ! null) { return JSON.parseObject(cache, SkuVO.class); } // 3. 查 DB 并回填TTL 加随机抖动 SkuVO vo skuMapper.selectById(skuId); redis.setex(key, 3600, JSON.toJSONString(vo)); return vo; } finally { // 4. 释放锁 redis.del(lockKey); } } // 5. 没抢到锁的请求睡 50ms 重读缓存最多重试几次后降级 Thread.sleep(50); cache redis.get(key); // 6. 重建完成前允许返回旧数据或兜底静态数据别干等 return cache null ? SkuVO.defaultSku() : JSON.parseObject(cache, SkuVO.class); }逐行拆第 1 行用SET key value NX EX 10原子命令千万别写成先setnx再expire两步——中间崩了这把锁就成死锁了第 2 行双重检查非常容易被省略但如果没有它第一个线程重建完后排队的线程拿到锁还会再查一次 DB互斥就失去了意义第 5-6 行是体验关键抢不到锁的请求不是阻塞等待而是短暂重试后拿旧数据或静态兜底把重建时间窗对用户透明化。这个方案的局限在于锁的过期时间要大于重建耗时而 DB 抖动时重建时间不可控。所以对极端热 key我更推荐逻辑过期方案数据永不过期值里带一个逻辑过期时间字段发现逻辑过期后由拿到互斥锁的线程异步重建期间所有人读旧值。旧数据比没数据好——这是我在缓存设计上最坚定的观点之一。雪崩的治法打散时间 多级兜底雪崩有两种成因治法也不同。成因一是大量 key 设置了相同 TTL同一秒集体失效。治法上面代码里已经埋了TTL 加随机抖动把失效时间摊开。我的习惯是base random(base * 0.2)比如基础 1 小时就抖动到 3600-4320 秒之间。成因二是 Redis 整体不可用这个靠打散 TTL 没用需要多级兜底// 三级读取本地缓存 → Redis → DB每一级都是 Redis 挂掉后的缓冲垫 public SkuVO getSkuWithL1(long skuId) { // 1. L1 本地缓存Caffeine容量 1 万条TTL 10 秒 SkuVO l1 localCache.getIfPresent(skuId); if (l1 ! null) { return l1; } // 2. L2 Redis正常情况下绝大多数请求在这一层返回 SkuVO l2 getSku(skuId); if (l2 ! null) { localCache.put(skuId, l2); return l2; } // 3. L3 数据库走到这里说明前两层都没有 SkuVO vo skuMapper.selectById(skuId); if (vo ! null) { redis.setex(sku: skuId, 3600, JSON.toJSONString(vo)); localCache.put(skuId, vo); } return vo; }逐行看第 1 行 Caffeine 本地缓存 TTL 我只给 10 秒因为本地缓存跨实例不一致TTL 越长一致性越差10 秒是大多数商品类业务能容忍的上限第 2 行是架构意义上的关键——Redis 正常时99% 请求到 L2 就结束了本地缓存的意义不在平时而在 Redis 故障那几分钟里顶住流量第 3 行写回时同时更新两层避免下个请求又穿透。另外必须提熔断限流即使三层缓存都穿了 Sentinel 在 DB 访问入口设的流控规则决定了故障是部分用户体验降级还是全站瘫痪。缓存防御的最后一道闸永远是限流不是缓存本身。我的 3 层防御清单层次防什么具体手段成本第 1 层入口拦截穿透布隆过滤器 / 参数合法性校验低常驻内存第 2 层缓存层穿透残余 击穿空值缓存 互斥重建 / 逻辑过期 TTL 抖动中增加代码复杂度第 3 层DB 保护雪崩 一切漏网多级缓存 Sentinel 流控 DB 连接池上限中高需要架构配合三层不是都要上。我的取舍标准很简单日活百万以下、数据量可控的业务第 2 层做扎实空值缓存 互斥重建 TTL 抖动就够了布隆过滤器的维护成本不划算有明确被攻击风险或热 key 场景极端的才值得上完整 3 层。说实话我见过太多团队把精力花在背面试题上真正线上出事的却是因为 TTL 忘了加抖动这种低级问题。方案不在多在于每一层都真正落地并演练过——Redis 挂掉时你的服务是什么表现这个问题答不上来上面所有方案都只是纸面功夫。思考题如果热 key 的逻辑过期方案里异步重建线程挂了比如线程池队列满了任务被拒旧数据会永远被读下去这个问题你会怎么发现、怎么兜底欢迎在评论区聊聊你的做法。