资讯动态

Redis缓存穿透、击穿、雪崩:成因、方案与实战避坑

发布时间:2026/10/14 7:35:41 来源:尧图企业网站定制
1. 三大问题各自是什么为什么总是捆绑出现做后端这几年Redis 缓存几乎是每个项目的标配。可只要并发一上来缓存层那点小毛病就会被无限放大。面试里天天问的穿透、击穿、雪崩不是三道孤立的面试题而是同一条故障链路里的三个环节。我见过不少团队把三者混为一谈拿着布隆过滤器去解决击穿拿互斥锁去扛雪崩最后越修越乱。先给不熟悉的朋友用一句话划清边界穿透查询一个根本不存在的数据缓存和数据库都没有请求每次都直穿到数据库。击穿查询一个存在但恰好过期的热点 Key大量并发同一瞬间打到数据库。雪崩一大批 Key 同时过期或者 Redis 实例整体不可用流量像雪崩一样压垮下游。一句话记忆法穿透是查了个寂寞击穿是单个热点挂了雪崩是一群热点一起挂。三者都和缓存失效有关但失效的对象和范围完全不同对应的技术方案也完全不同。这篇文章我会把三个问题的成因、判断方法、解决方案、代码示例和我在生产环境里踩过的坑一次性讲透。不管是刚接触 Redis 的初级开发还是被线上告警折腾过的老手都能在里面找到可以直接抄作业的部分。2. 缓存穿透问题不在缓存而在空数据2.1 穿透是怎么发生的先看一段最基础的缓存查询逻辑public Order getOrderById(Long id) { // 1. 先查缓存 String key order: id; Order order redis.get(key); if (order ! null) { return order; } // 2. 缓存没有查数据库 order orderMapper.selectById(id); if (order ! null) { // 3. 数据库有回写缓存 redis.set(key, order, 30 * 60); } return order; }这段代码有一个致命盲区如果 id 对应的订单在数据库里根本不存在第 2 步查不到数据第 3 步不会回写缓存。那么下一次同样的查询依然会穿透缓存直达数据库。单次查询打到数据库不可怕可怕的是这种请求被批量发起。比如攻击者遍历 id生成大量不存在的订单号每个请求都绕过缓存直接查库。数据库连接池被耗尽整个服务跟着雪崩。我在一次线上事故里亲眼见过一个对外开放的商品详情接口被人用不存在的商品 ID 连续扫了几分钟数据库 CPU 直接飙到 100%所有业务接口集体超时。2.2 为什么空值也缓存是最快的止血方案方案一把空结果也缓存起来设置一个较短的过期时间。public Order getOrderById(Long id) { String key order: id; Object cacheValue redis.get(key); // 缓存命中包括空值标记 if (cacheValue ! null) { // 如果是空值标记直接返回 null if (EMPTY_MARK.equals(cacheValue)) { return null; } return (Order) cacheValue; } Order order orderMapper.selectById(id); if (order ! null) { redis.set(key, order, 30 * 60); } else { // 空值也写入缓存过期时间设短一些比如 3~5 分钟 redis.set(key, EMPTY_MARK, 3 * 60); } return order; }这个方案的核心逻辑就一句话让不存在这件事也有缓存。注意空值缓存的过期时间要明显短于正常数据一般设为 3~5 分钟原因有二防止大量不存在的 Key 长期占用 Redis 内存如果数据库里真的新增了这条数据空值缓存能较快失效避免长时间读不到新数据。不过空值缓存只能挡住随机穿透挡不住批量构造不同 Key 的穿透——攻击者每次都用一个新的不存在的 Key你缓存里存的空值再多也架不住对方换着花样来。这时候需要第二层防线。2.3 布隆过滤器的正确打开方式布隆过滤器用来回答一个问题某个 Key 是否存在。它的特点是一定能判断不存在但可能误判存在。用生活化的话说它像一张粗筛子筛子说没有的那一定没有筛子说有的可能只是长得像。基于这个特性我们可以在查询缓存之前先问一句布隆过滤器这个 Key 存不存在不存在就直接返回根本不用碰缓存和数据库。public Order getOrderById(Long id) { // 布隆过滤器判断 Key 是否存在 if (!bloomFilter.contains(order: id)) { return null; } // 后面的缓存查询逻辑照旧 ... }布隆过滤器比较适合两类场景数据规模大且相对固定比如商品 ID、用户 ID这些 ID 在业务里基本是只增不减或少量删除。允许少量误判过滤器说存在但实际不存在的请求最终还是会被缓存和数据库兜住只是概率性穿透。实现上有两种做法应用启动时把全量 ID 加载进布隆过滤器适合数据量可控的场景用 Redis 4.0 提供的 Bloom Filter 模块bf.add、bf.exists命令让过滤器本身也住在 Redis 里适合需要动态维护的场景。我个人的经验是优先把参数校验和空值缓存做好布隆过滤器作为进阶手段引入。很多团队的穿透问题其实不是攻击而是业务代码里的非法参数没拦住比如前端传了个id-1或者id0。参数校验是成本最低的第一道门if (id null || id 0) { return null; }2.4 穿透问题的排查信号怎么判断自己是不是正在遭遇穿透看三个信号信号表现说明缓存命中率骤降从 95% 掉到 60% 以下大量请求没命中缓存直奔数据库数据库 QPS 异常升高与接口调用量不成比例说明缓存的拦截作用失效查询耗时波动大慢 SQL 数量明显增加数据库压力大执行变慢线上监控里我给每个缓存接口都挂了命中率指标阈值设的是 90%。一旦命中率连续五分钟低于这个值告警立刻拉群。穿透问题早发现五分钟数据库就少承受五分钟的压力。3. 缓存击穿热 Key 失效的那几秒决定系统生死3.1 击穿的本质是并发撞上失效击穿和穿透的差别在哪穿透是查不存在的数据击穿是查存在但刚过期的热点数据。想象一个促销活动页某个爆款商品的详情被缓存了过期时间 30 分钟。这个商品在活动期间每秒被几千人访问缓存的 key 在某一秒正好到期了。此时几千个请求全部发现缓存没命中于是一齐冲进数据库查同一条记录。这个场景最要命的地方在于只有 Redis 里那一个 Key 失效了数据库却要承受几千倍于平时同一时刻的查询压力。而且因为这几个请求几乎同时到达即使其中一个请求回写了缓存其他请求也已经拿到了没命中的结果来不及享受缓存重建的成果。3.2 方案一互斥锁让重建缓存的代价只付一次互斥锁的思路很直接缓存失效后只允许一个线程去查数据库并回写缓存其他线程拿不到锁就短暂等待等缓存重建好了再读。public Order getOrderById(Long id) { String key order: id; Order order redis.get(key); if (order ! null) { return order; } // 尝试获取分布式锁 String lockKey lock:order: id; boolean locked redis.setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS); if (!locked) { // 没拿到锁等一下再重新查询缓存避免打到数据库 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getOrderById(id); } try { // 双重检查可能上一个线程已经重建了缓存 order redis.get(key); if (order ! null) { return order; } // 只有拿到锁的线程才有资格查数据库 order orderMapper.selectById(id); redis.set(key, order, 30 * 60); return order; } finally { // 释放锁 redis.delete(lockKey); } }这里有两个细节必须注意双重检查不能省。拿到锁之后先再查一次缓存因为在你等待的过程中可能已经有线程完成了缓存重建。锁必须设置过期时间。防止持锁线程在查询数据库时崩溃或超时导致锁永远不释放后面的请求全部卡死。互斥锁的缺点也很明显如果热点 Key 非常多或者重建缓存本身很慢比如要聚合多个表的查询那么持有锁的这段时间里其他请求都在sleep等待接口响应时间会被拉长。这个方案适合单个热点 Key 偶尔失效的场景不适合大量热点 Key 集中失效。3.3 方案二逻辑过期用假过期绕开并发互斥锁的痛点在于真过期瞬间的并发竞争。那换一个思路让缓存物理上永不过期在业务逻辑上自定义一个过期时间。public class CacheItemT { private T data; // 业务数据 private long expireAt; // 逻辑过期时间 }数据写入缓存时不设置 Redis 的 TTL而是把过期时间戳也存进 value 里。读取时判断逻辑时间public Order getOrderById(Long id) { String key order: id; CacheItemOrder item redis.get(key); if (item null) { // 缓存真的不存在非过期说明数据被删了或尚未写入 return loadAndWrite(id); } // 逻辑时间未过期直接返回 if (item.getExpireAt() System.currentTimeMillis()) { return item.getData(); } // 逻辑过期了先返回旧数据再异步重建缓存 asyncRefresh(key, id); return item.getData(); }这个方案的精髓是过期了也不阻塞线上请求先把手头的旧数据返回给调用方然后异步线程去重建缓存。用户感受到的只是数据可能不是最新但响应很快系统感受到的是没有并发打到数据库。实际使用中要注意异步线程得用线程池不能让缓存重建逻辑自己把自己堵死如果数据对实时性要求太高比如库存、价格逻辑过期会导致用户看到旧值需要评估业务是否能接受短暂不一致逻辑过期的时间要设置比物理 TTL 短否则 Redis 淘汰策略可能在你预期之前删掉 Key。3.4 方案三热点 Key 永不过期 定时刷新这个方案的思路更简单粗暴既然热点 Key 会失效那就不让它失效。后台用一个定时任务每隔一段时间去刷新热点 Key 的过期时间保证它永远在缓存里。# 定时任务中执行 EXPIRE order:hot_1 1800同时刷新前先查一次数据库把最新数据重新写进缓存等于用主动更新的方式代替了被动失效再重建。这个方案适合那些访问量极高且变化不频繁的数据比如首页顶部的推荐位、活动页的静态配置、热卖榜单。但要注意定时刷新只能覆盖已知的热点 Key如果热点是突发的、动态的定时任务根本不知道该刷新谁。3.5 怎么识别热点 Key不管选哪个方案前提都是先知道哪些 Key 是热点。我给几个实践方法Redis 4.0 的hotkey参数通过redis-cli --hotkeys扫描访问频次高的 Key。客户端统计在缓存工具类里对 Key 的访问次数做本地计数超过阈值上报。业务预判活动期间哪些商品、哪些接口会爆提前和运营对齐手动将对应 Key 标记为热点。4. 缓存雪崩当故障从点扩散到面4.1 雪崩的两副面孔击穿是一个 Key 失效引发的局部压力雪崩则是大规模 Key 同一时间失效引发的系统性故障。雪崩有两种典型触发方式第一种大量 Key 设置了相同的过期时间。比如凌晨定时任务批量刷新数据把几千个商品统一设置了EXPIRE 3600。一小时后这几千个 Key 在同一秒集体过期缓存瞬间大量未命中请求齐刷刷打到数据库。数据库扛不住接口超时超时请求又重试进一步放大压力最终整个服务不可用。第二种Redis 实例本身宕机。这不是 Key 的问题是整个缓存层不可用。所有请求直接穿透缓存落到数据库这比任何单个 Key 失效都恐怖。即使 Redis 很快恢复重启后如果没做持久化内存里的缓存全部清空数据库依然要承受一次全量读请求的冲击。4.2 防御一过期时间加随机因子对抗大量 Key 同时失效最直接的手段就是给过期时间掺沙子// 基础过期时间 30 分钟加上 0~300 秒的随机偏移 int baseExpire 30 * 60; int randomExpire baseExpire ThreadLocalRandom.current().nextInt(300); redis.set(key, value, randomExpire, TimeUnit.SECONDS);这样即使一批 Key 是同一时间写入的过期时间也会被拉开到一定范围内不会整整齐齐地集体失效。随机偏移的最佳实践是设置为基础过期时间的 5%~10%既能错峰又不会让缓存周期失真太多。4.3 防御二多级缓存给数据库前面再加一道墙即使 Redis 缓存失效了如果前面还有一层本地缓存比如 Caffeine、Guava Cache数据库的压力会小很多。// 第一层本地缓存 Caffeine过期时间 30 秒 // 第二层Redis 缓存过期时间 30 分钟 // 第三层数据库兜底多级缓存的核心逻辑是用好几层不同层级、不同过期时间的缓存把请求层层过滤。Redis 挂了本地缓存至少还能扛住一部分流量本地缓存也过期了至少过期时间错开了不会三层同时失效。我自己在项目中常用的配置是本地缓存 30~60 秒Redis 缓存 5~30 分钟。本地缓存时间短一点避免数据长期不更新Redis 缓存时间长一点保证大部分读请求打在 Redis 上。4.4 防御三熔断、限流、降级缓存雪崩时不是所有请求都能满足总要有一部分被放弃。这就是熔断限流存在的意义。限流在数据库访问层加一个令牌桶或漏桶每秒放行一定比例的查询请求超出部分直接返回兜底数据。熔断当数据库的查询失败率达到阈值比如 5%直接打开熔断器后续一段时间内不再访问数据库统一返回缓存中的旧数据或空数据。降级热点数据提供一份降级版本比如商品详情查不到时返回基本的标题、图片舍弃轮播图、评价、推荐位等次要数据。这三个手段不一定能恢复服务但能保证系统不彻底瘫痪。我见过很多团队只做缓存加固不做降级预案结果 Redis 一挂数据库跟着挂数据库一挂整个应用集群全挂。限流降级不是锦上添花而是最后一道保命符。4.5 防御四Redis 高可用与持久化针对Redis 实例宕机这种雪崩要从架构层面解决主从复制 哨兵主节点宕机哨兵自动把从节点提升为主节点缓存服务不中断。集群模式Cluster数据分片存储单个节点故障只影响一部分 Key不会全盘失效。持久化开启 AOF或者 RDB 与 AOF 结合Redis 重启后能快速恢复缓存数据避免冷启动造成数据库被打爆。持久化这里要提醒一句RDB 的恢复速度虽然快但可能丢最近一段数据AOF 更可靠但文件大、恢复慢。建议用 AOFeverysec策略在可靠性和性能之间取平衡。5. 三类问题的排查思路与实操速查表5.1 先判断是穿透、击穿还是雪崩很多人在线上收到数据库压力过大的告警第一反应是找 DBA 看慢 SQL其实先判断缓存问题更关键。我整理了一套判断流程看 Redis 是否可用先排除实例宕机或网络分区这是雪崩的最大嫌疑。看缓存命中率命中率骤降看是整体下降还是单 Key 下降。整体下降大概率是大量 Key 过期或缓存服务异常单 Key 下降可能是热点 Key 失效。看 Key 是否存在查一下 Redis 里这个 Key 是被删了、过期了还是从未写入过。从未写过大概率是穿透。看数据库查询模式如果数据库 QPS 暴涨但查询的 Key 五花八门且都不存在穿透如果查询的都是同一个或极少数几个 Key击穿如果是一批 Key雪崩。5.2 线上常用排查命令命令/方法作用redis-cli --bigkeys找出大 Key排查大 Key 过期导致的阻塞redis-cli --hotkeys扫描热点 KeyINFO stats查看keyspace_hits和keyspace_misses算命中率SLOWLOG GET 10查看 Redis 慢命令确认是否大 Key 或复杂操作拖慢缓存MONITOR实时监控命令生产慎用会拖累 Redis 性能命中率的计算公式是命中率 keyspace_hits / (keyspace_hits keyspace_misses) × 100%。我在监控脚本里就是这么算的低于阈值就告警。5.3 三大问题核心方案对比问题推荐方案适用场景注意事项穿透参数校验 空值缓存 布隆过滤器查询不存在数据占比高空值缓存时间要短防止内存浪费击穿互斥锁 / 逻辑过期 / 热点永不失效单个热点 Key 高并发访问互斥锁要防死锁逻辑过期要接受短暂旧数据雪崩TTL 随机化 多级缓存 熔断降级 高可用批量数据同时过期或实例故障多级缓存要统一失效策略避免三层同时过期5.4 我踩过的三个坑提前帮你避开坑一互斥锁里忘了加过期时间。早期我写分布式锁只用了SETNX没有设置过期时间。有一次业务线程查询数据库超时锁一直没释放后续所有请求全部阻塞等待接口超时率飙到 90%。后来全部改成SET key value EX 30 NX的原子写操作并在 finally 里释放锁这个问题再没出现过。坑二空值缓存没有设置过期时间。团队里有同事图省事把所有空结果都永久缓存了结果 Redis 内存两天涨了 3GB。后来排查发现全是攻击者构造的不存在 Key 的空值缓存。空值缓存一定要设短过期时间否则内存被一堆无用 Key 占满。坑三布隆过滤器初始化时机不对。我在一个项目里把布隆过滤器初始化放在应用启动阶段直接全量加载数据库 ID。结果数据量 500 万启动耗时 40 多秒每次发版都超时被探活干掉。后来改为异步加载 分批填充启动时间压到 5 秒内。布隆过滤器不是越大越好要根据数据量和可接受的误判率设置合适的size和hashFunctions数量。6. 从搞定问题到不搞出问题的几条实践经验三个问题的解决方案讲完了最后聊点我个人在多个项目里的体会和取舍。先确认业务真的需要缓存再谈缓存架构。很多团队把 Redis 当成万能加速器什么数据都往里塞结果缓存的维护成本比数据库还高。我在项目里有一条原则只有读多写少且对数据一致性要求不那么苛刻的数据才适合用缓存。库存、余额这类强一致数据缓存方案再怎么设计都容易出问题不如直接走数据库配合分布式锁。方案选择要以并发量级为依据。并发几千的系统和并发几十万的系统解决穿透击穿的策略完全不同。小系统空值缓存 互斥锁就够大系统必须上布隆过滤器和多级缓存。别一上来就堆最重的方案过度设计带来的复杂度后期会反过来咬你一口。一定要监控一定要有告警。我见过太多缓存问题不是因为方案不行而是因为出了问题没人知道。命中率、Redis 内存、过期 Key 数量、数据库慢查询这些指标必须配齐。我的实践是命中率异常告警调成 5 分钟粒度误报多可以接受漏报一次可能就是一次 P0 事故。兜底预案永远比理想方案重要。不管你的缓存方案设计得多完美总有超出预期的情况流量爆炸、Redis 集群跨区故障、运营手动改数据导致缓存失效。所以每个核心接口都必须准备好降级逻辑——缓存挂了返回旧数据旧数据也没有就返回兜底页面兜底都不行就限流保护数据库。系统可以降级但不能瘫痪。最后分享一个小技巧给所有缓存 Key 打上清晰的命名前缀和业务标记比如order:detail:1001、activity:home:2025。线上出问题时你能一眼看出是哪个业务线的缓存排查效率能提高一倍以上。我甚至会在 Key 里带上版本号比如order:v2:1001缓存结构升级时直接切换 Key 前缀新旧数据平滑过渡不用停机迁移。缓存的三座大山本质上都是缓存和数据库之间协调失败的体现。把每一种失败的场景想清楚把对应的兜底策略提前备好线上才能睡得着觉。希望这篇文章能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑