4、缓存雪崩问题:定义与危害、解决方案

4.1 什么是缓存雪崩?

缓存雪崩,说白了就是大量缓存同时失效。

想象一下这个场景:你的系统里有一批热点数据,比如商品详情、用户信息,它们设置了相同的过期时间。某个时间点一到,这些缓存齐刷刷地过期了。紧接着,成千上万的请求发现缓存里没数据,一股脑全打到数据库上。

结果呢?数据库瞬间被压垮,连接池爆满,请求超时,服务不可用。这就是缓存雪崩。

我遇到过最夸张的一次,某个电商平台的大促活动,零点刚过,首页推荐数据的缓存全部过期。数据库 CPU 直接飙到 100%,整个系统瘫痪了将近 10 分钟。那损失,你想想看。

注意:缓存雪崩和缓存穿透不一样。穿透是查不到数据,雪崩是数据太多同时失效。前者是「没有」,后者是「太多」。

4.2 缓存雪崩的危害

危害其实很直接,我列几个关键点:

  • 数据库被打爆:大量请求同时涌入,数据库扛不住
  • 服务雪崩:一个服务挂了,依赖它的服务也跟着挂
  • 恢复困难:数据库重启后,又会有一波请求打过来,形成恶性循环
  • 用户体验极差:页面加载超时,用户直接流失

嗯,这里要注意,雪崩的破坏力往往比穿透大得多。穿透是零星请求,雪崩是海啸级别的。

4.3 解决方案一:随机过期时间

这是最常用的手段。思路很简单:不要让所有缓存同时过期。

具体做法是,在设置过期时间时,加一个随机偏移量。比如原本是 1 小时,现在改成 1 小时 ± 10 分钟。

// 伪代码示例
// 原本的做法
cache.set(key, value, 3600);

// 改进后的做法
int baseExpire = 3600; // 1小时
int randomOffset = new Random().nextInt(600) - 300; // ±5分钟
cache.set(key, value, baseExpire + randomOffset);

这样,缓存失效的时间点就分散开了。数据库的压力自然就小了。

我的经验:随机范围不要太大,一般控制在过期时间的 10%-20% 就行。太大可能导致部分缓存过早失效,太小又起不到分散效果。

4.4 解决方案二:多级缓存

多级缓存,说白了就是给数据多穿几层「防弹衣」。

常见的架构是两层:

  • 一级缓存(本地缓存):放在应用进程内,比如 Caffeine、Guava Cache
  • 二级缓存(分布式缓存):放在 Redis 里

请求来了,先查本地缓存。本地没有,再查 Redis。Redis 也没有,才去查数据库。

这样做的好处是:即使 Redis 里的缓存全部失效,本地缓存还能扛一阵子。而且本地缓存的过期时间可以设置得比 Redis 短一些,形成错峰。

// 多级缓存查询示例
public Object getData(String key) {
    // 1. 查本地缓存
    Object localData = localCache.get(key);
    if (localData != null) {
        return localData;
    }
    
    // 2. 查 Redis
    Object redisData = redisCache.get(key);
    if (redisData != null) {
        // 回填本地缓存
        localCache.put(key, redisData);
        return redisData;
    }
    
    // 3. 查数据库
    Object dbData = database.query(key);
    if (dbData != null) {
        // 回填 Redis 和本地缓存
        redisCache.put(key, dbData, 3600);
        localCache.put(key, dbData);
    }
    return dbData;
}
关键点:本地缓存要设置合理的容量上限,防止内存溢出。我一般控制在几百 MB 以内,具体看应用的内存配置。

4.5 其他辅助手段

除了上面两种,还有一些辅助方案:

方案 说明 适用场景
加锁/限流 缓存失效后,只允许一个线程去查数据库 高并发场景,防止数据库被打穿
缓存预热 提前加载热点数据到缓存 大促活动、定时任务
永不过期 缓存不设置过期时间,后台异步更新 数据变化不频繁的场景

我曾经在一个项目中用过「永不过期 + 异步更新」的方案。缓存永远不删,后台有个定时任务每隔一段时间去刷新数据。这样数据库的压力非常平稳,但缺点是实现复杂,而且数据有短暂的不一致。

4.6 实战代码示例

下面是一个完整的缓存雪崩防护示例,结合了随机过期时间和多级缓存:

import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

public class CacheAvalancheProtector {
    
    // 模拟本地缓存
    private ConcurrentHashMap<String, Object> localCache = new ConcurrentHashMap<>();
    // 模拟 Redis 缓存
    private ConcurrentHashMap<String, Object> redisCache = new ConcurrentHashMap<>();
    
    private Random random = new Random();
    
    public Object getData(String key) {
        // 1. 查本地缓存
        Object localData = localCache.get(key);
        if (localData != null) {
            return localData;
        }
        
        // 2. 查 Redis
        Object redisData = redisCache.get(key);
        if (redisData != null) {
            // 回填本地缓存,设置随机过期时间
            int expire = 300 + random.nextInt(60); // 5分钟 ± 30秒
            localCache.put(key, redisData);
            return redisData;
        }
        
        // 3. 查数据库(这里用 synchronized 模拟加锁)
        synchronized (this) {
            // 双重检查,防止重复查询
            redisData = redisCache.get(key);
            if (redisData != null) {
                return redisData;
            }
            
            Object dbData = queryDatabase(key);
            if (dbData != null) {
                // Redis 也设置随机过期时间
                int redisExpire = 3600 + random.nextInt(600); // 1小时 ± 5分钟
                redisCache.put(key, dbData);
                
                int localExpire = 300 + random.nextInt(60);
                localCache.put(key, dbData);
            }
            return dbData;
        }
    }
    
    private Object queryDatabase(String key) {
        // 模拟数据库查询
        return "data_" + key;
    }
}
避坑指南:我曾经在加锁时用了分布式锁,结果锁本身成了瓶颈。后来发现,对于单机应用,用 synchronized 就够了。分布式场景才需要用 Redis 分布式锁。

4.7 总结

缓存雪崩的解决方案,核心就两句话:

  • 分散失效时间:用随机过期时间,别让缓存同时过期
  • 增加防护层:用多级缓存,让本地缓存扛住第一波冲击

实际项目中,我一般会组合使用这两种方案。随机过期时间成本最低,先加上。如果业务对可用性要求高,再上多级缓存。至于加锁和限流,看具体情况,别过度设计。

嗯,最后提醒一句:缓存雪崩的预防比事后补救重要得多。等数据库被打爆了再想方案,那就晚了。