Redis缓存策略实战:穿透、击穿与雪崩的防护方案对比

Redis缓存策略的核心不是缓存怎么读写,而是缓存失效时系统怎么兜底。缓存穿透、缓存击穿与缓存雪崩是高并发场景下最常见的三类故障,处理不当会直接打垮数据库。本文给出三类问题的成因、判别方法与防护代码,覆盖数据库运维视角下最常见的缓存应用。

缓存穿透:查询不存在的数据打穿缓存

缓存穿透指请求查询数据库中不存在的数据,缓存与数据库都查不到,请求直接落到数据库。恶意攻击或高频无效查询会放大数据库压力。防护手段:

  • 缓存空值:查询结果为空时也写缓存,设置较短TTL(如60秒),避免空值缓存堆积。
  • 布隆过滤器:用bitmap判断key是否可能存在,不存在直接拦截。
  • 参数校验:对非法ID、超长参数在入口拦截。

// 缓存空值示例
public String get(String key) {
    String value = redis.get(key);
    if (value != null) {
        return "EMPTY".equals(value) ? null : value;
    }
    String dbValue = dao.query(key);
    redis.set(key, dbValue == null ? "EMPTY" : dbValue,
              dbValue == null ? 60 : 3600);
    return dbValue;
}

缓存击穿:热点key过期瞬间的高并发回源

缓存击穿指某个热点key在过期瞬间,大量并发请求同时回源数据库。防护方案:

  • 互斥锁(Mutex):只允许一个请求回源并重建缓存,其余请求等待或返回旧值。
  • 逻辑过期:不设置物理TTL,后台异步更新缓存,请求永远读缓存。
  • 热点数据延长TTL或设置永不过期,配合定时刷新。

// 互斥锁重建缓存(伪代码)
public String getWithLock(String key) {
    String value = redis.get(key);
    if (value != null) return value;
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 30)) {
        try {
            value = dao.query(key);
            redis.set(key, value, 86400);
        } finally {
            redis.del(lockKey);
        }
    } else {
        Thread.sleep(50);
        return getWithLock(key);
    }
    return value;
}

缓存雪崩:大量key同时失效压垮数据库

缓存雪崩指大批key在同一时间段过期,或Redis实例故障导致缓存整体不可用,请求全部落到数据库。防护手段:

  • TTL加随机值:过期时间分散,例如基础时间加0到300秒随机数。
  • 多级缓存:本地缓存(如Caffeine)作为第二层防线,Redis故障时仍能承载。
  • Redis高可用:哨兵或Cluster部署,配合熔断降级。

// TTL随机化示例
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(0, 300);
redis.setex(key, baseTtl + randomOffset, value);

缓存一致性:更新数据库后同步失效缓存

缓存与数据库的一致性问题容易被忽视。常用方案:先更新数据库,再删除缓存,配合延迟双删处理并发窗口:


// 更新数据库
dao.update(entity);
// 删除缓存
redis.del(key);
// 延迟双删:400ms后再删一次,处理读请求回填旧缓存
Thread.sleep(400);
redis.del(key);

读多写少场景用Cache Aside模式最省事;强一致场景直接用数据库,不做缓存。

Redis缓存监控与故障演练

上线前应做故障演练:手动淘汰热点key观察回源压力,模拟Redis宕机验证降级链路。监控指标重点看缓存命中率、回源QPS与Redis内存使用率,命中率持续低于80%说明缓存设计有问题,需要重新评估key粒度与TTL。

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

(0)
小编小编
上一篇 3天前
下一篇 3天前

相关推荐

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)
小编小编
上一篇 2026年7月30日
下一篇 2026年7月30日

相关推荐