Redis缓存策略实战:穿透、击穿与雪崩问题的诊断与解决方案

穿透、击穿、雪崩的本质区别

数据库运维中,Redis缓存策略的设计直接决定系统在高流量下的存活能力。三个最常见的问题——穿透、击穿、雪崩——经常被混淆,但它们的触发机制和解决方案完全不同:

缓存穿透:请求查询一个数据库中根本不存在的数据,缓存无法命中,请求直达数据库。恶意攻击或爬虫遍历ID时最容易触发。
缓存击穿:一个热点Key过期瞬间,大量并发请求同时穿透缓存打到数据库。本质是单Key的并发竞争问题。
缓存雪崩:大量Key同时过期或Redis节点宕机,请求大面积穿透到数据库。本质是批量Key的失效问题。

区分标准:穿透是”数据不存在”,击穿是”单Key过期”,雪崩是”批量Key失效”。

缓存穿透的诊断与防护

穿透问题的特征是Redis和数据库的QPS同时升高,且Redis的miss率接近100%。

方案一:空值缓存

最直接的方法——查不到数据时,在Redis中缓存一个空值(null或特殊标记),设置较短的TTL:

public String getProduct(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);

    if (cached != null) {
        if ("NULL".equals(cached)) {
            return null;  // 命中空值缓存,不查数据库
        }
        return cached;
    }

    // 查数据库
    Product product = productMapper.selectById(productId);

    if (product == null) {
        // 缓存空值,TTL设短(30秒-5分钟),避免占用过多内存
        redis.setex(cacheKey, 60, "NULL");
        return null;
    }

    redis.setex(cacheKey, 3600, JSON.toJSONString(product));
    return JSON.toJSONString(product);
}

空值缓存的陷阱:恶意请求如果使用随机ID,每个ID都会产生一条空值缓存记录,可能撑爆Redis内存。解决方案是对ID做格式校验,不合法的ID直接拒绝。

方案二:布隆过滤器

在请求到达Redis之前,先用布隆过滤器判断数据是否可能存在:

// RedisBloom模块(Redis 4.0+支持)
// 初始化布隆过滤器
redis.execute("BF.RESERVE", "product_bloom", 0.001, 10000000);
// 插入所有合法产品ID
redis.execute("BF.ADD", "product_bloom", productId);

// 查询时先判断
public String getProduct(String productId) {
    // 布隆过滤器判断
    boolean exists = redis.execute("BF.EXISTS", "product_bloom", productId);
    if (!exists) {
        return null;  // 一定不存在,直接返回
    }

    // 可能存在,查缓存再查数据库
    String cached = redis.get("product:" + productId);
    // ...
}

布隆过滤器的误判率需要根据业务规模计算:预期1000万数据、0.1%误判率需要约18MB内存,1000万数据、0.01%误判率需要约24MB。误判率越低内存越大,需在内存成本和穿透概率间取平衡。

数据迁移实战中,布隆过滤器需要全量预热——把数据库所有ID加载到布隆过滤器中,新写入的数据同步更新布隆过滤器。

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

击穿的核心是”一个Key过期时,多个请求同时穿透”。解决方案有两种思路。

方案一:互斥锁(Mutex Lock)

只允许一个请求去查数据库并重建缓存,其他请求等待:

public String getProductWithLock(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);

    if (cached != null) {
        return cached;
    }

    // 缓存未命中,获取互斥锁
    String lockKey = "lock:" + productId;
    String lockValue = UUID.randomUUID().toString();

    // SET NX EX:原子性获取锁,10秒自动过期
    boolean locked = redis.set(lockKey, lockValue, "NX", "EX", 10);

    if (locked) {
        try {
            // 双重检查:拿到锁后再查一次缓存
            cached = redis.get(cacheKey);
            if (cached != null) {
                return cached;
            }

            // 查数据库
            Product product = productMapper.selectById(productId);
            String value = product != null ? JSON.toJSONString(product) : "NULL";
            redis.setex(cacheKey, 3600, value);
            return value;
        } finally {
            // 释放锁(Lua脚本保证原子性)
            String unlockScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "  return redis.call('del', KEYS[1]) " +
                "else " +
                "  return 0 " +
                "end";
            redis.eval(unlockScript, 1, lockKey, lockValue);
        }
    } else {
        // 未获锁,短暂等待后重试
        Thread.sleep(50);
        return getProductWithLock(productId);
    }
}

互斥锁的问题:等待的请求会阻塞线程,高并发下线程池可能耗尽。解决方案是加最大重试次数限制(3次),超限直接返回降级数据。

方案二:逻辑过期

不设物理TTL,在值中嵌入逻辑过期时间。过期后返回旧数据,异步刷新缓存:

// 缓存值结构
{
  "data": { ... },           // 业务数据
  "expireTime": 1722384000   // 逻辑过期时间戳
}

public String getProductLogicalExpire(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);

    if (cached == null) {
        return null;
    }

    CacheWrapper wrapper = JSON.parseObject(cached, CacheWrapper.class);

    if (wrapper.getExpireTime() > System.currentTimeMillis()) {
        return JSON.toJSONString(wrapper.getData());
    }

    // 逻辑过期,异步刷新
    String lockKey = "lock:" + productId;
    boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);
    if (locked) {
        CompletableFuture.runAsync(() -> {
            try {
                Product product = productMapper.selectById(productId);
                CacheWrapper newWrapper = new CacheWrapper(
                    product,
                    System.currentTimeMillis() + 3600_000
                );
                redis.set(cacheKey, JSON.toJSONString(newWrapper));
            } finally {
                redis.del(lockKey);
            }
        });
    }

    // 无论是否获取锁,都返回旧数据
    return JSON.toJSONString(wrapper.getData());
}

逻辑过期适合对一致性要求不高的场景(如商品详情页)。好处是用户永远不等待缓存重建,缺点是可能返回过期数据。

缓存雪崩的防护体系

1. TTL随机化

批量设置缓存时,在基础TTL上叠加随机偏移:

// 基础TTL 1小时 + 随机0-10分钟
int baseTTL = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(0, 600);
redis.setex(cacheKey, baseTTL + randomOffset, value);

2. Redis高可用架构

Sentinel模式在主节点宕机时自动故障转移(30秒内完成切换)。Cluster模式分片存储,单节点宕机只影响1/N的数据。但Cluster模式下如果某个slot迁移失败,可能导致部分Key暂时不可用——需要配置客户端的集群拓扑自动刷新。

3. 多级缓存

本地缓存(Caffeine/Guava Cache)+ 分布式缓存(Redis),形成两层防护:

// Caffeine本地缓存配置
Cache<String, String> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .recordStats()
    .build();

public String getProductMultiLevel(String productId) {
    String cacheKey = "product:" + productId;

    // L1: 本地缓存
    String cached = localCache.getIfPresent(cacheKey);
    if (cached != null) {
        return cached;
    }

    // L2: Redis
    cached = redis.get(cacheKey);
    if (cached != null) {
        localCache.put(cacheKey, cached);
        return cached;
    }

    // L3: 数据库
    Product product = productMapper.selectById(productId);
    String value = product != null ? JSON.toJSONString(product) : "NULL";
    redis.setex(cacheKey, 3600, value);
    localCache.put(cacheKey, value);
    return value;
}

多级缓存的难点在于本地缓存的一致性:当数据更新时,需要通知所有应用实例清除本地缓存。常用方案是Redis Pub/Sub广播失效消息,各实例监听并清除对应的本地缓存条目。

监控与告警配置

Redis缓存问题的监控指标:

# Prometheus + Grafana 监控指标
# 1. 缓存命中率
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)

# 2. 缓存穿透检测:miss率突然升高
rate(redis_keyspace_misses_total[5m]) > threshold

# 3. 连接数异常
redis_connected_clients > redis_maxclients * 0.8

# 4. 内存使用率
redis_memory_used_bytes / redis_memory_max_bytes > 0.85

# 5. 阻塞命令数
rate(redis_slowlog_length[5m]) > 10

告警规则建议:缓存命中率低于90%持续5分钟触发warning,低于80%触发critical;Redis内存使用率超过85%触发warning,超过92%触发critical。结合数据库高可用架构的监控,形成从缓存到数据库的全链路告警覆盖。

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

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

相关推荐