Redis缓存穿透、雪崩、击穿系统性防御方案

三大缓存问题的本质区别

Redis缓存实践中,三个概念经常被混淆:

1. 缓存穿透:请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库
2. 缓存雪崩:大量缓存key同时过期,请求瞬间全部打到数据库
3. 缓存击穿:某个热点key过期瞬间,大量并发请求同时穿透到数据库

三者症状相似(数据库压力骤增),但根因和防御方案完全不同。下文逐个拆解。

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

穿透场景的典型例子:查询id=-1的用户、搜索不存在的商品SKU。恶意请求会构造大量不存在的key,绕过Redis直击MySQL。

方案1:布隆过滤器前置拦截

在Redis前加一层布隆过滤器,所有可能存在的数据ID写入布隆过滤器。请求到达时先查布隆过滤器,不存在的直接返回:

@Service
public class UserQueryService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private final RBloomFilter<Long> userBloomFilter;

    public UserQueryService(RedissonClient redisson) {
        // 布隆过滤器,预计100万元素,误判率0.01
        this.userBloomFilter = redisson.getBloomFilter("userBloom");
        userBloomFilter.tryInit(1_000_000L, 0.01);
    }

    public User getUser(Long id) {
        String key = "user:" + id;

        // 布隆过滤器判断
        if (!userBloomFilter.contains(id)) {
            return null; // 一定不存在
        }

        // 查缓存
        User user = (User) redisTemplate.opsForValue().get(key);
        if (user != null) {
            return user;
        }

        // 查数据库
        user = userMapper.selectById(id);
        if (user != null) {
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
        } else {
            // 空值缓存,短TTL
            redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
        }
        return user;
    }
}

布隆过滤器的特性:判断存在可能误判(false positive),判断不存在则一定准确(no false negative)。这意味着少量穿透仍会发生,但比例可控。

方案2:空值缓存

更简单的做法——数据库查不到时,缓存一个空对象,设短TTL:

public User getUserWithNullCache(Long id) {
    String key = "user:" + id;
    User cached = (User) redisTemplate.opsForValue().get(key);

    if (User.EMPTY.equals(cached)) {
        return null; // 空值缓存命中
    }
    if (cached != null) {
        return cached;
    }

    User user = userMapper.selectById(id);
    if (user != null) {
        redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
    } else {
        // 空值缓存5分钟
        redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
    }
    return user;
}

空值缓存的问题:恶意请求构造大量不同key时,Redis内存会被空值占满。解法是限制空值缓存的key空间——比如只缓存合法ID范围内的空值。

缓存雪崩:过期时间随机化与高可用架构

雪崩发生在大量key同时过期。常见场景:批量导入数据时所有key设了相同TTL,或凌晨低峰期批量写入的数据在早高峰同时过期。

方案1:TTL加随机偏移

public void batchSetCache(Map<String, Object> dataMap) {
    long baseTtl = 30 * 60; // 30分钟
    Random random = new Random();

    dataMap.forEach((key, value) -> {
        // TTL = 30分钟 ± 5分钟随机
        long ttl = baseTtl + random.nextInt(600) - 300;
        redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
    });
}

5分钟的随机偏移让过期时间分散开,避免同一秒大量key同时失效。

方案2:多级缓存

本地缓存(Caffeine)作为L1,Redis作为L2。Redis大面积失效时,L1仍能挡住部分流量:

@Configuration
public class MultiLevelCacheConfig {

    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    }
}

@Service
public class ProductQueryService {

    @Autowired
    private Cache<String, Object> localCache;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public Product getProduct(Long id) {
        String key = "product:" + id;

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

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

        // L3: 数据库
        Product product = productMapper.selectById(id);
        if (product != null) {
            redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
            localCache.put(key, product);
        }
        return product;
    }
}

本地缓存的TTL比Redis短(5分钟 vs 30分钟),且不同实例的本地缓存过期时间自然不同,不会出现同时失效。

方案3:Redis高可用兜底

雪崩的终极后果是数据库被打挂。在数据库前加最后一道限流:

# Sentinel限流配置
spring:
  cloud:
    sentinel:
      flow:
        rules:
          - resource: "queryUser"
            limitApp: default
            count: 500
            grade: 1
            controlBehavior: 0

数据库查询QPS超500直接拒绝,避免雪崩传导到存储层。被拒绝的请求走降级逻辑,返回缓存数据或友好提示。

缓存击穿:互斥锁重建缓存

击穿是单个热点key过期后,几百个并发请求同时穿透到数据库。与雪崩的区别——击穿是单key、高并发;雪崩是多key、同时过期。

方案1:互斥锁(Mutex Lock)

只允许一个线程重建缓存,其他线程等待:

public User getUserWithMutex(Long id) {
    String key = "user:" + id;
    String lockKey = "lock:user:" + id;

    User user = (User) redisTemplate.opsForValue().get(key);
    if (user != null) return user;

    // 尝试获取互斥锁
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (locked) {
        try {
            // 双重检查
            user = (User) redisTemplate.opsForValue().get(key);
            if (user != null) return user;

            // 查数据库并重建缓存
            user = userMapper.selectById(id);
            if (user != null) {
                redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
            } else {
                redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 等待50ms后重试
        try { Thread.sleep(50); } catch (InterruptedException ignored) {}
        return getUserWithMutex(id);
    }
    return user;
}

互斥锁方案简单有效,但存在两个细节问题:

1. 递归重试在高并发下可能栈溢出——改用循环+最大重试次数
2. 锁的过期时间(10秒)必须大于数据库查询耗时,否则锁提前释放会导致并发重建

方案2:逻辑过期

不设TTL,在value中嵌入逻辑过期时间。过期后返回旧数据,异步更新:

@Data
public class CacheData<T> {
    private T data;
    private long expireTime;
}

public User getUserWithLogicalExpire(Long id) {
    String key = "user:" + id;
    CacheData<User> cacheData = (CacheData<User>) redisTemplate.opsForValue().get(key);

    if (cacheData == null) {
        return loadAndCache(id, key);
    }

    if (cacheData.getExpireTime() > System.currentTimeMillis()) {
        return cacheData.getData();
    }

    // 逻辑过期,异步更新
    String lockKey = "lock:user:" + id;
    boolean locked = Boolean.TRUE.equals(
        redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS));

    if (locked) {
        CompletableFuture.runAsync(() -> {
            try {
                loadAndCache(id, key);
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }

    return cacheData.getData();
}

逻辑过期方案的优点:请求永远不会阻塞等待,用户体验最好。缺点是短暂返回过期数据,对一致性要求极高的场景不适用。

监控指标与告警配置

Redis缓存策略的防御体系需要监控支撑。关键指标:

# Prometheus Redis指标
- redis_connected_clients
- redis_keyspace_hits_total
- redis_keyspace_misses_total
- redis_evicted_keys_total

# 自定义告警:缓存命中率低于80%
- alert: RedisCacheHitRateLow
  expr: |
    redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.8
  for: 5m
  labels:
    severity: warning

命中率低于80%说明大量请求穿透到数据库,需要排查是穿透问题还是缓存容量不足。数据库运维的核心就是防患于未然——等数据库被打挂了再处理,止损成本已经是数量级差异了。

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

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

相关推荐