4、缓存架构设计:Redis集群、缓存穿透/击穿/雪崩、多级缓存(本地+分布式)、缓存一致性

缓存这东西,说白了就是拿空间换时间。但很多团队把缓存当成了万能药,结果一上线就出各种幺蛾子。我见过最夸张的一次,某个活动页面直接把数据库打挂了,原因就是缓存穿透——几百万个请求全怼到了MySQL上。

这一章,咱们就把缓存架构的坑一个个填平。从Redis集群搭建,到三大经典问题(穿透、击穿、雪崩),再到多级缓存和一致性方案。嗯,都是实战里摸爬滚打出来的经验。

核心观点:缓存不是银弹,但用好了能扛住90%的读压力。关键在于——分层、限流、兜底。

4.1 Redis集群:不只是主从复制

很多人觉得Redis集群就是搞个主从,再弄个哨兵就完事了。其实不然。我建议你根据业务场景选方案:

方案 适用场景 优点 缺点
主从+哨兵 读多写少,数据量<10G 部署简单,自动故障转移 写性能瓶颈在主节点
Redis Cluster 数据量>10G,需要水平扩展 自动分片,无中心化 跨节点操作受限,运维复杂
Proxy方案(Twemproxy/Codis) 业务层不想感知集群 对客户端透明 Proxy成为单点,有性能损耗

我个人习惯用Redis Cluster。为什么?因为它原生支持分片,扩容时不用停机。我记得有一次双十一前扩容,从6个节点扩到12个,整个过程业务无感知。

部署时要注意几个点:

  • 节点数最好是奇数——投票选举时不会平局
  • 不要跨机房部署——网络延迟会导致频繁的选举
  • 预留20%内存——给RDB持久化和主从同步留空间

小技巧:Redis Cluster的槽位分配是16384个。你可以用redis-cli --cluster rebalance手动调整,避免热点槽集中在某几个节点上。

4.2 缓存穿透:别让请求打到数据库

什么是缓存穿透?就是查询一个根本不存在的数据。缓存里没有,数据库里也没有。每次请求都穿透缓存,直接怼到数据库上。

我在项目中遇到过最典型的场景:用户批量查询不存在的商品ID。攻击者构造几万个不存在的ID,瞬间把数据库打满连接。

解决方案有三个层次:

  1. 缓存空对象——最简单,但要注意设置较短的过期时间(比如30秒),防止大量空key占内存
  2. 布隆过滤器——在缓存前面加一层过滤器,判断key是否存在。不存在直接返回,不查数据库
  3. 参数校验——比如ID必须是正整数,长度不超过10位。非法请求直接拒绝
// 布隆过滤器示例(Java + Guava)
public class BloomFilterDemo {
    private static final int EXPECTED_INSERTIONS = 100_0000;
    private static final double FPP = 0.01; // 误判率1%

    private static BloomFilter<String> bloomFilter =
        BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),
                           EXPECTED_INSERTIONS, FPP);

    // 初始化时加载所有合法ID
    public void init(List<String> ids) {
        ids.forEach(bloomFilter::put);
    }

    public boolean mightContain(String id) {
        return bloomFilter.mightContain(id);
    }
}

注意:布隆过滤器有误判率。它说“不存在”就一定不存在,但说“存在”可能不存在。所以过滤后还是要查缓存和数据库,只是把大部分无效请求挡在外面。

4.3 缓存击穿:热点key的生死时刻

缓存击穿和穿透不一样。击穿是某个热点key过期的瞬间,大量请求同时涌入,全部打到数据库上。

你想想看,一个爆款商品的详情页,平时每秒几万次请求。突然缓存过期了,所有请求都去查数据库——数据库不挂才怪。

我曾经踩过这个坑。那次是某个秒杀活动的商品信息缓存过期,数据库连接数瞬间飙到5000+,直接OOM了。

解决方案:

  • 互斥锁(Mutex)——只让一个线程去查数据库重建缓存,其他线程等待。用Redis的SETNX实现
  • 逻辑过期——缓存永不过期,但存一个逻辑过期时间。后台异步刷新
  • 热点key永不过期——对已知的热点key,不设置过期时间,通过后台任务定期更新
// 互斥锁重建缓存(伪代码)
public String getProduct(String id) {
    String cache = redis.get("product:" + id);
    if (cache != null) {
        return cache;
    }

    // 尝试获取锁
    String lockKey = "lock:product:" + id;
    if (redis.setnx(lockKey, "1", 3)) { // 3秒过期
        try {
            String dbData = db.query(id);
            redis.set("product:" + id, dbData, 60);
            return dbData;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待后重试
        Thread.sleep(50);
        return getProduct(id); // 递归重试
    }
}

经验之谈:互斥锁的过期时间要设置合理。如果数据库查询超过3秒,锁自动释放,其他线程又会去重建。我一般设置为“预估查询时间×2”。

4.4 缓存雪崩:大面积过期引发的灾难

缓存雪崩比击穿更可怕。它不是单个key过期,而是大量key在同一时间过期,或者Redis节点挂了,导致所有请求都打到数据库。

我记得有一次,运营团队在后台批量上架了1000个商品,所有商品的缓存过期时间都设成了凌晨0点。结果0点一到,数据库直接被打爆。

预防措施:

  1. 过期时间加随机值——比如基础时间60秒,再加0-30秒的随机偏移
  2. 多级缓存——本地缓存+分布式缓存,本地缓存扛住第一波
  3. 限流降级——数据库层面做限流,超出部分直接返回默认值或错误
  4. Redis高可用——主从+哨兵,或者Cluster模式,避免单点故障
// 过期时间加随机值
int baseExpire = 60;
int randomOffset = new Random().nextInt(30);
int expireTime = baseExpire + randomOffset;
redis.set(key, value, expireTime);

4.5 多级缓存:本地+分布式

单靠Redis扛百万并发?有点悬。网络开销和序列化成本摆在那里。我建议用多级缓存:

  • L1:本地缓存(Caffeine/Guava Cache)——毫秒级响应,扛住80%的热点请求
  • L2:分布式缓存(Redis)——毫秒级响应,扛住剩下的20%
  • L3:数据库——兜底,只处理少量请求

为什么本地缓存这么重要?因为Redis再快,也有网络开销。本地缓存直接在JVM内存里,0网络延迟。我做过压测,本地缓存的QPS能到几十万,而Redis单节点也就几万到十几万。

但本地缓存有个问题——数据一致性。每个节点的本地缓存可能不一样。怎么解决?看下一节。

架构建议:本地缓存只放“几乎不变”的数据,比如商品分类、配置信息。对于频繁变化的数据(比如库存),只用Redis。

4.6 缓存一致性:CAP的妥协

缓存一致性是个老生常谈的问题。说白了,就是数据库更新了,缓存怎么同步?

我见过很多团队用“先删缓存,再更新数据库”的方案。但这样会有并发问题:线程A删了缓存,还没更新数据库,线程B读到了旧数据,又把旧数据写回缓存。

业界常用的方案:

方案 原理 一致性 复杂度
Cache Aside 先更新数据库,再删缓存 最终一致 低
读写穿透 缓存层封装读写逻辑 强一致 中
异步通知(MQ) 数据库变更后发消息,缓存消费更新 最终一致 中
Binlog监听(Canal) 监听MySQL binlog,同步更新缓存 最终一致 高

我个人推荐Cache Aside + 延迟双删:

  1. 先更新数据库
  2. 删除缓存
  3. 延迟几百毫秒,再删一次缓存(解决并发读写的脏数据问题)
// 延迟双删示例
public void updateProduct(Product product) {
    // 1. 更新数据库
    db.update(product);

    // 2. 删除缓存
    redis.del("product:" + product.getId());

    // 3. 延迟再删(异步)
    executor.schedule(() -> {
        redis.del("product:" + product.getId());
    }, 500, TimeUnit.MILLISECONDS);
}

注意:延迟双删不能保证100%强一致,但能保证99.9%的场景下数据最终一致。如果业务要求强一致,建议用读写穿透方案,或者干脆不用缓存。

4.7 本章知识体系

下面这张图总结了缓存架构的核心逻辑。你可以看到,从客户端请求到数据库,中间经过了多层防护:

缓存架构知识体系图 缓存架构核心逻辑 客户端请求 布隆过滤器(防穿透) L1 本地缓存(Caffeine) L2 Redis集群(分布式缓存) 互斥锁(防击穿) L3 数据库(兜底) 每一层都承担了不同的职责:布隆过滤器挡无效请求,本地缓存扛热点,Redis集群做分布式缓存,互斥锁防击穿,数据库兜底

从这张图你能看到,一个请求从客户端到数据库,经过了五层防护。每一层都解决了一个特定问题。这就是缓存架构的精髓——分层防御,各司其职。

最后说一句:缓存架构没有银弹。你要根据业务场景选择方案。如果数据一致性要求极高,就别用缓存;如果QPS不高,单机Redis+主从就够了。别为了技术而技术。