Redis击穿,穿透和雪崩详解以及解决方案

发布于:2025-05-29 ⋅ 阅读:(41) ⋅ 点赞:(0)

在 Java 开发中,Redis 作为常用的缓存中间件,可能会面临击穿、穿透、雪崩这三类经典问题。以下是对这三个问题的详细解析及对应的 Java 解决方案:

一、Redis 缓存击穿(Cache Breakdown)

问题描述
  • 定义:大量请求同时访问一个过期的热点 key(如秒杀活动中的商品库存),导致请求直接穿透到数据库,引发瞬时高并发压力。
  • 核心原因
    • 热点 key 过期时,缓存失效。
    • 大量并发请求同时绕过缓存,直达数据库。
Java 解决方案
1. 互斥锁(Mutex Lock)
  • 思路:在缓存失效时,通过锁机制确保只有一个线程重建缓存,其他线程等待锁释放后从缓存获取数据。
  • 实现步骤
    1. 从 Redis 查询数据,若 key 过期或不存在,尝试获取分布式锁(如 Redisson、ZooKeeper 锁)。
    2. 获得锁的线程查询数据库,更新缓存,并释放锁。
    3. 其他线程在锁等待期间,休眠或重试查询缓存。
  • Java 代码示例(基于 Redisson)
    public String getProductInfo(String productId) {
        String cacheKey = "product:" + productId;
        String result = redisTemplate.opsForValue().get(cacheKey);
        
        if (result == null) { // 缓存失效
            RLock lock = redissonClient.getLock("mutex_lock:" + productId);
            try {
                lock.lock(); // 加锁
                // 二次验证(避免缓存重建期间其他线程重复查询)
                result = redisTemplate.opsForValue().get(cacheKey);
                if (result == null) {
                    // 查询数据库
                    String dbResult = queryFromDatabase(productId);
                    if (dbResult != null) {
                        redisTemplate.opsForValue().set(cacheKey, dbResult, 30, TimeUnit.SECONDS); // 重建缓存
                    }
                }
            } finally {
                lock.unlock(); // 释放锁
            }
        }
        return result;
    }
    
2. 热点 key 永不过期
  • 思路:为热点 key 设置逻辑过期时间(如在 value 中存储过期时间戳),通过异步线程更新缓存,避免主动过期导致的击穿。
  • 实现步骤
    1. 缓存数据时,在 value 中添加 expireTime 字段。
    2. 每次访问时,检查 expireTime,若过期则启动异步线程更新缓存,当前请求仍返回旧数据。
  • Java 代码示例
    public class CachedData {
        private String value;
        private long expireTime;
        // getter and setter
    }
    
    public String getHotProductInfo(String productId) {
        String cacheKey = "hot_product:" + productId;
        CachedData cachedData = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachedData == null || System.currentTimeMillis() > cachedData.getExpireTime()) {
            // 启动异步线程更新缓存(避免阻塞当前请求)
            CompletableFuture.runAsync(() -> {
                RLock lock = redissonClient.getLock("hot_product_lock:" + productId);
                try {
                    lock.lock();
                    // 二次验证
                    cachedData = redisTemplate.opsForValue().get(cacheKey);
                    if (cachedData == null || System.currentTimeMillis() > cachedData.getExpireTime()) {
                        String dbResult = queryFromDatabase(productId);
                        cachedData = new CachedData();
                        cachedData.setValue(dbResult);
                        cachedData.setExpireTime(System.currentTimeMillis() + 30 * 1000); // 逻辑过期时间
                        redisTemplate.opsForValue().set(cacheKey, cachedData, 60, TimeUnit.SECONDS); // 物理过期时间设为逻辑过期时间的 2 倍
                    }
                } finally {
                    lock.unlock();
                }
            });
            // 返回旧数据或默认值(若首次查询)
            return cachedData != null ? cachedData.getValue() : defaultResponse();
        }
        return cachedData.getValue();
    }
    

二、Redis 缓存穿透(Cache Penetration)

问题描述
  • 定义:大量请求访问不存在的 key(如恶意攻击、非法参数),导致请求直接穿透缓存,每次都查询数据库,造成数据库压力激增。
  • 核心原因
    • 缓存层不存储无效 key,导致所有无效请求直达数据库。
    • 攻击方利用不存在的 key 进行批量请求。
Java 解决方案
1. 布隆过滤器(Bloom Filter)
  • 思路:在请求进入数据库前,使用布隆过滤器过滤掉不存在的 key,避免无效请求到达数据库。
  • 实现步骤
    1. 提前将数据库中存在的 key 加载到布隆过滤器中。
    2. 每次请求先通过布隆过滤器判断 key 是否存在,若不存在则直接返回无效响应。
  • Java 代码示例(基于 Google Guava)
    // 初始化布隆过滤器(建议使用 Redis 存储布隆过滤器数据,避免内存溢出)
    private static BloomFilter<String> bloomFilter = BloomFilter.create(
        Funnels.stringFunnel(Charset.forName("UTF-8")), 
        1000000, // 预计元素数量
        0.01 // 误判率
    );
    
    // 服务启动时加载现有 key 到布隆过滤器(示例)
    @PostConstruct
    public void loadExistingKeys() {
        List<String> productIds = productDao.getAllProductIds(); // 从数据库获取所有存在的 productId
        bloomFilter.putAll(productIds);
    }
    
    public String getProductInfo(String productId) {
        if (!bloomFilter.mightContain(productId)) { // key 不存在
            return "无效的 productId";
        }
        // 正常查询缓存和数据库
        String cacheKey = "product:" + productId;
        String result = redisTemplate.opsForValue().get(cacheKey);
        if (result == null) {
            String dbResult = queryFromDatabase(productId);
            if (dbResult != null) {
                redisTemplate.opsForValue().set(cacheKey, dbResult, 30, TimeUnit.SECONDS);
            } else {
                // 缓存空值(防止重复查询)
                redisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES);
            }
            return dbResult;
        }
        return result;
    }
    
2. 缓存空值
  • 思路:当数据库查询结果为 null 时,将空值存入缓存(设置较短过期时间),避免后续相同请求穿透到数据库。
  • Java 代码示例
    public String getProductInfo(String productId) {
        String cacheKey = "product:" + productId;
        String result = redisTemplate.opsForValue().get(cacheKey);
        
        if (result == null) { // 缓存未命中
            String dbResult = queryFromDatabase(productId);
            redisTemplate.opsForValue().set(cacheKey, 
                dbResult != null ? dbResult : "", // 空值存为 ""
                dbResult != null ? 30 : 5, // 存在数据则设正常过期时间,空值设短过期时间(如 5 分钟)
                TimeUnit.SECONDS);
            return dbResult;
        }
        return result.isEmpty() ? null : result; // 空值返回 null
    }
    

三、Redis 缓存雪崩(Cache Avalanche)

问题描述
  • 定义大量缓存 key 同时过期或 Redis 服务宕机,导致大量请求直接涌入数据库,造成数据库负载过高甚至崩溃。
  • 核心原因
    • 缓存层大面积失效(如同一批次 key 的过期时间集中设置)。
    • Redis 实例故障(如主从切换、集群节点宕机)。
Java 解决方案
1. 过期时间随机化
  • 思路:为缓存 key 设置随机过期时间(在固定时间基础上增加随机偏移量),避免大量 key 同时过期。
  • Java 代码示例
    public void setProductCache(String productId, String data) {
        int baseExpireTime = 30 * 60; // 30 分钟
        int randomOffset = ThreadLocalRandom.current().nextInt(10 * 60); // 随机偏移 0~10 分钟
        int expireTime = baseExpireTime + randomOffset;
        redisTemplate.opsForValue().set("product:" + productId, data, expireTime, TimeUnit.SECONDS);
    }
    
2. 限流与降级
  • 思路
    • 限流:通过令牌桶、信号量等机制限制单位时间内进入数据库的请求量(如使用 Hystrix、Resilience4j 或 Spring Cloud Sentinel)。
    • 降级:当数据库压力过大时,直接返回默认值或提示信息,保护数据库。
  • Java 代码示例(基于 Resilience4j)
    // 引入 Resilience4j 依赖
    // 添加限流注解
    @CircuitBreaker(name = "databaseCircuitBreaker", fallbackMethod = "fallbackGetProductInfo")
    public String getProductInfo(String productId) {
        String cacheKey = "product:" + productId;
        String result = redisTemplate.opsForValue().get(cacheKey);
        if (result == null) {
            String dbResult = queryFromDatabase(productId); // 可能触发限流
            if (dbResult != null) {
                redisTemplate.opsForValue().set(cacheKey, dbResult, 30, TimeUnit.SECONDS);
            }
            return dbResult;
        }
        return result;
    }
    
    // 降级方法
    public String fallbackGetProductInfo(String productId, Throwable throwable) {
        log.error("数据库查询失败,productId: {}, error: {}", productId, throwable.getMessage());
        return "服务繁忙,请稍后重试"; // 返回默认值或提示
    }
    
3. Redis 高可用架构
  • 思路:搭建 Redis 集群(如 Sentinel 或 Cluster 模式),避免单点故障导致缓存层整体不可用。
  • 配置示例(Spring Boot + Redis Cluster)
    spring.redis.cluster.nodes=redis://node1:7000,redis://node2:7001,redis://node3:7002
    spring.redis.cluster.max-redirects=3
    

四、总结对比

问题类型 核心原因 典型解决方案 Java 关键技术 / 工具
击穿 单个热点 key 过期 互斥锁、热点 key 永不过期 Redisson、异步线程
穿透 大量无效 key 请求 布隆过滤器、缓存空值 Guava BloomFilter、Redis 空值缓存
雪崩 大量 key 同时过期或 Redis 宕机 过期时间随机化、限流降级、高可用架构 Resilience4j、Redis Cluster

五、最佳实践建议

  1. 预防为主
    • 对热点数据提前预热缓存,避免突发流量击穿。
    • 接口层做参数校验,拦截非法 key(如空值、格式错误)。
  2. 监控与报警
    • 监控 Redis 内存使用率、缓存命中率、过期 key 数量。
    • 监控数据库 QPS、TPS,设置阈值触发报警。
  3. 综合方案
    • 针对高并发场景,组合使用互斥锁 + 布隆过滤器 + 限流降级,形成多层防护。

网站公告

今日签到

点亮在社区的每一天
去签到