说到高并发缓存你早晚会撞上三个坑穿透、击穿、雪崩。这三个词在分布式缓存领域几乎无人不知但真到了线上出了问题很多人却分不清自己遇到的到底是哪一个更别提怎么对症下药了。我早期维护一个电商订单系统时就曾因为一个热点商品详情页缓存失效数据库瞬间被几千个并发请求打满页面直接超时那会儿才真正意识到光会写Redis读写还远远不够你得懂缓存失效背后的机制才知道该用什么手段去挡。这篇文章就把这三个问题彻底讲透缓存穿透怎么用布隆过滤器拦截缓存击穿怎么用互斥锁和逻辑过期扛住缓存雪崩怎么靠随机过期、多级缓存和限流降级来防御。适合后端开发、系统架构师以及自己搭过缓存服务但没经历过极端场景的人看。我会把原理、参数怎么算、代码怎么落、线上会踩什么坑全部分享出来争取你看完就能直接拿去排查和改造。1. 三大故障的本质穿透、击穿、雪崩到底差在哪1.1 三个故障的核心特征先给三个问题画个像。缓存穿透指的是请求的数据在缓存和数据库里压根就不存在。比如用户查一个不存在的商品ID或者爬虫故意构造一堆无效参数打你接口。缓存里没有数据库里也没有于是每次请求都穿透到数据库层最坏情况下直接把DB压垮。它的特征是“查了个不存在的东西”而且和并发量无关哪怕只有一个请求反复来也会持续打库。缓存击穿指的是某一个热点key在缓存过期的瞬间恰好有大量请求同时打进来。这个key平时是热点但它在那一刻失效了所有请求同时发现缓存里没数据于是全部涌向数据库。它的特征是“某一个key的失效瞬间”引发高并发穿透。它和穿透的区别在于数据本身是存在的只是缓存暂时空了。缓存雪崩指的是大量key在同一时间段集中过期或者缓存节点整体宕机导致大量请求同时落到数据库。它和击穿的区别是击穿是“单点失效”雪崩是“面状失效”。雪崩一旦发生数据库QPS瞬间暴涨往往连带着服务熔断、连接池耗尽引发更大范围的故障。1.2 为什么一定要区分这三者很多人觉得反正都是缓存没命中加个缓存不就完了其实这三个问题的应对思路完全相反穿透的关键是“别让无效请求到DB”击穿的关键是“热点key过期时别让并发重建”雪崩的关键是“让失效时间错开、让冲击被缓冲”。如果搞混了场景就容易用错方案。比如用互斥锁去防穿透你会发现锁根本防不住因为穿透请求压根不存在数据可回填锁重建了还是空用布隆过滤器去防击穿你会发现热点key明明存在却被过滤器误判挡住或者根本没进过滤器名单照样打穿。所以先定位清楚是哪一个问题再选方案这是最基本的思路。我建议你在排查线上故障时先看监控面板上的两个指标缓存命中率和数据库QPS曲线。穿透的特征是数据库QPS很高但缓存命中率长时间处于低位且请求的key五花八门击穿的特征是缓存命中率在某个时间点突然断崖式下跌然后迅速恢复雪崩的特征是命中率整体性下跌数据库QPS呈陡峭的尖峰持续时间相对较长。这三条观察经验比看任何日志都直观。2. 缓存穿透用布隆过滤器把无效请求挡在门外2.1 穿透是怎么发生的我见过最典型的穿透场景有两种。第一种是业务逻辑漏洞比如前端传了一个不存在的商品ID后端没校验就直接查缓存和数据库第二种是恶意攻击攻击者摸清了你的ID规则批量构造不存在的ID来刷接口。无论哪种结果都一样每次请求都绕过缓存、直击数据库。如果数据库每秒能扛1000 QPS攻击者只要每秒打2000个请求库就很容易被拖垮。要防穿透核心思路只有一个在缓存层和数据库层之间加一道“挡板”让不存在的key根本走不到数据库。常见的挡板有两种布隆过滤器以及空值缓存。空值缓存很简单查不到数据时在缓存里存一个空值并设置短TTL比如2到5分钟这样同样的key在TTL内不会再打库。但这个方案有两个问题一是占用缓存空间如果恶意key非常多空值缓存会越积越多二是攻击者换个key就能继续打。所以空值缓存更适合“偶发穿透”的场景严重点的还是得上布隆过滤器。2.2 布隆过滤器的原理与参数计算布隆过滤器本质上是一个超大的位数组bitmap加若干个哈希函数。当你把一个key塞进过滤器时会用k个哈希函数算出k个位置把这些位都置为1。判断一个key是否存在时同样算k个位置如果这些位全是1说明key“可能存在”只要有任何一个位是0说明key“一定不存在”。注意这个“可能存在”意味着它允许误判但绝不允许漏判也就是不存在的数据有可能被误认为存在但存在的数据绝不会被误判为不存在。这里就得说一下布隆过滤器的两个关键参数预期元素数量n和允许误判率p。有了这两个值就可以算出位数组大小m和哈希函数个数k。公式是m -(n * ln p) / (ln2)^2k (m / n) * ln2我举个实际例子。假设你的商品表有100万个有效ID你希望误判率控制在1%那么ln p ln(0.01) ≈ -4.605ln2 ≈ 0.693(ln2)^2 ≈ 0.480。代入公式m -(1000000 * -4.605) / 0.480 ≈ 9,594,000 bit折合下来大约1.14 MB。k (9,594,000 / 1,000,000) * 0.693 ≈ 6.64取整后就是7个哈希函数。也就是说一个100万数据量的过滤器只需要占用1.14MB内存加7次哈希计算就能换来1%误判率的拦截能力性价比极高。实际使用时我建议把误判率设置成0.1%到1%之间。1%的误判率意味着每100个不存在的key大约有1个会穿透过滤器穿透之后还能靠缓存空值或者数据库查询兜底。没必要追求0.01%因为误判率每降一个数量级位数组大小就要增加差不多一倍内存成本涨得很快。2.3 布隆过滤器的落地实践与取舍在Java生态里我常用两种方式。一种是Google Guava的BloomFilter适合单机场景另一种是Redisson的RBloomFilter适合分布式场景可以直接存在Redis里多个服务实例共享一份过滤器数据。Redisson的用法很直接先初始化一个RBloomFilter指定expectedInsertions和falseProbability然后调用tryInit再往里面塞数据。这里有个细节tryInit一旦执行过滤器已经从空开始初始化了如果之后发现数据量估算少了不能动态扩容只能重建一个新的过滤器。所以初始化时expectedInsertions宁可多估一点也别少估否则误判率会急剧上升。布隆过滤器还有一个容易被忽略的坑它需要和“数据写入”保持同步。商品新增时要把新ID塞进过滤器数据删除时布隆过滤器不支持删除元素因为多个元素可能共用一个bit位置清了会影响别的元素。所以实践中通常不是实时删除而是定期重建过滤器或者接受“已删除ID也能查到但库里查不到”的情况让空值缓存去兜底。我还需要提醒一点布隆过滤器是用来防穿透的不是用来做权限校验的。假设一个合法用户查询一个不存在的订单被过滤器误判挡住了对用户来说就是“明明数据不存在你却说有”体验上问题不大。但如果过滤器直接返回“存在”然后去查库相当于放行了一部分无效请求所以它拦的是“一定不存在的请求”拦不住的全部放行到下一层。这才是正确使用姿势。3. 缓存击穿互斥锁与逻辑过期的实战选择3.1 击穿场景为何比穿透更难防击穿的麻烦在于它只发生在一个热点key上但这个key在那一瞬间承载的流量非常惊人。典型例子是电商大促时的秒杀商品详情页平时这个key的QPS可能有几千结果缓存过期后几千个请求同时发现缓存没值齐刷刷地闯到数据库。数据库能扛几百并发就算不错了这一瞬间直接就跪。为什么布隆过滤器防不了击穿因为击穿请求的key是真实存在的布隆过滤器只会把它放行甚至过滤器里存的bit全都为1判断结果也是“存在”。所以击穿的应对重点不是拦截key而是控制“重建缓存”的过程保证只有一个线程能在并发中查库回填其他人要么等待要么用旧数据兜底。3.2 互斥锁方案实现与细节互斥锁的核心逻辑很简单缓存过期后请求先尝试获取分布式锁拿到锁的线程去查数据库并回填缓存其他线程拿不到锁就等待锁释放后再去缓存里取。听起来不难但代码里全都是细节。我写个最直白的伪代码版本String value cache.get(key); if (value null) { if (redis.lock(key)) { try { value cache.get(key); // double check防止等锁期间已被其他线程回填 if (value null) { value database.query(key); cache.set(key, value, ttl); } } finally { redis.unlock(key); } } else { Thread.sleep(50); value cache.get(key); // 等锁线程直接再查一次缓存 } }这个方案有几个关键点。第一拿到锁之后必须二次检查缓存因为在你等锁的这段时间里抢到锁的线程可能已经把缓存回填好了如果你不检查直接查库锁就白加了。第二锁的超时时间必须大于数据库查询耗时否则查询还没完成锁就过期了其他线程拿到锁又开始重复查库锁形同虚设。第三不能用简单的SETNX裸锁因为SETNX没有原子性的过期时间设置进程挂掉锁就永远不释放。我建议直接用Redisson的RLock它自带看门狗续期机制能避免锁超时误杀。你可能会问为什么不直接用本地锁因为线上服务通常是多实例部署本地锁只能锁住当前进程其他实例照样能查到数据库。分布式场景必须用分布式锁Redis没部署Sentinel的话至少也得是集群模式否则Redis单点挂掉的时候锁本身就成了新的单点故障。3.3 逻辑过期方案无锁也能扛住互斥锁最大的问题是当热点key过期瞬间那几百毫秒内拿不到锁的请求都在等待极端情况下会在接口层堆积拖慢整个服务的吞吐量。还有一种更丝滑的思路叫逻辑过期缓存里不设置Redis的TTL过期时间而是把过期时间直接写在value里。查询时先拿到这个value判断逻辑过期时间有没有到。如果没到直接返回如果到了就返回旧值同时启动一个线程去异步更新缓存。更新缓存时加上互斥锁防止多个异步线程重复查库。这样所有请求都能立刻拿到响应虽然拿到的可能是几百毫秒前的旧数据但保证了接口不阻塞、数据库压力可控。这种方案适合对实时性要求不那么苛刻的场景比如商品详情页、文章内容、配置信息。只要下一秒刷新时能拿到新数据用户几乎感知不到差异。我实际做过一次活动页的缓存改造用逻辑过期之后接口的P99从158ms降到了23ms数据库QPS高峰期反而比改造前更平稳因为永远不会出现“缓存空窗期全量打库”的情况。3.4 互斥锁与逻辑过期的选型对比选哪个不是拍脑袋我给一张对比表维度互斥锁方案逻辑过期方案数据一致性强一致拿到的一定是最新数据弱一致过期瞬间返回旧数据接口耗时最坏情况有等待阻塞永不阻塞返回旧值实现复杂度中等需要锁和二次检查稍高需要异步刷新线程适用场景金额、库存、对一致性要求高的场景详情页、推荐列表等可容忍轻微延迟的场景数据库压力只有一个线程重建但等待线程仍会重试只有异步线程重建压力更稳我个人更倾向于把两种方案混用一致性要求高的业务数据用互斥锁读多写少、可短暂不一致的热点数据用逻辑过期。别试图拿一个方案解决所有问题那才会出问题。4. 缓存雪崩从过期时间到多级缓存的体系化防御4.1 雪崩的成因范围雪崩的触发条件比前两个更广。最常见的原因是大量key的过期时间点相同比如你写了个定时任务把一批商品数据统一设置成明天凌晨0点过期结果0点一到几千个key同时失效流量瞬间全压到数据库。另一种是缓存节点宕机比如Redis主节点挂了没有及时切换或者网络分区导致客户端拿不到数据所有请求直接绕过缓存打DB。雪崩的杀伤力在于它不是某一个key的事而是整个缓存的防护能力在某个时间点归零。你的数据库原本只承受20%的直连流量还剩80%被缓存挡着雪崩一来直接变成100%而且往往是量的几倍几十倍连接池先爆紧接着是线程池排队最后服务雪崩式超时。4.2 让过期时间“错峰”对付过期型雪崩最简单有效的操作是给TTL加随机偏移量。比如原本统一设置3600秒过期现在改成3600加一个0到300之间的随机数ttl 3600 new Random().nextInt(300); value database.query(key); redis.set(key, value, ttl);这个改动成本极低但效果非常明显。它把原本“同一秒集体过期”变成了“5分钟内分散过期”数据库每秒多承受的请求量就被一个数量级地降下来。只要不是那个区间内所有key都一起失效数据库就能匀速处理回填请求。这里我特别想强调一个细节随机偏移量不能设得太小。如果base TTL是120秒随机范围只有0到5秒那和没加几乎一样随机范围至少得是TTL的10%到20%才有效果。而且加了随机之后缓存命中率的曲线波动会更平滑不会出现“每到整点就抖一下”的月经式故障。4.3 多级缓存本地缓存兜底光靠随机过期还不够因为雪崩还有可能来自Redis节点宕机。这时就需要多级缓存最经典的组合是“本地缓存 Redis 数据库”三层结构。本地缓存指的是Java进程内的Caffeine或者ConcurrentHashMap它的特点是访问极快QPS能顶到几十万但容量有限每个服务实例存的数据量不能太大。我建议把最热门的几百个key放进本地缓存比如商品详情页里访问量最高的前500个商品。查询顺序变成先查本地缓存没命中再查Redis再没命中才查数据库。当Redis宕机时本地缓存还在它能挡掉一大半的读取流量。等到Redis恢复再按正常流程回填。这样做还有个额外好处即使Redis压力很大本地缓存也分担了很大一部分读流量Redis的负载能降下来。本地缓存的大小设置要注意Caffeine可以配置maximumSize和expireAfterWrite我通常设置最大条目5000条过期时间60秒。这个配置能让本地缓存保持足够新鲜又不会占用太多堆内存。如果你把过期时间设成几小时那用户可能会看到很久之前的旧数据体验反而受影响。4.4 限流、熔断与预热兜底三件套多级缓存能把大部分流量挡在外层但数据库仍然需要最后一道保险。限流的作用是当数据库QPS超过它能承受的阈值时直接拒绝一部分请求宁可让少量用户看到“稍后重试”也不能让数据库整个垮掉。业界常用Sentinel或Hystrix做限流但你如果不想引入新组件也可以在网关层写一个简单的计数器限流。需要注意的是限流阈值要结合数据库压测结果来定而不是随便填个数字。熔断是限流的进阶。当我们发现数据库错误率超过阈值比如连续30秒内错误率超过20%就打开熔断器后续请求直接返回降级结果。降级结果可以是默认文案、静态页面或者是上一次缓存下来的兜底数据。这样数据库就有了喘息的机会等它恢复后熔断器再半开试探逐渐放量。缓存预热同样重要。比如大促开始前线上还没有真实流量时就先把热点商品的缓存数据加载好避免活动一开始就触发大面积缓存重建。预热的实现方式很多可以直接写个定时任务扫描热点ID列表批量查询后回填缓存也可以在应用启动时用项目中的接口主动刷一遍。我踩过的一个坑是预热的回填TTL没有加随机结果所有预热数据在同一秒过期又一次触发雪崩。所以记住预热也务必设置随机过期时间。5. 实战中的坑与排查清单5.1 布隆过滤器的误判问题怎么收场布隆过滤器误判本身是设计的一部分但误判带来的业务影响需要提前想好后果。我之前做过一个订单号拦截的场景布隆过滤器里的误判导致极少数合法订单被当成“不存在”处理用户查询订单时偶尔查不到客服那边收到好几个工单。后来我调整了方案布隆过滤器只用于主动拦截“判断为不存在”的高频恶意请求而一旦过滤器判断为“可能存在”查询链路照常走缓存数据库。这样误判最多是放行一些无效请求不会误伤合法数据。如果你连1%的误判都不能接受那就需要换一种完全精确的数据结构比如Redis的Set或者数据库索引表。但这也意味着存储成本和判断耗时都会上升。我给你的建议是布隆过滤器是拦截器不是裁判员它负责挡掉七成以上的无效流量剩下的交给下游兜底这才是性价比最高的用法。5.2 锁失效与线程阻塞击穿场景里互斥锁经常出问题。第一个坑是锁没有设置过期时间线程查到一半突然抛出异常finally里释放锁的代码又被return漏掉了锁永远不被释放后面所有请求都卡在等待上。这个问题最好的解法是加锁时强制设置leaseTime比如5秒锁到期自动释放配合Redisson的看门狗续期来避免业务没执行完锁就过期。第二个坑是等待锁的线程只用Sleep 重试这种写法在低并发下没事在高并发下会造成大量线程占用甚至拖垮线程池。更好的做法是设置一个最大等待时间比如200毫秒超时后直接返回一个降级结果不要死等。同时重试获取锁的次数要限制住否则就成了另类慢查询。5.3 缓存与数据库一致性是那个绕不开的话题雪崩和击穿解决的是“缓存没失效时怎么挡流量”的问题但缓存与数据库的数据一致性问题其实贯穿所有防御方案。经典的Cache Aside模式是先更新数据库再删除缓存。这个顺序是有讲究的因为删缓存失败的概率远低于改缓存失败一旦缓存没删掉下次读到旧数据的概率更大。我还会配合一个延迟双删的变体更新数据库后先删一次缓存等几百毫秒再删一次。这个“等几百毫秒”是为了处理并发更新时另一个线程把旧数据写回缓存的竞态窗口。注意延迟双删不是银弹它有极低概率在两个删除之间被旧值回填。如果你的系统对一致性要求极高我建议直接从架构上绕开——直接读数据库或者用Canal监听binlog再异步更新缓存不要在应用层死磕。5.4 一套趁手的排查流程和速查表最后分享一套我自己在用的排查流程遇到缓存事故时可以照着走先看监控大盘缓存命中率、数据库QPS、平均响应时间三个曲线一起看确认故障区间和持续时间。再定位key特征按key前缀统计访问量排行找出高并发key。确认是否存在不存在数据抽样几个高并发key直接查库验证数据库里有没有。看key的过期时间分布用Redis的scan命令把大key的TTL分布拉出来看是否集中在同一时间段。结合以上信息判定问题类型再采用对应方案。我把常见的定位思路整理成一张速查表现象大概率问题核心排查点首选防御手段大量不存在的key反复打库缓存穿透高并发key在库中是否存在布隆过滤器 空值缓存单个热点key过期瞬间数据库打爆缓存击穿是否集中在特定热点key互斥锁 或 逻辑过期大量key同时过期或Redis宕机后打库缓存雪崩TTL分布是否有尖峰Redis节点状态随机TTL 多级缓存 限流熔断这套流程其实并不复杂关键是你愿意在故障发生前就做好监控和预案。缓存这层防护做得越完善数据库就越少面对极限情况。而数据库一旦崩了恢复通常要按小时算这个代价比事先加一个布隆过滤器要高得多。我个人这些年最深的体会是处理缓存故障千万别只盯着某一种技术方案。布隆过滤器、互斥锁、随机过期、多级缓存这些东西单独拎出来都只是工具真正值钱的是你能看清流量特征、判断出故障类型、再把对应工具组合起来。每次线上缓存抖动都是一次免费的压力测试机会事后把参数调优记录写下来下次再遇到就会从容很多。