Redis 缓存穿透、击穿、雪崩:一次讲清楚

前言

面试 Redis 必问,生产环境必遇的三大缓存问题:穿透、击穿、雪崩

这三个概念名字很像,但问题和解决方案完全不同。很多候选人搞混了,面试官一听就知道你懂不懂。

这篇文章带你一次讲清楚,每个都有图解 + 代码 + 实战方案。


一、缓存穿透(Cache Penetration)

1.1 什么是缓存穿透?

问题描述:请求的数据在缓存中不存在,数据库中也不存在,导致每次请求都打到数据库。

markdown 复制代码
请求 → 查缓存(miss) → 查数据库(无数据) → 返回空
     ↓
   重复请求,每次都查数据库

典型场景

  • 恶意攻击:用不存在的 ID 批量请求
  • 数据被误删:缓存和数据库都没数据
  • 业务逻辑错误:查询了不存在的数据

1.2 解决方案

方案 1:布隆过滤器(推荐)

在缓存之前加一层布隆过滤器,快速判断数据是否存在。

kotlin 复制代码
// RedisBloom 布隆过滤器
@Bean
public RBloomFilter<String> userBloomFilter() {
    RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userFilter");
    // 初始化:预计 100 万数据,误判率 0.01
    bloomFilter.tryInit(1000000L, 0.01);
    return bloomFilter;
}

// 查询时先判断
public User getUser(Long id) {
    // 1. 布隆过滤器判断
    if (!bloomFilter.contains(id.toString())) {
        return null; // 一定不存在,直接返回
    }
    
    // 2. 查缓存
    User user = redisTemplate.opsForValue().get("user:" + id);
    if (user != null) {
        return user;
    }
    
    // 3. 查数据库
    user = userMapper.selectById(id);
    if (user != null) {
        redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
    }
    return user;
}

布隆 过滤器 原理

  • 使用多个哈希函数映射到位数组
  • 一定不存在的数据会被准确过滤
  • 可能存在的数据会有少量误判(可接受)

方案 2:缓存空值

把查询为空的结果也缓存起来。

sql 复制代码
public User getUser(Long id) {
    String key = "user:" + id;
    User user = redisTemplate.opsForValue().get(key);
    
    if (user != null) {
        // 空值标记
        if (user.getId() == null) {
            return null;
        }
        return user;
    }
    
    user = userMapper.selectById(id);
    if (user != null) {
        redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
    } else {
        // 缓存空值,短时间过期
        User emptyUser = new User();
        redisTemplate.opsForValue().set(key, emptyUser, 5, TimeUnit.MINUTES);
    }
    return user;
}

注意:空值缓存时间要短,防止数据更新后还是返回空。

方案 3:参数校验

在入口处拦截非法参数。

less 复制代码
@GetMapping("/user/{id}")
public User getUser(@PathVariable @Min(1) @Max(10000000) Long id) {
    return userService.getUser(id);
}

二、缓存击穿(Cache Breakdown)

2.1 什么是缓存击穿?

问题描述:热点数据突然过期,大量请求同时打到数据库。

复制代码
缓存过期瞬间:
请求1 → 查缓存(miss) → 查数据库 ✓
请求2 → 查缓存(miss) → 查数据库 ✓  
请求3 → 查缓存(miss) → 查数据库 ✓
...(N个请求同时打库)

典型场景

  • 秒杀活动:热点商品缓存过期
  • 微博热搜:某条微博缓存失效
  • 大 V 主页:用户缓存被清理

2.2 解决方案

方案 1:互斥锁(Mutex)

只允许一个线程重建缓存,其他线程等待。

kotlin 复制代码
public User getUserWithLock(Long id) {
    String key = "user:" + id;
    String lockKey = "lock:user:" + id;
    
    // 1. 查缓存
    User user = redisTemplate.opsForValue().get(key);
    if (user != null) {
        return user;
    }
    
    // 2. 获取锁
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    try {
        if (!locked) {
            // 没拿到锁,短暂等待后重试
            Thread.sleep(50);
            return getUserWithLock(id);
        }
        
        // 3. 双重检查
        user = redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }
        
        // 4. 查数据库并重建缓存
        user = userMapper.selectById(id);
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
        }
        return user;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return null;
    } finally {
        // 5. 释放锁
        redisTemplate.delete(lockKey);
    }
}

方案 2:逻辑过期(永不过期)

缓存不设过期时间,通过逻辑时间判断是否过期。

kotlin 复制代码
@Data
public class RedisData<T> {
    private T data;
    private LocalDateTime expireTime; // 逻辑过期时间
}

public User getUserWithLogicalExpire(Long id) {
    String key = "user:" + id;
    
    // 1. 查缓存
    RedisData<User> redisData = (RedisData<User>) redisTemplate.opsForValue().get(key);
    
    if (redisData == null) {
        return null; // 数据不存在
    }
    
    // 2. 判断是否逻辑过期
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
        return redisData.getData(); // 未过期
    }
    
    // 3. 已过期,尝试获取锁重建
    String lockKey = "lock:user:" + id;
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        // 4. 开启线程异步重建缓存
        CompletableFuture.runAsync(() -> {
            try {
                User user = userMapper.selectById(id);
                if (user != null) {
                    RedisData<User> newData = new RedisData<>();
                    newData.setData(user);
                    newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
                    redisTemplate.opsForValue().set(key, newData);
                }
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }
    
    // 5. 返回过期数据(总比没有好)
    return redisData.getData();
}

适用场景:对一致性要求不高的热点数据。


三、缓存雪崩(Cache Avalanche)

3.1 什么是缓存雪崩?

问题描述:大量缓存同时过期,或 Redis 宕机,导致所有请求打到数据库。

复制代码
Redis 宕机:
请求1 → Redis 连接失败 → 查数据库 ✓
请求2 → Redis 连接失败 → 查数据库 ✓
请求3 → Redis 连接失败 → 查数据库 ✓
...(数据库被打爆)

典型场景

  • 缓存集中过期:凌晨批量清除缓存
  • Redis 集群故障:主从切换、节点宕机
  • 缓存预热失败:服务重启后缓存为空

3.2 解决方案

方案 1:过期时间加随机值

避免大量缓存同时过期。

ini 复制代码
// 基础过期时间 + 随机值(0-300秒)
int expireTime = 1800 + RandomUtil.randomInt(300);
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);

方案 2:多级缓存

本地缓存 + Redis + 数据库,层层防护。

scss 复制代码
@Component
public class MultiLevelCache {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // Caffeine 本地缓存
    private LoadingCache<String, User> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(key -> loadFromRedis(key));
    
    public User get(String key) {
        // 1. 查本地缓存
        User user = localCache.getIfPresent(key);
        if (user != null) {
            return user;
        }
        
        // 2. 查 Redis
        user = (User) redisTemplate.opsForValue().get(key);
        if (user != null) {
            localCache.put(key, user); // 回填本地缓存
            return user;
        }
        
        // 3. 查数据库
        user = loadFromDatabase(key);
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
            localCache.put(key, user);
        }
        return user;
    }
}

方案 3:熔断降级

数据库压力大时,直接熔断返回默认值。

less 复制代码
@HystrixCommand(
    fallbackMethod = "getUserFallback",
    commandProperties = {
        @HystrixProperty(name = "circuitBreaker.enabled", value = "true"),
        @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
        @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "10000")
    }
)
public User getUser(Long id) {
    // 正常逻辑
}

// 降级方法
public User getUserFallback(Long id) {
    return new User(); // 返回空对象或默认值
}

方案 4:Redis 高可用

  • 主从复制:读写分离,提高可用性
  • 哨兵模式:自动故障转移
  • 集群模式:分片存储,水平扩展
yaml 复制代码
# Redis 集群配置
spring:
  redis:
    cluster:
      nodes:
        - 192.168.1.101:6379
        - 192.168.1.102:6379
        - 192.168.1.103:6379
        - 192.168.1.104:6379
        - 192.168.1.105:6379
        - 192.168.1.106:6379
      max-redirects: 3

四、三者的区别对比

问题 现象 原因 核心解决方案
缓存穿透 查询不存在的数据 恶意攻击/数据不存在 布隆过滤器、缓存空值
缓存击穿 热点数据过期瞬间 单个热点 key 过期 互斥锁、逻辑过期
缓存雪崩 大量请求打到数据库 大量 key 同时过期/Redis 宕机 随机过期时间、多级缓存、熔断降级

五、面试回答模板

"缓存穿透是指查询不存在的数据,绕过缓存直接打到数据库,解决方案是用布隆过滤器或缓存空值。缓存击穿是热点数据过期瞬间大量请求打到数据库,可以用互斥锁或逻辑过期解决。缓存雪崩是大量缓存同时过期或 Redis 宕机,需要随机过期时间、多级缓存和熔断降级来防护。"


总结

Redis 三大缓存问题,核心都是如何保护数据库

穿透 :布隆过滤器拦截 + 缓存空值 ✅ 击穿 :互斥锁串行化 + 逻辑过期 ✅ 雪崩:随机过期 + 多级缓存 + 熔断降级

生产环境建议组合拳:布隆过滤器 + 互斥锁 + 随机过期 + 多级缓存


💡 面试锦囊:遇到 Redis 问题,先判断是穿透、击穿还是雪崩,然后给出对应的解决方案。最好结合实际项目经验,比如"我们之前用布隆过滤器解决了爬虫攻击导致的缓存穿透问题"。
下一期:《我用 AI 写代码,效率提升了 300%》,敬请期待!🚀

相关推荐
用户6996228806052 小时前
PocketBase:3分钟搭建全功能后端的轻量级神器
后端
猹叉叉(学习版)2 小时前
【ASP.NET CORE】 11. SignalR
笔记·后端·c#·asp.net·.netcore
程序边界2 小时前
从MySQL到国产数据库的真实迁移笔记:那些坑爹的坑和意外的爽点
后端
qq5680180762 小时前
一个基于Spring Boot的简单网吧管理系统
java·spring boot·后端
hashiqimiya2 小时前
spring报错
java·后端·spring
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 基于Springboot的养老服务管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
包包5552 小时前
WxJava微信公众号开发实战
后端
陈随易2 小时前
向日葵+AI,远程操控又进化了
前端·后端·程序员