Redis缓存穿透雪崩击穿问题诊断与解决方案实战

Redis作为高并发场景的核心缓存层,缓存穿透、雪崩、击穿是数据库运维中最频发的问题类型。三者表象相似但成因不同,诊断思路和解决方案存在本质差异。本文从问题现象出发,给出每种问题的诊断方法、代码实现和架构层面的预防措施。

缓存穿透:不存在的Key穿透到数据库

缓存穿透指查询一个数据库中不存在的数据,由于缓存无法命中(缓存中不存在该Key),每次请求都打到数据库。恶意攻击或爬虫大量请求不存在的ID时,数据库连接池迅速耗尽。诊断特征:Redis miss率异常升高,数据库QPS突增,但返回结果为空。

解决方案一:缓存空值。查询数据库未命中时,将空结果缓存到Redis并设置较短的过期时间(30秒至5分钟),避免同一不存在的Key反复穿透:

// Java实现:缓存空值防穿透
public User getUserById(Long userId) {
    String cacheKey = "user:" + userId;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    
    if (cached != null) {
        if ("NULL".equals(cached)) {
            return null;  // 空值缓存命中,直接返回
        }
        return JSON.parseObject(cached, User.class);
    }
    
    // 缓存未命中,查询数据库
    User user = userMapper.selectById(userId);
    if (user == null) {
        // 缓存空值,过期时间较短
        redisTemplate.opsForValue().set(cacheKey, "NULL", 30, TimeUnit.SECONDS);
        return null;
    }
    
    redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    return user;
}

解决方案二:布隆过滤器。在缓存之前增加布隆过滤器层,请求先经过布隆过滤器判断Key是否存在,不存在则直接拒绝。布隆过滤器存在误判率(假阳性),但不会漏判(假阴性),适合数据集相对固定的场景:

// 使用Redisson布隆过滤器
@Configuration
public class BloomFilterConfig {
    @Bean
    public RBloomFilter<Long> userBloomFilter(RedissonClient redisson) {
        RBloomFilter<Long> filter = redisson.getBloomFilter("user:bloom");
        // 预计元素100万,误判率0.01%
        filter.tryInit(1_000_000L, 0.0001);
        // 启动时加载所有用户ID
        List<Long> userIds = userMapper.selectAllIds();
        userIds.forEach(filter::add);
        return filter;
    }
}

public User getUserWithBloom(Long userId) {
    // 布隆过滤器前置拦截
    if (!userBloomFilter.contains(userId)) {
        return null;  // 一定不存在,直接返回
    }
    // 继续正常缓存查询逻辑
    return getUserById(userId);
}

缓存雪崩:大量Key同时过期导致数据库过载

缓存雪崩指大量Key在同一时间段过期,或Redis节点宕机导致缓存大面积失效,所有请求涌向数据库。与穿透的区别在于:穿透是单个不存在的Key反复查询,雪崩是大量存在的Key同时失效。诊断特征:数据库连接数飙升、CPU满载、响应时间剧增,且集中在某个时间点爆发。

核心解决方案:过期时间随机化。设置缓存过期时间时叠加随机偏移量,避免大量Key同时过期:

// 过期时间随机化防雪崩
public void cacheUser(User user) {
    String cacheKey = "user:" + user.getId();
    int baseExpire = 1800;  // 基础过期时间30分钟
    int randomOffset = new Random().nextInt(300);  // 随机0到5分钟
    int expireTime = baseExpire + randomOffset;
    
    redisTemplate.opsForValue().set(
        cacheKey, 
        JSON.toJSONString(user), 
        expireTime, TimeUnit.SECONDS
    );
}

// 多级缓存架构防雪崩
public User getUserMultiLevel(Long userId) {
    // L1: 本地缓存(Caffeine)
    User user = caffeineCache.getIfPresent(userId);
    if (user != null) return user;
    
    // L2: Redis分布式缓存
    String cached = redisTemplate.opsForValue().get("user:" + userId);
    if (cached != null) {
        user = JSON.parseObject(cached, User.class);
        caffeineCache.put(userId, user);  // 回填L1
        return user;
    }
    
    // L3: 数据库,使用分布式锁防止缓存重建并发
    String lockKey = "lock:user:" + userId;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            // 双重检查
            cached = redisTemplate.opsForValue().get("user:" + userId);
            if (cached != null) {
                return JSON.parseObject(cached, User.class);
            }
            user = userMapper.selectById(userId);
            if (user != null) {
                cacheUser(user);
                caffeineCache.put(userId, user);
            }
            return user;
        }
        // 获取锁失败,短暂等待后重试读取缓存
        Thread.sleep(50);
        return getUserMultiLevel(userId);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

缓存击穿:热点Key过期瞬间并发穿透

缓存击穿指某个热点Key过期的瞬间,大量并发请求同时查询该Key,全部穿透到数据库。与雪崩的区别在于:雪崩是大量Key同时失效,击穿是单个热点Key失效。典型场景:秒杀商品详情、热门文章。诊断特征:单个数据库查询QPS突增,对应一个特定的缓存Key。

解决方案:互斥锁重建缓存。缓存未命中时,只有一个线程查询数据库并重建缓存,其他线程等待或返回旧数据:

// 互斥锁防击穿
public Product getHotProduct(Long productId) {
    String cacheKey = "product:hot:" + productId;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }
    
    // 缓存未命中,获取分布式锁
    String lockKey = "lock:product:" + productId;
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查,避免重复查库
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return JSON.parseObject(cached, Product.class);
            }
            
            Product product = productMapper.selectById(productId);
            if (product != null) {
                // 逻辑过期:不设置TTL,通过value中的过期时间字段判断
                redisTemplate.opsForValue().set(
                    cacheKey, JSON.toJSONString(product), 2, TimeUnit.HOURS
                );
            }
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 未获取到锁,短暂自旋等待缓存重建
        try { Thread.sleep(100); } catch (InterruptedException ignored) {}
        return getHotProduct(productId);
    }
}

架构层面预防措施:热点Key永不过期(逻辑过期),在value中嵌入过期时间戳,后台异步线程定期刷新;Redis Cluster部署保障高可用,主节点宕机后Sentinel自动故障转移;数据库连接池设置合理上限(如HikariCP maximumPoolSize=20),防止缓存失效时数据库被压垮。监控告警层面,通过Redis INFO命令采集expired_keys指标,配合Grafana监控缓存过期速率,异常波动及时预警。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-xue-beng-ji-chuan-wen-ti-zhen-duan/

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

相关推荐