大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
缓存是扛高并发的利器,但缓存不是"上了就稳了"。用不好,三个经典问题轮流轰炸你:穿透、击穿、雪崩。
这仨名字听着像兄弟,但成因、危害、解法完全不同,甚至解法之间还有互相踩坑的地方。今天往深里讲。
一、缓存穿透:查一个根本不存在的东西
场景:用户查一个 id=99999 的商品,这个 id 数据库里根本没有。缓存里自然也没有,于是每次请求都"穿透"缓存,直接打到数据库。数据库查了一圈,返回空。
危害:正常的空查询本身不致命,但攻击者会利用这一点——用大量不存在的 id 疯狂请求,每次请求都绕过缓存直达数据库,直接把数据库打挂。
解法一:布隆过滤器
在缓存前面加一层过滤器,快速判断"这个 key 一定不存在"。
布隆过滤器的原理是一个 bit 数组 + 多个 hash 函数。写入时,对 key 做 k 次 hash,把对应位置的 bit 置 1;查询时,只要有一个位置是 0,就说明这个 key 一定不存在。
它的特性是:"不存在"是确定的,"存在"可能误判。所以它天然适合拦截非法 id——宁可偶尔放过一个不存在的 id 去打数据库,也不能误杀正常请求。
两个关键参数要算对:
- bit 数组大小 m:根据预估数据量 n 和目标误判率 p,公式是
m = -n * ln(p) / (ln2)^2 - hash 函数个数 k:
k = m/n * ln2
举个例子:预估 1000 万个 key,误判率控制在 1%,bit 数组大约需要 9.6MB,hash 函数取 7 个。参数没算对,要么误判率飙升,要么内存浪费。
工程上直接用 Guava 的 BloomFilter 或 Redisson 的 RBloomFilter,别自己手写 hash。
解法二:缓存空值
查不到也往缓存里塞一个 null,设个短过期时间(比如 60 秒)。下次同样的请求直接命中缓存的 null,不再打数据库。
这里有个隐蔽的坑:如果攻击者用海量不同的非法 id,每个 id 都缓存一个 null,会把缓存内存塞满。所以空值的过期时间一定要短,而且要限制数量。
解法三:参数校验
明显非法的请求(id 为负数、超范围、格式不对)在入口直接拦截。这层是成本最低的防御,但只能挡住"一眼假"的请求,挡不住"看起来合法但不存在"的 id。
三个解法不是三选一,而是分层配合:参数校验挡最外层,布隆过滤器挡中间层,缓存空值兜底。
二、缓存击穿:热点 key 过期的瞬间
场景:某个热点 key(比如秒杀商品的库存、首页的热门内容)在过期的一瞬间,大量请求同时涌进来,发现缓存没了,全部打到数据库。数据库瞬间被压垮。
和穿透的区别:穿透是"查不存在的数据",击穿是"查存在但刚好过期了的热点数据"。
解法一:互斥锁(Mutex)
缓存未命中时,只有一个请求能拿到锁去数据库查询并重建缓存,其他请求等待。
这里最容易写错的是忘了双重检查。正确的流程是:
1. 查缓存,命中 → 直接返回
2. 未命中 → 尝试获取锁
3. 拿到锁 → 再查一次缓存(可能别的线程已经重建好了)
4. 还是没有 → 查数据库 → 写缓存 → 释放锁
5. 没拿到锁 → sleep 重试,回到第 1 步
第 3 步的"再查一次缓存"是关键,省掉它,高并发下会重复查库。锁的粒度也要小,用分布式锁(Redisson)时要注意锁的过期时间设短一点,防止线程挂掉后锁不释放。
解法二:逻辑过期
给热点 key 不设 Redis 的物理过期时间,而是在 value 里存一个"逻辑过期时间戳",比如 {data: "...", expireAt: 1712345678}。
读取时判断时间戳,没过期直接返回;发现过期了,先返回旧值(保证用户体验),同时启动一个后台线程去查数据库更新缓存。核心思想是"先返回、后更新",不让用户在缓存重建期间等待。
逻辑过期适合"热点明确、能容忍短暂旧数据"的场景,比如首页 banner、排行榜。
解法三:提前预热
在热点 key 过期前主动刷新。可以在代码里做定时任务,或者在 key 快过期时触发异步刷新。预热解决的是"过期瞬间"的问题,但前提是你知道哪些 key 是热点。
三、缓存雪崩:大量 key 同时失效
场景:两种典型情况——要么是大量 key 在同一时间过期,要么是 Redis 直接宕机。无论哪种,一瞬间所有请求都打到数据库。
危害:这是三个问题里最严重的,击穿是"一个点",雪崩是"一整片"。
解法一:过期时间加随机值
别让所有 key 在同一时刻过期。给每个 key 的过期时间加一个随机偏移,比如基础 5 分钟 + 随机 0-300 秒,把过期时间打散到 5-10 分钟之间。
这是成本最低、收益最高的一招,很多雪崩就死在"所有 key 用同一个过期时间"。
解法二:多级缓存
本地缓存(Caffeine)+ Redis 两级。Redis 挂了,本地缓存还能顶一阵,给恢复争取时间。
但要注意两级缓存的失效顺序:更新数据时,先更新 Redis,再更新本地缓存;本地缓存的过期时间要短于 Redis,避免本地缓存里的旧数据活太久。
解法三:Redis 高可用
单点 Redis 是雪崩的最大诱因。方案有两个层次:
- 主从 + 哨兵:主节点挂了,哨兵自动把从节点提升为主,秒级切换
- Redis Cluster:数据分片到多个节点,单个节点挂掉只影响一部分数据,配合副本还能继续服务
能上 Cluster 就别只用主从,主从切换期间的短暂不可用,在极端流量下也可能引发雪崩。
解法四:限流降级
数据库入口做限流,超过阈值直接熔断。宁可部分用户看到降级提示,也不要让全库被压垮。Sentinel 或 Hystrix 都能做。
四、三兄弟对比表
| 问题 | 成因 | 关键特征 | 核心解法 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 每次都打到 DB | 布隆过滤器 / 缓存空值 / 参数校验 |
| 击穿 | 热点 key 过期瞬间 | 单点瞬间流量洪峰 | 互斥锁 / 逻辑过期 / 预热 |
| 雪崩 | 大量 key 同时失效 / Redis 宕机 | 整片缓存失效 | 过期随机化 / 多级缓存 / 高可用 / 限流 |
五、一个真实的生产事故
曾经有个电商系统,首页 banner 的缓存过期时间统一设成了 2 小时。运维在凌晨统一上线,结果凌晨 2 点整,所有 banner 缓存同时过期,流量全部打到数据库。数据库连接池瞬间打满,整个首页白屏。
最后只改了一件事:给每个 key 的过期时间加随机偏移。一行代码,问题彻底消失。
缓存问题往往不是技术多难,而是没想到那个"同时"。
缓存是放大器——用对了放大性能,用错了放大故障。穿透、击穿、雪崩的区别和解法,加上每一招背后的实现细节和隐藏的坑,是用好 Redis 的必修课。别等数据库被打挂了才想起来补。
小耶在手,SQL不愁。
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~