Redis缓存穿透与雪崩防护方案:布隆过滤器接入与多级缓存架构设计

Redis作为缓存层大幅提升读取性能,但缓存穿透、缓存雪崩、缓存击穿三类故障在高并发场景下频繁出现。本文从问题诊断出发,给出布隆过滤器防穿透、随机过期防雪崩、多级缓存防击穿的完整方案,附带可直接复用的代码实现。

缓存穿透问题诊断与布隆过滤器方案

缓存穿透指查询一个数据库中不存在的数据,缓存和数据库都未命中,每次请求都穿透到数据库。恶意攻击或爬虫高频请求不存在的ID时,数据库连接池可能被耗尽。

传统方案是缓存空值(null),但无法应对海量随机不存在的Key。布隆过滤器(Bloom Filter)在缓存之前增加一道预检层,以极高空间效率判断Key是否可能存在:

// 使用Redisson的布隆过滤器
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("product:bloom");
// 初始化:预计元素数量100万,误判率0.01%
bloomFilter.tryInit(1_000_000L, 0.0001);

// 数据预热:将所有存在的商品ID加入布隆过滤器
List<Long> allProductIds = productMapper.selectAllIds();
allProductIds.forEach(bloomFilter::add);

public Product getProduct(Long id) {
    // 第一层:布隆过滤器预检
    if (!bloomFilter.contains(id)) {
        return null;  // 一定不存在,直接返回
    }
    
    // 第二层:查Redis缓存
    String cacheKey = "product:" + id;
    Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
    if (product != null) {
        return product;
    }
    
    // 第三层:查数据库
    product = productMapper.selectById(id);
    if (product != null) {
        redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
    } else {
        // 缓存空值,短过期时间
        redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
    }
    return product;
}

布隆过滤器的误判率(false positive)可控但无法消除。tryInit(1_000_000, 0.0001)表示100万元素下误判率0.01%,占用约1.8MB内存。被误判为”可能存在”的Key会进入缓存和数据库查询,但比例极低。布隆过滤器不支持删除,数据删除时需定期重建或使用布谷鸟过滤器(Cuckoo Filter)替代。

缓存雪崩的随机过期时间策略

缓存雪崩指大量Key在同一时间过期,请求全部涌向数据库。常见原因是批量设置缓存时使用了相同的过期时间。解决方案是给过期时间加上随机偏移:

public void cacheProduct(Product product) {
    String cacheKey = "product:" + product.getId();
    // 基础过期30分钟 + 0~10分钟随机偏移
    int baseTTL = 1800;
    int randomTTL = ThreadLocalRandom.current().nextInt(0, 600);
    redisTemplate.opsForValue().set(
        cacheKey, 
        product, 
        baseTTL + randomTTL, 
        TimeUnit.SECONDS
    );
}

// 批量预热时的分散策略
public void warmupCache(List<Product> products) {
    products.parallelStream().forEach(p -> {
        int ttl = 1800 + ThreadLocalRandom.current().nextInt(0, 600);
        redisTemplate.opsForValue().set("product:" + p.getId(), p, ttl, TimeUnit.SECONDS);
    });
}

随机偏移量建议设为基础过期时间的10%到30%。此外,Redis实例宕机也会引发雪崩,生产环境必须部署Redis Cluster或Sentinel保证高可用。Redis Cluster配置至少3主3从,主节点宕机时从节点自动切换。应用层通过熔断器(如Sentinel或Resilience4j)限制数据库并发查询数,防止缓存层失效时数据库被压垮:

@CircuitBreaker(name = "dbQuery", fallbackMethod = "fallbackQuery")
public Product queryFromDB(Long id) {
    return productMapper.selectById(id);
}

// application.yml 熔断配置
resilience4j:
  circuitbreaker:
    instances:
      dbQuery:
        sliding-window-size: 100
        failure-rate-threshold: 50
        wait-duration-in-open-state: 30s
        permitted-number-of-calls-in-half-open-state: 10

缓存击穿与热点Key多级缓存

缓存击穿指某个热点Key过期瞬间,大量并发请求同时查数据库。与雪崩的区别在于击穿是单个Key,雪崩是大量Key。解决方案是互斥锁(Mutex)防止缓存重建时的并发穿透:

public Product getHotProduct(Long id) {
    String cacheKey = "product:" + id;
    Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
    
    if (product != null) {
        return product;
    }
    
    // 获取分布式锁,只有一个线程查DB
    String lockKey = "lock:product:" + id;
    String lockValue = UUID.randomUUID().toString();
    
    try {
        boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
        
        if (locked) {
            // 双重检查:拿到锁后再查一次缓存
            product = (Product) redisTemplate.opsForValue().get(cacheKey);
            if (product != null) {
                return product;
            }
            
            // 查数据库并重建缓存
            product = productMapper.selectById(id);
            int ttl = 1800 + ThreadLocalRandom.current().nextInt(0, 600);
            redisTemplate.opsForValue().set(cacheKey, product, ttl, TimeUnit.SECONDS);
            return product;
        } else {
            // 未获取锁,短暂等待后重试
            Thread.sleep(50);
            return getHotProduct(id);
        }
    } finally {
        // 释放锁(Lua脚本保证原子性)
        releaseLock(lockKey, lockValue);
    }
}

双重检查(Double-Check)是关键:获取锁后再次查询缓存,避免重复构建。锁的超时时间设为略大于数据库查询耗时,防止死锁。等待重试方案在高并发下可能堆积请求,可改用信号量限制并发数。

多级缓存架构整合

将以上策略整合为完整的多级缓存架构。请求链路:本地缓存(Caffeine)→ 分布式缓存(Redis)→ 布隆过滤器预检 → 数据库。本地缓存处理极热点数据,减少Redis网络开销:

@Configuration
public class CacheConfig {
    
    @Bean
    public Cache<Long, Product> localCache() {
        return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .recordStats()
            .build();
    }
}

public Product getMultiLevelProduct(Long id) {
    // L1: 本地缓存 (Caffeine)
    Product product = localCache.getIfPresent(id);
    if (product != null) {
        return product;
    }
    
    // 布隆过滤器预检
    if (!bloomFilter.contains(id)) {
        return null;
    }
    
    // L2: 分布式缓存 (Redis) + 互斥锁防击穿
    product = getHotProduct(id);
    
    if (product != null) {
        // 回填本地缓存
        localCache.put(id, product);
    }
    
    return product;
}

本地缓存过期时间应短于Redis缓存,保证数据新鲜度。Caffeine的recordStats()开启统计,通过Cache.stats()监控命中率。正常运行的系统,L1命中率应在80%以上,L2命中率95%以上,数据库查询QPS维持在极低水平。当数据库QPS突增时,按L1→L2→布隆过滤器→互斥锁的顺序逐层排查防护是否失效。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-yu-xue-beng-fang-hu-fang-an-bu/

(0)
小编小编
上一篇 15小时前
下一篇 15小时前

相关推荐