缓存雪崩:当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。
架构是这样的:
- 请求先查本地缓存(JVM内存)
- 本地缓存没有,再查Redis
- Redis也没有,才查数据库
- 查到的数据,同时写入本地缓存和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;
}
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 "系统繁忙,请稍后重试";
}
最后说一句,缓存雪崩的解决方案不是选一个,而是组合使用。随机化过期时间 + 多级缓存 + 高可用部署 + 限流熔断,四管齐下才能万无一失。我在生产环境中就是这样配置的,至今没再出过雪崩事故。