第三篇:缓存穿透、击穿、雪崩——从原理到解决方案

前言

在前两篇文章中,我们拆解了Redis的数据结构底层和内存管理机制。但面试中,Redis最常被问到的不是数据结构,而是三个经典问题:

"缓存穿透和缓存击穿有什么区别?"

"布隆过滤器能解决穿透吗?它有什么局限?"

"雪崩怎么防?多级缓存是怎么用的?"

这三个问题看似简单,但真正能讲清楚底层原理和解决方案边界的人不多。本文从成因出发,拆解每种问题的本质和解决思路,最后对接秒杀项目中的多级缓存实战------让你面试时能从原理讲到落地。

本文核心问题:

  1. 穿透、击穿、雪崩分别是什么?各自的成因是什么?
  2. 缓存穿透怎么防?布隆过滤器的原理和局限是什么?
  3. 缓存击穿怎么防?互斥锁和逻辑过期各适合什么场景?
  4. 缓存雪崩怎么防?多级缓存、过期时间随机化、熔断降级如何组合?
  5. 为什么多级缓存(Caffeine+Redis)能体系化地解决这三大问题?
  6. 这些方案在秒杀项目中是怎么落地的?

读完本文,你将对Redis的三大缓存问题拥有从原理到解决方案的完整理解,并能对接真实的项目实践。


一、三兄弟各长什么样------先分清谁是谁

疑问:缓存穿透、击穿、雪崩听起来很像,怎么区分?

回答:穿透是"查不存在的数据",击穿是"热点Key突然过期",雪崩是"一大批Key同时过期"。 三者的成因和冲击点完全不同,但最终都导致同一个后果------大量请求绕过缓存直接命中数据库。

问题 成因 现象 防护核心
穿透 查询不存在的数据,缓存没有,数据库也没有 每次查询都穿透到数据库 拦截非法请求 + 缓存空值
击穿 热点Key过期瞬间,大量并发请求同时打到数据库 数据库瞬间压力飙升 控制并发查库的线程数
雪崩 大量Key同时过期或Redis宕机,缓存大面积失效 数据库被海量请求冲垮 过期时间分散 + 多级兜底

二、缓存穿透------查不存在的数据

疑问:为什么会有缓存穿透?数据库里没有的数据,缓存当然也没有,这不是正常的吗?

回答:正常用户查的是存在的数据,恶意攻击者专门查不存在的数据。这些请求每次都绕过缓存直接打数据库,缓存形同虚设。

2.1 穿透的成因

复制代码
正常请求:GET /api/course/1001
  → 查缓存:有 → 返回 ✓

穿透请求:GET /api/course/-1(不存在的课程ID)
  → 查缓存:无
  → 查数据库:无
  → 缓存还是无(没有数据可缓存)
  → 下次再查 -1 → 继续穿透到数据库

恶意攻击:用脚本遍历ID=-1, -2, -3...100万个不存在的ID
  → 每个请求都穿透到数据库
  → 数据库CPU飙升 → 正常请求也慢 → 雪崩

穿透的本质:缓存中没有这个Key → 数据库也没有 → 这个Key没有数据可缓存 → 下次查询仍然没有缓存 → 形成"永远没有缓存"的死循环。攻击者利用这个死循环,用极小的请求成本就把数据库打崩。

2.2 解决方案一:布隆过滤器

布隆过滤器是一种"不存储数据本身,只判断值存不存在"的概率型数据结构。它用多个哈希函数将Key映射到一个二进制数组的多个位置。查询时看所有映射位置是否都为1------如果都是1,说明"可能存在";如果存在一个0,说明"一定不存在"。

复制代码
存入 "course:1001":
  哈希1 → 位置3
  哈希2 → 位置7
  哈希3 → 位置11
  → 将位置3,7,11都设为1

查询 "course:1001":
  三个位置都是1 → "可能存在" → 放行,继续查缓存
查询 "course:-1":
  至少一个位置是0 → "一定不存在" → 直接返回不存在,不查数据库

布隆过滤器的关键特性:它不会"漏掉真正存在的数据"------实际存在的数据,它一定返回"可能存在"。但它可能"谎报军情"------不存在的数据,它有概率也返回"可能存在"。这个误判率由数组长度和哈希函数数量共同决定------数组越长、哈希函数越多,误判率越低,但内存占用越大。需要在误判容忍度和内存预算之间取平衡。

为什么不能删除元素? 多位哈希映射的本质决定了:两个不同元素可能共享某个位。删除一个元素时把这个位置为0,另一个元素就查不到了------它的"可能存在"被第一个元素的删除操作意外破坏了。

2.3 解决方案二:缓存空值

查数据库发现结果为空时,在Redis中缓存一个特殊标志(如NULL),设置一个较短的过期时间(如10秒)。后续相同请求在过期时间内直接返回"不存在",不会反复穿透到数据库。

和布隆过滤器的配合:布隆过滤器是"第一道防线"------直接在应用进程内存中快速拦截大量非法请求。缓存空值是"第二道防线"------即使布隆过滤器判断为"可能存在"并放行了,到了数据库发现确实不存在,空值缓存也能防止这个ID在接下来的10秒内再次穿透。

2.4 秒杀项目中的实际使用

java 复制代码
// 启动时加载所有合法课程ID到布隆过滤器
@PostConstruct
public void initBloomFilter() {
    BloomFilter<Long> bloom = BloomFilter.create(
        Funnels.longFunnel(), 10000, 0.01  // 期望1万个ID,误判率1%
    );
    courseMapper.getAllIds().forEach(bloom::put);
}

// 查询接口
public Course getCourse(Long courseId) {
    // 第一道防线:布隆过滤器快速拦截
    if (!bloomFilter.mightContain(courseId)) {
        return null;  // 一定不存在,直接返回
    }
    
    // 第二道防线:缓存+数据库正常查询
    Course course = redisTemplate.opsForValue().get("course:" + courseId);
    if (course != null) return course;
    
    course = courseMapper.selectById(courseId);
    if (course != null) {
        redisTemplate.opsForValue().set("course:" + courseId, course, 5, TimeUnit.MINUTES);
    } else {
        // 第三道防线:缓存空值,10秒过期
        redisTemplate.opsForValue().set("course:" + courseId, "NULL", 10, TimeUnit.SECONDS);
    }
    return course;
}

三、缓存击穿------热点Key突然过期

疑问:击穿和穿透有什么不同?为什么击穿更危险?

回答:穿透是"数据本来就不存在",击穿是"数据存在但缓存刚好过期了"。击穿更危险------因为它是热点Key,正好过期在大量用户访问的同一瞬间。

3.1 击穿的成因

复制代码
秒杀活动开始前,课程信息缓存了5分钟
T1:缓存过期
T2:大量用户同时刷新课程页(比如1000个请求同时到达)
T3:所有请求都发现缓存不存在 → 全部去查数据库
T4:数据库执行1000次相同的查询
T5:数据库CPU飙升 → 响应变慢 → 更长的查询时间 → 连接池耗尽

3.2 解决方案:互斥锁

核心思想------缓存过期后,只允许一个线程去查数据库,其他线程排队等待。

java 复制代码
public Course getCourse(Long courseId) {
    Course course = redisTemplate.opsForValue().get("course:" + courseId);
    if (course != null) return course;
    
    // 分布式锁:只有第一个线程能获取锁并查数据库
    String lockKey = "lock:course:" + courseId;
    String lockValue = UUID.randomUUID().toString();
    
    try {
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            // Double Check:获取锁后再检查一次缓存
            course = redisTemplate.opsForValue().get("course:" + courseId);
            if (course != null) return course;
            
            // 查数据库 → 回写缓存
            course = courseMapper.selectById(courseId);
            redisTemplate.opsForValue().set("course:" + courseId, course, 5, TimeUnit.MINUTES);
        } else {
            // 没获取到锁 → 休眠重试
            Thread.sleep(50);
            return getCourse(courseId);  // 递归重试,此时缓存大概率已被第一个线程写入
        }
    } finally {
        // Lua脚本原子释放锁:判断value是否匹配再删除
        String lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), 
            List.of(lockKey), lockValue);
    }
    return course;
}

关键设计

  1. Double Check:获取锁后再次检查缓存------其他线程可能已经重建完缓存
  2. 休眠重试:没获取锁的线程休眠后递归重试,而不是一直空转
  3. Lua原子释放:验证锁是自己的才能删除,防止误删

四、缓存雪崩------大面积缓存失效

疑问:雪崩和击穿有什么不同?

回答:击穿是"一个热点Key"过期,雪崩是"一大批Key同时过期"或"Redis宕机"。雪崩影响面更大------不是某个接口慢,而是整个系统的缓存层失效。

4.1 雪崩的两种成因

成因一:大批Key同时过期

复制代码
T1:缓存预热时给所有课程设了相同的过期时间(5分钟)
T2:5分钟后,所有缓存同时失效
T3:大量请求直接穿透到数据库 → 数据库瞬间压力激增

成因二:Redis宕机

复制代码
Redis进程崩溃、网络分区、机器故障 → 所有缓存不可用
→ 请求全部直接操作数据库 → 数据库被冲垮

4.2 解决方案一:过期时间加随机值

java 复制代码
// ❌ 所有缓存的过期时间相同
int expire = 300;  // 5分钟

// ✅ 加随机抖动
int expire = 300 + ThreadLocalRandom.current().nextInt(60);  // 5分钟 + 0~60秒随机

4.3 解决方案二:多级缓存

当Redis失效时,本地Caffeine缓存仍然可以扛住一部分请求。Redis宕机时,Caffeine返回本地缓存的最近值------虽然可能是短时间内的旧数据,但至少不会直接打穿到数据库。

复制代码
Caffeine(本地) → Redis(分布式) → MySQL(数据库)
      ↓                ↓
   即使Redis挂了    即使数据库压力大
   本地缓存还能扛    Caffeine还能兜底

4.4 解决方案三:熔断降级

Sentinel检测到数据库压力激增时,触发熔断------直接返回友好提示,不允许新的数据库查询,给数据库喘息的时间窗口。

4.5 秒杀项目中的组合防御

防御层 策略 作用
过期时间分散 所有缓存过期时间加随机值 防止同时失效
三级缓存 Caffeine → Redis → MySQL Redis挂了Caffeine顶,数据库压力大时Redis+本地的热数据密度降低对数据库的冲击
熔断降级 Sentinel 极端情况下牺牲用户体验保护数据库

五、三大问题的对比与选型

问题 根因 核心解决思路 秒杀项目中的实现
穿透 查不存在的数据 提前拦截 + 空值兜底 布隆过滤器 + 缓存空值(10s)
击穿 热点Key突然过期 控制并发查库 互斥锁 + Double Check
雪崩 大批Key同时过期或Redis宕机 分散过期 + 多级兜底 过期时间随机化 + Caffeine+Redis+MySQL三级

总结

  • 穿透、击穿、雪崩本质都是"缓存失效",但成因不同:穿透=数据不存在,击穿=热点Key过期,雪崩=大面积Key同时过期
  • 穿透的核心防线是布隆过滤器------它能用极小的内存"快速否定不存在的数据"。但它不能删除元素,误判率需要在设计时预设
  • 击穿的核心武器是互斥锁------只允许一个线程查库,其他线程等待缓存重建。Double Check和Lua原子释放是安全保证
  • 雪崩的核心策略是分而治之------过期时间随机化 + 多级缓存 + 熔断降级,每一层兜住下一层的压力
  • 多级缓存(Caffeine+Redis+MySQL)是体系化的防御方案:L1挡住高频请求,L2分担L1的压力,L3兜底L2的失效。三层协同,保证在最差情况下数据库也不会被冲垮

下一篇预告:Redis原理(四)------RDB与AOF持久化:宕机后数据怎么恢复。拆解RDB的Copy-On-Write原理、AOF的三种刷盘策略、以及生产环境的混合持久化配置。

相关推荐
番茄炒鸡蛋加糖1 小时前
缓存三大问题
缓存
江晓鱼未暖6 小时前
十七、Redis 核心原理与架构详解
大数据·数据库·数据仓库·redis·缓存·架构
红莲凪是6 小时前
Redis有哪些部署方案?了解哨兵机制吗?
java·redis·eureka
小罗水6 小时前
第13章 Redis 缓存、幂等锁与任务状态
数据库·redis·缓存
热心市民lcj6 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
zx1154507 小时前
【热点 Key 探测与加长缓存 TTL 实战】
缓存
Devin~Y8 小时前
从本地生活电商到 AI RAG:互联网大厂 Java 面试场景完整实战
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag
toooooop821 小时前
如何用 ss + ps 精准定位本机 Redis 的“隐形”消费者?
linux·数据库·redis·缓存
软萌萌的121 小时前
C#中的多级缓存架构设计与实现深度解析
缓存·c#·wpf
veminhe1 天前
redis从6.2.x版本升级到8.8.1
redis