线上业务半夜报警数据库连接数打满慢查询堆积成山缓存Redis里明明还有数据上游接口却一片超时。翻了一圈监控发现罪魁祸首不是代码bug也不是流量突增而是一个本该在缓存层就被拦下来的无效请求直接穿透到了数据库。这种事故我在不同团队遇到过至少三次每一次的根因都绕不开三个词缓存穿透、缓存击穿、缓存雪崩。这三个问题听起来像兄弟实际上成因、表现、杀伤力完全不同对应的防御方案也各有讲究。这篇内容我打算把这三个“高并发缓存杀手”一次性讲透从原理层面拆解它们为什么会让数据库崩溃再到布隆过滤器、互斥锁、多级缓存这些主流防御手段的落地细节。文章会贴大量实操代码、参数配置和我在生产环境里实测过的数据适合正在维护高并发服务的后端开发、架构师以及准备系统学习缓存知识的技术人。看完之后你至少能搞清楚一件事当缓存失效的那一瞬间你的系统到底在经历什么以及怎么把它扛住。1. 三种缓存失效场景别再把它们混为一谈很多文章把穿透、击穿、雪崩并列来讲但实际工作中我发现很多人对这三个概念停留在“都会导致数据库压力暴增”的模糊印象。其实它们的触发条件、影响范围、修复手段差别非常大搞混了会直接导致方案选型错误。我先用最直白的方式把它们区分开。1.1 缓存穿透查询了一个根本不存在的数据穿透的本质是请求的key在缓存中不存在在数据库中也不存在于是每次请求都直接打到数据库。攻击者如果构造一批随机不存在的ID比如负数ID、超长字符串、随机UUID就能让数据库承受大量无意义的查询。举个例子。一个电商订单查询接口订单号是19位数字。正常情况下用户查的都是真实订单第一次查询没命中缓存回源数据库然后回填缓存。但如果有恶意请求连续传入12938192831928391283这种并不存在的订单号缓存永远不可能命中数据库每次都要去做一次全表扫描级别的查询连接池很快就会被耗尽。最阴险的地方在于这种流量很难通过常规的限流手段识别因为它们的参数看起来完全合法只是在业务语义上不存在。而且穿透请求往往带有随机性缓存中不可能被提前写入这些key所以缓存层约等于透明。1.2 缓存击穿热点Key在过期瞬间被高并发打穿击穿针对的是某一个热点Key。比如一个秒杀商品的详情页平时QPS可能只有几百活动开始后瞬间飙升到几万。这个商品信息的缓存设置了30分钟过期缓存服务在过期时间到达的那一刻如果正好有一大批请求同时来查这个Key从Redis中取不到数据它们会一起回源数据库。这里的关键词是“同时”。如果只有一两个请求回源数据库毫无压力但如果同一时刻有几千个请求同时回源数据库瞬间就被打垮。击穿和穿透的最大区别是击穿的数据在数据库中是真实存在的只是缓存恰好过期穿透的数据在数据库中压根不存在永远无法回填缓存。对业务的影响也完全不同击穿恢复后缓存会被重新回填系统能自动恢复穿透则会持续打数据库直到流量停止或采取了额外防护。1.3 缓存雪崩大量Key同时过期导致缓存层整体失效雪崩的覆盖面比击穿大得多。如果一批缓存Key设置的过期时间相同比如都是整点过期、都是30分钟过期到了某个时间点这大批Key会一起失效。此时请求全部回源数据库数据库压力瞬间达到峰值很容易触发连接池耗尽、慢查询堆积、主从延迟等一系列连锁反应。我见过一个典型事故运营人员把一批活动页面的缓存过期时间统一设置为600秒结果每天上午10点整这批页面同时刷新数据库在10点整那一分钟内的QPS比平时翻了20倍直接宕机。更麻烦的是Redis中大量Key失效后如果缓存服务本身也扛不住回源请求比如Redis线程被阻塞故障会从数据库向上蔓延形成级联效应。还有一种雪崩场景不是Key过期导致的而是Redis实例宕机。Redis挂了之后所有请求直接绕过缓存打到数据库这种物理级别的雪崩往往比Key过期更致命因为缓存层面已经完全没有保护能力。1.4 三兄弟对比一张表看懂差异维度缓存穿透缓存击穿缓存雪崩触发场景查询不存在的Key单个热点Key过期大量Key同时过期 / Redis宕机数据真实性数据库不存在数据库存在数据库存在影响范围单个无效请求持续不断单个Key高并发瞬间多个Key全局性压力恢复方式无需回填需要拦截回填缓存自动恢复回填大量缓存耗时较长核心防御布隆过滤器、空值缓存互斥锁、逻辑过期过期时间随机、多级缓存、高可用分清这三种场景之后下面我逐个讲对应的防御方案每一步都会给出可以直接抄作业的实现。2. 布隆过滤器拦截穿透请求的第一道闸门布隆过滤器的核心价值在于用极低的内存成本快速判断一个Key“一定不存在”。它允许误判存在但不允许误判不存在。也就是说如果布隆过滤器说“这个Key不存在”那它一定不存在可以安心拦截如果它说“可能存在”则需要放行到后续逻辑去验证。2.1 布隆过滤器的原理和数学直觉布隆过滤器的底层是一个位数组bit数组 一组哈希函数。插入一个Key时用k个哈希函数分别计算得到k个位置把这些位置上的bit全部置为1查询一个Key时同样计算k个位置如果所有位置都是1则认为“可能存在”只要有一个位置是0就认为“一定不存在”。为什么说“可能存在”因为哈希碰撞。不同Key可能映射到相同的位置导致某个Key虽然没有插入过但它的所有哈希位置都已经被其他Key置为1这就产生了误判。误判率可以估算经典公式是误判率 p ≈ (1 - e^(-kn/m))^k其中n是预计插入的元素数量m是位数组长度k是哈希函数个数。实际操作中可以先预估n然后根据可接受的误判率p反推m和k。常用的简化公式是m -n * ln(p) / (ln2)^2 k m/n * ln2举个例子。假设预计插入100万个订单号希望误判率控制在1%代入公式m约等于958万位换算成字节约1.2MBk约等于7。也就是说用1.2MB内存和一亿分之一的误判概率就能挡住几乎所有不存在的Key查询。这个性价比非常高。2.2 布隆过滤器在生产环境中的三种落地姿势第一Guava的BloomFilter。适合单机应用数据在本地内存中加载后只能单机判断。对于部署在多个实例上的服务每个实例都需要各自构建一份适合Key集合相对固定、实例数不多、数据量在百万级的场景。BloomFilterString filter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000, // 预计元素数 0.01); // 期望误判率 filter.put(order_123456); boolean mightContain filter.mightContain(order_987654);第二Redis的布隆过滤器插件。Redis 4.0之后可以通过模块方式安装bf命令常见的是RedisBloom模块。这种方式的好处是过滤器数据集中在Redis中所有应用实例共享同一个判断结果不必每个实例各自构建。缺点是Redis需要额外加载模块有一定的运维成本。BF.RESERVE order_filter 0.01 1000000 BF.ADD order_filter order_123456 BF.EXISTS order_filter order_987654第三基于Redis字符串位图手动实现。用SETBIT和GETBIT命令来模拟位数组操作代码可控性强不依赖额外模块适合已有Redis集群不想引入新组件的情况。不过哈希函数需要自己在代码里实现处理起来稍微繁琐一些。2.3 布隆过滤器误判的后果和处理方法误判意味着某些不存在的Key会被放行到数据库。虽然误判率可以控制在1%甚至更低但1%的无效回源请求在千万级QPS下依然很可观。所以要组合使用布隆过滤器做第一层拦截拦截掉99%的穿透流量剩下1%的漏网之鱼再用空值缓存方案兜底——把查询结果为null的Key也缓存起来设置一个较短的过期时间比如60秒。这样即使布隆过滤器误判放行第二次相同请求也会被空值缓存挡住。我在实际项目中推荐的组合逻辑是请求进来 - 布隆过滤器判断不存在 - 直接返回空结果 请求进来 - 布隆过滤器判断可能存在 - 查Redis缓存 - 命中直接返回 请求进来 - 布隆过滤器判断可能存在 - 查Redis缓存未命中 - 查数据库 - 回填缓存含空值缓存注意布隆过滤器无法删除元素。如果想删除旧数据只能用“定期重建过滤器”的方式或者选择支持删除的变体比如布谷鸟过滤器但这个变体的实现复杂度会明显上升。3. 互斥锁与逻辑过期击穿防御的两条实战路线击穿的核心矛盾是热点Key过期后一瞬间有大量并发请求同时回源。要解决这个问题思路很简单——让这些请求不要同时回源而是只放一个请求去数据库重建缓存其余请求要么等要么拿旧值。3.1 互斥锁方案的实现细节互斥锁的思路是当缓存未命中时不是立刻查数据库而是先尝试获取一把分布式锁。拿到锁的请求去查数据库并回填缓存其他请求在锁上等待然后重新读取缓存。这里的关键问题是用哪种分布式锁等待多久锁何时释放我推荐直接用Redis的SET NX PX命令实现避免引入额外的ZooKeeper或Etcd依赖毕竟Redis已经在链路里了。// 尝试获取锁设置5秒超时 String token UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:hot_key, token, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { // 二次检查缓存防止在等锁期间缓存已经被其他线程重建 Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } // 查数据库 Object data queryFromDatabase(key); redisTemplate.opsForValue().set(key, data, Duration.ofSeconds(600)); return data; } finally { // 释放锁时需要校验持有者身份防止误删其他线程的锁 String lockValue redisTemplate.opsForValue().get(lock:hot_key); if (token.equals(lockValue)) { redisTemplate.delete(lock:hot_key); } } } else { // 没拿到锁休眠一小段时间后重试 Thread.sleep(50); return getFromCacheOrDb(key); // 递归重试注意控制重试次数 }这段代码有几个细节值得展开。关于锁的持有者校验释放锁时如果直接delete可能把其他线程刚获取的新锁误删。比如线程A的锁5秒超时自动释放线程B获取了新锁此时线程A的finally块执行delete就会把B的锁删掉导致B的互斥失效。所以释放前必须比对value值我这里用UUID做标识只有持有者自己才能删除。关于等待和重试没拿到锁就短睡眠然后递归重试但这个递归需要加次数限制。我见过一个线上问题热点Key的数据库查询特别慢超过锁的5秒超时第一个线程的锁已经自动失效了第二个线程拿到锁又开始查库结果多个线程实际上都在查库互斥完全失效。所以锁超时时间必须大于“建缓存”的耗时通常建议压测后确定一般取数据库查询耗时的3-5倍。关于JVM内锁的补充如果所有请求都落在同一个Java进程内用JDK内置的synchronized或ReentrantLock做本地锁就够了性能远高于分布式锁。只有当服务是集群部署时才需要跨进程的分布式锁。我常用的优化方案是“本地锁分布式锁”两级结合先获取本地锁同一个实例内的并发先被本地锁挡掉只有不同实例之间才需要竞争分布式锁。这种方式能大幅减少Redis锁请求的压力。3.2 逻辑过期方案用旧数据扛过真空期互斥锁的痛点是如果热点Key过期瞬间有大量请求在等待响应时间会变长重试也会占用线程资源。逻辑过期方案则完全不同——它不存在“缓存过期”的概念。具体做法是缓存数据中额外存储一个逻辑过期时间字段但Redis中的Key本身不过期或者设置一个很长的物理过期时间。请求读取到数据后先判断逻辑过期时间是否已到。如果没过期直接返回如果已过期则尝试获取互斥锁获取成功后开启一个异步线程去数据库更新缓存当前请求则直接返回“过期的旧数据”。public Object getProductInfo(String key) { // 从缓存读取Redis中的Key永不过期 String json redisTemplate.opsForValue().get(key); if (json null) { return null; // 理论上这种情况非常少Key被手动清理了 } CacheEntry entry JSON.parseObject(json, CacheEntry.class); if (entry.getExpireTime() System.currentTimeMillis()) { return entry.getData(); // 逻辑时间未过期直接返回 } // 逻辑过期尝试获取锁异步刷新 if (tryGetLock(key)) { try { // 异步更新缓存 executorService.submit(() - { Object data queryFromDatabase(key); String newJson JSON.toJSONString(new CacheEntry(data, System.currentTimeMillis() 600_000)); redisTemplate.opsForValue().set(key, newJson); }); } finally { releaseLock(key); } } // 未拿到锁的请求直接返回旧数据 return entry.getData(); }这个方案的核心优势是请求永远都不会因为缓存过期而等待响应时间稳定。核心缺点是在缓存异步更新的窗口期内用户看到的是旧数据。对于实时性要求不高的场景比如商品详情、文章内容、配置信息这个缺点完全可以接受。但如果数据是余额、库存这种强一致要求的数据就不能用逻辑过期。这里有一个生产环境常见的坑异步线程池如果处理不过来缓存永远不更新旧数据会一直返回。所以要给这个线程池加上合理的队列大小、拒绝策略和监控报警。我一般用独立线程池核心线程数和最大线程数根据数据库能承受的并发来定同时监控队列深度超过阈值就报警。4. 雪崩防御多级缓存与过期时间的艺术雪崩的杀伤力在于覆盖面大动辄几十上百个Key同时失效。防御思路也分两条一是让失效时间分散二是让数据库上面不止缓存一道防线。4.1 过期时间的随机化与业务错峰最基础的方案是把固定的过期时间改成“基础过期时间随机偏移量”。比如原来统一3600秒现在改成“3600 随机数60~600秒”。这个策略在代码里改动最小却能显著降低同时失效的概率。基础时间 随机偏移 实际过期时间 3600 random(60, 600) 3660~4200秒要注意的一点是不要所有Key都用一个随机算法生成完全随机的过期时间那样会导致缓存命中率下降。更好的做法是“按业务分组打散”。比如活动页面的Key集中在300~600秒随机商品详情的Key集中在3600~7200秒随机这样同类Key的过期时间大致在一个接近的区间但具体时间点错开既保持了缓存效果又避免了集中失效。另一个工程上的细节很多团队用同一个RedisTemplate的set(key, value, time)方法设置缓存如果key的命名规则里包含业务前缀可以通过前缀来配置不同的随机范围。我习惯在缓存工具类中维护一个过期时间策略表用Map维护前缀和随机范围新增缓存场景时只需要加一行配置而不需要到处改业务代码。4.2 多级缓存把压力分散到更靠近用户的地方当Redis整实例宕机时随机化过期时间完全失效。这时真正能扛住的是多级缓存架构。常见的分级是本地缓存Caffeine - Redis缓存 - 数据库。本地缓存放在应用进程内读写纳秒级不依赖网络。即使Redis挂了只要本地缓存还有数据请求就不会打到数据库。这里最常用的组件是Caffeine它支持基于时间的过期策略和基于大小的淘汰策略。CacheString, Object localCache Caffeine.newBuilder() .maximumSize(10_000) // 最多缓存1万个Key .expireAfterWrite(Duration.ofSeconds(30)) // 写入30秒后过期 .build();读流程变成读取本地缓存 - 未命中则读取Redis - 未命中则查数据库 - 回填两级缓存这里强调一个关键点本地缓存的过期时间必须比Redis短。否则本地缓存长期持有旧数据Redis中的数据更新后本地缓存感知不到就会出现数据不一致。常见的设置为本地缓存30秒Redis缓存10-15分钟。这样Redis更新后最多30秒内本地缓存会过期重新从Redis拉取新数据。多级缓存最大的槽点是数据一致性。如果后台更新了数据库需要主动失效Redis中的旧缓存但本地缓存分布在每个应用实例中无法统一失效。我的实践经验是对一致性要求不高的读取场景接受本地缓存30秒的短时不一致对一致性要求较高的场景不要加本地缓存直接走Redis。4.3 Redis高可用的兜底还有一个层面需要重视Redis本身的可用性。Redis主从切换、哨兵、集群模式都是基础设施层面的雪崩防御。但要注意的是即便Redis是高可用的主从切换的那几秒内缓存写入依然可能失败。所以在业务代码中写缓存必须捕获异常并降级不能因为Redis报错而影响主流程。try { redisTemplate.opsForValue().set(key, value, timeout); } catch (Exception e) { // Redis异常时降级本次不写缓存下次请求继续尝试 log.warn(write redis failed, e); }同理读缓存也要做降级。如果Redis读超时不要抛异常让请求失败而是直接返回null并进入数据库查询流程。虽然这会加重数据库压力但至少业务是可用的配合熔断机制可以控制住影响范围。5. 一套完整的防御体系高并发读接口的落地框架前面分别讲了穿透、击穿、雪崩的防御手段但真实的生产环境要的是把这些手段组合成一个统一的读接口框架而不是在每一个业务代码里东拼西凑。我构建过一套比较通用的“四级防御”读接口在这里给出完整的设计。5.1 四级防御链路拆解整个读请求的流程可以分为四层第一层本地缓存。Caffeine承载过期时间30秒。这一层的目的是扛住极端QPS下对Redis的压力。如果本地缓存命中整体响应时间在毫秒级。第二层布隆过滤器。判断Key是否可能存在。如果不存在直接返回空结果或缓存中的空标记杜绝穿透流量进入数据库。布隆过滤器可以放在RedisRedisBloom模块中也可以放在本地Guava根据数据量权衡。第三层Redis缓存。承载主要的缓存命中流量过期时间根据业务配置采用基础时间随机偏移。这一层是系统的主力缓存层。第四层数据库。只有前面三层全部未命中才会到达这一层。到达这一层时还要用互斥锁控制并发回源的数量保证同时只有少数请求在查库。5.2 统一的读接口模板代码下面是我常用的一个泛型化模板的骨架业务方只需要传入Key生成函数和数据库查询函数public T T queryWithDefense(String key, FunctionString, T dbQuery, ClassT clazz) { // 本地缓存1Caffeine Object local localCache.getIfPresent(key); if (local ! null) { return (T) local; } // 防线2布隆过滤器不存在直接返回 if (!bloomFilter.mightContain(key)) { return null; } // 防线3Redis缓存 String json redisTemplate.opsForValue().get(key); if (json ! null) { T data JSON.parseObject(json, clazz); localCache.put(key, data); // 回填本地缓存 return data; } // 防线4互斥锁控制回源 T data loadFromDbWithLock(key, dbQuery, clazz); if (data ! null) { localCache.put(key, data); } return data; }要注意的是这个模板把布隆过滤器的判断放在Redis缓存之前。这么做的原因是布隆过滤器的查询时间比较快而且如果它判断Key不存在就没有必要再去Redis执行一次G一边GET。另一个细节如果业务允许一定程度的延迟这里的“防线2”可以在本地缓存中实现但如果布隆过滤器数据量较大本地内存撑不住就必须放在Redis中。5.3 实测效果与调优参数参考我把这套框架用在一个商品详情接口上压测结果供大家参考接口原生QPS约800数据库单机可承受约2000 QPS的查询。加四级防御后Jmeter压测线程数从100调到1000数据库实际接收的查询QPS稳定在30左右接口P99响应时间从40ms降到了12ms。主要参数配置是这样的参数值说明本地缓存过期时间30秒必须小于Redis缓存过期时间本地缓存最大条目10000防止内存溢出需要根据Heap大小调整Redis缓存过期时间600秒随机120秒兼顾命中率和防雪崩布隆过滤器预计元素量实际数据量×1.1留出10%余量布隆过滤器误判率0.01取值越低内存越大锁超时时间5秒大于数据库慢查询耗时异步刷新线程池核心4最大8队列1000根据数据库能力配置这个配置不是固定不变的仅供参考。核心原则是数据库回源量必须压到数据库能承受的范围以内同时保证缓存命中率在95%以上。6. 常见问题与排查技巧实录在实际维护这套体系的过程中我踩过不少坑也总结了一些排查路径。单独拎出来说一遍可能比上面那些代码更有价值。6.1 布隆过滤器误判偏高怎么办误判率升高先别急着调大位数组。排查一下是不是数据量增长超出预期导致初始化的n值过小。比如你初始化的预计元素量是100万实际写进了500万误判率会急剧升高。解决办法是监控过滤器中的BF.INFORedisBloom或者布隆过滤器当前元素数的估算值超过预计量的80%就触发自动重建或者提前把预计量设大一点。还有一个容易忽略的问题业务代码中对Key的序列化方式不统一。有的地方用String有的地方用DigestUtils.md5DigestAsHex做了哈希如果Key的格式不统一布隆过滤器中的数据和查询的数据无法对应会产生大量“假误判”。排查时可以先打日志确认同一个Key在写入和查询时经过的字符串是否完全一致。6.2 互斥锁失效缓存被重复重建锁失效最典型的场景是持锁线程执行时间超过了锁的自动过期时间。锁被Redis自动删除后其他线程又拿到新锁造成多个线程同时查库。排查时看日志里“acquire lock”和“release lock”的间隔如果经常接近锁超时时间说明建缓存过程太慢需要优化数据库查询或者把锁的超时时间拉长。还有一个更隐蔽的问题锁的key设置了过期时间但释放锁时用delete命令如果恰好在这个时刻锁已经过期删除操作就会把别人刚创建的新锁删掉。前面代码里面已经提到解决方案就是用UUID作为锁的value并校验后再删。这个细节能挡住很多人都会踩的坑。6.3 多级缓存数据不一致如何缓解多级缓存一定会面临不一致的问题。Redis更新了本地缓存可能还是旧数据数据库更新了Redis缓存也还是旧数据。我的建议是不要追求强一致而是把一个可接受的不一致时间明确写进文档。对于主动更新的场景可以在更新数据库后先删Redis缓存再通过延迟双删1秒后再次删除来清理可能被读请求重新写入的旧缓存。本地缓存这边如果框架支持消息通知可以通过Redis的Pub/Sub广播一个“清本地缓存”的消息如果没条件就接受它最多30秒的过期时间。重要提示多级缓存的一致性考验的不是技术而是业务容忍度。设计架构前先和业务方确认数据更新后最多可以接受多少秒的延迟把这个数字写清楚比你用再高级的组件都重要。6.4 缓存回填风暴的预防当大量缓存同时失效时回填过程本身也会造成数据库压力。除了随机过期时间以外还有个技巧是“回填节流”——不是所有请求都去数据库回填而是只让一部分请求去查询其他请求直接返回默认值或空数据。比如用互斥锁保证同一时刻只有一个请求回填或者用一个“回填信号量”控制全局的最大回填并发数。我在高并发场景下还用过一种“渐进式回填”缓存失效时返回的旧数据里带一个refresh_after字段表示“这个数据最多再服务多久”。到达这个时间后只允许一个异步任务去刷新而同步请求永远拿旧数据。这是逻辑过期方案的变种非常适用于数据更新不频繁但读取量极大的场景。7. 这些方案的边界和取舍布隆过滤器、互斥锁、多级缓存这些方案都不是银弹它们有各自的边界。我在实际项目中反复调整过很多次逐渐摸索出一些取舍的经验。布隆过滤器最大的边界是它只能用于“判断存在性”不适合作为唯一的穿透防御。如果业务中合法请求都是“近期创建的数据”那么布隆过滤器很快就会被塞满重建成本很高。这种情况下空值缓存也许更直接。两种方案可以组合空值缓存放Redis布隆过滤器放本地前者兜底后者拦截绝大多数恶意流量。互斥锁的边界在于它本质上是在“用性能换一致性”——请求需要等待必然增加延迟。如果热点Key的过期频率很高比如每分钟都过期那么锁竞争本身就会成为新的瓶颈。这时候逻辑过期方案更合适因为它几乎没有锁等待。多级缓存的边界在于“数据一致性”和“内存成本”。本地缓存的引入意味着每个应用实例要额外占用内存如果缓存条目太大会导致JVM GC压力上升。选择哪些数据进本地缓存、哪些只走Redis需要按数据热度来分。热度高的、读取量大的、一致性要求低的数据才适合放本地。我个人的原则是先解决主要矛盾。如果问题是穿透流量大优先上布隆过滤器如果问题是热点数据偶尔过期导致抖动优先上互斥锁如果问题是周期性雪崩优先随机化过期时间。不要一上来就上一个全量的“缓存防御全家桶”复杂度会翻倍排查问题的难度也会翻倍。8. 一次真实事故的复盘从报警到修复的完整思路最后分享一次我亲身经历的事故把前面这些理论串起来看效果会更好。某次大促预热期凌晨两点监控突然报警数据库CPU打满慢查询暴涨。查看Redis的hit ratio从平时的98%掉到了76%。第一反应是查RedisKey的过期情况发现大量活动相关的Key同时过期——运营同学在后台定时任务里批量刷新活动数据刷新代码统一设置的过期时间是300秒而定时任务每5分钟执行一次相当于每300秒就有几百个Key同时被重建。解决思路分了三步第一步止血。临时把定时任务的刷新逻辑改掉刷新完成后不删旧缓存而是先更新数据库再通过异步任务在后台批量重写缓存让缓存过期时间错开。这一步花了5分钟数据库压力立即回落。第二步治理。把活动缓存代码统一改成随机过期时间并且加了一个开关如果某个批次Key的数量超过阈值就随机挑选其中一半先刷新剩下的延迟30秒再刷新避免瞬时并发回填。第三步预防穿透。大促当天存在大量用户反复刷新不存在的活动页面布隆过滤器提前添加了所有活动ID的集合无效请求在过滤器就直接被拦掉。事故复盘后我意识到一个核心问题很多缓存故障不是技术方案不行而是业务侧的定时任务、批量操作没有考虑缓存失效的“共振效应”。技术防御是必要的但更重要的是在业务流程中加入缓存意识——比如批量更新数据的任务一定要把“集中过期”作为一项风险来评估。这套防御体系我的建议是你不用全上根据自己的业务体量从布隆过滤器或互斥锁选一个开始逐步叠加。先把最痛的那一个问题解决了再考虑引入更多组件。缓存这层看上去简单但真正要扛住高并发那些“缓存失效瞬间”的处理才见功底。