ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis 缓存三大问题:穿透、击穿、雪崩,生产环境真正管用的解法

Redis 缓存三大问题:穿透、击穿、雪崩,生产环境真正管用的解法 去年双十一前压测我们有个商品详情接口QPS 从 8000 掉到 300Redis CPU 打到 95%数据库连接池直接打满。最后查出来原因很朴素一个热点 key 在峰值前一分钟过期了。三千个请求同时发现缓存没命中三千个请求同时打到数据库数据库慢查询堆积连接池耗尽整个服务连锁雪崩。重启、限流、加缓存折腾了两小时才缓过来。事后复盘我发现团队里对“缓存穿透、击穿、雪崩”这三个词人人都能背定义但真到写代码的时候十个人有八个写的是玩具方案——比如用一个synchronized扛并发比如布隆过滤器建完从来不考虑扩容比如空值缓存设了 30 秒过期结果被爬虫打成新的攻击面。这篇文章不谈概念背诵只谈三件事怎么判断你遇到的是哪一种、每种的生产级解法长什么样、以及这些解法各自的坑在哪。代码以 Java Spring Boot Redisson 为例思路跨语言通用。一、先把三个词分清楚别混着治这三个问题表象都是“缓存没命中 数据库压力飙升”但触发条件和解法完全不同搞错了就是白干。触发条件典型特征根本矛盾穿透请求的 key在数据库里根本不存在命中率长期偏低攻击者可以故意构造不存在的 id缓存层挡不住“无效请求”击穿某一个热点 key 在过期瞬间被大量并发命中单个 key 失效的瞬间QPS 尖峰数据库出现大量相同 SQL缓存重建没有互斥重复劳动雪崩大批量key 同时过期或Redis 节点宕机整体命中率断崖式下跌多个业务同时报警缓存容量或可用性整体失效一个很实用的现场判断方法看数据库慢日志里的 SQL 是不是同一个。全是同一条select * from item where id ?参数高度集中 →击穿参数是随机的、大量查不到数据 →穿透各种 SQL 一起冒出来且 Redis 监控里有大量 key 在同一时刻被删除或节点抖动 →雪崩这个区分很重要因为击穿靠互斥穿透靠前置过滤雪崩靠分散和降级——药不能乱吃。二、穿透布隆过滤器不是万能药空值缓存也不是最土的解法参数校验。如果 id 是自增主键id 0或id 最大已知值直接拒绝如果是固定枚举如订单状态非法值直接拦。这一层能挡住大部分随机爬虫成本几乎为零但很多人跳过它直接上复杂方案。解法 A空值缓存缓存 nullpublicItemgetItem(Longid){Stringkeyitem:id;ItemitemredisTemplate.opsForValue().get(key,Item.class);if(item!NULL_PLACEHOLDER){// 注意不能用 null 判断要区分没命中和命中了空值returnitem;}// 查库itemitemMapper.selectById(id);if(itemnull){// 空值缓存短 TTL防止真实数据写入后长期不一致redisTemplate.opsForValue().set(key,NULL_PLACEHOLDER,60,TimeUnit.SECONDS);returnnull;}redisTemplate.opsForValue().set(key,item,30,TimeUnit.MINUTES);returnitem;}三个必须处理的细节必须能区分“缓存未命中”和“缓存命中了一个空值”。用 Redis 的exists/get返回 null 做判断会出问题——建议存一个特殊占位符如__NULL__或者用两个 keykey存数据key:n标记空值。TTL 要短。空值缓存的目的是“挡住同一批攻击”不是长期占位。设太长会导致真实数据写入后长时间读不到不一致窗口。要防内存膨胀。如果攻击者每次用不同的随机 id 打你空值缓存会把 Redis 打成垃圾场。所以空值缓存必须配合“单 key 限流”或“布隆过滤器”否则只是把压力从数据库挪到了 Redis而 Redis 被打挂更难救。解法 B布隆过滤器// RedissonRBloomFilterLongfilterredisson.getBloomFilter(item:bloom);filter.tryInit(1000_000L,0.01);// 预期元素 100 万误判率 1%这里有两个工程上必踩的坑坑一误判率不是越小越好。误判率从 1% 降到 0.1%哈希函数数量和位数组大小会显著上升内存和计算开销跟着涨。而且布隆过滤器只有误判false positive没有漏判false negative——这意味着它说“不存在”的一定不存在这部分请求被安全拦截它说“存在”的有 1% 其实不存在这 1% 会正常落到数据库由空值缓存兜底。所以布隆过滤器 空值缓存是互补的不是二选一。坑二删除和更新怎么办。标准布隆过滤器不支持删除。商品下架了怎么办两种务实做法容忍。下架商品被误判为“存在”请求落到数据库发现已下架然后写空值缓存。代价是可接受的少量多余查询。重建。定时如每天凌晨低峰期全量重建一个新的过滤器建完后用原子操作切换 keyRENAME是原子的旧的下个周期再删。注意重建期间要有双写或回退逻辑否则切换窗口会有误判 spike。另外别把布隆过滤器做成同步阻塞的初始化。应用启动时如果等几百万元素灌完才提供服务上线时间不可控。正确做法是异步预热 降级开关——预热完成前先用空值缓存兜底或者干脆放行此时等同于没有这一层防护。三、击穿互斥锁的写法决定你是解决问题还是制造问题击穿的核心是缓存重建这个过程同一时刻只能有一个线程做其他线程要么等要么拿旧值。错误示范我见过最多的版本// ❌ 用 synchronized 只能防单机集群部署毫无作用synchronized(this){itemloadFromDb(id);cache.set(key,item);}// ❌ 锁的粒度是整个方法所有商品串行QPS 直接归零StringlockKeycache:lock;if(redisson.getLock(lockKey).tryLock()){...}正确版本一分布式锁 双重检查 细粒度 keypublicItemgetItem(Longid){Stringkeyitem:id;Itemitemcache.get(key);if(item!null)returnitem;// 锁的粒度 单个业务实体不同 id 互不影响StringlockKeylock:item:id;RLocklockredisson.getLock(lockKey);try{// 注意等待时间要短持有时间要给足重建余量if(lock.tryLock(2,10,TimeUnit.SECONDS)){try{// 双重检查拿到锁的线程可能不需要重建了itemcache.get(key);if(item!null)returnitem;itemloadFromDb(id);if(itemnull){cache.set(key,NULL_PLACEHOLDER,60,TimeUnit.SECONDS);returnnull;}cache.set(key,item,30,TimeUnit.MINUTES);returnitem;}finally{// 只释放自己持有的锁生产环境建议用看门狗或 UUID 校验 ownerlock.unlock();}}else{// 拿不到锁说明别的线程在重建退避后重试读缓存Thread.sleep(50);returncache.get(key);// 可能还是 null调用方按 null 处理即可}}catch(InterruptedExceptione){Thread.currentThread().interrupt();returnnull;}}这里有几个容易漏的点双重检查必须有。否则排队等待的那批线程拿到锁后还会再查一次库互斥就白做了。等待时间要短1~3 秒不要无限等。缓存重建失败时比如数据库也挂了不能让所有线程一直阻塞那会把线程池拖死。拿不到锁时不要立刻重试查库应该短暂 sleep 后读缓存或者直接返回旧值/null。立刻重试等于把锁的语义废掉了。正确版本二逻辑过期永不过期 异步重建——高并发场景更推荐互斥锁的缺点是等待的线程拿不到数据对用户体验不友好。逻辑过期的思路是缓存永不过期但在 value 里自带一个过期时间戳读到“已过期”的旧值时先返回旧值同时让一个线程异步去重建。DatapublicclassCacheWrapperT{privateTdata;privatelongexpireAt;// 逻辑过期时间戳}publicItemgetItem(Longid){Stringkeyitem:id;CacheWrapperItemwrappercache.get(key,CacheWrapper.class);if(wrapper!nullwrapper.getExpireAt()System.currentTimeMillis()){returnwrapper.getData();// 未过期直接返回}// 已过期或未命中尝试抢重建权StringrebuildLockrebuild:item:id;booleanacquiredredisson.getLock(rebuildLock).tryLock(0,3,TimeUnit.SECONDS);if(acquired){// 抢到权的线程异步重建当前线程仍返回旧值保证可用性rebuildExecutor.submit(()-{try{ItemfreshloadFromDb(id);cache.set(key,newCacheWrapper(fresh,System.currentTimeMillis()30*60*1000),0);// 物理上永不过期}finally{redisson.getLock(rebuildLock).unlock();}});}// 无论是否抢到都返回旧值可能为 nullreturnwrappernull?null:wrapper.getData();}这个方案的收益和代价都很明确收益读永远不阻塞缓存重建期间服务可用彻底消除“过期瞬间的尖峰”。这也是应对击穿的终极形态。代价数据有一致性延迟最多一个重建周期需要额外的线程池重建失败要有重试和告警物理永不过期的 key 要注意内存——必须配合主动淘汰或定期扫描清理僵尸 key。适用选择对一致性要求不高、读多写少的热点数据商品详情、配置、榜单→ 逻辑过期对一致性要求高的库存、余额→ 不用缓存或走强一致方案别在这上面纠结锁的写法。四、雪崩分散、分层、降级三招缺一不可雪崩有两种成因解法也不同。成因一大量 key 同时过期自找的很多项目喜欢把缓存 TTL 设为整点整小时如 30 分钟、1 小时结果每到整点就有一批 key 集体失效。解法很简单但极有效基础 TTL 随机抖动。longttl30*60ThreadLocalRandom.current().nextInt(300);// 30min ± 5mincache.set(key,value,ttl,TimeUnit.SECONDS);别小看这几分钟的抖动它能将集体失效从“一次重击”摊平成“持续平缓的补充流量”。这是性价比最高的一条优化。成因二Redis 节点故障真正的灾难这时候任何 key 级别的技巧都没用了因为缓存层整体不可用所有流量直接砸向数据库。所以必须有第二道和第三道防线防线 1本地缓存兜底多级缓存// Caffeine 作为 L1Redis 作为 L2privatefinalLoadingCacheLong,ItemlocalCacheCaffeine.newBuilder().maximumSize(5_000).expireAfterWrite(5,TimeUnit.MINUTES).build(id-{// 注意这里不要再走 Redis 降级逻辑避免递归ItemitemloadFromDb(id);returnitemnull?NULL_ITEM:item;});Redis 挂了L1 还能扛住一部分热点读。关键设计原则L1 的 TTL 必须远小于 L2如 5 分钟 vs 30 分钟否则 L1 会成为主要的不一致来源。同时本地缓存容量要严格控制防止 OOM——宁可小一点只放最热的那几千个 key。防线 2限流 熔断 降级这是最后一道闸门。用 Sentinel 或 Resilience4j 给数据库访问加限流QPS 超过阈值 → 直接拒绝返回默认值或缓存旧值不要让请求去挤数据库连接池。数据库响应时间连续超标 → 熔断短时间内直接走降级逻辑给数据库喘息时间。降级策略要提前准备好返回默认值、返回历史快照、返回简化版数据而不是返回 500。防线 3Redis 自身的高可用哨兵或集群是底线但要注意主从切换期间仍然会有几十秒到几分钟的不可用这段时间依然要靠上面两道防线扛。所以“上了集群就不会雪崩”是个错觉。另外集群模式下要注意热点 key 倾斜——单个 key 的 QPS 过高会把某个分片打满这时候布隆过滤器和逻辑过期反而比扩容更管用。五、一个容易被忽略的第四类问题缓存重建失败引发的“重试风暴”这个不在经典三件套里但我认为它比雪崩更阴险。场景某个 key 失效第一个线程去重建但数据库此时正好慢查询。这个线程持锁 10 秒没释放后面排队的几百个线程全部超时超时后它们纷纷 fallback 去查数据库——于是互斥锁不但没保护住数据库反而把所有请求攒成了一个更大的尖峰在超时那一刻释放出去。这就是“锁把流量从脉冲变成了堰塞湖”。防御措施锁的等待时间和持有时间都要设上限且要明显小于调用方的超时时间。比如网关超时 3 秒锁等待就别超过 1 秒。重建失败要快速失败 指数退避重试不要死守。重建过程本身要有限流和超时控制Future.get(timeout)别让单次重建拖死线程池。加监控埋点缓存重建耗时、重建失败次数、锁等待超时次数。这三项一旦异常就要告警因为它们是雪崩的前兆指标比“CPU 95%”早好几分钟。六、顺手提几个配套动作不做的话前面都白搭1. 缓存一致性先更新数据库再删除缓存Cache Aside别用“先更新缓存再更新数据库”也别用“更新数据库同时更新缓存”并发下必出脏数据。删除失败怎么办订阅 binlogCanal/Maxwell做重试比手动双写可靠得多。延迟双删可以作为临时补丁但不推荐当主方案——两次删除的时间间隔很难给准且第二次删除失败同样要处理。2. 大 key 和热 key 要单独治理大 key如一个 hash 里几十万 field会导致删除超时、主从同步卡顿、内存碎片。用redis-cli --bigkeys或--hotkeys定期扫。热 key 除了前面说的方案还可以做本地缓存副本或key 拆分打散key_1…key_N分散到不同分片。3. 缓存不是越多越好每个缓存都意味着一份不一致风险和一个失效点。先问“这个接口能不能不加缓存”——很多时候慢是因为 N1 查询、缺少索引、一次性拉十万行这些靠缓存救不回来。我们那次事故的根本原因后来也查清了那个商品详情的 SQL 少了一个联合索引缓存只是遮羞布。4. 压测要压“缓存失效”的场景绝大多数团队的压测是在“缓存全命中”状态下做的数据漂亮得不得了。但真实的故障永远发生在缓存 miss 的那一刻。正确的做法压测中途手动清空一批热点 key观察 QPS 曲线和数据库连接数。如果曲线掉成锯齿状或者直接不归零说明你的击穿防护没生效。七、一张可执行的检查清单穿透入口参数合法性校验id 范围、枚举值、签名单 IP / 单用户 / 单 key 维度的限流布隆过滤器容量按实际数据量 × 1.2 预留误判率 0.5%~1%支持异步重建与原子切换空值缓存TTL 控制在分钟级且有数量上限或配合布隆使用击穿分布式锁粒度 单个业务实体带双重检查锁等待时间 ≪ 调用方超时时间热点数据评估是否改用逻辑过期永不过期 异步重建重建线程池独立隔离带超时和失败告警雪崩所有 TTL 加随机抖动±10%~20%避免批量任务在同一时刻刷缓存错峰、分批L1 本地缓存兜底容量受限TTL L2限流、熔断、降级策略已配置且演练过Redis 哨兵/集群 主从切换演练通用监控缓存命中率、重建耗时、锁超时次数、大 key / 热 key 定期扫描缓存失效场景的专项压测明确哪些数据不该进缓存强一致、写多读少最后说句实话网上讲缓存三件套的文章成千上万大部分停在“画三张图、背三个定义”。但生产环境的差别从来不在概念而在那些不起眼的数字上锁等 2 秒还是 20 秒、空值缓存 60 秒还是 60 分钟、TTL 抖不抖那 5 分钟、重建失败了有没有人知道。这些东西书上不写只能踩坑踩出来。我希望你看完这篇至少能把你们项目里的synchronized和整点 TTL 改掉——这两处改完大概率就能挡住一半的事故。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进