Redis用了这么多年线上被缓存穿透、雪崩、击穿这三个问题坑过的次数两只手都数不过来。每次出了问题监控图上那几道断崖式的曲线都像是在提醒你缓存设计不是在写CRUD是在做防御工程。这篇文章我不绕弯子直接把这三大问题的成因、判断方法、实战解决方案拿出来配合我实际用过、验证过的代码和参数配置逐个拆开讲透。1. 缓存穿透问题本质与实战解法1.1 穿透是怎么发生的先明确一下缓存穿透指的是请求的数据在缓存里不存在、在数据库里也不存在导致每次请求都直接打到了数据库。最典型的场景是用户查一个不存在的商品ID或者爬虫拿随机生成的ID来遍历你的接口Redis里查不到数据库也查不到于是每次请求都穿透缓存直达MySQL。穿透问题的核心特征是请求量越大数据库压力越大而且是持续性的。因为它不像雪崩和击穿那样过了高峰期或者热点key过期后就能缓解。穿透是只要你接口对外开放就随时可能被制造出来。这里要提醒一个容易误判的点很多人觉得“缓存里没命中就是穿透”其实不对。如果数据库里有数据只是缓存没缓存顶多叫“缓存空命中”数据库还能扛得住。真正的穿透必须是“缓存没有 数据库也没有”这时候才会产生对数据库的无效查询压力。1.2 空值缓存方案的细节穿透的第一个解法是空值缓存也就是把“查不到”这个结果本身也缓存起来。原理很简单数据库查不到数据时往Redis里写一个空值占位设置一个较短的过期时间。具体实现我推荐这样处理// 伪代码核心逻辑 public Object getProduct(String productId) { Object value redis.get(product: productId); if (value ! null) { return value; } // 从数据库加载 Product product db.getProductById(productId); if (product null) { // 缓存空值设置较短过期时间 redis.set(product: productId, EMPTY, 60); return null; } redis.set(product: productId, product, 300); return product; }这个方案里几个细节是关键空值缓存时间不宜过长5分钟以内比较合适。太长了会导致数据已经写入但用户一直看到“不存在”的状态。空值标记最好和正常数据类型区分开。比如用字符串EMPTY或者专门的字节标记避免反序列化时出现类型转换异常。建议将空值key的过期时间设置为一个随机值比如60~120秒之间随机避免大量空值key同时过期造成不必要的缓存重建。空值缓存的局限也很明显如果攻击者不断换新的不存在的ID来请求每个ID都会写一条空值缓存内存消耗会逐渐增加。所以空值缓存通常要配合下面的布隆过滤器一起使用或者对空值key的数量做上限控制。1.3 布隆过滤器实战布隆过滤器是解决穿透最常用的结构方案。它的思想是在缓存和数据库之间加一层“存在性判断”如果布隆过滤器说“不存在”那就直接返回连Redis都不用查了。我平常用的方案是基于Redisson的RBloomFilter使用成本最低// 初始化布隆过滤器 RBloomFilterString bloomFilter redisson.getBloomFilter(productBloom); bloomFilter.tryInit(1000000L, 0.01); // 预计容量100万误判率1% // 预热启动时把所有商品ID写入布隆过滤器 for (String productId : getAllProductIds()) { bloomFilter.add(productId); } // 查询时先判断 public Object getProduct(String productId) { if (!bloomFilter.contains(productId)) { // 布隆过滤器判断不存在直接返回 return null; } // 后续走Redis和数据库查询 }关于参数计算这里给一个参考公式。tryInit里的两个参数expectedSize代表预期存储的元素数量falseProbability代表允许的误判率。布隆过滤器底层就是一个bit数组你会根据这两个参数计算出bit数组长度。举个例子预期100万个元素误判率设为1%最终底层bit数组大约是120万个bit也就是约150KB的内存。误判率设置得越小bit数组越长内存占用越大。实际项目里我一般设1%性价比比较高。还要注意布隆过滤器无法删除元素所以对于删除商品这类操作要么定期重建过滤器要么用Counting Bloom Filter变体但那个实现复杂度高一些大部分场景没有必要。需要补充的是布隆过滤器解决的是“过滤不存在的key”不能完全替代空值缓存因为布隆过滤器有一定误判率。当它说“存在”时实际可能不存在这时候仍可能打到数据库。所以更稳的做法是布隆过滤器挡住绝大多数不存在的请求再配合空值缓存兜底双保险。2. 缓存雪崩从成因到防御策略2.1 雪崩的两种典型场景雪崩和我接下来要讲的击穿很容易混淆我遇到过不少同事把这两个名词混用。这里先把定义掰扯清楚。缓存雪崩是指大量缓存key在同一时间过期或者Redis服务整体不可用导致所有请求瞬间打到数据库数据库压力飙升甚至宕机。雪崩通常有两种触发场景场景一大量key设置了相同的过期时间比如凌晨某个定时任务批量写缓存统一设置过期时间为当天的24点结果到点之后所有key同时过期流量瞬间全部压到数据库。场景二Redis实例宕机或者网络分区整个缓存层不可用所有请求全部穿透到数据库。场景二更危险因为它不只是“缓存失效”而是缓存层面整体功能丧失。实际故障中很多时候场景一和场景二还会叠加大量key同时过期导致数据库压力增大数据库的慢查询反过来拖垮了业务接口最后Redis也因为过载而出现异常整个链路雪崩。2.2 过期时间随机化针对场景一最直接的做法就是让key的过期时间尽量分散避免集中失效。具体操作是在设置过期时间时加入一个随机偏移量private static final long BASE_EXPIRE_TIME 60 * 60 * 1000; // 1小时 private static final long RANDOM_RANGE 10 * 60 * 1000; // 正负10分钟 // 设置过期时间时 long expireTime BASE_EXPIRE_TIME ThreadLocalRandom.current().nextLong(-RANDOM_RANGE, RANDOM_RANGE); redis.set(key, value, expireTime, TimeUnit.MILLISECONDS);这个方案对现有代码的改动很小只需要在写缓存的地方统一加上随机偏移即可。有一些基础框架会把“过期时间随机化”封装成公共模块这样不用在业务代码里到处改。但要注意随机化只是减缓了雪崩的冲击并没有根治问题。如果整个Redis集群同时宕机再随机也没有用。所以对于高可用要求高的业务还需要做下面的多级缓存和降级措施。2.3 多级缓存降级方案针对场景二我的建议是“本地缓存兜底 熔断降级”双管齐下。本地缓存兜底指的是在应用进程内维护一层短生命周期的缓存。当Redis请求异常或超时时降级到本地缓存读取数据虽然数据新鲜度会下降但至少数据库不会被流量秒杀。我常用的方案是Caffeine配置一个几分钟级别的过期策略// 定义一个本地缓存5分钟过期 CacheString, Object localCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(10000) .build(); public Object getProduct(String productId) { // 先查本地缓存 Object localValue localCache.getIfPresent(productId); if (localValue ! null) { return localValue; } // 再查Redis Object redisValue null; try { redisValue redis.get(product: productId); } catch (Exception e) { // Redis异常记录日志继续走本地缓存 log.warn(Redis exception: {}, e.getMessage()); } if (redisValue ! null) { localCache.put(productId, redisValue); return redisValue; } // 最后查数据库 Object dbValue db.getProductById(productId); if (dbValue ! null) { redis.set(product: productId, dbValue, 300); localCache.put(productId, dbValue); } return dbValue; }熔断降级这块通常会用Sentinel或者Resilience4j。对Redis访问设置一个熔断规则比如“10秒内异常比例超过40%就熔断”熔断后在一段时间内不访问Redis直接走本地缓存或数据库兜底等Redis恢复后再放量过去。还有一个容易被忽视的点数据库连接池也要做保护。很多雪崩事故里数据库不是被正常流量打死而是被连接池打满之后拖死的。建议在数据库连接池配置里预留一些缓冲连接同时对等待获取连接的时间设置超时超过阈值直接快速失败返回业务兜底结果。3. 缓存击穿热点key的护城河3.1 击穿与穿透的区别缓存击穿特指某一个或者极少数的热点key在过期的一瞬间大量并发请求同时涌入导致这些请求同时打到数据库。注意击穿和穿透、雪崩的最大区别是穿透查的数据根本不存在缓存和数据库都没有。雪崩大量key同时过期或者缓存层整体不可用大面积请求打到数据库。击穿单个热点key过期瞬间的高并发请求只影响这一个key但是因为它是热点瞬时流量可能非常大。判断是不是击穿有个很实用的经验如果Redis里只有一个key过期了数据库的QPS就瞬间翻了几倍而且过一会儿就自己恢复了那大概率是击穿。如果库里出现了大量慢查询且持续不恢复那就要往雪崩方向排查。3.2 互斥锁方案击穿最常见的解法是互斥锁让同时到达的请求只有一个能去查数据库并重建缓存其他请求等待缓存重建完成后再读。常规实现是这样的public Object getProduct(String productId) { Object value redis.get(product: productId); if (value ! null) { return value; } // 尝试获取锁 String lockKey lock:product: productId; boolean lockAcquired redis.setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!lockAcquired) { // 锁被占用短暂等待后重试 Thread.sleep(50); return getProduct(productId); // 递归重试注意设置递归次数限制 } try { // 获取到锁的线程重新读一遍缓存因为可能已经被其他线程重建了 Object cached redis.get(product: productId); if (cached ! null) { return cached; } // 查数据库 Object dbValue db.getProductById(productId); redis.set(product: productId, dbValue, 300); return dbValue; } finally { // 释放锁 redis.delete(lockKey); } }互斥锁方案有几个细节要特别注意递归重试很容易造成递归深度过大实际开发中建议用循环加次数上限比如最多重试10次超过就返回兜底数据。锁的过期时间设置需要合理。如果业务查数据库需要200ms锁10秒过期足够了如果数据库查询要几秒锁时间就要相应调大否则锁提前释放多个线程又会同时重建缓存。在finally块释放锁时要防止一个线程删除了另一个线程的锁。建议用Lua脚本保证“先比较value再删除”是原子的或者在value里存线程唯一标识。互斥锁的缺点也很明显热点key失效期间所有请求都要等待第一个请求查库完成这段时间内接口的响应时间是拉长的。如果热点key的并发量非常大即使只用几个线程重建缓存也可能会让几十万个请求堆积等待对延迟敏感的接口不友好。3.3 逻辑过期方案为了解决互斥锁方案的等待问题我实际项目里用得更多的是“逻辑过期”方案。思路是缓存数据里存两个值一个是业务数据一个是逻辑过期时间。不再依赖Redis的物理过期时间而是在读取时判断逻辑时间是否过期过期则尝试重建。Data public class CacheDataT { private T data; private Long expireTime; } // 写入缓存时设置一个逻辑过期时间 public void setWithLogicalExpire(String key, Object data, long expireSeconds) { CacheDataObject cacheData new CacheData(); cacheData.setData(data); cacheData.setExpireTime(System.currentTimeMillis() expireSeconds * 1000); redis.set(key, JSON.toJSONString(cacheData)); // 不设置Redis过期时间 } // 读取时判断 public Object getProductWithLogicalExpire(String productId) { String json redis.get(product: productId); if (json null) { return null; } CacheDataObject cacheData JSON.parseObject(json, CacheData.class); if (cacheData.getExpireTime() System.currentTimeMillis()) { // 逻辑上未过期直接返回数据 return cacheData.getData(); } // 逻辑过期尝试重建 rebuildCache(productId); // 重建期间直接返回旧数据 return cacheData.getData(); }这个方案的精妙之处在于过期后不删除缓存而是继续返回旧数据唯一的请求去更新缓存其他请求不会等待接口响应时间不会波动。代价是数据一致性会短暂延迟如果对一致性要求极高需要结合其他手段。rebuildCache里同样要用互斥锁来保证只有一个线程重建但是因为其他请求读的是旧值所以锁的处理可以比互斥锁方案更宽松锁竞争压力小很多。3.4 热点key永不过期策略有些业务场景下一个key实际上就是某个核心配置或热门商品它的数据本身很少变化更新可以由后台任务主动触发。这种情况下可以把热点key设置为永不过期并通过后台异步刷新来保证数据更新。这是我个人比较推荐的做法因为它实现最简单性能也最稳定- 热点key不设置过期时间长期驻留在Redis中。 - 后台用一个定时任务比如每5分钟主动去数据库拉取最新的数据判断是否有变化如果有则更新Redis中的value。 - key的物理过期时间不设置但逻辑上通过后台任务保证数据的新鲜度。这个方案对业务方完全无感知没有任何锁竞争也不会出现缓存重建导致的性能抖动。适用场景就是那些“数据有变化但变化不频繁”的热点数据比如商品库存、促销配置、系统参数等。它的缺点是需要额外的维护成本你需要维护一张“永不过期key清单”并且确保后台刷新任务的稳定性。如果你忘刷了Redis里就会一直保存旧数据。我的经验是把刷新任务纳入监控体系每次刷新都打点记录耗时和变更次数异常时告警。4. 三大问题对比与综合防御体系4.1 三大问题速查对比表很多面试和实践中都容易把这三个问题混淆我整理了一张对比表建议你直接存下来维度缓存穿透缓存雪崩缓存击穿现象缓存和数据库都没有数据请求直达DB大量key同时过期或Redis不可用请求大面积打DB单个热点key过期瞬间高并发同时打DB触发原因查询不存在的key恶意攻击或遍历ID过期时间设成同一时间点或Redis整体故障热点key过期且并发请求量大影响范围单个key或持续性的无效DB查询大面积DB可能直接被压垮单个key但瞬时流量巨大常用解法空值缓存、布隆过滤器过期时间随机化、多级缓存、熔断降级互斥锁、逻辑过期、永不过期异步刷新关键区别“数据不存在”引发“缓存整片失效”引发“单个热点key失效”引发线上排查时我一般先看监控图如果是单一key的数据库QPS飙升多半是击穿如果是全表慢查询数量暴增且涉及多个key大抵是雪崩如果是大量200ms以内空结果查询打在DB上而且请求的key都是库中不存在的那就是穿透。4.2 综合防御架构建议实际项目中这三种问题往往不是单独出现尤其在促销、秒杀这类极端流量场景下穿透、击穿、雪崩可能同时发生。所以不要只针对一个问题做防护而是要建立分层防御架构。我的分层思路是这样的第一层接口层做参数校验。对于明显不合法的请求直接拒绝比如ID格式不对、负数ID、超长字符串等。这一步能挡掉一部分低水平攻击。第二层布隆过滤器校验存在性。挡掉绝大多数“数据不存在”的请求。第三层Redis缓存读取配合空值缓存做兜底。第四层本地缓存兜底Caffeine。Redis异常时还能扛一波。第五层数据库保护包括连接池限流、熔断降级、快速失败。第六层异步重建与持久化保障。热点数据后台刷新不依赖请求触发重建。这套架构下来每一层都会挡掉一部分流量最终落在数据库上是少量必须的查询。对于大部分中小规模业务不会需要特别复杂的组件Caffeine加Redisson加Sentinel基本就够用了。还要提一个关键思路缓存设计一定要有“未雨绸缪”意识不要等线上出问题了才来加防护。每次设计一个缓存key的时候就问自己三个问题这个key如果失效了数据库扛得住并发吗这个key对应的数据可能被恶意请求伪造吗如果Redis整体挂了业务还有没有替代方案把这三个问题想清楚很多事故在设计阶段就能避免。5. 实战中的坑与排查技巧实录5.1 踩过的坑我在处理这些缓存问题时踩过不少坑挑几个典型的说说这些都是普通文档里不会写的经验。第一个坑布隆过滤器误判率设得太低导致内存占用过高。有一次预估容量100万个元素误判率设置成0.1%实际占用的内存比预想多了好几倍。其实对于商品ID这种场景1%的误判率完全够用因为布隆过滤器只是做预过滤后面还有空值缓存兜底没必要把误判率压得太狠。第二个坑互斥锁释放时误删了别的线程的锁。这也是最经典的Redis锁坑。线程A拿到锁后执行时间较长锁到期自动释放了线程B拿到锁A执行完释放锁把B的锁删了然后线程C拿到锁。最终导致锁失效、缓存多次重建。解决方式是锁的value存一个UUID删除前先判断value是否一致而且判断和删除要用Lua脚本保证原子性。别用“先get再delete”这两步之间不是原子的照样会误删。第三个坑本地缓存和Redis缓存数据不一致。本地缓存5分钟过期Redis缓存5分钟过期但如果数据库在这5分钟内更新了数据本地缓存就会一直读到旧值。我在一个活动配置场景中就遇到了这个情况后台改了活动状态但用户端一直看到未生效。后来统一了设计本地缓存只用来做Redis异常兜底并且过期时间缩短到1分钟同时数据变更时主动刷新本地缓存才算解决。第四个坑空值缓存把正常数据的“查询结果”覆盖了。如果空值缓存和正常缓存用同一个key而且正常数据后来被写入了但空值缓存还没过期用户可能一直收到空结果。所以在写入缓存前要先判断当前缓存是不是空值标记如果是空值标记且数据库现在有数据了要立即覆盖并刷新过期时间。5.2 排查工具与方法排查这三个问题时我常用的方法顺序是看监控大盘。重点关注Redis命中率、数据库QPS、接口响应时间三条曲线。如果Redis命中率没降数据库QPS却升高了说明有请求绕过了Redis优先排查穿透。看Redis key的过期分布。用redis-cli --random-key配合TTL命令抽样检查或者用监控平台看keyspace的过期事件数量变化。如果同一秒有过期事件暴增那就是雪崩式的集中过期。看日志里的慢查询。数据库慢查询日志里如果有大量重复的SQL而且参数都是同一个值那就是击穿如果SQL参数分散且都是不存在的数据那就是穿透。看Redis异常日志。如果Redis超时或连接拒绝报错频繁说明缓存层本身出了问题这种要优先恢复Redis同时检查本地缓存兜底是否生效。还有一个实用的技巧为缓存击穿和穿透分别建立独立的监控指标。比如记录“布隆过滤器拦截次数”和“空值缓存命中次数”这两个指标的变化能直观反映当前是哪种风险在增加。很多监控平台没有这个指标需要自己在代码里埋点上报。这一块看起来不起眼但在排障时能节省大量时间。5.3 实战经验总结根据我个人经验Redis三大缓存问题的防御最关键的不是某个具体方案而是三个基础原则。第一缓存key的设计阶段就要想清楚失效策略。不要所有key都套用同一个过期时间要根据数据的业务属性分别设计。核心数据用永不过期加后台刷新普通数据用随机过期时间临时数据用短过期时间。第二兜底方案一定要有但不要滥造。本地缓存、空值缓存、布隆过滤器这些方案都有各自的使用场景。我说的层数多不等于每个业务都要把六层全做上。小业务做个空值缓存加互斥锁就够了大业务才需要把多级缓存和熔断做完整。过度设计也会引入新的复杂度。第三熟悉你正在用的Redis客户端和框架。很多Redis客户端本身就提供了布隆过滤器、分布式锁的高级API不用自己从零实现。用成熟封装比自己写要稳得多Redisson里的RLock、RBloomFilter都值得认真看看。关键是理解底层原理这样出问题时才能快速定位。最后再分享一个小技巧每次上线前可以做一个“缓存失效演练”。随便挑一个热点key手动删除它的缓存观察接口的响应时间曲线、数据库QPS的变化看看是否在可接受范围内。如果删除一个key都引起明显的性能抖动说明你的击穿防护没做扎实趁早补上。这个演练成本很低但能给线上安全增加不少信心。