缓存雪崩:当Redis集体“罢工”时

缓存雪崩,说白了就是大量缓存同时失效,或者Redis直接挂了。这时候所有请求像洪水一样涌向数据库,数据库扛不住就崩了。

我印象特别深,有一次凌晨三点被电话吵醒。用户反馈系统完全打不开,登录一看,数据库CPU 100%,连接数爆满。查了半天才发现,原来是所有缓存key设置了相同的过期时间,凌晨两点同时过期了。嗯,这就是典型的缓存雪崩。

雪崩的成因分析

为什么会发生雪崩?我总结下来主要有三种情况:

  • 大量key同时过期:比如你给一批热点数据统一设置了1小时过期,那1小时后它们集体消失。请求全部穿透到数据库。
  • Redis实例宕机:单节点Redis挂了,缓存层直接消失。所有请求打到后端。
  • 网络分区或故障:Redis集群和客户端之间网络断了,缓存不可用。

你想想看,正常情况下数据库每秒能扛2000请求。缓存雪崩时,瞬间涌入几万请求,数据库不崩才怪。

⚠️ 注意:缓存雪崩和缓存穿透不一样。穿透是查不到数据,雪崩是缓存层整体失效。前者是“漏”,后者是“塌”。

缓存过期时间随机化

这是最直接的解决方案。我个人的习惯是,在设置过期时间时加一个随机偏移量。

// 基础过期时间:3600秒(1小时)
// 加上随机偏移:0~600秒(0~10分钟)
int baseExpire = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(600);
int finalExpire = baseExpire + randomOffset;

redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);

这样做的好处很明显:key不会在同一时刻集体过期。它们会分散在1小时到1小时10分钟之间陆续失效。数据库的压力就被平摊了。

我曾经在一个电商项目中,商品详情页的缓存全部设置了相同的1小时过期。结果整点的时候,数据库CPU直接飙到90%。后来改成随机化,CPU稳定在30%以下。就这么简单的一个改动,效果立竿见影。

多级缓存架构(本地缓存+Redis)

光靠Redis一层不够保险。我建议再加一层本地缓存,比如Caffeine或Guava Cache。

架构是这样的:

  1. 请求先查本地缓存(JVM内存)
  2. 本地缓存没有,再查Redis
  3. Redis也没有,才查数据库
  4. 查到的数据,同时写入本地缓存和Redis

这样做的好处是,即使Redis挂了,本地缓存还能扛一阵子。本地缓存的过期时间通常设得比Redis短,比如Redis设1小时,本地缓存设5分钟。

// 使用Caffeine作为本地缓存
Cache<String, Object> localCache = Caffeine.newBuilder()
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .maximumSize(10000)
    .build();

// 查询逻辑
public Object getData(String key) {
    // 1. 查本地缓存
    Object localResult = localCache.getIfPresent(key);
    if (localResult != null) {
        return localResult;
    }
    
    // 2. 查Redis
    Object redisResult = redisTemplate.opsForValue().get(key);
    if (redisResult != null) {
        localCache.put(key, redisResult);
        return redisResult;
    }
    
    // 3. 查数据库
    Object dbResult = queryFromDatabase(key);
    if (dbResult != null) {
        redisTemplate.opsForValue().set(key, dbResult, 1, TimeUnit.HOURS);
        localCache.put(key, dbResult);
    }
    return dbResult;
}
💡 小提示:本地缓存不要设太大,毕竟占的是JVM内存。我一般控制在1万条以内,每条数据不超过1KB。否则容易引发Full GC。

Redis高可用部署(主从+哨兵)

单节点Redis太脆弱了。我建议至少用主从架构加哨兵模式。

主从架构:一台主节点负责写,多台从节点负责读。主节点挂了,从节点可以顶上。

哨兵模式:哨兵负责监控主节点状态。如果主节点挂了,哨兵自动从从节点中选举一个新的主节点。整个过程对客户端透明。

组件 职责 数量建议
主节点 处理写请求,同步数据到从节点 1个
从节点 处理读请求,备份数据 2~3个
哨兵 监控、通知、自动故障转移 3个(奇数)

配置示例:

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

这里有个坑:哨兵数量必须是奇数。为什么?因为哨兵之间需要投票选举新主节点,奇数才能保证多数票。我曾经见过有人只部署了2个哨兵,结果网络抖动时两边各执一词,选举失败。嗯,这个教训挺深刻的。

限流与熔断机制

即使做了上面所有措施,极端情况下缓存还是可能崩。这时候需要限流和熔断来保护数据库。

限流:控制单位时间内的请求量。比如每秒最多允许1000个请求打到数据库。

// 使用Guava RateLimiter做限流
RateLimiter limiter = RateLimiter.create(1000.0); // 每秒1000个令牌

public Object getDataWithRateLimit(String key) {
    // 尝试获取令牌,等待100ms
    boolean acquired = limiter.tryAcquire(100, TimeUnit.MILLISECONDS);
    if (!acquired) {
        // 限流了,返回降级数据或错误提示
        return fallbackData(key);
    }
    // 正常查询逻辑
    return getData(key);
}

熔断:当错误率达到阈值时,直接断开后续请求,快速失败。等恢复后再慢慢放行。

// 使用Hystrix或Resilience4j做熔断
@CircuitBreaker(name = "redisCache", fallbackMethod = "fallback")
public Object getDataWithCircuitBreaker(String key) {
    return getData(key);
}

public Object fallback(String key, Throwable t) {
    // 返回默认值或空数据
    return "系统繁忙,请稍后重试";
}
🔑 核心要点:限流是“慢下来”,熔断是“停下来”。两者配合使用,效果最好。我一般把限流放在最外层,熔断放在内层。限流挡住大部分流量,熔断处理异常情况。

最后说一句,缓存雪崩的解决方案不是选一个,而是组合使用。随机化过期时间 + 多级缓存 + 高可用部署 + 限流熔断,四管齐下才能万无一失。我在生产环境中就是这样配置的,至今没再出过雪崩事故。