缓存雪崩模式:当你的缓存集体“罢工”时

说实话,缓存雪崩这个问题,我职业生涯里遇到过两次。第一次是在一个电商大促的凌晨,系统突然扛不住了——数据库连接池被打满,接口响应时间从10ms飙升到30秒。排查下来,发现是大量缓存key在同一时间过期,请求全部穿透到了数据库。嗯,那次教训挺深刻的。

缓存雪崩,说白了就是:大量缓存数据在同一时刻失效,导致所有请求直接打到数据库,数据库扛不住,系统就崩了。这和缓存穿透不一样——穿透是查不存在的数据,雪崩是数据存在但缓存没了。

雪崩是怎么发生的?

我总结下来,主要有三种场景:

  • 批量过期:比如你给一批商品设置了相同的过期时间(比如凌晨0点),一到点全部失效
  • 缓存服务宕机:Redis挂了,所有缓存数据瞬间消失
  • 热点数据集中失效:某个大V发了一条微博,大量用户同时访问,缓存刚好过期

你想想看,平时数据库能扛1000 QPS,缓存扛1万 QPS。缓存一挂,1万 QPS全压到数据库上,数据库不崩才怪。

解决方案一:缓存预热

缓存预热,就是在系统上线或重启之前,先把热点数据加载到缓存里。别等到用户请求来了才去查数据库。

我个人习惯的做法是:写一个预热脚本,在应用启动时执行。比如Spring Boot项目里,用@PostConstruct或者ApplicationRunner来做。

// Java示例:缓存预热
@Component
public class CachePreloader implements ApplicationRunner {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private ProductService productService;

    @Override
    public void run(ApplicationArguments args) {
        // 加载前1000个热门商品
        List<Product> hotProducts = productService.getHotProducts(1000);
        for (Product product : hotProducts) {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
        }
        System.out.println("缓存预热完成,共加载 " + hotProducts.size() + " 条数据");
    }
}
我的经验:预热时别一股脑全加载。先分析一下访问日志,找出真正的热点数据。我曾经见过有人把全量数据都预热了,结果Redis内存爆了,反而引发雪崩。

解决方案二:随机过期时间

这是最常用、也最有效的办法。核心思想很简单:不要让所有key在同一时刻过期。给过期时间加一个随机偏移量。

// Java示例:设置随机过期时间
public void setWithRandomExpire(String key, Object value, long baseExpire) {
    // 基础过期时间上,加一个0~600秒的随机值
    long randomExpire = baseExpire + ThreadLocalRandom.current().nextLong(0, 600);
    redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
}
// C++示例:使用std::mt19937生成随机过期时间
void setWithRandomExpire(const std::string& key, 
                         const std::string& value, 
                         long baseExpire) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(0, 600);
    
    long randomExpire = baseExpire + dis(gen);
    redisClient.set(key, value, randomExpire);
}

这样做的好处是:过期时间分散开了,不会出现“集体阵亡”的情况。我在项目中一般把随机范围设为基础过期时间的10%~20%,效果不错。

解决方案三:多级缓存

多级缓存,就是给系统加几道“防线”。我常用的架构是:本地缓存 + Redis + 数据库

  • 一级缓存(本地):Caffeine或Guava Cache,放在应用进程内,访问最快
  • 二级缓存(分布式):Redis,多个应用共享
  • 三级存储:数据库,兜底方案

查询流程是这样的:先查本地缓存 → 没命中查Redis → 还没命中查数据库 → 查到后逐级回填。

// Java示例:多级缓存查询
public Product getProduct(Long id) {
    String key = "product:" + id;
    
    // 1. 查本地缓存
    Product product = localCache.getIfPresent(key);
    if (product != null) {
        return product;
    }
    
    // 2. 查Redis
    product = (Product) redisTemplate.opsForValue().get(key);
    if (product != null) {
        // 回填本地缓存
        localCache.put(key, product);
        return product;
    }
    
    // 3. 查数据库
    product = productMapper.selectById(id);
    if (product != null) {
        // 回填Redis和本地缓存
        redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
        localCache.put(key, product);
    }
    
    return product;
}
注意:多级缓存要处理好数据一致性问题。我曾经遇到过本地缓存和Redis数据不一致的情况,排查了半天才发现是更新操作只更新了Redis,忘了清本地缓存。建议在更新数据时,同时失效所有级别的缓存。

解决方案四:Redis集群高可用

如果Redis本身挂了,前面那些方案都白搭。所以Redis集群的高可用是最后一道防线。

我常用的方案是Redis SentinelRedis Cluster

方案 适用场景 特点
Redis Sentinel 数据量不大,但要求高可用 主从切换,自动故障恢复
Redis Cluster 数据量大,需要水平扩展 分片存储,自动负载均衡

配置Sentinel的示例:

# sentinel.conf
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000

Java客户端连接Sentinel:

// Java:使用Jedis连接Sentinel
Set<String> sentinels = new HashSet<>();
sentinels.add("192.168.1.1:26379");
sentinels.add("192.168.1.2:26379");

JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels);
try (Jedis jedis = pool.getResource()) {
    jedis.set("key", "value");
}

知识体系总览

下面这张图,是我梳理的缓存雪崩应对策略全景图。你可以把它当作一个检查清单——每次设计缓存系统时,对照着看看有没有遗漏。

缓存雪崩应对策略全景图 缓存雪崩 缓存预热 随机过期时间 多级缓存 Redis集群高可用 启动时加载热点数据 避免冷启动穿透 基础过期时间 + 随机偏移 分散失效时间点 本地缓存 + Redis + DB 逐级回填,层层防护 Sentinel / Cluster 自动故障转移

避坑指南

最后,分享几个我踩过的坑:

  • 不要只依赖一种方案:我曾经在一个项目里只用了随机过期时间,结果Redis节点宕机了,照样雪崩。多级缓存和集群高可用最好一起上。
  • 预热数据要选对:不是所有数据都需要预热。我见过有人把全量用户数据都预热了,结果大部分是冷数据,白白浪费内存。
  • 注意本地缓存的大小:本地缓存虽然快,但内存有限。我一般限制每个本地缓存不超过100MB,用LRU淘汰策略。
  • 监控要到位:建议对缓存命中率、过期key数量、Redis内存使用率做实时监控。一旦发现命中率骤降,立刻告警。

总结一下:缓存雪崩不是单一问题,需要组合拳来解决。缓存预热解决冷启动,随机过期时间分散失效点,多级缓存提供多重保障,Redis集群确保服务不挂。这四招一起用,基本能挡住99%的雪崩场景。


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