想靠背定义蒙混过关的,一到“那你会怎么解决”基本就卡壳了。我这些年做过不少缓存治理的复盘,也以面试官身份问过这个问题,发现“缓存穿透、击穿、雪崩”能被多少人答得漂亮,不完全取决于背得多熟,而是取决于你有没有真正理解缓存链路里每层方案的价值边界。这篇文章会把三兄弟的成因、差别、完整技术对策和面试表达技巧串一遍,目标是让你既能听明白,也能在真正的业务故障里用上。
1. 先看懂这道题的原始场景:缓存失效与数据库兜底之间的博弈
在讲任何名词之前,把底层场景拉出来看,后面一切都会顺很多。常规读请求的路径通常是:客户端请求先打到应用层,应用层先查 Redis,命中就直接返回;不命中则回源数据库,拿到数据后再把结果写回 Redis,顺便设置一个过期时间。
这套链路本身没有错,但一旦出现“缓存没挡住”的情况,流量就会砸到数据库头上。缓存穿透、缓存击穿、缓存雪崩,本质上都是同一个信号:有大量的请求因为某种原因没有从缓存里拿到想要的“现成结果”,于是被迫走了回源数据库这条路。它们的表象非常相似——数据库压力飙升、连接池被打满、慢查询增多、接口超时;但根因却完全不同,对应的解法也完全不同。
为什么我会强调“先理解场景”而不是“先背方案”?因为面试官考察的是你能否通过现象判断故障类型。同样是数据库被打挂,可能是有人拿着不存在的 ID 不停刷接口,也可能是某个热点 key 恰好到了过期时间,还可能是凌晨批量任务把大量 key 同时设置成了同一个过期时刻。如果你只知道“穿透用布隆过滤器,击穿用互斥锁,雪崩用随机过期”,一旦面试官追问一句“当前这个故障你判断是哪一种”,你很可能答不到点子上。
如果把整个链路画成一条流水线:第一层是网关/参数校验,第二层是 Redis,第三层是多级缓存或数据库。三个问题的破坏面也不是同一个级别的:
- 穿透:数据在数据库里根本不存在,所以缓存永远存不住,每次请求都会穿透到数据库,相当于“无效回源”被高频放大。
- 击穿:数据真实存在,但只有一个热点 key 的缓存恰好失效了,一瞬间大量针对同一个 key 的并发请求同时涌向数据库。
- 雪崩:大量 key 在同一时间窗口同时失效,或者 Redis 整个不可用,所有本来该被挡住的读请求,一次性全部落地到数据库。
从“缓存 miss 的范围”来区分它们特别清晰:穿透是“无论缓存有没有,数据本身不存在”,击穿是“大量并发命中了同一个刚过期的 key”,雪崩是“一大批 key 或缓存整体不可用”。后两者虽然也和过期有关,但一个是局部瞬间压力,一个是整体容量风险。这个前提理顺了,后面你才能在不同方案之间做取舍,而不是生搬硬套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存穿透:缓存和数据库都“查无此数据”,为什么会被打穿
2.1 穿透的典型路径与危害
缓存穿透指的是查询一个在数据库里根本不存在的数据。假设我维护一个用户体系,用户 ID 从 10000 开始自增,这时候有人用一个不存在的 ID,比如 100000000,去查用户详情。缓存里自然没有这个 key,数据库里也没有这一行,查询结果就是空。
问题出在“空结果不会被缓存”上。正常的用户数据在第一次查询后会被写回 Redis,下次请求就能命中;但一个不存在的 ID 每次查询都是缓存 miss,于是每一次请求都会老老实实地落到数据库。如果只是偶尔有人手滑输入了错误 ID,数据库完全顶得住;但如果是恶意攻击者批量生成大量非法 ID 来遍历接口,或者业务代码有 bug,把某一类查询结果全部回源到数据库,那么数据库的 QPS 会在短时间内被无效查询打满,最终拖垮整个服务。
很多人会把穿透理解成一个“安全攻击”才有的话题,实际业务中也很常见。比如搜索商品时,用户搜索一个已经下架的商品 ID;订单详情里查一笔不属于当前用户的订单;运营系统后台批量核对一批失效数据。这些场景下系统不是被“攻击”,但它们同样是“缓存存不住结果的读请求”。
穿透的真正危害,不只是每一次查询多花了几毫秒,而是它是一种“放大效应”。数据库的连接数是有限的,每一次回源查询都要占用一个连接,如果缓存 miss 的比例太高,数据库的连接池线程会全部卡在这些无效查询上,真正有数据的正常请求反而没有连接可用,最后形成雪崩式的连锁故障。
2.2 第一道防线:参数校验与非法请求拦截
解决穿透的第一个手段常常被低估,就是参数校验。很多请求根本不该进入业务查询链路,你可以在 Controller 层或网关层直接把它过滤掉。
以用户 ID 为例:如果系统规定 ID 是数字且在一个区间范围内,那么负数、超长数字、带字母的内容可以直接返回参数错误;如果查询的是订单号,可以通过前缀校验判断这个订单号是否属于当前登录用户;如果是手机号,可以按运营商的号段规则先做合法性校验。这些校验成本低、见效快,能把“格式上就不可能存在”的请求挡在 Redis 和数据库之前。
但仅靠参数校验是远远不够的,因为攻击者很容易构造出符合格式规则但依然不存在的 ID。比如我观察到你系统的用户 ID 最大到 500 万,然后从 500 万开始逐个加 1 去查,这些 ID 格式合法、区间合理,但数据库里就是没有。所以你还需要第二道和第三道防线。
2.3 缓存空值:给“不存在”一个短期的临时身份
缓存空值的思想非常朴素:既然数据不存在导致缓存存不住,那我干脆把“查无此数据”这个结果也当作一个缓存内容存起来。下一次再有相同的 key 来查询,Redis 能直接命中的就是这个“空结果”,请求就不会再穿透到数据库了。
不过缓存空值的实现里藏着不少细节。第一个细节是空值不能永不过期,因为数据库里的数据是动态变化的,很可能几分钟后这条数据就被创建了;如果你把空值缓存一小时,用户创建完账号后立即去查,还是会被“缓存里的空结果”挡住。业界一般把空值 TTL 设置为 3 到 5 分钟,既能挡住绝大多数高频穿透,又不会让“新增数据不可见”的时间太长。
第二个细节是空值的存储体积。如果你直接用一个特殊字符串表示空值,比如 "NULL_VALUE_FLAG",要比存一个 null 值更明确,也方便反序列化时区分。真实场景中可以在 Redis 里直接用一个字节的标记,占用内存很小。当数据库中新增了这条记录,你需要主动删除对应的空值缓存,或者等它自然过期,两种策略根据业务对实时性的要求来选。
这里给出一个带空值缓存的查询流程,便于理解:
java复制public Object getUserById(Long userId) {
// 参数基本校验
if (userId == null || userId <= 0) {
return Result.error("参数不合法");
}
String key = "user:info:" + userId;
Object cacheData = cache.get(key);
if (cacheData != null) {
// 用约定的空值标记判断是否为空结果
if ("NULL_VALUE_FLAG".equals(cacheData)) {
return Result.error("用户不存在");
}
return Result.success(cacheData);
}
// 回源数据库
User user = userMapper.selectById(userId);
if (user == null) {
cache.set(key, "NULL_VALUE_FLAG", Duration.ofMinutes(3));
return Result.error("用户不存在");
}
cache.set(key, user, Duration.ofMinutes(30));
return Result.success(user);
}
2.4 布隆过滤器:在缓存之前判断“肯定不存在”
空值缓存能挡住同一个 key 的多次穿透,但如果攻击者每秒生成上万个不同的非法 ID,每个 key 只会穿透一次,空值缓存就力不从心了,因为每次你都要等到第一次查询打穿数据库后才能把这个空值写进去。这时候就需要把防线再提前,在到达 Redis 之前就完成一次“是否存在”的判断。
布隆过滤器就是一种非常适合这个场景的数据结构。你可以把所有数据库里真实存在的用户 ID 预先加载进布隆过滤器里,查询请求来了之后先去过滤器里判断:如果过滤器说这个 ID 不存在,那就一定不存在,直接返回,根本不会走到 Redis 和数据库。如果过滤器说可能存在,那再继续走原有的缓存和数据库链路。
布隆过滤器的核心特征是:它不会漏报,但可能会误报。也就是说,一个真实存在的 ID 绝不会被它判定为不存在;但一个不存在的 ID 有小概率被它判定为“可能存在”。这种特性非常适合拦截穿透:即使有小概率误判放进来几个不存在的 ID,后续的空值缓存和数据库查询也能兜住,但绝大多数无效请求会在过滤器这一层被拦截掉。
使用布隆过滤器要考虑两个工程问题。第一个问题是初始化:系统启动时要把存量数据导入过滤器,如果数据量很大,这个过程会比较慢,需要使用异步任务分批加载。第二个问题是数据增减:布隆过滤器本身不支持删除元素,如果业务上有大量删除,会导致过滤器中的某些位长期占有,误报率逐渐升高。常见的处理是定期重建过滤器,或者改用支持删除的变种结构。实际落地时,如果用户量可控,可以在应用本地放一个 ThreadLocal 版的布隆过滤器,零网络开销;如果数据量很大,可以基于 Redis 的位图结构实现,或者使用现成的 RedisBloom 模块,避免重复造轮子。
穿透的三层防线,按性价比排序应该是:参数校验最便宜,布隆过滤器最有效,空值缓存做兜底。真实系统里通常组合使用,而不是只依赖某一个方案。面试时如果能把这个组合逻辑讲清楚,会让面试官觉得你不是在背方案,而是真的设计过。
3. 热点 key 惊群:缓存击穿的互斥锁、逻辑过期和多级兜底
3.1 击穿到底是什么:真实存在的数据,为什么也会被“打穿”
缓存击穿的经典定义是:一个热点 key 在缓存过期的瞬间,大量并发请求同时发现缓存 miss,于是全部回源数据库。这个热点 key 在数据库里是有数据的,但因为缓存恰好过期,导致所有请求在同一时刻都拿不到缓存,一起涌向数据库。
前面我们说穿透是“查询不存在的数据”,击穿则完全不同:它查询的是一个热点数据,比如某个正在秒杀的商品、某个突然上热搜的帖子、某个被明星转发的动态。平时这些数据一直热在 Redis 里,请求命中率极高;但它一旦到了 TTL 的过期时间,缓存项被删掉,新的请求一进来全部 miss。如果这个 key 的并发访问量是每秒几万甚至几十万,那么过期的瞬间就像大坝决堤一样,几万个请求同时冲向数据库。
这类问题也叫“惊群效应”或“thundering herd problem”,核心矛盾是:同一个 key 的并发请求太多了,DB 侧只需要一个请求去查询并回填缓存就够了,但常规写法会让所有 miss 的请求都去执行一次 DB 查询,造成资源的巨大浪费。
3.2 互斥锁方案:保证只有一个请求回源数据库
解决击穿最直观的办法是加互斥锁,让同一时刻只有一个线程能回源数据库,其他线程等待并重新读取缓存。
实际操作中,我们使用 Redis 自身实现一个分布式锁,锁的粒度要精细到业务 key 上。比如热点数据是 product:detail:10001,那么锁的 key 可以设计为 lock:product:detail:10001。加锁命令使用 SET lockKey requestId NX PX 3000,其中 NX 保证只有一个线程能加锁成功,PX 设置锁的过期时间避免死锁,requestId 是一个唯一标识,用来保证只有持锁线程才能释放锁。
获取锁成功的线程执行数据库查询,并把结果回填到 Redis;没获取到锁的线程不直接查数据库,而是短暂休眠后重新尝试读取缓存,因为大概率那个拿锁的线程已经完成了回填。关键代码我贴一下,逻辑不复杂,但细节值得反复看:
java复制public Product getProductById(Long id) {
String key = "product:detail:" + id;
Product product = cache.get(key);
if (product != null) {
return product;
}
String lockKey = "lock:product:detail:" + id;
String requestId = UUID.randomUUID().toString();
boolean locked = cache.setIfAbsent(lockKey, requestId, Duration.ofSeconds(3));
if (!locked) {
// 拿不到锁:说明有其他线程正在重建缓存,短暂等待后重新读取
try {
Thread.sleep(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductById(id);
}
// 拿到锁,但也要双重检查,可能等待期间缓存已被其他线程回填
try {
Product again = cache.get(key);
if (again != null) {
return again;
}
Product dbResult = productMapper.selectById(id);
cache.set(key, dbResult, Duration.ofMinutes(5));
return dbResult;
} finally {
// 使用 Lua 脚本安全释放锁:先比对 requestId 再删除
cache.unlock(lockKey, requestId);
}
}
上面这段伪代码里的递归写法是为了演示重试逻辑,生产中不建议用递归,容易造成栈溢出,应该改造成 for 循环,在统一的次数上限内自旋重试,比如最多重试 3 次,每次间隔 30 到 50 毫秒。如果超过次数还是拿不到锁,就要按降级策略处理,直接返回一个降级结果,不要让请求无限等下去。
释放锁的细节经常被忽略,但面试官非常爱追问。你不能直接 DEL lockKey,因为如果线程 A 执行缓存回填的时间超过了锁自动过期时间,锁已经被 Redis 自动释放,线程 B 随后获取到了新锁;这时线程 A 执行完删除命令,会把线程 B 刚获得的锁误删掉,导致线程 B 的保护名存实亡。正确做法是在删除前先判断锁里的 value 是不是自己的 requestId,再执行删除。这里必须用 Lua 脚本保证“判断+删除”的原子性:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
还有一个很容易被忽略的点:锁的过期时间不能设置得太短。如果数据库中这个热点数据的查询需要 500 毫秒,而你给锁设置的过期时间只有 200 毫秒,那么第一个线程还没回填完,锁就过期了,第二个线程会立即拿到新锁再次进入数据库,互斥锁就名存实亡了。建议把锁的过期时间设置为预估最大 DB 查询耗时的 3 到 5 倍,并且对慢查询要有兜底监控。
3.3 逻辑过期:用“旧数据顶住”的方式消解瞬时压力
互斥锁方案虽然有效,但它有一个副作用:拿不到锁的请求要短暂等待,虽然等待时间通常只有几十毫秒,但对于实时性要求很高的接口来说,用户会感知到一点延迟。逻辑过期方案可以做到让请求完全不等,代价是允许短暂读到旧数据。
逻辑过期的思路是:不再依赖 Redis 自带的 TTL 让 key 过期,而是把过期时间作为一个字段写在缓存的值里。请求读取缓存时,解析出数据的同时也拿到逻辑过期时间戳,代码自行判断当前时间是否超过了这个时间戳。如果没超过,正常返回;如果超过了,不立即把 key 从 Redis 删掉,而是先将这份“逻辑过期”的旧数据返回给调用方,同时在后台启动一个异步任务去刷新缓存。
这样做的精妙之处在于:任何一瞬间都不会出现“缓存里没有值”的状态,因为 key 永远不删除,只会在逻辑上标记为过期。大量并发请求过来时,大家都能拿到旧数据并立即返回,数据库只会被后台的刷新任务访问一次。核心实现如下,我们用一个包装类把数据和逻辑过期时间绑在一起:
java复制public class CacheEntry<T> {
private T data;
private long expireAt; // 逻辑过期时间,毫秒时间戳
}
java复制public Product getProductDetail(Long id) {
String key = "product:detail:" + id;
CacheEntry<Product> entry = cache.getObject(key);
if (entry == null) {
return loadByMutexLock(id, key); // 缓存完全为空,走互斥锁回源
}
if (entry.getExpireAt() > System.currentTimeMillis()) {
return entry.getData(); // 逻辑时间未过期,直接返回
}
// 逻辑已过期,先尝试获取重建锁,成功则异步刷新
String lockKey = "rebuild:product:detail:" + id;
boolean lock = cache.setIfAbsent(lockKey, id.toString(), Duration.ofSeconds(2));
if (lock) {
asyncExecutor.submit(() -> refreshProductCache(id, key));
}
// 无论是否抢到锁,都先把旧数据返回给调用方
return entry.getData();
}
逻辑过期方案对数据库最友好,因为它完全消除了“缓存空窗期”,请求永远有值可用。但它也有明显的代价:在一段短暂窗口内,客户端读到的是旧数据。如果业务对数据一致性要求很高,比如商品库存、账户余额,就不能直接用这个方案;更适合的是新闻详情、商品基础信息这类允许短暂延迟的业务。
顺带提一个更简单的方案:永不过期并配合异步刷新。你可以给热点 key 不设置 TTL,然后用一个定时任务定期回源数据库更新缓存。这个方案实现最简单,但要特别注意刷新任务挂了怎么办,一旦定时刷新出问题,缓存里的数据就会一直是旧值且没有自愈机制。逻辑过期方案天然带有一点自愈能力:即使异步刷新偶发失败,下一次请求发现逻辑过期后又会触发新的刷新任务。
3.4 多级缓存:把热点流量拦在 Redis 之外
最后一个常用补充手段是多级缓存,在 Redis 之前再加一层应用本地缓存。如果同一个商品 key 的 QPS 高达几十万,即使它永久不过期、每次都命中 Redis,Redis 的单实例网络开销也已经相当可观。这时可以在每个应用实例里放一个本地缓存,例如 Caffeine,容量限制为十几万个 key,命中后直接在当前进程内存里返回,连 Redis 都不需要访问。
多级缓存的引入会让问题复杂一些,本地缓存是每个实例各存一份,数据一致性更难保证。它更适合用来解决“极端热点 key”的查询,并且需要在本地缓存的刷新策略上做文章。这里我把它定位成补强方案:它不能替代互斥锁或逻辑过期,但可以在两者之上再把流量的冲击降低一个数量级。对于击穿问题,互斥锁和逻辑过期是主力,多级缓存是加分项。
4. 缓存雪崩:大面积同时过期和 Redis 宕机,得按两件事分别治理
4.1 雪崩的两个常见成因,千万别混为一谈
缓存雪崩是一个范围更大的故障,它不是单个 key 的问题,而是很多 key 或整个缓存层同时失守。最常见的雪崩成因有两个,它们的长相完全不一样,解决思路也不一样,很多人在面试时只讲一个,容易让面试官觉得理解不完整。
