
缓存击穿这个坑干后端的人多少都踩过。平时缓存扛着绝大部分读请求数据库岁月静好结果某个热点Key一过期瞬间几十个请求同时打到数据库上慢查询、连接池打满、CPU飙高一气呵成。我一直觉得缓存击穿是三个“缓存杀手”里最阴险的一个——缓存穿透是打空门缓存雪崩是成片倒而击穿是盯着一个点往死里锤锤完数据库就半瘫。这篇文章想聊的就是以“50并发同时请求一个已过期热点Key”为基准场景把缓存击穿防护的几种主流方案掰开揉碎讲清楚从互斥锁到逻辑过期再到多级缓存每一层都给出可以直接抄作业的代码和配置顺手把我踩过的坑、调过的参、压过的测也都交代一遍。适合正在做高并发接口、被热点流量折磨过的Java开发也适合刚接触分布式缓存、想搞明白“为什么缓存和数据库之间总要多一层锁”的同学。1. 先搞清楚缓存击穿到底是怎么发生的1.1 缓存击穿与穿透、雪崩的本质区别很多人把缓存击穿、穿透、雪崩混为一谈面试的时候也经常被绕进去我建议直接用一个生活场景把它们分开。想象你是一家奶茶店的老板门口排着长队客人点的都是同一款招牌奶茶。缓存穿透是一个客人来点“珍珠椰果布丁全家福”这种根本不存在的单品你翻遍菜单都找不到每次都白忙活一趟操作台被无效订单白白占用。缓存雪崩是你家所有菜单上的饮品同时下架所有客人都得去后厨重新催单后厨瞬间被挤爆。而缓存击穿是你家招牌奶茶的配方牌子突然被风吹掉了原本看一眼牌子就能直接做的客人这会儿全挤到后厨问你“怎么做怎么做”后厨一个人对着一群客人手忙脚乱之下锅都翻了。对应到技术层面上缓存穿透是查询一个缓存和数据库中都不存在的数据缓存永远不命中请求直接怼到数据库这种情况可以用布隆过滤器或者缓存空值来处理。缓存雪崩是大量Key在同一时间集中过期或者Redis实例整体宕机海量请求绕过缓存直达数据库。缓存击穿是某个热点Key在缓存过期的那一瞬间恰好有大量并发请求同时访问它所有请求发现缓存未命中于是争先恐后地去数据库查询数据库连接瞬间被打满。注意这里有个容易被忽略的关键点——击穿只针对热点Key冷门Key根本不存在“击穿”的问题因为冷门Key压根没有并发量同时访问的那几个人根本不构成压力。用一个数值来感受一下假设某个热点Key承载了每秒5000次查询缓存命中率99%那么每秒也会有50次请求因为缓存未命中而落到数据库上如果这50次请求刚好集中在缓存过期的1秒内而数据库平时只能稳定扛住每秒100次查询那这50次请求虽然看起来不算多但配合上其他正常流量数据库可能就顶不住了。更极端的情况如果热点Key过期瞬间有500个并发请求那数据库基本就告别这次服务了。1.2 为什么“50并发”是一个值得认真对待的临界场景我见过不少同学对“50并发”嗤之以鼻说50个并发算什么数据库随便扛。这是典型的想当然。你看一个数字不能只看绝对值要看它落在谁身上。50个并发如果均匀分布在10秒内那每秒才5个请求确实毫无压力。但50个并发如果同时砸在同一个数据库查询上那可就是另一回事了。打个比方50个人不是排队进电影院而是同时挤一扇只有半米宽的小门。数据库的每个连接、每条查询都有开销MySQL默认的连接数上限也就100多一个热点Key的缓存击穿能直接吃掉数据库一半的连接配额再叠加其他业务流量连接池就满了后续新请求全部排队等待表现就是接口RT从50ms飙升到3000ms然后雪崩式扩散。所以我在设计防护方案的时候辛辛苦苦制定了这样的测试基线模拟50个并发线程同时请求一个刚刚过期的热点Key观测数据库侧的QPS变化和接口响应耗时。这实际上是一个非常贴近生产的场景50并发不算极端但足以暴露80%的缓存击穿隐患。我见过太多项目缓存击穿防护方案上线后压测200并发过了结果线上50并发就出问题——原因是方案本身存在“锁的空转”或“回源风暴”问题并发量低时恰好被掩盖了。1.3 缓存击穿防护的总体思路地图基于上面的分析可以总结出三条主流的防护路线。第一条是“互斥锁路线”也就是让同一时刻只有一个请求能去数据库回源其他请求阻塞等待典型实现有JVM锁、分布式锁、锁分段。第二条是“逻辑过期路线”也就是缓存永不过期但数据内部记录一个逻辑过期时间由后台异步线程负责刷新这样请求永远在缓存层面命中压根不给数据库制造压力。第三条是“多级缓存路线”在Redis前面再加一层本地缓存让热点请求在应用内部就被拦截能有效减少Redis压力但数据一致性维护稍显麻烦。这三条路线不是非此即彼的关系生产环境下经常组合使用。比如用本地缓存挡掉80%的热点请求剩下20%回源RedisRedis再配合互斥锁做回源保护层层设防数据库才能安稳。后面我会把每条路线逐个拆开结合代码、参数和真实踩坑经历详细说明怎么实现、怎么取舍。如果你现在只想记住一个结论那就是任何一层防护都是为了减少回源请求的并发度而不是消灭回源请求本身。2. 互斥锁路线从JVM锁到分布式锁的进化之路2.1 第一版synchronized 双重检查简单但有效最直接的想法是在回源查数据库的代码块外面加一把锁保证同一时刻只有一个线程进入。为了不让所有读请求都排队等锁在加锁前后各查一次缓存这就是经典的“双重检查锁”模式。虽然这是一个很基础的方案但作为兜底手段非常实用。public String getData(String key) { // 第一次查缓存 String value redis.get(key); if (value ! null) { return value; } // 加锁保证同一时刻只有一个线程回源 synchronized (this) { // 第二次查缓存拿到锁的线程可能已经把缓存刷新了 value redis.get(key); if (value ! null) { return value; } // 真正回源查数据库 value queryFromDB(key); // 回填缓存设置过期时间 redis.set(key, value, 300, TimeUnit.SECONDS); return value; } }这个方案的核心逻辑很容易理解缓存里没有数据先别急着去查数据库大家排队谁拿到锁谁去查查完回填缓存后面排队的人进来一看缓存已经有了直接返回。第二次查缓存那一步是这个小方案的灵魂少了这一步所有线程都会白白等锁然后各自查一次数据库。但这里要泼一盆冷水synchronized (this)锁的是当前对象如果是Spring默认的单例Bean那这把锁就是整个JVM实例级别的锁。意思是在同一个进程内有效但如果你部署了多个实例每台机器都有各自的锁50个并发如果分摊到5台机器上每台同时大概有10个线程回源虽然压力小了一些但并没有从根上解决问题而且this锁的范围太大如果这个类里有多个方法都用synchronized (this)保护互相之间也会阻塞。所以这个方案只能作为单机兜底不能作为分布式环境的主力方案。2.2 第二版分布式锁 自旋等待解决多实例互斥问题服务是多实例部署的JVM锁跨不了机器那就得引入分布式锁。用Redis的SETNX命令实现一个简易分布式锁拿不到锁的线程轮询等待并反复检查缓存是否已经被其他线程回填这样既能保证全集群只有一个回源线程又能让等待的线程尽早返回。public String getData(String key) { String value redis.get(key); if (value ! null) { return value; } String lockKey lock: key; String requestId UUID.randomUUID().toString(); // 尝试获取分布式锁设置5秒超时防止死锁 boolean locked redis.setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (locked) { try { // 拿到锁后再次检查缓存 value redis.get(key); if (value ! null) { return value; } value queryFromDB(key); redis.set(key, value, 300, TimeUnit.SECONDS); return value; } finally { // 释放锁之前比较requestId防止误删其他线程的锁 if (requestId.equals(redis.get(lockKey))) { redis.delete(lockKey); } } } // 没拿到锁的线程自旋等待 int retryTimes 0; while (retryTimes 100) { value redis.get(key); if (value ! null) { return value; } // 短暂休眠避免CPU空转 Thread.sleep(10); retryTimes; } // 自旋超时兜底回源 return queryFromDB(key); }这个版本有几个细节值得留意。锁的过期时间设成了5秒这意味着如果回源查数据库超过5秒锁就会自动释放其他等待的线程会再次争抢锁回源这本身是一种保护防止某个线程宕机后锁永不释放。释放锁之前做了一次requestId比对这是因为Redis没有原生实现“仅当持有者才能删除”的原子操作如果不做这个比对一个线程的锁如果已经过期了另一个线程又拿到了同一个锁那第一个线程释放锁的时候就会把第二个线程的锁删掉这会造成锁的“误删”问题。自旋参数的取值我习惯用100次重试、每次休眠10毫秒合起来最多等待1秒。这个等待时间要结合数据库回源耗时来定。我在压测中发现如果数据库查询平均耗时50毫秒那持有锁的线程回源回填缓存大约需要60毫秒自旋等待1秒足够让其他线程在锁释放后立刻读到缓存几乎没有感知。2.3 升级版锁分段 50个锁把竞争降到最低分布式锁解决了跨机器互斥问题但细心的同学会发现一个性能隐患如果所有热点Key都用同一个锁Key比如lock:data那不同的Key之间也会互相阻塞。实际业务里热点Key往往不止一个比如一个商品详情接口有100个热门商品如果这100个商品的Key共用一把锁那么某个商品的回源会阻塞其他99个商品的缓存读取这是完全没必要的。我的做法是引入锁分段的思想。把Key根据哈希值映射到固定数量的锁段上每个锁段相互独立比如标题里提到的50就可以理解为把锁空间划分成50个段。具体来说为每个Key生成一个lockIndex取值范围是0到49然后加锁时操作的是lock:segment:{lockIndex}。这样不同Key的锁竞争被分散到50个互不干扰的槽位同一个Key始终落在同一个槽位仍然能保证互斥。private static final int SEGMENT_COUNT 50; private String getLockKey(String key) { int segmentIndex Math.abs(key.hashCode()) % SEGMENT_COUNT; return lock:segment: segmentIndex; }有人可能会问hashCode()取模会不会导致分布不均匀确实会Java的String.hashCode()算法对类似product:1001这种连续编号的Key取模后可能集中落在某几个段上。所以我实际生产代码里用的是MurmurHash3分布更均匀配合50个段热点Key基本能打散。如果你不想引入额外依赖也可以在hashCode()基础上加一个扰动函数比如hash key.hashCode() ^ (key.hashCode() 16)效果会比直接用原始哈希值好一些。锁分段的本质是“用空间换并行度”。50个锁段意味着同一时刻最多允许50个不同Key的线程同时回源而同一个Key仍然只能有一个线程回源。对于热点相对分散的业务这个设计既保证了正确性又大大提升了并发吞吐。我基于50锁段跑过一个对比实测100个热点Key、每个Key对应50个并发线程共5000个并发请求单一锁方案下平均RT为180ms锁分段方案降到35ms吞吐量提升接近5倍。这组数据足以说明锁的粒度不是越小越好而是越贴合业务热点分布越好。2.4 互斥锁方案选型的适用边界和性能隐患互斥锁方案最大的好处是逻辑简单、数据一致性最强缓存里没有就回源回源完回填不会出现数据为空、只有后台刷新的说法。但它也有两个绕不过去的性能问题。第一高并发下自旋等待会对Redis产生额外压力。没有拿到锁的线程每隔10毫秒就要查询一次缓存如果同时有100个线程在等待那么每秒会额外产生10000次Redis查询虽然压力不大但在Redis已经被其他业务挤到边缘的情况下这可能是压垮骆驼的最后一根稻草。针对这个问题我习惯把自旋延迟改成指数退避前几次间隔10毫秒后面逐渐拉长到50毫秒、100毫秒减少无效查询。第二锁持有期间如果数据库查询很慢所有等待线程都会堆积。比如数据库出现了慢SQL单次查询耗时2秒那么锁的5秒过期时间可能直接触底第一批线程还没有回填缓存锁就自动释放了第二批线程又冲进来回源形成多波回源。这种场景下即使有分布式锁数据库依然可能被拖垮。我踩过一次这样的坑后来把锁过期时间改成动态值用“预估DB查询耗时乘以2再加500毫秒”来计算效果比固定值好很多。3. 逻辑过期方案让热点Key永不过期3.1 核心思想把过期时间挪到数据内部逻辑过期这个方案第一次接触可能觉得反直觉——你不是让我防击穿吗怎么又让Key永不过期了别急这里的“永不过期”指的是Redis层面不给Key设置TTL但往Value里塞进一个业务过期时间字段。查询的时候拿着这个字段对比一下如果没到过期时间直接返回数据如果已经过期也不会立刻去数据库回源而是把“需要刷新”的任务丢给后台线程当前线程返回旧数据顺带触发一次异步刷新。这种设计的巧妙之处在于缓存永远有值所以缓存击穿这个事从根上就不会发生。用户看到的永远是“过期瞬间的旧数据”但刷新动作完全由后台线程代替用户请求来承担用户的请求不会直接打到数据库上。public class CacheWrapperT { // 逻辑过期时间戳单位毫秒 private long expireTime; // 真实数据 private T data; }3.2 实现一个带异步刷新的逻辑过期缓存具体实现时可以构建一个基于ThreadPoolExecutor的刷新线程池。业务请求发现缓存数据已经逻辑过期就往刷新队列里提交一个任务然后立刻返回当前的旧数据。后台线程消费任务执行数据库查询把新数据回填到缓存并更新时间戳。private static final ThreadPoolExecutor REFRESH_POOL new ThreadPoolExecutor( 4, 16, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), new ThreadPoolExecutor.DiscardPolicy() ); public String getDataWithLogicalExpire(String key) { CacheWrapperString wrapper redis.get(key); if (wrapper null) { // 缓存里根本没有值还是一个走互斥锁套路的回源 return getDataWithMutex(key); } if (wrapper.getExpireTime() System.currentTimeMillis()) { // 逻辑时间还没过期直接返回 return wrapper.getData(); } // 逻辑过期提交异步刷新任务先返回旧数据 REFRESH_POOL.submit(() - { String newValue queryFromDB(key); CacheWrapperString newWrapper new CacheWrapper(); newWrapper.setData(newValue); newWrapper.setExpireTime(System.currentTimeMillis() 300_000); redis.set(key, newWrapper); }); return wrapper.getData(); }这里有一个细节值得展开线程池的拒绝策略我用的是DiscardPolicy也就是队列满了就直接丢弃任务。有人可能会问丢弃刷新任务不会导致缓存永远不更新吗其实不会因为被丢弃只是代表这次没刷新成功下一次请求如果发现缓存还是逻辑过期会重新提交刷新任务。真正需要担心的是如果数据库查询很慢而刷新任务一直被提交线程池里的任务会堆积所以队列容量不能设太大我压测得出1000是一个不错的折中值——既能承受瞬时高峰又不至于让内存被任务对象占爆。3.3 逻辑过期方案的经典缺陷短期数据不一致逻辑过期方案最大的代价是数据短期不一致。假设商品库存缓存了10秒逻辑过期时间一到异步刷新线程从数据库读到最新库存之前所有请求拿到的都是旧库存值。在电商秒杀场景、库存扣减这种强一致场景下这是不可接受的。所以我一直强调逻辑过期适合读多写少且允许短暂容忍延迟的数据比如商品详情标题、用户昵称、配置类信息这些数据即使旧个几秒钟也没有人会在意。如果你要用逻辑过期方案处理库存这类敏感数据我建议把“逻辑过期时间”当做一个“最长容忍窗口”同时叠加一层主动失效机制。也就是在数据变更点手动删除缓存Key或直接更新缓存值保证数据变更发生时缓存能立即同步异步刷新只是为了兜底那些没有被主动更新到的字段。这两种手段结合使用以后不一致窗口就被压缩到了一个极小的范围。3.4 互斥锁与逻辑过期的正面PK我把两种方案放到同一张表格里做过一轮对比测试自己压出来的数据加上线上实际运行观察结论如下。对比维度互斥锁方案逻辑过期方案数据一致性强每次回源拿到的都是最新数据弱过期后短暂返回旧数据用户感知RT等待锁的线程可能有几十毫秒延迟完全无感永远直接返回数据库压力每个Key只放行一个回源线程由后台异步线程控制刷新频率实现复杂度需要引入分布式锁复杂度中等需要包装缓存值额外维护线程池典型适用场景强一致要求如订单、库存、金额弱一致要求如配置、详情、榜单我个人的经验是如果业务对数据一致性要求高闭眼选互斥锁如果业务能容忍几秒的延迟并且热点流量非常大逻辑过期能带来更好的用户体验。两种方案不是二选一的敌对关系我见过很多项目在核心链路上用互斥锁在非核心、频繁读取的meta信息上用逻辑过期分层配合各取所长。4. 多级缓存路线在JVM内部就把流量拦截住4.1 多级缓存架构Caffeine与Redis的组合拳前两种方案都在解决“Redis缓存过期后如何控制回源节奏”这个问题但不管怎么做每一层防护落地时都伴随一次Redis网络IO。在极端高并发场景下Redis本身也会成为瓶颈热点Key的单节点压力不容小觑。这时候就该引入多级缓存了——在应用进程内再放一层本地缓存让请求优先命中本地减少对Redis的依赖。我常用的组合是Caffeine作为一级缓存Redis作为二级缓存数据库作为三级兜底。Caffeine是一个基于Java的高性能本地缓存库底层实现了W-TinyLFU淘汰算法对热点数据的识别和保留能力比传统LRU更聪明。它的读性能是纳秒级的虽然缓存的是各机器自己的本地副本数据一致性更难保证但换来的是极致的响应速度。4.2 多级缓存查询链路与缓存更新策略查询链路设计成三层递进。请求先查Caffeine命中的话直接返回整个过程不经过网络没有命中再查RedisRedis有则回填Caffeine并返回Redis也没有才回源数据库回填Redis和Caffeine。这套链路能明显降低Redis的压力尤其是在热点比较集中的场景下Caffeine的命中率往往能达到70%以上相当于把七成流量挡在了应用内部。但多级缓存最让人头疼的是数据一致性问题。本地缓存是每台机器各存一份A机器更新了缓存B机器的缓存还是旧的这种偏差在某些场景下会被无限放大。我的处理思路是对于一致性要求极高的数据比如用户余额、订单状态直接剔除本地缓存这层只走Redis对于一致性要求中等、更新频率不那么高的数据比如商品详情、排行榜可以用“Redis更新后主动发布一个失效通知”各机器收到通知后删除本地缓存中的对应Key下次请求重新拉取。这个通知机制可以用Redis的Pub/Sub实现结合业务实际情况我一般只对热点Key启用避免因消息堆积造成不必要的网络开销。4.3 本地缓存的过期时间设计短而精本地缓存的过期时间我通常会设置得比Redis短。原因很简单本地缓存是不可控的机器上旧数据分分钟就被调用了短过期时间能限制陈旧数据的生命周期。经验值一般是30到60秒不会超过5分钟。Redis缓存可以设置5到10分钟的过期时间因为它是全局统一的一份副本更新时及时失效就好。有一个真实案例可以说明这套设计的威力。我优化过一个B端接口热点是某个配置项读多写少。优化前所有请求直连Redis压测3000 QPS时Redis的CPU已经到40%。加了Caffeine本地缓存后同样的压测流量Redis的QPS降到了300CPU降到6%接口平均RT从2ms降到0.3ms。这不是夸张多级缓存就是有这么立竿见影的效果值得投入。4.4 多级缓存如何配合互斥锁做最后兜底多级缓存也不是万能的。Caffeine没有命中、Redis又没有数据此时大量并发请求还是会到达数据库。所以我在设计多级缓存链路时会在数据库回源的地方继续使用互斥锁防护。也就是说本地缓存帮我削减了至少一半的请求Redis承担的并发度已经很低再加上分布式锁保证了同一时刻只有一个线程能回源到数据库层层叠加之后数据库在这些环节上的压力已经被压到极限。这里有个细节我提醒一下不要在所有请求上都套分布式锁不然多级缓存带来的性能提升会被锁等待抵消掉。正确做法是本地缓存和Redis都未命中时才尝试加锁这个分支本身就是低频路径加上锁对大多数请求没有影响。5. 真实生产环境的坑与排查实录5.1 缓存空值引发的“击穿回马枪”有一个很隐蔽的场景数据库里本身查不到数据你没有把null写进缓存此时每个请求都会因为缓存未命中而触发回源逻辑。如果这个Key恰好被外部频繁调用比如用户查询一个不存在的优惠券那就相当于把缓存穿透和缓存击穿叠加在了一起。我之前排查一个线上问题发现某个接口的数据库慢查询特别多查了半天发现是有大量恶意扫描请求在查询不存在的订单号每次都直扑数据库。解决办法是“缓存空值”或者“布隆过滤器”。缓存空值最简单——数据库查不到数据时在Redis里放一个空值标记过期时间设置短一点比如60秒这样后续同样的请求可以直接命中缓存的空值标记不再打数据库。要注意的是这种空值缓存不能设置太长过期时间否则数据库真的写入新数据后缓存里还是空值标记用户就要等接近一分钟才能看到数据这是不能接受的。5.2 锁的误删与死锁是分布式锁方案最大的坑分布式锁的经典坑我在前面已经提到过但实操中还是会反复踩。最典型的是线程A拿到锁后因为GC停顿或者网络抖动锁自动过期了线程B在这时拿到同一个锁并回源然后线程A恢复过来执行finally块里的删除操作直接把线程B持有的锁删掉了。紧接着线程C冲进来拿到锁此时线程B可能还没执行完两个线程同时回源互斥性就被破坏了。我针对这个问题的完整解法是三步走。第一给锁的Value写入一个全局唯一的requestId释放锁时用Lua脚本原子地比较并删除这是标准解法。第二把锁的超时时间设置得足够充裕我这里一般按照预估回源耗时的5倍设置宁可锁多占一会儿也不能提前释放。第三如果对一致性要求极高还可以引入Redisson的看门狗机制它会自动续期避免锁在业务执行期间提前过期。我当时刚接触的时候图省事用过一个固定5秒超时的自研锁上线第一周就出现了两次超时导致的缓存多次回源后来切换成看门狗方案问题彻底消失。5.3 热点Key的“单一节点瓶颈”与本地缓存的独特价值还有一个容易被忽略的问题Redis集群模式下每个Key都是按照哈希槽分布在固定的节点上热点Key访问量再大也只由一台Redis实例承担。如果热点集中在个别Key上那么这台Redis实例会成为压力瓶颈CPU先被打满其他节点再闲也无能为力。这个问题用分布式锁是解决不了的因为锁也需要访问Redis流量同样压在这台实例上而多级缓存可以在应用本地解决一大部分Redis请求。此外还可以考虑用“热点Key备份随机访问”的方式也就是在Redis里为热点Key多存几份命名为hotKey#1、hotKey#2读取时随机选一个后缀访问把流量分散到多个哈希槽位上。这个方案能有效缓解单节点压力但在实施时要注意所有副本的更新都要同步到位否则会出现数据不一致。我建议把“备份数”控制在3到5份太少缓解不了压力太多又让更新操作变繁琐。5.4 压测验证如何证明你的防护方案真的有效方案写出来不算完还得用压测数据证明它确实扛住了。我的压测方法一般分成三步。第一步准备一个测试Key先把它的缓存值删除确保它处于“未命中”状态。第二步用压测工具一次性发起50个并发请求观察数据库侧的真实查询次数。第三步对比防护前后的数据库QPS曲线和接口RT分布。如果防护生效你会看到数据库侧只有1到2条查询SQL其余请求全部通过缓存命中返回。如果防护失效数据库侧会瞬间出现50条左右的查询SQL接口RT出现明显尖刺。这个验证方法我一直沿用它足够直观比看什么理论推演都有说服力。压测工具有很多种我个人偏爱用JMeter配合一个自研的Java测试程序因为这样可以更精确地控制并发数和每个线程的出发时间做到真正意义上的“同一瞬间50个请求”。压测结果我也记录过一组典型数据不做任何防护时50个并发请求同时回源数据库QPS瞬间飙到50接口P99耗时1.2秒数据库连接池出现排队。加上分布式锁后数据库QPS降到1P99耗时降到35毫秒效果可以说是天壤之别。这就是一个典型的“50并发缓存击穿防护”场景的完整验证过程。5.5 兜底思维防护方案本身也要能优雅降级最后一条心得是关于“兜底”的。任何防护方案都不是银弹分布式锁依赖Redis如果Redis本身挂了锁就完全不可用逻辑过期依赖线程池如果线程池被打满刷新任务会被丢弃多级缓存依赖本地内存如果内存不够触发频繁GC缓存命中率会直线下跌。所以方案设计到了一定程度要考虑的是“如果防护本身的依赖挂了怎么办”。我的做法是在回源路径上增加一个熔断开关。当数据库查询异常率超过阈值比如30%就自动触发熔断一段时间内所有请求直接走缓存哪怕是旧数据不再尝试回源数据库。这看起来是“降低一致性换可用性”但比起数据库被打挂导致整个服务不可用短暂返回旧数据是一个更理性的选择。这段经验是我在一次大促压测里总结出来的当时如果不健熔断Redis抖动会顺着锁等待传导到数据库最终全链路雪崩。6. 最后的实战建议如果你所在的项目正被缓存击穿问题困扰我的建议是先别急着上复杂方案按这个顺序来。第一步确认热点Key的分布和并发度用日志或监控统计出Top10热点Key的访问量和过期时间分布这一步是基础。第二步根据一致性要求选主线方案强一致用分布式锁配合锁分段弱一致用逻辑过期。第三步如果并发量非常大且Redis压力已经成为瓶颈再引入Caffeine本地缓存。第四步无论什么方案都要叠加“缓存空值”处理和“熔断降级”兜底这两层是保命用的。我在实际项目中最常用的组合是Caffeine本地缓存短过期 Redis缓存正常过期 分布式锁锁分段 空值缓存兜底。这套组合在压测和线上都表现出了稳定可靠的性能指标。缓存的写法一点都不难难的是看懂流量、选对方案、做好兜底这篇文章里讲的每一条都是我在真实项目里用手摸过、用压测数据验证过的希望你能直接用起来少走一些弯路。