Redis 缓存三大经典问题深度解析:穿透、击穿、雪崩的原理与实战解决方案

《Redis 缓存三大经典问题深度解析:穿透、击穿、雪崩的原理与实战解决方案》

前言

在高并发系统中,Redis 几乎是标配的缓存中间件。合理使用缓存可以大幅降低数据库压力、提升接口响应速度。但如果缓存使用不当,反而会引发严重的线上事故------缓存穿透、缓存击穿、缓存雪崩,这三个问题被称为 Redis 缓存的"三大杀手"。

很多开发者在日常工作中用过 Redis,但对这三个问题的理解停留在"背八股文"的层面,一旦在生产环境中遇到,往往手足无措。

这篇文章从原理出发,结合实际代码(基于 Spring Boot + Redis),把三大问题的成因、危害、解决方案讲透,帮你建立完整的缓存防护体系。


一、问题全景:三大缓存故障的本质区别

先建立全局认知,三大问题的核心区别如下:

问题 触发条件 本质原因 影响范围
缓存穿透 查询一个根本不存在的数据 缓存和数据库都没有该数据,请求直达数据库 针对单个 key 持续攻击
缓存击穿 一个热点 key 在过期的瞬间,大量并发请求同时命中 热点 key 过期,所有请求同时打到数据库 针对单个热点 key
缓存雪崩 大量 key 在同一时间集中过期,或 Redis 节点宕机 缓存大面积失效,请求全部涌向数据库 针对整个缓存系统

三者看似相似,但触发条件和解决思路完全不同。下面逐一拆解。


二、缓存穿透:查一个不存在的数据
2.1 问题描述

用户请求查询一条数据,但该数据在缓存和数据库中都不存在

正常流程:请求 → 查缓存 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空 → 写入缓存(空值)→ 返回。

问题在于:如果攻击者持续用不存在的 key 发起请求,每次都会穿透缓存直达数据库。因为数据不存在,缓存中永远不会有这个 key,数据库每次都要执行查询。当请求量足够大时,数据库会被打垮。

复制代码
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
恶意用户 ──请求 id=-1──▶ 缓存(未命中)──▶ 数据库(无数据)──▶ 返回空
...(每秒数万次)
2.2 解决方案一:缓存空值

最简单的方案------当数据库也查不到数据时,在缓存中写入一个空值(如 "" 或特殊标记 NULL_PLACEHOLDER),并设置一个较短的过期时间。

java 复制代码
public User getUserById(Long userId) {
    String cacheKey = "user:" + userId;
    
    // 1. 查缓存
    String cached = redisTemplate.opsForValue().get(cacheKey);
    
    // 2. 缓存命中(包括空值)
    if (cached != null) {
        // 判断是否为空值标记
        if ("NULL_PLACEHOLDER".equals(cached)) {
            return null; // 直接返回,不查数据库
        }
        return JSON.parseObject(cached, User.class);
    }
    
    // 3. 缓存未命中,查数据库
    User user = userMapper.selectById(userId);
    
    if (user != null) {
        // 数据存在,写入缓存,过期时间 30 分钟
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    } else {
        // 数据不存在,缓存空值,过期时间 5 分钟(较短,防止长期占用)
        redisTemplate.opsForValue().set(cacheKey, "NULL_PLACEHOLDER", 5, TimeUnit.MINUTES);
    }
    
    return user;
}

优点:实现简单,效果立竿见影。

缺点

  • 如果攻击者用大量随机 key 发起攻击,缓存中会堆积大量空值,浪费内存。
  • 空值的过期时间需要权衡:太短则防护效果差,太长则浪费内存。
2.3 解决方案二:布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断"一个元素是否存在于集合中"。它有两个特性:

  • 如果布隆过滤器说"不存在",那一定不存在(不会误判)
  • 如果布隆过滤器说"存在",可能存在,也可能不存在(有一定误判率)

利用这个特性,在请求到达缓存之前,先用布隆过滤器判断 key 是否可能存在:

java 复制代码
@Component
public class UserBloomFilter {
    
    private static final int EXPECTED_INSERTIONS = 1000000; // 预计插入100万条
    private static final double FPP = 0.01; // 误判率 1%
    
    @Autowired
    private RedissonClient redissonClient;
    
    private RBloomFilter<String> bloomFilter;
    
    @PostConstruct
    public void init() {
        bloomFilter = redissonClient.getBloomFilter("user:bloom");
        bloomFilter.tryInit(EXPECTED_INSERTIONS, FPP);
    }
    
    /**
     * 初始化:将所有有效用户ID加入布隆过滤器
     */
    public void loadAllUserIds() {
        List<Long> allUserIds = userMapper.selectAllUserIds();
        for (Long userId : allUserIds) {
            bloomFilter.add(String.valueOf(userId));
        }
    }
    
    /**
     * 判断用户ID是否可能存在
     */
    public boolean mightContain(Long userId) {
        return bloomFilter.contains(String.valueOf(userId));
    }
    
    /**
     * 新增用户时,同步加入布隆过滤器
     */
    public void addUser(Long userId) {
        bloomFilter.add(String.valueOf(userId));
    }
}

查询时的完整流程:

java 复制代码
public User getUserById(Long userId) {
    // 1. 布隆过滤器预判
    if (!userBloomFilter.mightContain(userId)) {
        // 布隆过滤器说不存在 → 一定不存在,直接返回
        return null;
    }
    
    // 2. 布隆过滤器说可能存在 → 查缓存
    String cacheKey = "user:" + userId;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return JSON.parseObject(cached, User.class);
    }
    
    // 3. 查数据库
    User user = userMapper.selectById(userId);
    if (user != null) {
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    }
    
    return user;
}

优点

  • 从源头拦截无效请求,数据库完全不受影响
  • 内存占用极小(100万条数据,1% 误判率,只需约 1.14MB)

缺点

  • 存在误判率(可配置降低,但不能完全消除)
  • 标准布隆过滤器不支持删除操作(可用 Counting Bloom Filter 解决)
  • 需要定期重建或增量维护
2.4 方案选型建议
场景 推荐方案
攻击量不大,key 空间有限 缓存空值即可
key 空间很大,攻击量大 布隆过滤器 + 缓存空值(双重防护)
对误判零容忍 布隆过滤器 + 缓存空值 + 参数校验

三、缓存击穿:热点 key 过期瞬间的并发风暴
3.1 问题描述

某个数据是高频访问的热点数据(如秒杀商品、热搜话题、首页 Banner),其缓存 key 在某一时刻过期。在过期的瞬间,大量并发请求同时到达:

  1. 所有请求查缓存 → 缓存已过期,全部未命中
  2. 所有请求同时查数据库 → 数据库瞬间承受巨大压力
  3. 第一个请求查到数据,重建缓存
  4. 后续请求查到缓存,恢复正常

问题就出在第 2 步------在缓存重建的短暂窗口期内,所有请求都打到了数据库。如果这个热点 key 的 QPS 是 10 万,那数据库就要瞬间承受 10 万并发查询。

复制代码
时间线:
T0: 热点key过期
T0: 10万个请求同时到达 → 缓存未命中 → 全部查数据库
T0+50ms: 第一个请求完成数据库查询,重建缓存
T0+50ms之后: 后续请求命中缓存,恢复正常
3.2 解决方案一:互斥锁(分布式锁)

核心思路:只允许一个线程去查数据库并重建缓存,其他线程等待或返回旧数据。

java 复制代码
public User getUserByIdWithLock(Long userId) {
    String cacheKey = "user:" + userId;
    
    // 1. 查缓存
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return JSON.parseObject(cached, User.class);
    }
    
    // 2. 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:" + cacheKey;
    String lockValue = UUID.randomUUID().toString();
    
    // 尝试加锁,等待时间 100ms,锁过期时间 10s
    boolean locked = false;
    try {
        locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
        
        if (locked) {
            // 获取锁成功,再次检查缓存(双重检查)
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return JSON.parseObject(cached, User.class);
            }
            
            // 查数据库
            User user = userMapper.selectById(userId);
            if (user != null) {
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
            }
            return user;
        } else {
            // 获取锁失败,短暂休眠后重试(或返回默认值)
            Thread.sleep(50);
            return getUserByIdWithLock(userId); // 递归重试
        }
    } finally {
        // 释放锁(只释放自己加的锁)
        if (locked) {
            String currentValue = redisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(currentValue)) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

生产环境中建议使用 Redisson 的 RLock 来实现分布式锁,它内部封装了看门狗(Watch Dog)机制,可以自动续期,避免锁提前过期导致的问题。

优点:保证数据一致性,同一时刻只有一个线程查数据库。

缺点:其他线程需要等待,响应时间增加。

3.3 解决方案二:逻辑过期(永不过期 + 后台异步更新)

核心思路:缓存不设置物理过期时间,而是在 value 中存储一个"逻辑过期时间"。读取时检查是否逻辑过期,如果过期则开启一个异步线程去更新缓存,当前请求返回旧数据。

java 复制代码
@Data
public class CacheWrapper<T> {
    private T data;
    private long expireTime; // 逻辑过期时间戳(毫秒)
}

public User getUserByIdWithLogicalExpire(Long userId) {
    String cacheKey = "user:" + userId;
    
    // 1. 查缓存
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached == null) {
        // 缓存不存在,查数据库并初始化缓存
        return initCache(userId);
    }
    
    CacheWrapper<User> wrapper = JSON.parseObject(cached, 
        new TypeReference<CacheWrapper<User>>() {});
    
    // 2. 检查是否逻辑过期
    if (System.currentTimeMillis() < wrapper.getExpireTime()) {
        // 未过期,直接返回
        return wrapper.getData();
    }
    
    // 3. 已过期,尝试获取锁去更新缓存
    String lockKey = "lock:" + cacheKey;
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        // 获取锁成功,开启异步线程更新缓存
        executorService.submit(() -> {
            try {
                User user = userMapper.selectById(userId);
                CacheWrapper<User> newWrapper = new CacheWrapper<>();
                newWrapper.setData(user);
                newWrapper.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000L);
                // 不设置物理过期时间(或设置一个很长的过期时间)
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newWrapper));
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }
    
    // 4. 返回旧数据(无论是否拿到锁)
    return wrapper.getData();
}

优点

  • 无需等待,所有请求都能立即返回数据(虽然是旧数据)
  • 不会造成数据库压力

缺点

  • 数据存在短暂不一致(逻辑过期到缓存更新完成之间)
  • 需要额外的存储空间来保存逻辑过期时间
  • 缓存永不过期,如果数据被删除,缓存中会残留脏数据
3.4 方案选型建议
场景 推荐方案
对数据一致性要求高(如库存、余额) 互斥锁
对实时性要求不高,允许短暂旧数据(如商品详情、文章) 逻辑过期
热点 key 数量少且可预判 永不过期 + 定时任务主动刷新

四、缓存雪崩:大面积缓存同时失效
4.1 问题描述

缓存雪崩有两种典型触发场景:

场景一:大量 key 在同一时间集中过期

比如系统凌晨 2 点启动时批量加载了 10 万个热点数据到缓存,过期时间都设为 2 小时。凌晨 4 点,这 10 万个 key 同时过期,所有请求瞬间打到数据库。

场景二:Redis 节点宕机

Redis 主节点宕机,且主从切换失败或未配置高可用,导致整个缓存系统不可用,所有请求直达数据库。

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

最简单也最有效的方案------在基础过期时间上加一个随机偏移量,避免大量 key 同时过期。

java 复制代码
// 基础过期时间 30 分钟 + 随机 0~10 分钟
int baseExpire = 30; // 分钟
int randomExpire = ThreadLocalRandom.current().nextInt(0, 11); // 0~10 分钟
int totalExpire = baseExpire + randomExpire;

redisTemplate.opsForValue().set(cacheKey, value, totalExpire, TimeUnit.MINUTES);

这样 10 万个 key 的过期时间会分散在 30~40 分钟之间,不会出现集中过期的情况。

4.3 解决方案二:多级缓存架构

引入本地缓存(如 Caffeine)作为第一级缓存,Redis 作为第二级缓存,数据库作为最终兜底:

java 复制代码
// 使用 Caffeine 作为本地缓存
private final Cache<String, User> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存 5 分钟过期
    .build();

public User getUserByIdMultiLevel(Long userId) {
    String cacheKey = "user:" + userId;
    
    // 1. 查本地缓存(L1)
    User user = localCache.getIfPresent(cacheKey);
    if (user != null) {
        return user;
    }
    
    // 2. 查 Redis(L2)
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        user = JSON.parseObject(cached, User.class);
        localCache.put(cacheKey, user); // 回填本地缓存
        return user;
    }
    
    // 3. 查数据库(L3)
    user = userMapper.selectById(userId);
    if (user != null) {
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 
            30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.MINUTES);
        localCache.put(cacheKey, user);
    }
    
    return user;
}

多级缓存的优势

  • 即使 Redis 大面积失效,本地缓存仍能挡住大部分请求
  • 本地缓存的响应时间在纳秒级,远快于 Redis 的毫秒级
  • 热点数据在本地缓存中命中率极高,减少网络开销
4.4 解决方案三:Redis 高可用架构

针对 Redis 节点宕机导致的雪崩,需要从架构层面保障:

  • 主从复制 + 哨兵模式:主节点宕机后,哨兵自动将从节点提升为主节点,实现自动故障转移
  • Redis Cluster 集群模式:数据分片存储在多个节点上,单个节点宕机只影响部分数据
  • 多机房部署:极端情况下,单个机房故障不会影响整体服务
4.5 解决方案四:限流降级兜底

当缓存全面失效时,用限流和降级保护数据库不被打垮:

java 复制代码
// 使用 Sentinel 进行限流
@SentinelResource(value = "getUserById", 
    blockHandler = "getUserByIdBlock",
    fallback = "getUserByIdFallback")
public User getUserById(Long userId) {
    // 正常业务逻辑
    return getUserByIdMultiLevel(userId);
}

// 限流时的处理
public User getUserByIdBlock(Long userId, BlockException ex) {
    log.warn("请求被限流,userId={}", userId);
    return null; // 或返回默认值
}

// 降级时的处理(如数据库也查不到时)
public User getUserByIdFallback(Long userId, Throwable ex) {
    log.error("服务降级,userId={}", userId, ex);
    return User.defaultUser(); // 返回默认值
}

五、三大问题对比总结
维度 缓存穿透 缓存击穿 缓存雪崩
触发条件 查询不存在的数据 热点key过期瞬间 大量key同时过期/Redis宕机
影响范围 单个key持续被攻击 单个热点key 整个缓存系统
核心方案 布隆过滤器 + 缓存空值 互斥锁 / 逻辑过期 随机过期时间 + 多级缓存 + 高可用
额外成本 布隆过滤器的内存和维护 锁的开销 / 数据短暂不一致 多级缓存的内存 / 集群运维成本

六、生产环境缓存设计 Checklist

最后给出一份生产环境缓存设计的检查清单:

  • 过期时间是否加了随机值,避免集中过期
  • 热点数据是否做了特殊处理(永不过期 / 逻辑过期 / 互斥锁)
  • 是否部署了布隆过滤器,拦截不存在的 key
  • 缓存空值是否设置了较短的过期时间,防止内存泄漏
  • 是否配置了 Redis 高可用(哨兵 / Cluster)
  • 是否有限流降级兜底方案,防止缓存全面失效时数据库被打垮
  • 缓存 key 的命名是否规范,避免不同业务之间 key 冲突
  • 是否监控了缓存命中率,命中率过低时及时告警
  • 大 key 是否做了拆分,避免单个 key 过大导致阻塞
  • 热 key 是否做了本地缓存,减少 Redis 的网络开销

写在最后

缓存穿透、击穿、雪崩,本质上都是缓存与数据库之间的流量控制问题。解决思路可以归纳为三句话:

  • 穿透:在请求到达数据库之前,用布隆过滤器把无效请求拦截掉。
  • 击穿:在缓存重建的窗口期内,用锁或逻辑过期控制并发,避免所有请求同时打到数据库。
  • 雪崩:通过随机过期时间、多级缓存、高可用架构,分散风险、层层兜底。

没有一种方案能解决所有问题,实际生产中往往是多种方案组合使用。理解每种方案的原理和适用场景,才能在不同业务需求下做出合理的技术选型。

相关推荐
spter。3 小时前
一次 Redis SETNX 的小插曲,我重新理解了原子性
前端·redis·bootstrap
星空5 小时前
Springboot复习
java·spring boot·spring
霸道流氓气质7 小时前
Redis Pub/Sub — 概念、原理、场景与代码示例
数据库·redis·缓存
wno7048 小时前
Spring Boot JdbcTemplate配置Druid多数据源
java·spring boot·后端
wear工程师8 小时前
Spring Boot 服务挂了却没重启?先分清 systemd 的三次判断
spring boot
旺仔学长 哈哈9 小时前
52486|高校专业评估不再靠表格:Spring Boot 本科专业质量评估管理系统实战
数据库·spring boot·毕业设计·教育管理
weixin_BYSJ19879 小时前
springboot小区物业管理小程序---附源码45555
java·javascript·spring boot·python·django·flask·php
RuoyiOffice10 小时前
抓包还能看到密码?Vue3 + Spring Boot 3 接口 AES/RSA 加解密(@ApiEncrypt)全链路实战
spring boot·vue3·aes·rsa·接口安全·spring boot 3·apiencrypt
凤山老林10 小时前
零停机数据库演进:Spring Boot 集成 Flyway 与平滑DDL变更策略
数据库·spring boot·后端
抓哇小菜鸡10 小时前
Spring Boot + 本地大模型(Ollama/DeepSeek) + MyBatis-Plus 企业级智能体数据分析系统从零到一源码全解析
spring boot·后端·mybatis