3、缓存策略设计:Redis缓存架构、缓存穿透/击穿/雪崩解决方案、本地缓存与分布式缓存结合、缓存预热与更新策略

各位同学,咱们今天聊缓存。说实话,在高并发系统里,缓存就是那个「扛把子」的角色。没有缓存,数据库分分钟被冲垮。但缓存也不是银弹,用不好反而会惹出一堆麻烦。

我个人习惯把缓存策略分成四个维度来设计:架构选型、异常防护、多级协同、生命周期管理。咱们一个一个拆开讲。

3.1 Redis缓存架构设计

先说说Redis。很多同学觉得Redis就是「存个key-value」,太肤浅了。在高并发场景下,Redis的架构设计直接决定了系统的天花板。

核心原则:Redis集群模式下,一定要避免「大Key」和「热Key」。我见过一个项目,就因为一个Hash存了上百万个字段,导致集群分片不均,整个系统响应时间从2ms飙升到2s。

我建议的Redis架构方案是这样的:

  • 主从架构:适合读多写少的场景。主节点负责写,从节点负责读。注意,从节点挂了不影响写,但读能力会下降。
  • 哨兵模式:在主从基础上加了自动故障转移。嗯,这里要注意,哨兵只是解决了高可用,没解决容量问题。
  • Redis Cluster:真正的分布式方案。数据自动分片到16384个槽位,每个节点负责一部分槽。我个人比较推荐这个方案,尤其是数据量超过单机内存的场景。

举个例子,我之前负责的一个CRM系统,用户数据量在5000万左右。我们用了Cluster模式,分了12个节点,每个节点负责约400万key。效果还不错,QPS稳定在10万+。

# Redis Cluster 配置示例
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
# 每个节点分配的内存
maxmemory 4gb
maxmemory-policy allkeys-lru

小技巧:配置maxmemory-policy时,我建议用allkeys-lru。别用volatile-lru,万一你忘了设过期时间,内存就爆了。我在项目中踩过这个坑。

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

这三个问题,说白了就是缓存失效引发的连锁反应。咱们一个一个说。

3.2.1 缓存穿透

什么是穿透?就是请求的数据在缓存和数据库里都不存在。每次请求都直接打到数据库上。你想想看,如果有人恶意构造不存在的ID,数据库瞬间就跪了。

解决方案:

  • 布隆过滤器:在缓存前面加一层过滤器。不存在的数据直接拦截掉。我习惯用Guava的BloomFilter,或者Redis自带的布隆模块。
  • 缓存空对象:如果数据库查不到,也缓存一个空值,过期时间设短一点,比如30秒。这样至少能挡住重复请求。
// 布隆过滤器示例
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预计数据量
    0.01      // 误判率
);

// 查询时先判断
if (!bloomFilter.mightContain(key)) {
    return null; // 直接返回,不查数据库
}

避坑指南:我曾经在布隆过滤器的误判率上吃过亏。设成0.1,结果大量无效请求穿透到数据库。建议生产环境设成0.01以下,虽然内存占用多一点,但安全。

3.2.2 缓存击穿

击穿和穿透不一样。击穿是指一个热点key突然过期,大量并发请求同时打到数据库。说白了,就是「一个key扛不住了」。

解决方案:

  • 互斥锁:当key过期时,只让一个线程去查数据库,其他线程等待。我常用Redis的SETNX实现。
  • 逻辑过期:不设置物理过期时间,而是在value里存一个过期时间戳。后台异步更新缓存。这样永远不会出现key突然消失的情况。
// 互斥锁示例
String lockKey = "lock:" + key;
Boolean lock = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(lock)) {
    try {
        // 查数据库,更新缓存
        Object data = queryFromDB(key);
        redisTemplate.opsForValue().set(key, data, 1, TimeUnit.HOURS);
    } finally {
        redisTemplate.delete(lockKey);
    }
} else {
    // 等待重试
    Thread.sleep(100);
    return getFromCache(key);
}

3.2.3 缓存雪崩

雪崩比击穿更可怕。大量key在同一时间过期,或者Redis整个挂了,所有请求直接打到数据库。系统瞬间瘫痪。

解决方案:

  • 过期时间加随机值:避免大量key同时过期。我习惯在基础过期时间上加一个随机数,比如1小时±10分钟。
  • 多级缓存:Redis挂了,还有本地缓存顶着。
  • 限流降级:数据库扛不住时,直接返回默认值或错误提示,别硬撑。

重要提醒:缓存雪崩的终极方案是「高可用」。Redis一定要做集群+持久化。我见过一个团队,Redis单节点部署,半夜机器宕机,第二天早上才发现,数据全丢了。那叫一个惨。

3.3 本地缓存与分布式缓存结合

很多同学觉得有了Redis就够了,干嘛还要本地缓存?你想想看,每次请求都走网络,哪怕延迟只有1ms,100万次请求就是1000秒。本地缓存直接走内存,延迟是纳秒级的。

我建议的方案是两级缓存

  • 一级缓存(本地):用Caffeine或Guava Cache。容量小,但速度极快。适合存热点数据。
  • 二级缓存(分布式):用Redis。容量大,支持集群。适合存全量数据。

查询流程是这样的:

  1. 先查本地缓存,命中直接返回。
  2. 本地没命中,查Redis。
  3. Redis也没命中,查数据库。
  4. 查到的数据,先写Redis,再写本地缓存。
// 两级缓存查询示例
public Object getData(String key) {
    // 1. 查本地缓存
    Object localData = localCache.getIfPresent(key);
    if (localData != null) {
        return localData;
    }
    
    // 2. 查Redis
    Object redisData = redisTemplate.opsForValue().get(key);
    if (redisData != null) {
        // 同步到本地缓存
        localCache.put(key, redisData);
        return redisData;
    }
    
    // 3. 查数据库
    Object dbData = queryFromDB(key);
    if (dbData != null) {
        redisTemplate.opsForValue().set(key, dbData, 1, TimeUnit.HOURS);
        localCache.put(key, dbData);
    }
    return dbData;
}

注意:本地缓存一定要设置上限。我习惯用Caffeine的maximumSize,比如10000条。不然内存会被撑爆。另外,本地缓存的过期时间要比Redis短,比如Redis设1小时,本地设5分钟。这样能保证数据最终一致性。

3.4 缓存预热与更新策略

缓存预热,说白了就是系统上线前,先把热点数据加载到缓存里。别等用户请求来了再慢慢加载,那体验太差了。

预热方案:

  • 定时任务预热:系统启动后,跑一个定时任务,把最近7天的活跃用户数据加载到缓存。
  • 手动触发预热:运营后台提供一个「刷新缓存」按钮,方便运营人员手动触发。
  • 懒加载预热:第一个请求触发加载,后续请求直接命中。适合数据量特别大的场景。

缓存更新策略,我总结了三种:

策略 说明 适用场景
Cache Aside 先更新数据库,再删除缓存 读多写少,一致性要求不高
Read/Write Through 缓存层代理数据库操作 需要强一致性
Write Behind 先更新缓存,异步写数据库 写性能要求极高,能接受数据丢失

我个人最常用的是Cache Aside模式。为什么?因为简单可靠。先更新数据库,再删缓存。下次查询时,缓存没命中,重新从数据库加载。这样能保证数据最终一致性。

避坑指南:我曾经犯过一个错误——先删缓存,再更新数据库。结果在并发场景下,另一个线程读到旧数据写回了缓存,导致缓存和数据库不一致。记住,一定是「先更新数据库,再删缓存」。

好了,关于缓存策略,咱们就聊这么多。总结一下:

  • 架构上,用Redis Cluster,避免大Key和热Key。
  • 防护上,布隆过滤器防穿透,互斥锁防击穿,过期时间加随机值防雪崩。
  • 协同上,本地缓存+Redis两级缓存,速度与容量兼顾。
  • 生命周期上,预热加载热点数据,Cache Aside保证一致性。

下一章,咱们聊聊数据库层面的优化。到时候见。