Redis缓存策略实战:缓存穿透、雪崩、击穿的解决方案与代码实现

Redis缓存三大经典问题

Redis作为高性能缓存层,在缓解数据库压力、提升响应速度方面发挥关键作用。但在高并发场景下,缓存穿透、缓存雪崩、缓存击穿是三个必须解决的核心问题。这三个问题的本质都是缓存未命中导致大量请求直达数据库,区别在于触发原因不同:穿透是查询不存在的数据,雪崩是大量缓存同时过期,击穿是热点数据过期。

缓存穿透:布隆过滤器与空值缓存

缓存穿透指客户端请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库。常见场景是恶意攻击者使用不存在的ID频繁请求接口。如果不做防护,数据库连接池可能被耗尽。

解决方案有两种:空值缓存和布隆过滤器。空值缓存简单直接,但会占用额外内存且可能造成数据不一致。布隆过滤器在内存占用和准确性之间取得平衡,适合海量数据场景。

// Spring Boot + Redisson 布隆过滤器实现
@Configuration
public class BloomFilterConfig {

    @Bean
    public RBloomFilter<Long> productBloomFilter(RedissonClient redisson) {
        RBloomFilter<Long> filter = redisson.getBloomFilter("product:bloom");
        // 初始化:预计元素数量100万,误判率0.01%
        filter.tryInit(1_000_000L, 0.0001);
        return filter;
    }
}

@Service
public class ProductService {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RBloomFilter<Long> productBloomFilter;

    private static final String CACHE_PREFIX = "product:detail:";

    public Product getProductById(Long id) {
        String cacheKey = CACHE_PREFIX + id;

        // 1. 布隆过滤器前置检查
        if (!productBloomFilter.contains(id)) {
            // 数据一定不存在,直接返回
            return null;
        }

        // 2. 查询缓存
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (product != null) {
            return product;
        }

        // 3. 查询数据库
        product = productMapper.selectById(id);
        if (product != null) {
            // 缓存有效数据,设置随机过期时间防止雪崩
            int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);
            redisTemplate.opsForValue().set(cacheKey, product, ttl, TimeUnit.SECONDS);
        }
        return product;
    }

    // 数据预热时将ID加入布隆过滤器
    public void preloadBloomFilter() {
        List<Long> allIds = productMapper.selectAllIds();
        allIds.forEach(productBloomFilter::add);
    }
}

布隆过滤器的误判率需要根据业务场景选择。误判率越低,需要的位数组越大。100万元素、0.01%误判率约需要2MB内存。布隆过滤器存在假阳性(不存在的数据可能被判定为存在),但不存在假阴性(存在的数据不会被判定为不存在),因此不会漏放真实数据。

缓存雪崩:随机过期与多级缓存

缓存雪崩指大量缓存在同一时间过期,导致所有请求同时打到数据库。典型场景是缓存预热时批量设置了相同TTL,或在整点时间批量失效。

// 解决方案1:随机过期时间
public void cacheProduct(Product product) {
    String cacheKey = CACHE_PREFIX + product.getId();
    // 基础TTL 30分钟 + 随机偏移 0-10分钟
    int baseTtl = 1800;
    int randomOffset = ThreadLocalRandom.current().nextInt(600);
    redisTemplate.opsForValue().set(
        cacheKey, product,
        baseTtl + randomOffset, TimeUnit.SECONDS
    );
}

// 解决方案2:多级缓存(本地缓存 + Redis)
@Service
public class MultiLevelCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // Caffeine本地缓存
    private final Cache<String, Product> localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .recordStats()
        .build();

    public Product getProduct(Long id) {
        String key = CACHE_PREFIX + id;

        // L1: 本地缓存
        Product product = localCache.getIfPresent(key);
        if (product != null) {
            return product;
        }

        // L2: Redis缓存
        product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            localCache.put(key, product);
            return product;
        }

        // L3: 数据库
        product = productMapper.selectById(id);
        if (product != null) {
            // 双写缓存
            localCache.put(key, product);
            int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);
            redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.SECONDS);
        }
        return product;
    }
}

多级缓存通过本地缓存(Caffeine)作为第一层,Redis作为第二层,有效降低了Redis的压力。本地缓存TTL设置较短(60秒),主要应对突发流量;Redis缓存TTL设置较长(30分钟),作为持久化缓存层。当Redis整体不可用时,本地缓存仍能提供部分服务,起到降级保护作用。

缓存击穿:互斥锁与逻辑过期

缓存击穿指热点数据过期瞬间,大量并发请求同时查询数据库。与雪崩的区别在于击穿是单个Key的问题,雪崩是大量Key的问题。

互斥锁方案通过分布式锁保证只有一个请求查询数据库,其他请求等待结果:

@Service
public class HotProductService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    @Autowired
    private ProductMapper productMapper;

    public Product getHotProduct(Long id) {
        String cacheKey = "product:hot:" + id;
        String lockKey = "lock:product:" + id;

        // 1. 查缓存
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (product != null) {
            return product;
        }

        // 2. 获取分布式锁
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试获取锁,等待3秒,锁自动释放10秒
            boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (acquired) {
                try {
                    // 双重检查:获取锁后再次查缓存
                    product = (Product) redisTemplate.opsForValue().get(cacheKey);
                    if (product != null) {
                        return product;
                    }

                    // 查数据库
                    product = productMapper.selectById(id);
                    if (product != null) {
                        redisTemplate.opsForValue().set(
                            cacheKey, product,
                            1800, TimeUnit.SECONDS
                        );
                    }
                    return product;
                } finally {
                    lock.unlock();
                }
            } else {
                // 获取锁失败,短暂等待后重试
                Thread.sleep(50);
                return getHotProduct(id);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取缓存失败", e);
        }
    }
}

互斥锁方案的缺点是获取锁失败的线程会自旋等待,增加响应延迟。逻辑过期方案通过在缓存值中记录逻辑过期时间,避免物理过期,适用于对一致性要求不极端的场景:

// 逻辑过期方案
@Data
public class CacheData<T> {
    private T data;
    private long expireTime;  // 逻辑过期时间戳
}

public Product getProductWithLogicalExpire(Long id) {
    String cacheKey = "product:logical:" + id;
    CacheData<Product> cacheData = (CacheData<Product>)
        redisTemplate.opsForValue().get(cacheKey);

    if (cacheData == null) {
        // 缓存不存在,理论上不会发生(预热时永久写入)
        return null;
    }

    // 检查逻辑过期时间
    if (cacheData.getExpireTime() > System.currentTimeMillis()) {
        // 未过期,直接返回
        return cacheData.getData();
    }

    // 已过期,尝试获取锁异步刷新
    String lockKey = "lock:refresh:" + id;
    RLock lock = redissonClient.getLock(lockKey);
    boolean acquired = lock.tryLock(0, 10, TimeUnit.SECONDS);
    if (acquired) {
        try {
            // 异步刷新缓存
        CompletableFuture.runAsync(() -> {
            Product fresh = productMapper.selectById(id);
            CacheData<Product> newData = new CacheData<>();
            newData.setData(fresh);
            newData.setExpireTime(System.currentTimeMillis() + 1800_000);
            redisTemplate.opsForValue().set(cacheKey, newData);
        });
        } finally {
            lock.unlock();
        }
    }

    // 返回旧数据(接受短暂不一致)
    return cacheData.getData();
}

逻辑过期方案的核心思想是缓存永不物理失效,过期后返回旧数据的同时异步刷新。获取到锁的线程负责刷新缓存,未获取到锁的线程直接返回旧数据。这种方案牺牲了短暂的数据一致性,换取了零等待时间的高可用性。对于商品详情页、热门文章等对实时性要求不高的场景,逻辑过期是更优选择。

缓存一致性保障策略

缓存与数据库的一致性是缓存系统设计的核心问题。Cache Aside Pattern是最常用的策略:读操作先查缓存,未命中查数据库并回写缓存;写操作先更新数据库,再删除缓存。删除而非更新缓存的原因是避免并发写导致的缓存数据不一致。

// Cache Aside Pattern 实现
public Product updateProduct(Product product) {
    // 1. 更新数据库
    productMapper.updateById(product);
    // 2. 删除缓存
    redisTemplate.delete(CACHE_PREFIX + product.getId());
    return product;
}

// 延迟双删:解决读写并发导致的脏缓存
public Product updateProductWithDoubleDelete(Product product) {
    // 1. 先删除缓存
    redisTemplate.delete(CACHE_PREFIX + product.getId());
    // 2. 更新数据库
    productMapper.updateById(product);
    // 3. 延迟500ms后再次删除缓存
    ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
    executor.schedule(() -> {
        redisTemplate.delete(CACHE_PREFIX + product.getId());
    }, 500, TimeUnit.MILLISECONDS);
    return product;
}

延迟双删方案通过在数据库更新前后两次删除缓存,覆盖了读写并发场景下缓存被旧数据回写的窗口期。延迟时间需要大于一次数据库读操作的耗时,通常设置为500ms。对于强一致性场景,需要引入消息队列保证缓存删除的可靠性,或将缓存删除操作放入数据库事务中。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shi-zhan-huan-cun-chuan-tou-xue-beng/

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

相关推荐