臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

核心结论

三者的共同结果都是大量请求访问数据库:穿透是查不存在的数据,击穿是一个热点 key 失效,雪崩是大量 key 同时失效或 Redis 整体不可用。

二、🧭 理论:它们分别是什么

2.1 缓存穿透

请求的数据在 Redis 和数据库中都不存在,所以每次查询都会绕过缓存访问数据库。

例如攻击者不断请求不存在的商品 ID。因为数据库查不到结果,普通代码不会写入缓存,同一个无效请求下次还会继续查询数据库。

常用解决办法:

  • 校验参数,直接拦截明显非法的 ID。
  • 把“空结果”也缓存一小段时间,例如 1~5 分钟。
  • 数据量很大时使用布隆过滤器,提前判断数据是否可能存在。
  • 配合限流,防止恶意请求持续冲击系统。

布隆过滤器的特点

它判断“不存在”时一定不存在;判断“可能存在”时仍可能误判,所以不能代替数据库,只能挡住大部分无效请求。

2.2 缓存击穿

一个访问量很高的热点 key 突然过期,大量并发请求同时发现缓存未命中,又同时查询数据库。这也常被称为 cache stampede(缓存踩踏)。

常用解决办法:

  • 使用互斥锁或 single-flight,只允许一个请求查询数据库并重建缓存,其他请求短暂等待。
  • 热点数据不设置物理过期时间,改为保存“逻辑过期时间”,后台异步更新。
  • 确实长期有效的热点数据可以不过期,但必须在数据变化时主动更新或删除缓存。

2.3 缓存雪崩

大量 key 在同一时间过期,或者 Redis 故障导致大量缓存同时不可用,请求集中落到数据库,最终可能让数据库和应用一起被压垮。

常用解决办法:

  • 给过期时间增加随机值,避免大量 key 在同一秒失效。
  • 缓存预热时分批写入,不让所有 key 使用完全相同的 TTL。
  • Redis 使用主从、哨兵或集群等高可用方案。
  • 应用增加限流、熔断、降级和本地缓存,给数据库留出保护空间。
  • 监控缓存命中率、Redis 状态、数据库连接数和慢查询。

2.4 三者怎么区分

问题关键原因典型范围核心解决思路
缓存穿透数据本来就不存在大量无效 key参数校验、缓存空值、布隆过滤器
缓存击穿一个热点 key 失效单个热点 key互斥重建、逻辑过期
缓存雪崩大量 key 同时失效或 Redis 故障大量 key 或整个缓存服务TTL 打散、高可用、限流降级

记忆方法

穿透:缓存和数据库都被穿过去;击穿:一个热点被打穿;雪崩:一大片缓存一起倒。

三、⚙️ 理论:问题是怎么发生的

3.1 正常的 Cache Aside 查询

请求 → 查询 Redis
          ├─ 命中 → 返回缓存
          └─ 未命中 → 查询数据库 → 写入 Redis → 返回结果

Redis 官方的 Cache Aside 示例也是先读缓存,未命中时读取主数据源,再把结果写回缓存并设置 TTL。

3.2 为什么数据库会被压垮

危险点不只是一次缓存未命中,而是大量请求在同一时间未命中。数据库变慢后,请求超时并重试,又会进一步增加数据库压力。

四、🚀 实践:从预防到验证

4.1 前置准备

本篇目标是面试表达,不搭建完整业务项目。准备一个 Redis 实例和 redis-cli 即可验证 TTL、空值缓存和互斥锁三个核心手段。

4.2 可以拿来干什么

4.2.1 缓存空值,防止穿透

数据库确认商品不存在后,写入一个特殊值,并设置较短的 TTL:

SET cache:product:999999 "__NULL__" EX 120
GET cache:product:999999
TTL cache:product:999999

应用读到 __NULL__ 后直接返回“商品不存在”,不用再次访问数据库。空值 TTL 不宜过长,否则数据库刚新增的数据可能暂时查不到。

4.2.2 使用互斥锁,防止热点 key 被击穿

SET lock:product:1 request-123 NX EX 10
  • 返回 OK:当前请求获得锁,负责查询数据库并重建缓存。
  • 返回空:已有请求在重建缓存,当前请求应短暂等待后重查缓存,不能直接冲向数据库。
  • EX 10:即使持锁应用异常退出,锁也会自动过期,降低死锁风险。

释放锁时必须先比较锁中的唯一标识,再删除锁;生产代码通常用 Lua 脚本保证“比较并删除”是一个原子操作,不能直接无条件执行 DEL。

4.2.3 打散 TTL,降低雪崩概率

不要让一批缓存统一设置成正好 30 分钟,可以使用:

实际 TTL = 基础 TTL + 随机时间
例如:1800 秒 + 0~300 秒随机值

这样 key 会分散在 30~35 分钟之间过期。它只能缓解“集中到期”,不能解决 Redis 整体故障,因此仍需高可用和限流降级。

4.3 完整实践:串起一次缓存查询

面试时可以按下面的伪代码描述实际处理流程:

Product queryProduct(long id) {
    // 1. 参数校验:先拦截明显不合法的请求,减少缓存穿透。
    if (id <= 0) {
        throw new IllegalArgumentException("商品 ID 不合法");
    }
 
    // 2. 先查 Redis:命中正常值就直接返回;命中空值标记就返回不存在。
    String value = redis.get("cache:product:" + id);
    if (value != null) {
        return "__NULL__".equals(value) ? null : deserialize(value);
    }
 
    // 3. 缓存未命中时尝试加锁,保证只有一个请求负责重建热点缓存。
    String lockKey = "lock:product:" + id;
    String requestId = UUID.randomUUID().toString();
    boolean locked = redis.setIfAbsent(lockKey, requestId, 10, SECONDS);
    if (!locked) {
        // 实际项目应设置最大等待时间,短暂等待后重新查缓存,不能无限递归。
        sleepBriefly();
        return queryProduct(id);
    }
 
    try {
        // 4. 获得锁后再查一次缓存,防止等待加锁期间已有请求完成重建。
        value = redis.get("cache:product:" + id);
        if (value != null) {
            return "__NULL__".equals(value) ? null : deserialize(value);
        }
 
        Product product = database.findById(id);
        if (product == null) {
            // 5. 数据不存在时缓存空值,并使用较短 TTL 防止穿透。
            redis.set("cache:product:" + id, "__NULL__", 120, SECONDS);
            return null;
        }
 
        // 6. 正常数据使用“基础 TTL + 随机值”,避免大量 key 集中过期。
        long ttl = 1800 + random.nextLong(301);
        redis.set("cache:product:" + id, serialize(product), ttl, SECONDS);
        return product;
    } finally {
        // 7. 用 Lua 原子比较 requestId 后再删除,避免误删其他请求后来获得的锁。
        redis.unlockIfOwner(lockKey, requestId);
    }
}

验证时重点观察:

  • 连续查询不存在的 ID,数据库只在第一次被访问,之后命中空值缓存。
  • 并发查询同一个已过期热点 key,只有获得锁的请求访问数据库。
  • 批量写入的 key 具有不同 TTL,不会集中在同一秒过期。

五、🎤 面试回答模板

5.1 一分钟回答

缓存穿透是查询缓存和数据库都不存在的数据,导致请求每次都访问数据库,可以用参数校验、缓存空值和布隆过滤器解决。缓存击穿是某个热点 key 过期后,大量并发同时访问数据库,可以用互斥锁或逻辑过期解决。缓存雪崩是大量 key 同时过期,或者 Redis 整体不可用,导致请求集中访问数据库,可以通过随机 TTL、缓存预热、Redis 高可用以及限流降级来处理。三者的区别主要是:穿透针对不存在的数据,击穿针对单个热点 key,雪崩影响大量 key 或整个缓存系统。

5.2 常见追问

为什么缓存空值不能设置太久?

因为数据库后续可能新增这条数据。空值还没过期时,应用仍会返回不存在,造成短时间的数据不一致。

为什么加锁后还要再查一次缓存?

当前请求等待锁期间,前一个请求可能已经查询数据库并写好了缓存。获得锁后再次检查,可以避免重复访问数据库。

随机 TTL 能彻底解决雪崩吗?

不能。它只能打散集中到期;Redis 节点故障仍要依靠高可用、限流、熔断、降级和监控处理。

六、📌 总结

  • 穿透:数据不存在;用参数校验、缓存空值、布隆过滤器。
  • 击穿:单个热点 key 失效;用互斥重建或逻辑过期。
  • 雪崩:大量 key 同时失效或 Redis 不可用;用随机 TTL、高可用和限流降级。
  • 所有方案都存在取舍,面试时要同时说明数据一致性、锁超时和降级策略。
  • 最短记忆句:无效数据穿透,热点过期击穿,大面积失效雪崩。

七、🔗 官方资料