什么是缓存击穿、缓存穿透、缓存雪崩?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 基础掌握度:面试官不仅仅是想知道这三个概念的定义,更是想知道你是否能清晰区分它们的触发场景("查不到" vs "过期了" vs "集体过期"),以及各自对应的解决方案。

  2. 方案设计能力:考察你是否了解布隆过滤器、互斥锁、随机 TTL、熔断降级等生产级别的防护手段,而不是只停留在 "加缓存" 这种表面回答。

  3. 架构思维:能否认识到这三类问题本质上都是 "缓存失效 → 大量请求打到 DB" 的不同变体,需要从 缓存层、应用层、数据库层 多维度防御。

核心答案

问题触发场景核心特征首选方案
缓存穿透查询 根本不存在 的数据缓存和 DB 都没有,每次都穿透布隆过滤器 + 空值缓存
缓存击穿热点 Key 突然过期大量并发请求同时打到 DB互斥锁 + 逻辑过期
缓存雪崩大量 Key 同时过期 或 Redis 宕机瞬间全部请求压向 DB随机 TTL + Redis 高可用 + 熔断降级

一句话结论:穿透是 "查的东西根本不存在",击穿是 "一个热点 Key 过期了",雪崩是 "一堆 Key 同时过期或 Redis 挂了"。三者本质都是缓存挡不住请求,DB 被压垮。

深度解析

一、缓存穿透:查的东西根本不存在

上图展示了缓存穿透的核心问题:

  • 请求的数据在缓存和数据库中都不存在,比如查询 id = -1 的用户,缓存里没有,数据库里也没有。
  • 因为没有数据,所以也 无法写入缓存,下次同样的请求还是会穿透到数据库。
  • 恶意攻击者可以利用这一点,大量发送查询不存在数据的请求,直接把数据库打挂。

解决方案一:布隆过滤器(推荐)

上图展示了布隆过滤器的防护逻辑:

  • 在请求到达缓存之前,先经过布隆过滤器快速判断数据是否存在。
  • 布隆过滤器判断 不存在则一定不存在,可以直接拒绝请求,不查缓存也不查 DB。
  • 判断存在则 可能存在(有误判率),走正常的缓存 → DB 查询流程。
  • 适合 数据相对固定、可预加载 的场景(如商品 ID、用户 ID)。

解决方案二:缓存空值

public Object getUserById(Long id) {
    String key = "user:" + id;

    // 1. 查缓存
    String value = redis.get(key);
    if (value != null) {
        // 命中缓存(包括空值缓存)
        return "NULL".equals(value) ? null : deserialize(value);
    }

    // 2. 查数据库
    Object user = userDao.findById(id);

    if (user != null) {
        // 正常数据,写入缓存,TTL 30 分钟
        redis.set(key, serialize(user), 30, TimeUnit.MINUTES);
    } else {
        // 空值也要缓存!TTL 设短一些,比如 5 分钟
        redis.set(key, "NULL", 5, TimeUnit.MINUTES);
    }

    return user;
}

关键点:

  • 当数据库也查不到时,往缓存中写入一个 空值标记(如 "NULL"),并设置一个较短的 TTL。
  • 下次同样的请求会命中这个空值缓存,直接返回,不再打到 DB。
  • 注意:空值 TTL 不能设太长,否则当数据真正被插入后,缓存中的空值会阻止读取到新数据。

方案对比

方案优点缺点适用场景
布隆过滤器内存占用极小,判断高效有误判率,不支持删除,需预加载数据相对固定,ID 类查询
缓存空值实现简单,通用浪费缓存空间,可能数据不一致查询条件多样,无法预加载

二、缓存击穿:一个热点 Key 突然过期

上图展示了缓存击穿的核心问题:

  • 某个 热点 Key(如热门商品信息、首页推荐数据)在缓存中过期了。
  • 过期的瞬间,大量并发请求同时发现缓存 miss,全部涌向数据库。
  • 典型场景:秒杀商品详情热门文章微博热搜 等高并发读的热点数据。

解决方案一:互斥锁(推荐)

public Object getHotData(String key) {
    // 1. 查缓存
    String value = redis.get(key);
    if (value != null) {
        return deserialize(value);
    }

    // 2. 缓存 miss,尝试获取分布式锁(只让一个请求去查 DB)
    String lockKey = "lock:" + key;
    boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);

    if (locked) {
        try {
            // 3. 获取锁成功,double-check 缓存(可能被其他线程写入了)
            value = redis.get(key);
            if (value != null) {
                return deserialize(value);
            }

            // 4. 查数据库
            Object data = db.query(key);

            // 5. 写入缓存
            redis.set(key, serialize(data), 30, TimeUnit.MINUTES);

            return data;
        } finally {
            // 6. 释放锁
            redis.del(lockKey);
        }
    } else {
        // 7. 获取锁失败,说明其他线程在查 DB,稍等后重试
        Thread.sleep(100);
        return getHotData(key); // 递归重试
    }
}

关键点:

  • 大量请求中,只有 获取到分布式锁的那个请求 去查数据库,其他请求等待后重试。
  • 其他请求重试时,缓存可能已经被第一个请求写入了,直接命中缓存返回。
  • Double-check:获取锁后再查一次缓存,避免重复查 DB。

解决方案二:逻辑过期(不设 TTL)

上图展示了逻辑过期的核心思路:

  • 缓存数据中包含一个 逻辑过期时间 字段,但 Redis 中 不设物理 TTL,数据永远不会被 Redis 主动删除。
  • 读取时判断逻辑过期时间:未过期直接返回;已过期则获取互斥锁,获取成功后 异步开启一个线程 去更新缓存,当前请求返回旧数据。
  • 其他请求发现过期但拿不到锁,也直接返回旧数据。
  • 适合对一致性要求不高、但高可用要求高的场景(如商品详情页,旧数据短暂展示可以接受)。

方案对比

方案一致性性能复杂度适用场景
互斥锁强一致(同一时刻只有一个数据源)有等待时间一致性要求高
逻辑过期最终一致(短暂返回旧数据)无等待,性能最佳高可用优先,允许短暂不一致

三、缓存雪崩:大量 Key 同时过期或 Redis 宕机

上图展示了缓存雪崩的两种触发场景:

  • 场景一:大量 Key 同时过期。如果一批 Key 的 TTL 设成一样的(比如批量导入数据时都设了 30 分钟),到期后全部失效,瞬间大量请求全部打到 DB。
  • 场景二:Redis 宕机。缓存层整体不可用,所有请求直接打到数据库,数据库扛不住就整体雪崩。
  • 与击穿的区别:击穿是 "一个热点 Key 过期",雪崩是 "大量 Key 集体过期",范围更广,危害更大。

解决方案:多维度防御

上图展示了缓存雪崩的四层防御体系:

  • 第一层:随机 TTL。给缓存过期时间加一个随机偏移量,把集中过期打散成分散过期。这是最简单也最有效的手段。
  • 第二层:Redis 高可用。用哨兵或集群模式保证 Redis 本身不会单点故障。还可以加一层本地缓存(如 Caffeine)作为二级缓存兜底。
  • 第三层:熔断降级。当 DB 压力过大时,通过 Sentinel 等熔断框架直接拒绝部分请求,返回降级数据(如默认值、温馨提示),保护数据库不被打挂。
  • 第四层:限流排队。用令牌桶或漏桶算法控制打到 DB 的请求速率,避免瞬时流量压垮数据库。

随机 TTL 代码示例

// 批量设置缓存时,给 TTL 加随机偏移量
public void batchSetCache(Map<String, Object> dataMap) {
    Random random = new Random();
    for (Map.Entry<String, Object> entry : dataMap.entrySet()) {
        // 基础 TTL 30 分钟 + 0~10 分钟随机偏移
        long ttl = 30 * 60 + random.nextInt(10 * 60);
        redis.set(entry.getKey(), serialize(entry.getValue()), (int) ttl, TimeUnit.SECONDS);
    }
}

四、三者对比总结

面试高频追问

  1. 追问一:布隆过滤器有误判怎么办?

    布隆过滤器判断 "存在" 时有一定误判率(可能实际不存在),但判断 "不存在" 时是 100% 准确的。可以通过增大位数组大小和增加哈希函数个数来降低误判率。生产环境通常设置误判率在 1% 以下即可。如果误判了,最多也就是多查一次数据库,不会造成严重后果。

  2. 追问二:互斥锁方案中,如果查 DB 的线程挂了怎么办?锁会不会死锁?

    分布式锁必须设置过期时间(如 10 秒),即使持有锁的线程挂了,锁也会自动释放。但要注意 锁过期时间要大于 DB 查询时间,否则锁提前释放会导致其他线程也去查 DB。如果担心,可以在代码中加 try-finally 确保锁一定被释放。

  3. 追问三:如果 Redis 宕机了,怎么办?

    第一道防线是 Redis 高可用(哨兵/集群),保证单节点故障不影响整体服务。第二道防线是 本地缓存(Caffeine、Guava Cache),Redis 不可用时退而求其次用本地缓存。第三道防线是 熔断降级,Redis 和本地缓存都不行时,直接返回降级数据,保护数据库。

常见面试变体

  • 变体一:"缓存穿透怎么解决?"
  • 变体二:"热点 Key 过期了怎么办?"
  • 变体三:"如何防止大量 Key 同时过期?"
  • 变体四:"Redis 宕机了怎么办?"
  • 变体五:"你项目中遇到过缓存相关的问题吗?怎么解决的?"

记忆口诀

穿透:查的东西不存在 —— 布隆过滤挡在前,空值缓存兜在后。

击穿:一个热点突然过期 —— 互斥锁只放一个去查 DB,逻辑过期异步更新不阻塞。

雪崩:大量 Key 集体过期 —— 随机 TTL 打散时间,高可用防宕机,熔断限流保命。

核心区别:穿透是 "没有",击穿是 "一个没了",雪崩是 "一堆没了"。

总结

缓存击穿、穿透、雪崩本质上都是 "缓存失效 → 请求打到 DB → DB 被压垮" 的不同变体。穿透 是查询不存在的数据,用布隆过滤器和空值缓存解决;击穿 是热点 Key 过期,用互斥锁或逻辑过期解决;雪崩 是大量 Key 同时过期或 Redis 宕机,用随机 TTL + 高可用 + 熔断降级多维度防御。生产环境需要 多层防护,不能只依赖单一方案。