☰
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; } // 后面的缓存查询逻辑照旧 ... }

布隆过滤器比较适合两类场景:

  1. 数据规模大且相对固定:比如商品 ID、用户 ID,这些 ID 在业务里基本是只增不减或少量删除。
  2. 允许少量误判:过滤器说"存在"但实际不存在的请求,最终还是会被缓存和数据库兜住,只是概率性穿透。

实现上有两种做法:

  • 应用启动时把全量 ID 加载进布隆过滤器,适合数据量可控的场景;
  • 用 Redis 4.0 提供的 Bloom Filter 模块(bf.add、bf.exists命令),让过滤器本身也住在 Redis 里,适合需要动态维护的场景。

我个人的经验是:优先把参数校验和空值缓存做好,布隆过滤器作为进阶手段引入。很多团队的穿透问题其实不是攻击,而是业务代码里的非法参数没拦住,比如前端传了个id=-1或者id=0。参数校验是成本最低的第一道门:

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); } }

这里有两个细节必须注意:

  1. 双重检查不能省。拿到锁之后先再查一次缓存,因为在你等待的过程中,可能已经有线程完成了缓存重建。
  2. 锁必须设置过期时间。防止持锁线程在查询数据库时崩溃或超时,导致锁永远不释放,后面的请求全部卡死。

互斥锁的缺点也很明显:如果热点 Key 非常多,或者重建缓存本身很慢(比如要聚合多个表的查询),那么持有锁的这段时间里,其他请求都在sleep等待,接口响应时间会被拉长。这个方案适合"单个热点 Key 偶尔失效"的场景,不适合"大量热点 Key 集中失效"。

3.3 方案二:逻辑过期,用"假过期"绕开并发

互斥锁的痛点在于"真过期瞬间的并发竞争"。那换一个思路:让缓存物理上永不过期,在业务逻辑上自定义一个过期时间。

public class CacheItem<T> { private T data; // 业务数据 private long expireAt; // 逻辑过期时间 }

数据写入缓存时,不设置 Redis 的 TTL,而是把"过期时间戳"也存进 value 里。读取时判断逻辑时间:

public Order getOrderById(Long id) { String key = "order:" + id; CacheItem<Order> 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 是热点。我给几个实践方法:

  1. Redis 4.0 的hotkey参数:通过redis-cli --hotkeys扫描访问频次高的 Key。
  2. 客户端统计:在缓存工具类里对 Key 的访问次数做本地计数,超过阈值上报。
  3. 业务预判:活动期间哪些商品、哪些接口会爆,提前和运营对齐,手动将对应 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,其实先判断缓存问题更关键。我整理了一套判断流程:

  1. 看 Redis 是否可用:先排除实例宕机或网络分区,这是雪崩的最大嫌疑。
  2. 看缓存命中率:命中率骤降,看是整体下降还是单 Key 下降。整体下降大概率是大量 Key 过期或缓存服务异常;单 Key 下降可能是热点 Key 失效。
  3. 看 Key 是否存在:查一下 Redis 里这个 Key 是被删了、过期了,还是从未写入过。从未写过大概率是穿透。
  4. 看数据库查询模式:如果数据库 QPS 暴涨但查询的 Key 五花八门且都不存在,穿透;如果查询的都是同一个或极少数几个 Key,击穿;如果是一批 Key,雪崩。

5.2 线上常用排查命令

命令/方法作用
redis-cli --bigkeys找出大 Key,排查大 Key 过期导致的阻塞
redis-cli --hotkeys扫描热点 Key
INFO 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 前缀,新旧数据平滑过渡,不用停机迁移。

缓存的三座大山,本质上都是"缓存和数据库之间协调失败"的体现。把每一种失败的场景想清楚,把对应的兜底策略提前备好,线上才能睡得着觉。希望这篇文章能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询