上周值班,凌晨两点被报警电话叫醒。监控大屏上,订单服务的错误率从 0.1% 直接飙到了 87%,数据库连接池被打满,Redis 的 CPU 饱和到 100%。重启一遍 Redis,三分钟后又是同样的景象。最后查清楚了:一批羊毛党脚本拿着根本不存在的商品 ID 疯狂刷接口,缓存层挡不住,请求全部穿透到底层 MySQL。这就是 Redis 缓存治理里最典型的两个杀手——缓存穿透和缓存雪崩。这两个问题要是没提前做好防线,平时看着没事,流量一上来直接教做人。
这篇文章我把缓存穿透和缓存雪崩从原理到实战完整拆一遍,包括空值缓存、布隆过滤器、过期时间打散、多级缓存这些方案怎么选、怎么做、代码怎么写,最后附上我自己踩坑总结的排查速查表。不管是刚上手 Redis 的初学者,还是正在做缓存治理的中间件开发,都应该能在这篇里找到可以直接复用的东西。
1. 先把问题定义清楚:穿透和雪崩到底在说什么
很多文章把缓存穿透、缓存击穿、缓存雪崩三个词混着讲,概念越看越糊。我建议先把三个场景分清楚,因为它们的诱因完全不同,解决方案也完全不一样。
1.1 缓存穿透:查了一个根本不存在的数据
缓存穿透指的是,请求的数据在缓存里没有,在数据库里也没有,结果就是每次请求都必须打到数据库。正常流程是:先查 Redis,没有就查 MySQL,查到了回填缓存。可如果一个 key 对应的数据本来就不存在,那缓存里永远不会有这个 key,所有的请求都会穿透 Redis 直接打到数据库。单个请求没什么问题,但如果是大量请求都在查同一个不存在的 key,或者攻击者故意构造大量不存在的 key,数据库的压力就会瞬间爆表。
用生活里的例子类比:你开了一家面馆,门口有个小黑板写着今日菜单。正常客人看黑板点菜,厨师按菜单做。可有个捣乱的客人,每天专门点那些菜单上根本没有的菜。服务员每次都得到后厨问一遍"有没有这道菜",后厨每次都白忙活。黑板上的菜单永远帮不上忙。
1.2 缓存雪崩:大量 key 在同一时间集体失效
缓存雪崩是指,缓存里的大量 key 在同一时间段内集中过期,或者 Redis 整个服务宕机,导致海量请求绕过缓存直接打到数据库。数据库扛不住,系统就崩了。
这两个问题最大的区别是:穿透是"数据本身不存在",雪崩是"数据存在但缓存没接住"。前者是结构性问题,后者是时间点或可用性问题。
1.3 为什么这两个问题在 Redis 架构里特别容易出
Redis 作为中间件,承担的是流量缓冲区的角色。MySQL 能承受的并发一般也就几百到一两千,Redis 单机就能扛 8 万到 10 万 QPS。这中间差了两个数量级。缓存层一旦失效,所有流量就打回原来的数据库,这种量变到质变的瞬间,就是系统雪崩的根源。所以缓存治理的核心思路,不是让数据库变强,而是想尽一切办法让"数据库被请求打穿"这件事不发生。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存穿透的深度拆解:为什么空值和恶意 key 能击垮业务
缓存穿透看着简单,但实际发生的时候排查起来并不容易。这里把原理和最常见的几个诱因拆开来讲。
2.1 一次订单服务被打挂的现场还原
那天凌晨的订单服务,被攻击的并不是真实的商品 ID,而是大量随机生成的、形如 -1 和 99999999 之类的非法 ID。这些 ID 在数据库里完全查不到,所以每次请求都触发 full query,MySQL 的 rows examined 疯狂上涨,磁盘 IO 被打满,连接池耗尽后,正常用户的请求也全部阻塞。
我后来在复盘时看到监控数据:短短 30 分钟内,MySQL 的 QPS 从平时的 3000 涨到 12 万,平均每条 SQL 扫描行数超过 10 万行。这种量级下,任何连接池优化都是杯水车薪,唯一的出路是让这些请求根本到不了数据库。
2.2 缓存穿透的三个常见诱因
实际业务里,穿透不全是恶意攻击,很多时候是我们自己代码的漏洞:
恶意攻击或爬虫。攻击者构造大量不存在的 key 定向轰炸,目的是拖垮数据库。这种请求的频率和规律性很强,监控里很容易看出异常波形。
业务数据本身存在空窗口。比如用户刚注册还没生成首页推荐数据,或者后台管理系统录入延迟,前端已经把请求发过来了。此时数据在库里就是没有,缓存也自然没有。
代码逻辑没有做回填。缓存里没有,数据库里也没有,代码就直接返回 null,完全没有把"null 这种情况也缓存起来"的处理。这是最常见的低级问题。
2.3 穿透的代价不只是数据库压力
穿透最直接的后果是数据库被打挂,但还有其他隐性成本。MySQL 每秒处理不了太多请求,一旦超负荷,连接池排队、超时、主从延迟全部接踵而至,连锁反应。另一方面,Redis 在穿透请求面前也很受伤,因为穿透请求照样会先打一遍 Redis,白白消耗 CPU 和内存。所以穿透防护要同时在入口层和缓存层做拦截,不能只靠数据库端死扛。
3. 防穿透方案实战:空值缓存、布隆过滤器与入口校验
穿透的解决方案分三层:入口层拦掉明显的垃圾请求,缓存层让"不存在"这件事本身也有缓存可用,必要时用布隆过滤器做更严格的过滤。
3.1 第一道防线:参数校验与非法请求拦截
最简单的防护往往效果最好。在进 Redis 之前,先做一轮参数校验:
- 商品 ID 必须是正整数,限定在合理范围内
- 用户 ID 必须符合业务规则
- 查询枚举值必须命中允许列表
给接口层面加一个拦截器,非法参数直接返回错误码,不进缓存、不进数据库。我当时排查的时候发现,订单服务被攻击的请求里有大量 ID 为负数或超长数字的,如果入口层早做校验,后面什么事都没有。
这里可以顺带提一嘴:这种校验可以是 spring 的 HandlerInterceptor,也可以是网关层的 Lua 脚本,关键点是校验逻辑必须非常轻量,不能为了防穿透反而把入口层也搞成性能瓶颈。
3.2 第二道防线:空值缓存
空值缓存的含义很直白:即使数据库查询结果为空,也把 "null" 写进 Redis,设置一个较短的过期时间,比如 30 到 60 秒。这样,同一个不存在的 key 在短时间内不会再次打到 MySQL。
写法参考:
code复制public Object getProductInfo(String productId) {
String cacheKey = "product:" + productId;
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return cacheValue;
}
// 检查是否是空值标记,避免重复查库
if (NULL_MARK.equals(cacheValue)) {
return null;
}
Object dbValue = productDao.queryById(productId);
if (dbValue == null) {
// 空值也缓存,过期时间设置短一些,比如 30 秒
redisTemplate.opsForValue().set(cacheKey, NULL_MARK, 30, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(cacheKey, dbValue, 300, TimeUnit.SECONDS);
return dbValue;
}
这里有两个关键细节:空值标记和真实查询结果在 Redis 里必须能区分开,否则会出现缓存了空值,之后真实数据写入却读不到的问题。第二,空值缓存的过期时间要比真实数据的过期时间短很多。为什么?因为空值本身说明业务还没就绪,真实数据可能马上就会写入,太长的空值缓存会导致数据不一致。
3.3 第三道防线:布隆过滤器
空值缓存的限制在于,它只能应付"同一个不存在的 key"。如果是大量随机化的不存在 key——比如每天几百万个随机 ID——空值缓存就失效了,因为每个 key 都只在第一次被查,根本没有复用的机会。这时候需要布隆过滤器。
布隆过滤器的核心是:一个对象要么绝对不在集合里,要么可能在集合里。不可能存在误判为"不在",只可能误判为"在"。这恰好符合我们的场景——我们要拦截的是"绝对不存在于数据库"的 key,牺牲一点误判率,换取极大的内存节省。
用一个亿级数据量的场景来算笔账。存一亿个 ID,如果用 HashSet,每个 ID 按 20 字节算,需要 2GB 内存。用布隆过滤器,误判率控制在 1% 时,大概只需要 120MB 左右,差了十几倍。
Java 里我常用 Google Guava 的 BloomFilter:
code复制BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
100000000, // 期望插入的数据量,一亿
0.01); // 期望误判率,1%
// 初始化时把库里的所有 ID 都插入过滤器
// 请求进来时先判断
if (!bloomFilter.mightContain(productId)) {
// 一定不存在,直接返回
return null;
}
布隆过滤器在系统启动时做预热,把数据库已有的 ID 全部加载进去,后面新增的 ID 在写库的同时也写入过滤器里。需要注意的一点是,布隆过滤器不支持删除,所以如果业务里有大量删除数据的场景,或者 ID 需要复用,就要考虑用带删除能力的数据结构(如布谷鸟过滤器),或者定期重建过滤器来避免误判率过高。
3.4 三层防护怎么配合
实际项目中,三层防护不是同时启用的。我的建议是:
- 所有项目都要做入口参数校验,这是零成本、必做项
- 数据"空窗口"明显的业务,配合空值缓存
- 数据量在千万级以上、存在恶意遍历风险的业务,上布隆过滤器
三层配合的前提是 "能拦在前面就绝不放到后面"。每一层都有自己的性能代价,入口校验最轻,布隆过滤器次之,空值缓存已经需要消耗 Redis 的内存了,所以要分层,不要一股脑全上。
4. 缓存雪崩:批量失效和瞬时宕机的连锁反应
缓存雪崩的杀伤力,比穿透还要大,因为它会让整个缓存层的保护作用在一瞬间归零。
4.1 雪崩的两个触发场景
场景一:大量 key 在同一时刻过期。比如我们在代码里统一用 set(key, value, 3600) 给一批活动商品设置 1 小时的过期时间,所有 key 都会在同一时刻集体失效。此时如果线上流量一小时前是高峰,那么一小时后这批 key 的访问会同时掉进数据库,瞬时冲击量极大。
场景二:Redis 服务不可用。比如 Redis 主从切换失败、机器宕机、网络分区,Redis 集群整体不可服务。这种情况下,所有原本应该打在 Redis 上的请求全部落到数据库。无论是缓存中间件还是数据库,任何一个环节都需要高可用方案来支撑。
4.2 雪崩的破坏链条
雪崩是一个正反馈过程:数据库压力增大 → 数据库响应变慢 → 调用方等待超时 → 业务线程被阻塞 → 更多请求积压在入口 → 连接池耗尽 → 新请求只能排队或直接拒绝 → 系统从"缓慢"变成"不可用"。我们当时处理的故障,前面半小时的表现在监控里还只是"延迟升高",后面半小时就彻底没响应了,整个链路演变成了续滚式崩溃。
4.3 缓存击穿和雪崩的区别
老生常谈的缓存击穿,实际上是雪崩的一个特例:单个热点 key 过期,在高并发访问下,所有线程同时去数据库重建缓存,数据库瞬时压力上升。它和雪崩的区别只是"量级"——一个是单个 key,一个是多个 key 集体失效。解决方案也有相通之处,比如互斥锁重建、逻辑过期等。后面我会单独给热 key 的防护方案,这里先说明关系,方便大家理解概念体系。
5. 雪崩防护的四个落地方案
说了雪崩的严重性,下面是实战中我用过且效果稳定的方案。
5.1 给过期时间加随机扰动
防集中失效,最经典的办法是把固定过期时间改成随机区间。比如原来是 1 小时过期,现在可以设置成 1 小时加上 0 到 600 秒的随机偏移量。
code复制// 不推荐:所有 key 同一秒过期
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
// 推荐:过期时间打散,分散到 0~600 秒的随机区间
long baseExpire = 3600L;
long randomExpire = baseExpire + ThreadLocalRandom.current().nextLong(600);
redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
看似一个很小的改动,实际效果非常显著。之前一批 100 万个 key 在同一秒过期,加了随机扰动后,过期时间均匀分散在 3600 到 4200 秒之间,每秒过期的 key 数量从 100 万降到了 100 万除以 600 秒,也就是 1667 个左右。这个量级对数据库来说完全无感。
5.2 多级缓存与本地缓存兜底
为了避免 Redis 单点故障导致所有请求落库,可以在业务服务里加一层本地缓存。常见做法是使用 Caffeine 或 Guava Cache 作为一级缓存,Redis 作为二级缓存,MySQL 作为三级存储。
查询顺序:本地缓存 → Redis → MySQL。写入时,本地缓存设置很短的过期时间,比如 30 秒;Redis 设置正常的业务过期时间。这样即使 Redis 挂了,本地缓存还能扛住一部分请求,给运维留出恢复时间。
本地缓存对内存的占用要控制好,通常只缓存访问频次最高的几百到几千个 key,设置最大容量和淘汰策略,防止本地缓存自己把服务内存打爆。
code复制Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
Object value = localCache.getIfPresent(key);
if (value == null) {
value = redisTemplate.opsForValue().get(key);
}
5.3 数据库侧做限流降级
再完备的缓存策略,也难免有缓存失效和击穿的瞬间。这时候数据库端的自我保护必须有。业界常用的方案有两种。
一种是 Hystrix 或 Sentinel 这类熔断组件,对查询数据库的操作设置信号量隔离和 QPS 限流,超阈值后快速失败,返回兜底数据,而不是让请求继续堆积在数据库等待。
另一种是数据库连接池参数控制。这不算主动降级,但至少能保住数据库进程不被打死。把 connectionPool.maximumPoolSize 控制在数据库可承受范围内,宁可让部分请求快速失败,也不能让所有请求都卡在连接池等待。
5.4 高可用部署:Redis 集群与主从切换
单机 Redis 无论如何优化,都有宕机风险。生产环境至少要搭主从复制加哨兵模式,或者直接上 Redis Cluster 集群。核心目的是让 Redis 的无状态、可用性得到保证,发生主节点故障时能在几秒内完成主从切换,对业务几乎无感知。
同时,业务代码里要配置合理的连接超时和命令超时,比如 lettuce 连接超时设置 3 秒,命令超时设置 2 秒。否则 Redis 挂掉时,所有线程都会卡在获取连接上,雪上加霜。这一点我特别强调,因为很多团队只做了集群,但客户端 timeout 没配,Redis 切换期间照样打崩业务线程。
5.5 针对热点 key 的击穿防护
这个方案可以放到雪崩防章节里一起讲。热点 key 过期时,为了避免所有线程同时回源,可以加一把分布式锁,只让一个线程去数据库重建缓存,其他线程等锁释放后从缓存里读取。用 Redis 的 SETNX 命令实现简单的分布式锁,这是成本最低的方案。
code复制// 伪代码示意
String lockKey = "lock:product:" + productId;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked) {
try {
// 查询数据库,重建缓存
Object dbValue = productDao.queryById(productId);
redisTemplate.opsForValue().set("product:" + productId, dbValue, 3600, TimeUnit.SECONDS);
} finally {
redisTemplate.delete(lockKey);
}
return dbValue;
} else {
// 其他线程短暂等待后重试
Thread.sleep(100);
return getProductInfo(productId);
}
要注意的是,分布式锁的过期时间要大于重建缓存需要的时间,否则会出现锁提前释放、同样导致多个线程回源的问题。但过长的锁过期时间也有风险,如果线程执行到一半异常退出,锁要等很久才能自动释放。实际项目里通常配合看门狗机制或者执行完成后手动释放,同时设置合理的最大等待时间。
6. 实操经验:参数选型与 Redis 运维细节
方案的原理都懂了还是不够,实际部署时的参数选型有很多坑,这里集中说一下。
6.1 布隆过滤器的关键参数怎么定
使用布隆过滤器前,最重要的两个参数是预估的数据量 n 和可接受的误判率 p。误判率设得越低,需要的位数组越大,空间开销和初始化耗时都跟着涨。我用过一个比较实用的铁律:
- 数据量 1000 万以下,误判率 1% 即可,空间占用大概 10MB 级别
- 数据量 1 亿以上,误判率 5% 也可以接受,空间占用可控制在 100MB 左右
- 时间换空间还是空间换时间,取决于你的 Redis 内存是否宽裕
另外,初始化布隆过滤器时要避免一个常见失误:直接把所有数据一次性塞进过滤器,期间如果有内存飙升、阻塞的情况,会拖慢系统启动。更好的方式是分批加载,每批 1 万条,插入一次后 sleep 一下,减少对 Redis 的瞬时压力。
6.2 空值缓存的数据一致性如何保障
空值缓存最大的坑在于:真实数据写入了,但缓存里的空值还没过期,导致业务用户读不到新数据。解决思路有两条:一是把空值的过期时间调短,比如 10 到 30 秒,就算数据已经写入,最多延迟 30 秒可见,业务一般可接受;二是在数据写入的接口里,同时主动删除空值缓存,这样下次读取就会重新查数据库。
java复制// 新增数据时,主动删除空值缓存
public void addProduct(Product product) {
productDao.insert(product);
redisTemplate.delete("product:" + product.getId());
}
6.3 监控指标:怎么提前发现穿透和雪崩
依赖人的经验去发现问题,永远不如监控系统好使。我常用的几组指标是:
- Redis 内存命中率。正常命中率应该在 95% 以上,如果命中率在短时间内下滑 10 个百分点以上,说明有大量 cache miss,需要排查是穿透还是批量失效。
- MySQL 慢查询数量和扫描行数。这两项是穿透的直接信号。
- Redis 的 keyspace miss 计数。如果 miss 的绝对值持续走高,而且 miss 的 key 分布很零散,十有八九是穿透。
- key 的过期数量曲线。如果过期数量在某一个时间点出现尖峰,说明有多个 key 设置的过期时间太集中,赶紧查代码是不是用了固定过期时间。
6.4 Redis 可视化客户端在排障中的用处
排查缓存问题时,我习惯用 Redis Desktop Manager 或 Another Redis Desktop Manager 这类可视化工具,快速扫一眼 key 的分布和过期时间。尤其是排查集中过期问题时,直接按 TTL 排序,能一眼看到大量 TTL 相同或相近的 key,比命令行敲几十遍 redis-cli --scan 高效得多。
7. 常见问题与排查技巧实录
这一年多里,我把工作中遇到的典型缓存穿透和雪崩问题整理成了速查表,分享出来供大家参考。
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| Redis 命中率正常但 MySQL 压力暴增 | 空值穿透,大量不存在的 key 直接落库 | 查看 miss 的 key 集合是否集中在某些不存在的 ID 上 | 空值缓存,入口参数校验 |
| Redis miss 的 key 零散、随机、无规律 | 恶意遍历攻击,伪造随机 key | 看请求来源 IP、用户 token,确认是否批量注册或爬虫 | 布隆过滤器过滤不存在的 key |
| 同一时间点大量 key 同时过期 | 代码统一设置了固定过期时间 | 可视化客户端查看 TTL 分布,确认是否大量 TTL 一样 | 过期时间加随机扰动 |
| Redis 正常但 MySQL 瞬时 QPS 尖峰 | 热点 key 过期导致缓存击穿 | 查看事故时间点的热点 key 和对应缓存 TTL | 分布式锁重建,本地缓存兜底 |
| Redis 宕机后全站不可用 | 只依赖 Redis 缓存,没有降级方案 | 检查是否有本地缓存和熔断策略 | 多级缓存,数据库限流,高可用集群 |
| 数据写入后读不到新值 | 空值缓存未及时失效 | 查看是否存在空值标记 key,确认真实数据写入后是否删除该 key | 写入时主动删除空值缓存,缩短空值过期时间 |
| 布隆过滤器误判率逐渐升高 | 数据量超过初始设定值,或存在大量删除 | 定期统计命中过滤器的请求有多少最终在库里查不到 | 定期重建过滤器,或改用布谷鸟过滤器 |
7.1 一个差点踩踏数据的误判案例
我以前负责一个商品系统,上线了布隆过滤器,初始数据量预估是 5000 万,误判率 1%。半年后商品表涨到了 6000 万,期间还做了一次历史数据清理,删掉了近 1000 万条商品。结果突然有一天监控显示,很多真实存在的商品接口返回"商品不存在"。
排查后发现问题:布隆过滤器不支持删除,历史商品虽然从库里删了,但过滤器里的位还在。同时新的 1000 万商品插入过滤器后,哈希碰撞增多,误判率飙升。一部分真实存在的商品被误判为"不存在"直接挡在了过滤器外面。最后方案是:下线过滤器,重新基于当前数据库全量数据重建,再挂回线上。从那以后,我定了一个规矩:布隆过滤器上线时必须埋点统计误判率,误判率超过阈值就要触发重建告警。
7.2 排查穿透问题时最容易被忽略的一个点
很多人排查穿透,只盯着业务接口的 key,却忘了检查 Redis 里是不是已经有大量"脏空值"堆积。空值缓存如果没有统一的过期和清理策略,时间久了会占掉大量 Redis 内存,拖累整体性能。我用过一个土办法:keyspace notification 监听 key 过期事件,对空值标记做更短周期的二次清理,或者直接给所有空值 key 统一加一个前缀,比如 empty:,这样排查和清理时可以用 scan 按前缀扫。
7.3 关于缓存治理的整体节奏
缓存穿透和缓存雪崩的防护,不是上线一套代码就一劳永逸的事。我现在的习惯是每次大促前,专门做一轮缓存巡检:检查所有过期时间设置是否合理、布隆过滤器数据量是否超限、Redis 集群容量和主从状态、数据库连接池阈值。这套巡检流程拉通下来,比临时救火管用得多。
8. 一些实操过程中的个人经验
最后聊几句我自己的体会,不一定系统,但都是真金白银换来的。
第一,方案不是越复杂越好。我见过很多团队一上来就上布隆过滤器,但他们的实际业务场景根本不存在恶意攻击,只是偶尔数据没查到。这种情况下,一个入口参数校验加一个空值缓存就完全够了。过度设计不仅增加维护成本,还容易引入新的误判问题。
第二,过期时间随机扰动这个方案,建议直接做成框架级的默认配置,不要指望每个开发都记得加随机数。团队里有几十个业务方,每个人都自定义过期时间策略,最后的结果就是没人能说清楚 Redis 里有多少 key 会在哪一秒集体过期。最稳妥的是封装一层 Redis 工具类,set 方法默认就是 base TTL 加随机偏移,并强制规范过期的上下限。
第三,缓存治理一定要配套监控和告警。很多东西在海量请求下,靠人工根本看不出来问题在哪。要提前把 Redis 命中率、MySQL 扫描行数、key 过期分布这些指标接到监控大屏上,每天定时巡检,才能在问题发生前就发现端倪。
第四,按我的经验,缓存穿透和雪崩的大多数问题都可以通过“让请求少打到数据库”来解决,而“少打数据库”的核心无非是:拦截(参数校验和布隆过滤器)、缓存空值(空值缓存)、错峰(随机过期时间)、降级(本地缓存和熔断限流)。把这四层做扎实了,线上缓存出大问题的概率会低很多。
