Redis缓存三大问题深度解析:缓存穿透、击穿、雪崩完全解决!

难度:⭐⭐⭐⭐⭐ | 适合人群:想掌握Redis生产级方案的开发者


💥 开场:一次"血的教训"

时间: 周五晚上8点
地点: 公司(准备下班)
事件: 生产事故

告警短信疯狂轰炸: 📱📱📱

【告警】数据库连接池耗尽!
【告警】API响应超时!
【告警】服务器CPU 100%!
【告警】Redis宕机!

我: "完了完了..." 😱(赶紧打开电脑)


查看监控:

18:00 - Redis QPS:1万/秒(正常)
18:05 - Redis QPS:10万/秒(暴增!)
18:10 - Redis宕机
18:11 - MySQL QPS:从100/秒 → 5万/秒(爆炸!)
18:12 - MySQL连接池耗尽
18:13 - 整个系统崩溃

我: "怎么回事?好端端的Redis怎么就崩了?" 😰


紧急查看Redis日志:

[18:05:23] 大量查询不存在的key:product:-1
[18:05:24] 大量查询不存在的key:product:-2
[18:05:25] 大量查询不存在的key:product:-3
...
[18:10:15] OOM command not allowed when used memory > 'maxmemory'
[18:10:16] Redis crashed

哈吉米(电话里): "这是典型的缓存穿透攻击!有人在恶意查询不存在的商品!"

我: "缓存穿透?什么意思?" 🤔

南北绿豆(也紧急上线): "查询不存在的数据,Redis没有,每次都打到数据库,数据库也扛不住..."

阿西噶阿西: "这是缓存的三大经典问题之一,另外两个是缓存击穿和缓存雪崩。来,我给你讲讲怎么解决..."


🕳️ 第一问:缓存穿透

什么是缓存穿透?

定义: 查询一个不存在的数据,缓存和数据库都没有,但每次请求都会打到数据库。

正常流程:

请求 product:1
    ↓
查询Redis → 没有
    ↓
查询MySQL → 有
    ↓
存入Redis
    ↓
返回数据
    ↓
下次请求直接从Redis返回 ✅

穿透流程:

请求 product:-1(不存在的ID)
    ↓
查询Redis → 没有
    ↓
查询MySQL → 也没有
    ↓
返回null(不缓存)
    ↓
下次请求又查Redis → 没有
    ↓
又查MySQL → 还是没有
    ↓
每次都穿透到数据库! ❌

危害

阿西噶阿西: "缓存穿透的危害巨大!"

恶意攻击场景:
    使用不存在的ID疯狂请求
    ↓
    每次都穿透到数据库
    ↓
    数据库压力暴增
    ↓
    数据库崩溃
    ↓
    整个系统崩溃

实际案例:

// 攻击者发起100万次请求
for (int i = -1; i >= -1000000; i--) {
    http.get("/api/product/" + i);  // 查询不存在的商品
}

// 每次请求都打到MySQL
// MySQL瞬间崩溃

解决方案1:缓存空值

哈吉米: "最简单的方法:把null也缓存起来!"

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        
        // 1. 查询Redis
        String json = redisTemplate.opsForValue().get(key);
        
        if (json != null) {
            if ("null".equals(json)) {
                return null;  // 缓存的空值
            }
            return JSON.parseObject(json, Product.class);
        }
        
        // 2. 查询数据库
        Product product = productDao.findById(productId);
        
        // 3. 存入Redis
        if (product != null) {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                1, TimeUnit.HOURS);
        } else {
            // 缓存空值(过期时间短一点)
            redisTemplate.opsForValue().set(key, "null", 
                5, TimeUnit.MINUTES);
        }
        
        return product;
    }
}

效果:

1次请求 product:-1
    ↓
Redis没有 → MySQL没有
    ↓
缓存"null"5分钟)
    ↓
第2次请求 product:-1
    ↓
Redis有"null" → 直接返回null
    ↓
不再打到数据库 ✅

优点:

  • ✅ 简单易实现
  • ✅ 能解决大部分穿透问题

缺点:

  • ❌ 占用Redis内存
  • ❌ 如果攻击ID每次都不同,还是会穿透

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

南北绿豆: "布隆过滤器才是最优解!"

什么是布隆过滤器?

布隆过滤器 = 一个超大的位数组 + 多个哈希函数

特点:
- 判断元素一定不存在 ✅ 100%准确
- 判断元素可能存在 ⚠️ 有一定误判率

工作原理:

添加元素:
1. 计算多个哈希值
2. 将对应位置设为1

product:1hash1(product:1) = 10bit[10] = 1
hash2(product:1) = 25bit[25] = 1
hash3(product:1) = 99bit[99] = 1
查询元素:
1. 计算多个哈希值
2. 检查对应位置是否都为1

查询product:1:
bit[10]=1 && bit[25]=1 && bit[99]=1 → 可能存在 ✅

查询product:-1:
bit[5]=0 || bit[18]=0 || bit[77]=0 → 一定不存在 ✅

Redisson实现:

@Configuration
public class BloomFilterConfig {
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
    
    @Bean
    public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:bloom");
        
        // 初始化布隆过滤器
        // 预计元素数量:1000万,误判率:0.01
        bloomFilter.tryInit(10000000L, 0.01);
        
        return bloomFilter;
    }
}

使用布隆过滤器:

@Service
public class ProductService {
    
    @Autowired
    private RBloomFilter<Long> productBloomFilter;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    /**
     * 初始化:将所有商品ID加入布隆过滤器
     */
    @PostConstruct
    public void initBloomFilter() {
        List<Long> productIds = productDao.findAllIds();
        for (Long id : productIds) {
            productBloomFilter.add(id);
        }
        System.out.println("布隆过滤器初始化完成,加载了 " + productIds.size() + " 个商品ID");
    }
    
    /**
     * 获取商品
     */
    public Product getProduct(Long productId) {
        
        // 1. 布隆过滤器判断(快速过滤不存在的)
        if (!productBloomFilter.contains(productId)) {
            System.out.println("布隆过滤器:商品不存在 " + productId);
            return null;  // 一定不存在,直接返回
        }
        
        String key = "product:" + productId;
        
        // 2. 查询Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            return JSON.parseObject(json, Product.class);
        }
        
        // 3. 查询数据库(可能存在)
        Product product = productDao.findById(productId);
        
        // 4. 存入Redis
        if (product != null) {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                1, TimeUnit.HOURS);
        }
        
        return product;
    }
    
    /**
     * 创建商品时,加入布隆过滤器
     */
    public void createProduct(Product product) {
        productDao.save(product);
        
        // 加入布隆过滤器
        productBloomFilter.add(product.getId());
    }
}

效果对比:

恶意请求 product:-1(不存在)

不用布隆过滤器:
    Redis查询 → MySQL查询 → 返回null
    耗时:10ms
    
用布隆过滤器:
    布隆过滤器判断 → 直接返回null
    耗时:0.1ms
    
性能提升100倍!

⚡ 第二问:缓存击穿

什么是缓存击穿?

定义: 热点Key过期的瞬间,大量请求同时打到数据库。

场景:

热点商品(iPhone新品)
    ↓
缓存Key:product:99999
过期时间:1小时
    ↓
11:00:00 - Key过期
11:00:00 - 同时来了10000个请求
    ↓
10000个请求都发现Redis没有
    ↓
10000个请求同时查询MySQL
    ↓
MySQL瞬间崩溃!

和穿透的区别:

缓存穿透:
    查询的数据不存在
    每次请求都打到数据库

缓存击穿:
    查询的数据存在
    只是热点Key过期的瞬间,大量请求打到数据库

解决方案1:互斥锁

哈吉米: "只允许一个线程去查数据库,其他线程等待。"

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        
        // 1. 查询Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            return JSON.parseObject(json, Product.class);
        }
        
        // 2. Redis没有,获取锁
        String lockKey = "lock:product:" + productId;
        String lockValue = UUID.randomUUID().toString();
        
        try {
            // 尝试获取锁(10秒过期)
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(
                lockKey, lockValue, 10, TimeUnit.SECONDS);
            
            if (Boolean.TRUE.equals(locked)) {
                // 获取锁成功,查询数据库
                System.out.println("获取锁成功,查询数据库:" + productId);
                
                Product product = productDao.findById(productId);
                
                // 存入Redis
                if (product != null) {
                    redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                        1, TimeUnit.HOURS);
                }
                
                return product;
                
            } else {
                // 获取锁失败,等待后重试
                System.out.println("获取锁失败,等待重试:" + productId);
                
                Thread.sleep(50);  // 等待50ms
                
                // 再次查询Redis(其他线程可能已经加载了)
                json = redisTemplate.opsForValue().get(key);
                if (json != null) {
                    return JSON.parseObject(json, Product.class);
                }
                
                // 还是没有,递归重试
                return getProduct(productId);
            }
            
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            // 释放锁(判断是自己加的锁)
            String currentValue = redisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(currentValue)) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

效果:

10000个并发请求 product:99999(热点Key刚过期)
    ↓
线程1获取锁成功 → 查询MySQL → 缓存到Redis
线程2-10000获取锁失败 → 等待 → 从Redis获取
    ↓
只有1个请求打到MySQL ✅

解决方案2:热点数据永不过期

南北绿豆: "对于热点数据,可以设置永不过期。"

@Service
public class ProductService {
    
    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        
        // 查询Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            ProductCache cache = JSON.parseObject(json, ProductCache.class);
            
            // 检查逻辑过期时间
            if (cache.getExpireTime() > System.currentTimeMillis()) {
                return cache.getData();  // 未过期,直接返回
            }
            
            // 逻辑过期,异步更新
            CompletableFuture.runAsync(() -> {
                refreshCache(productId);
            });
            
            // 返回旧数据(不阻塞)
            return cache.getData();
        }
        
        // 缓存没有,查询数据库
        return refreshCache(productId);
    }
    
    private Product refreshCache(Long productId) {
        String key = "product:" + productId;
        
        Product product = productDao.findById(productId);
        
        if (product != null) {
            // 封装缓存对象(带逻辑过期时间)
            ProductCache cache = new ProductCache();
            cache.setData(product);
            cache.setExpireTime(System.currentTimeMillis() + 3600000);  // 1小时后逻辑过期
            
            // 存入Redis(永不过期)
            redisTemplate.opsForValue().set(key, JSON.toJSONString(cache));
        }
        
        return product;
    }
}

/**
 * 缓存对象
 */
class ProductCache {
    private Product data;
    private Long expireTime;  // 逻辑过期时间
    
    // Getter和Setter...
}

优点:

  • ✅ 永远有缓存,不会击穿
  • ✅ 异步更新,不阻塞请求

缺点:

  • ❌ 可能返回过期数据
  • ❌ 实现复杂

解决方案3:随机过期时间

// 给缓存加上随机过期时间,避免同时过期
int randomExpire = 3600 + new Random().nextInt(300);  // 1小时 + 0-5分钟随机
redisTemplate.opsForValue().set(key, json, randomExpire, TimeUnit.SECONDS);

❄️ 第三问:缓存雪崩

什么是缓存雪崩?

定义: 大量Key同时过期,或Redis宕机,导致大量请求打到数据库。

场景1:大量Key同时过期

批量导入商品(10000个)
    ↓
全部设置1小时过期
    ↓
1小时后,10000个Key同时过期
    ↓
大量请求同时打到数据库
    ↓
数据库崩溃

场景2:Redis宕机

Redis服务器宕机
    ↓
所有请求都打到数据库
    ↓
数据库瞬间压力暴增
    ↓
数据库崩溃
    ↓
整个系统雪崩

危害演示

阿西噶阿西: "我们模拟一下雪崩的威力。"

// 批量设置缓存(都是1小时过期)
for (int i = 1; i <= 10000; i++) {
    redisTemplate.opsForValue().set("product:" + i, "data", 1, TimeUnit.HOURS);
}

// 1小时后,10000个Key同时过期

// 此时并发请求
ExecutorService executor = Executors.newFixedThreadPool(1000);
for (int i = 1; i <= 10000; i++) {
    final int id = i;
    executor.submit(() -> {
        productService.getProduct(id);  // 10000个请求同时打到MySQL
    });
}

// MySQL连接数瞬间飙升
// 数据库崩溃!

解决方案1:随机过期时间

哈吉米: "最简单的方法:让Key不要同时过期!"

public void cacheProduct(Product product) {
    String key = "product:" + product.getId();
    
    // 基础过期时间:1小时
    int baseExpire = 3600;
    
    // 随机过期时间:0-300秒(5分钟)
    int randomExpire = new Random().nextInt(300);
    
    // 最终过期时间:1小时 + 随机5分钟
    int expire = baseExpire + randomExpire;
    
    redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
        expire, TimeUnit.SECONDS);
}

效果:

Key过期时间分布:
product:13610秒后过期
product:23750秒后过期
product:33590秒后过期
...

不会同时过期,避免雪崩 ✅

解决方案2:多级缓存

南北绿豆: "不要只依赖Redis,建立多级缓存!"

请求
    ↓
本地缓存(Caffeine/Guava Cache)
    ↓ 没有
Redis缓存
    ↓ 没有
MySQL数据库

实现:

@Service
public class ProductService {
    
    // 本地缓存
    private Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    public Product getProduct(Long productId) {
        
        // 1. 查询本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            System.out.println("本地缓存命中");
            return product;
        }
        
        // 2. 查询Redis
        String key = "product:" + productId;
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            System.out.println("Redis缓存命中");
            product = JSON.parseObject(json, Product.class);
            
            // 存入本地缓存
            localCache.put(productId, product);
            return product;
        }
        
        // 3. 查询数据库
        System.out.println("查询数据库");
        product = productDao.findById(productId);
        
        if (product != null) {
            // 存入Redis
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                1, TimeUnit.HOURS);
            
            // 存入本地缓存
            localCache.put(productId, product);
        }
        
        return product;
    }
}

优势:

  • ✅ 即使Redis挂了,还有本地缓存
  • ✅ 降低Redis压力
  • ✅ 响应更快(本地缓存)

解决方案3:Redis集群 + 熔断降级

Redis集群:

Redis主从 + 哨兵
    ↓
主节点宕机 → 自动故障转移
    ↓
从节点升级为主节点
    ↓
服务继续可用

熔断降级:

@Service
public class ProductService {
    
    @Autowired
    private CircuitBreaker circuitBreaker;  // Hystrix或Resilience4j
    
    public Product getProduct(Long productId) {
        
        return circuitBreaker.run(
            // 正常逻辑
            () -> getProductFromCache(productId),
            
            // 降级逻辑(Redis挂了)
            (throwable) -> {
                System.out.println("Redis异常,降级处理");
                // 直接查数据库,但限流
                return getProductFromDB(productId);
            }
        );
    }
}

💥 第四问:三大问题对比

对比表格

问题原因现象危害解决方案
缓存穿透查询不存在的数据每次都打DB恶意攻击可击垮DB布隆过滤器、缓存空值
缓存击穿热点Key过期瞬间大量请求打DBDB瞬时压力大互斥锁、永不过期
缓存雪崩大量Key同时过期或Redis宕机大规模请求打DB系统崩溃随机过期、多级缓存、集群

问题识别

如何判断遇到了哪种问题?

南北绿豆: "看监控指标!"

缓存穿透:
    Redis未命中率突然升高
    大量查询不存在的Key
    数据库QPS异常升高

缓存击穿:
    某个时刻数据库QPS突然暴增
    只持续很短时间
    Redis某个热点Key刚好过期

缓存雪崩:
    Redis整体不可用
    或大量Key同时过期
    数据库QPS持续暴增
    系统整体响应变慢

💻 第五问:完整的生产级解决方案

综合方案

阿西噶阿西: "生产环境要综合使用多种方案!"

@Service
public class ProductService {
    
    // 本地缓存
    private Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    // 布隆过滤器
    @Autowired
    private RBloomFilter<Long> productBloomFilter;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductDao productDao;
    
    /**
     * 综合方案获取商品
     */
    public Product getProduct(Long productId) {
        
        // 第1层:布隆过滤器(防穿透)
        if (!productBloomFilter.contains(productId)) {
            return null;  // 一定不存在
        }
        
        // 第2层:本地缓存(防雪崩)
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }
        
        // 第3层:Redis缓存
        String key = "product:" + productId;
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            product = JSON.parseObject(json, Product.class);
            localCache.put(productId, product);
            return product;
        }
        
        // 第4层:数据库(加锁防击穿)
        product = getProductWithLock(productId);
        
        if (product != null) {
            // 随机过期时间(防雪崩)
            int expire = 3600 + new Random().nextInt(300);
            redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                expire, TimeUnit.SECONDS);
            
            localCache.put(productId, product);
        } else {
            // 缓存空值(防穿透)
            redisTemplate.opsForValue().set(key, "null", 5, TimeUnit.MINUTES);
        }
        
        return product;
    }
    
    /**
     * 加锁查询数据库(防击穿)
     */
    private Product getProductWithLock(Long productId) {
        String lockKey = "lock:product:" + productId;
        String lockValue = UUID.randomUUID().toString();
        
        try {
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(
                lockKey, lockValue, 10, TimeUnit.SECONDS);
            
            if (Boolean.TRUE.equals(locked)) {
                return productDao.findById(productId);
            } else {
                Thread.sleep(50);
                // 递归重试...
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            releaseLock(lockKey, lockValue);
        }
        
        return null;
    }
    
    private void releaseLock(String lockKey, String lockValue) {
        String currentValue = redisTemplate.opsForValue().get(lockKey);
        if (lockValue.equals(currentValue)) {
            redisTemplate.delete(lockKey);
        }
    }
}

防护层级

           请求
             ↓
    ┌────────────────┐
    │  布隆过滤器     │ ← 防穿透:过滤不存在的
    └────────────────┘
             ↓
    ┌────────────────┐
    │  本地缓存       │ ← 防雪崩:Redis挂了还能用
    └────────────────┘
             ↓
    ┌────────────────┐
    │  Redis缓存      │ ← 主缓存
    └────────────────┘
             ↓
    ┌────────────────┐
    │  分布式锁       │ ← 防击穿:只有一个查DB
    └────────────────┘
             ↓
    ┌────────────────┐
    │  数据库         │
    └────────────────┘

💡 知识点总结

三大问题核心要点

缓存穿透

  • 原因:查询不存在的数据
  • 危害:每次都打DB,恶意攻击
  • 解决:布隆过滤器(推荐)、缓存空值

缓存击穿

  • 原因:热点Key过期
  • 危害:瞬时大量请求打DB
  • 解决:互斥锁、永不过期、随机过期

缓存雪崩

  • 原因:大量Key同时过期或Redis宕机
  • 危害:系统整体崩溃
  • 解决:随机过期、多级缓存、Redis集群、熔断降级

综合方案

  • 布隆过滤器(防穿透)
  • 本地缓存(防雪崩)
  • 分布式锁(防击穿)
  • 随机过期(防雪崩)
  • 集群部署(高可用)

监控指标

  • Redis未命中率
  • 数据库QPS
  • Redis QPS
  • 响应时间

记忆口诀

穿透查询不存在,
布隆过滤挡门前。
击穿热点同时访,
互斥锁来保平安。
雪崩大量齐过期,
随机时间来分散。
多级缓存加集群,
生产环境要全面。

🤔 常见面试题

Q1: 缓存穿透、击穿、雪崩的区别?

A:

穿透:查询不存在的数据,每次都打DB
击穿:热点Key过期,瞬间大量请求打DB
雪崩:大量Key同时过期或Redis宕机,系统崩溃

记忆:
- 穿透 = 打洞(每次都穿过缓存)
- 击穿 = 打点(打穿热点)
- 雪崩 = 崩塌(整体崩溃)

Q2: 如何解决缓存穿透?

A:

方案1:布隆过滤器(推荐)
- 快速判断数据是否存在
- 不存在直接返回
- 几乎不占内存

方案2:缓存空值
- 查询不到也缓存null
- 设置较短过期时间
- 简单但占内存

方案3:接口校验
- 参数合法性校验
- 限流
- 黑名单

Q3: 生产环境如何防止缓存问题?

A:

综合方案:

1. 缓存设计
   - 随机过期时间
   - 热点数据永不过期
   - 合理的过期时间

2. 多层防护
   - 布隆过滤器
   - 本地缓存
   - Redis集群
   - 分布式锁

3. 降级熔断
   - 限流
   - 熔断
   - 降级方案

4. 监控告警
   - Redis监控
   - 数据库监控
   - 及时告警

💬 写在最后

从缓存穿透到击穿再到雪崩,我们深入学习了Redis的三大经典问题:

  • 🕳️ 理解了三大问题的原因和危害
  • 🛡️ 掌握了多种解决方案
  • 💻 完成了生产级综合方案
  • 🔧 学会了如何监控和预防

这篇文章,希望能让你的Redis应用更加稳定可靠!

如果这篇文章对你有帮助,请:

  • 👍 点赞支持
  • ⭐ 收藏备用
  • 🔄 转发分享
  • 💬 评论交流

下一篇我们聊Redis分布式锁! 👋