4. 缓存策略设计:缓存穿透、缓存击穿、缓存雪崩的解决方案、多级缓存架构(本地缓存+Redis)

聊到库存系统的缓存设计,我得先跟你掏心窝子说一句:缓存不是银弹,用不好反而会变成定时炸弹。我在做库存系统优化时,踩过最多的坑就是缓存那点事儿。今天咱们就把缓存穿透、击穿、雪崩这三个老冤家,以及多级缓存架构,一次性讲透。

4.1 缓存穿透:查了个不存在的数据

什么叫缓存穿透?说白了就是:请求查一个肯定不存在的数据。比如用户查一个不存在的商品ID,缓存里没有,数据库里也没有。每次请求都直接打到数据库,缓存形同虚设。

我在项目中遇到过最夸张的一次,有人写了个脚本疯狂请求不存在的商品ID,数据库连接池瞬间被打满。嗯,这就是典型的缓存穿透攻击。

解决方案:布隆过滤器 + 空值缓存

我个人习惯用两种方式组合防御:

  • 布隆过滤器:在请求到达缓存前,先判断这个key是否可能存在。如果布隆过滤器说“不存在”,直接返回,连缓存都不查。
  • 空值缓存:如果查数据库发现确实不存在,也把这个key缓存起来,值设为null,过期时间设短一点(比如30秒)。这样同样的请求就不会再穿透了。

核心思路:让“不存在”的数据也变得“存在”,只是存在的时间短一点。

// 伪代码示例:缓存穿透防御
public Product getProduct(String productId) {
    // 1. 布隆过滤器前置拦截
    if (!bloomFilter.mightContain(productId)) {
        return null; // 肯定不存在,直接返回
    }
    
    // 2. 查缓存
    Product product = redis.get("product:" + productId);
    if (product != null) {
        return product;
    }
    
    // 3. 查数据库
    product = db.query(productId);
    if (product == null) {
        // 空值缓存,过期时间30秒
        redis.set("product:" + productId, null, 30);
        return null;
    }
    
    // 4. 正常缓存
    redis.set("product:" + productId, product, 3600);
    return product;
}

避坑指南:布隆过滤器不支持删除操作。如果商品ID被删除了,布隆过滤器里还认为它存在。我建议定期重建布隆过滤器,或者用计数布隆过滤器。

4.2 缓存击穿:热点key突然失效

缓存击穿和穿透不一样。击穿是:一个热点key在缓存失效的瞬间,大量请求同时涌入数据库。你想想看,双11秒杀时某个爆款商品的库存key突然过期了,几千个请求同时去查数据库,那画面太美我不敢看。

解决方案:互斥锁 + 逻辑过期

我常用的方案有两种:

  • 互斥锁(Mutex Lock):当缓存失效时,只让一个线程去查数据库重建缓存,其他线程等待。说白了就是“排队”。
  • 逻辑过期:缓存永不过期,但存一个逻辑过期时间。后台异步线程去刷新缓存。用户请求时如果发现逻辑过期了,先返回旧数据,同时触发异步更新。
// 互斥锁方案
public Product getProductWithLock(String productId) {
    Product product = redis.get("product:" + productId);
    if (product != null) {
        return product;
    }
    
    // 尝试获取分布式锁
    String lockKey = "lock:product:" + productId;
    if (redis.setnx(lockKey, "1", 10)) { // 10秒过期
        try {
            // 查数据库
            product = db.query(productId);
            redis.set("product:" + productId, product, 3600);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(100);
        return getProductWithLock(productId); // 递归重试
    }
    return product;
}

注意:互斥锁方案在高并发下会有一定的性能损耗。如果热点key特别多,我建议用逻辑过期方案,虽然实现复杂一点,但性能更好。

4.3 缓存雪崩:大面积缓存同时失效

缓存雪崩比击穿更可怕。击穿是一个key失效,雪崩是大量key在同一时间失效。比如你给所有商品缓存都设置了1小时过期,结果1小时后所有请求都打到数据库,数据库直接跪了。

我记得有一次,我们系统凌晨做缓存预热,结果所有key的过期时间都设成了早上8点整。8点一到,数据库CPU直接飙到100%,那叫一个酸爽。

解决方案:过期时间打散 + 多级缓存

方案 说明 推荐指数
过期时间加随机值 在基础过期时间上加上随机数(比如±300秒) ⭐⭐⭐⭐⭐
多级缓存 本地缓存+Redis,本地缓存扛住第一波 ⭐⭐⭐⭐⭐
限流降级 对数据库请求做限流,保护数据库 ⭐⭐⭐⭐
缓存预热 提前加载热点数据,避免冷启动 ⭐⭐⭐⭐
// 过期时间打散:基础时间 + 随机偏移
int baseExpire = 3600; // 1小时
int randomOffset = new Random().nextInt(600); // 0-600秒随机
int expireTime = baseExpire + randomOffset;
redis.set("product:" + productId, product, expireTime);

4.4 多级缓存架构:本地缓存 + Redis

说到多级缓存,我得先画个图让你直观感受一下。

多级缓存架构流程图 客户端请求 本地缓存(Caffeine) Redis 分布式缓存 MySQL 数据库 命中率:约60% 命中率:约30% 命中率:约10% 缓存回写

这张图你看懂了吗?说白了就是:请求先查本地缓存,没命中再查Redis,Redis还没命中才查数据库。查到的数据逐级回写。

为什么需要本地缓存?

Redis再快,也有网络开销。本地缓存(比如Caffeine)是JVM内存,毫秒级响应。我做过压测:本地缓存命中时,响应时间在1ms以内;Redis命中时,响应时间在5-10ms。对于库存这种高频访问的场景,本地缓存能扛住80%的请求。

多级缓存的核心原则

  • 本地缓存:存最热的数据(比如前100个爆款商品的库存)
  • Redis:存所有商品的库存
  • 数据库:只做最终一致性保证

多级缓存的一致性怎么保证?

嗯,这里有个坑。本地缓存和Redis之间怎么同步?我常用的方案是:

  • Redis订阅发布:当Redis中的库存发生变化时,发布一个消息,所有应用实例收到消息后,清除本地缓存。
  • 短过期时间:本地缓存设置很短的过期时间(比如5秒),即使没收到消息,也会自动失效重新加载。
// 本地缓存配置(Caffeine)
Cache<String, Product> localCache = Caffeine.newBuilder()
    .maximumSize(1000)          // 最多缓存1000个
    .expireAfterWrite(5, TimeUnit.SECONDS)  // 5秒过期
    .recordStats()              // 记录命中率
    .build();

// Redis消息监听:收到更新通知后清除本地缓存
public void onMessage(String message) {
    String productId = parseProductId(message);
    localCache.invalidate(productId);
}

避坑指南:我曾经把本地缓存过期时间设成了1小时,结果Redis里的库存都扣完了,本地缓存还是旧数据,导致超卖。后来我学乖了:本地缓存过期时间不要超过10秒

4.5 总结:缓存策略的黄金法则

说了这么多,我总结几条黄金法则:

  1. 穿透用布隆过滤器+空值缓存,别让无效请求打到数据库。
  2. 击穿用互斥锁或逻辑过期,热点key要特殊保护。
  3. 雪崩用过期时间打散+多级缓存,别让所有key同时失效。
  4. 多级缓存是王道,本地缓存扛热点,Redis扛全量,数据库兜底。

最后送你一句话:缓存设计没有银弹,只有根据业务场景不断调优。我在库存系统里,就是靠这套多级缓存架构,把数据库QPS从10万降到了5000,系统稳稳地扛住了双11的流量。

核心指标

  • 本地缓存命中率:60%以上
  • Redis命中率:30%以上
  • 数据库命中率:控制在10%以内

专注资料整理