缓存雪崩模式:当你的缓存集体“罢工”时
说实话,缓存雪崩这个问题,我职业生涯里遇到过两次。第一次是在一个电商大促的凌晨,系统突然扛不住了——数据库连接池被打满,接口响应时间从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() + " 条数据");
}
}
解决方案二:随机过期时间
这是最常用、也最有效的办法。核心思想很简单:不要让所有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 Sentinel或Redis 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节点宕机了,照样雪崩。多级缓存和集群高可用最好一起上。
- 预热数据要选对:不是所有数据都需要预热。我见过有人把全量用户数据都预热了,结果大部分是冷数据,白白浪费内存。
- 注意本地缓存的大小:本地缓存虽然快,但内存有限。我一般限制每个本地缓存不超过100MB,用LRU淘汰策略。
- 监控要到位:建议对缓存命中率、过期key数量、Redis内存使用率做实时监控。一旦发现命中率骤降,立刻告警。
总结一下:缓存雪崩不是单一问题,需要组合拳来解决。缓存预热解决冷启动,随机过期时间分散失效点,多级缓存提供多重保障,Redis集群确保服务不挂。这四招一起用,基本能挡住99%的雪崩场景。
公众号:蓝海资料掘金营,微信deep3321