4、缓存策略:Redis缓存架构、缓存穿透/击穿/雪崩解决方案、本地缓存与分布式缓存

说到性能优化,缓存绝对是绕不开的核心话题。我做了这么多年系统,见过太多因为缓存没用好导致线上事故的案例。说白了,缓存就是拿空间换时间,但怎么换、换多少、换完怎么保证不出问题,这里面的门道可不少。

这一章,咱们就聊聊缓存策略。我会从Redis缓存架构讲起,然后重点分析三个经典问题——缓存穿透、击穿、雪崩,最后再对比一下本地缓存和分布式缓存的使用场景。

4.1 Redis缓存架构设计

Redis是目前最主流的分布式缓存方案。我个人习惯把它当作系统的「第二层内存」来用。为什么这么说?因为应用服务器的本地内存是第一层,Redis是独立部署的第二层,两者配合才能发挥最大效果。

核心架构要点:

  • 读写分离:主节点负责写,从节点负责读。我在项目中遇到过,如果不做读写分离,单节点QPS到10万左右就开始抖动了。
  • 哨兵模式:自动故障转移。嗯,这里要注意,哨兵只是解决了高可用问题,不解决容量问题。
  • 集群模式:数据分片存储。每个节点只存一部分数据,总容量可以横向扩展。

下面这张图是我总结的Redis缓存架构核心逻辑,你看一眼就明白了:

Redis缓存架构核心逻辑 应用服务器 本地缓存(Caffeine) Redis集群 主节点 从节点1 从节点2 主节点 从节点1 从节点2 哨兵集群 MySQL数据库 读写 本地缓存 回源查询

我的经验:Redis集群的节点数建议控制在100以内。超过这个数,节点间的通信开销会明显上升。我曾经帮一个客户优化,他们搞了200多个节点,结果一半的带宽都用在gossip协议上了。

4.2 缓存穿透、击穿、雪崩解决方案

这三个问题,是缓存面试的必考题,也是线上最容易踩的坑。咱们一个一个说。

4.2.1 缓存穿透

缓存穿透,说白了就是「查了个不存在的数据」。请求直接绕过缓存打到数据库,如果并发高,数据库瞬间就跪了。

解决方案:

  • 缓存空对象:查询结果为空时,也缓存一个空值,设置较短的过期时间(比如30秒)。
  • 布隆过滤器:在缓存前面加一层过滤器,判断数据是否存在。不存在直接返回,不用查数据库。

我个人更推荐布隆过滤器。为什么?因为缓存空对象有个问题——如果恶意攻击用随机key来打,你的缓存里会塞满空对象,内存扛不住。

// 布隆过滤器示例(Java)
public class BloomFilterDemo {
    private BloomFilter<String> bloomFilter;
    
    public BloomFilterDemo() {
        // 预计插入100万条数据,误判率0.01
        this.bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            1_000_000, 
            0.01
        );
    }
    
    public boolean mightContain(String key) {
        return bloomFilter.mightContain(key);
    }
    
    public void put(String key) {
        bloomFilter.put(key);
    }
}

注意:布隆过滤器有误判率,它会说「某个key可能存在」,但不会说「一定不存在」。误判率一般控制在1%以内就够用了。我曾经见过有人把误判率设成0.0001%,结果布隆过滤器占了几百MB内存,得不偿失。

4.2.2 缓存击穿

缓存击穿,指的是某个热点key突然过期,大量请求同时打到数据库。你想想看,双十一的时候某个爆款商品的缓存过期了,那场景...

解决方案:

  • 互斥锁:只让一个线程去查数据库重建缓存,其他线程等待。
  • 逻辑过期:缓存永不过期,但数据里带一个过期时间字段。后台异步更新。

我建议用互斥锁方案,实现简单,效果直接。逻辑过期虽然能避免锁竞争,但实现复杂,容易出bug。

// 互斥锁重建缓存
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    // 尝试获取锁
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 10)) {
        try {
            // 查数据库
            value = db.query(key);
            redis.set(key, value, 3600);
            return value;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待后重试
        Thread.sleep(50);
        return getWithMutex(key);
    }
}

4.2.3 缓存雪崩

缓存雪崩,就是大量key同时过期,或者Redis整个挂了。这时候所有请求直接打到数据库,系统瞬间瘫痪。

解决方案:

  • 过期时间加随机值:避免大量key同时过期。比如基础过期时间1小时,再加0-10分钟的随机值。
  • 多级缓存:Redis挂了,还有本地缓存顶着。
  • 限流降级:数据库层面做限流,保护核心业务。

我曾经踩过的坑:有一次上线新功能,把所有缓存key的过期时间都设成了整点过期。结果凌晨12点一到,数据库CPU直接飙到100%。从那以后,我每次设过期时间都会加随机值,成了肌肉记忆。

4.3 本地缓存与分布式缓存

很多人在选型时会纠结:到底用本地缓存还是分布式缓存?我的看法是:两者不是二选一,而是配合使用。

对比维度 本地缓存 分布式缓存
访问速度 纳秒级(内存直接访问) 毫秒级(网络IO)
数据一致性 各节点不一致 全局一致
容量 受限于单机内存 可横向扩展
典型实现 Caffeine、Guava Cache Redis、Memcached
适用场景 高频访问、变化不频繁的数据 共享数据、跨服务数据

我一般这样设计:

  • 第一层:本地缓存(Caffeine),存最热的数据。比如用户信息、配置信息。
  • 第二层:Redis,存所有缓存数据。本地缓存没命中时,查Redis。
  • 第三层:数据库,兜底。

这样做的好处是:本地缓存扛住了绝大部分请求,Redis的压力很小。即使Redis挂了,本地缓存还能撑一会儿,给运维留出恢复时间。

一个小技巧:本地缓存的过期时间要比Redis短。比如Redis设1小时,本地缓存设10分钟。这样即使本地缓存数据过期了,还能从Redis拿到最新数据,不会直接打到数据库。

嗯,缓存这块内容不少,但核心就三点:架构设计要分层、三个穿透问题要防住、本地和分布式要配合。你把这些想明白了,缓存这块基本就稳了。


公众号:蓝海资料掘金营,微信deep3321