"凌晨3点,整个集群的QPS从2万暴跌到200,值班手机被打爆------就因为一个简单的expire设置。"
去年双十一大促前,我们给核心商品页的缓存加了TTL。本以为只是个小优化,没想到压测时Redis集群突然全瘫,数据库连接池被打满。事后复盘才发现:所有Key在同一秒失效 ,请求像雪崩一样砸向数据库。
今天我就拆解这次事故,说说Redis雪崩怎么防、怎么治。
1. 雪崩是怎么发生的?
我们的场景很典型:商品详情页缓存2万SKU,TTL统一设了10分钟。听起来没问题对吧?但压测时用redis-cli --bigkeys一看,发现所有Key的expire时间戳完全一致!
- 根因在RDB持久化 *:Redis默认的RDB快照会清空过期时间,重启后所有Key会同时加载 。如果你像我们一样用
EXPIRE key 600批量设置TTL,所有Key的死亡时间就被对齐了!
错误代码长这样:
java
// 批量设置缓存+过期时间(错误示范)
public void loadProducts(List<Product> products) {
try (Jedis jedis = jedisPool.getResource()) {
Pipeline pipeline = jedis.pipelined();
products.forEach(p -> {
pipeline.set("product:" + p.getId(), serialize(p));
pipeline.expire("product:" + p.getId(), 600); // 所有Key会在同一秒失效
});
pipeline.sync();
}
}
2. 解法:时间戳要加随机抖动
正确做法是在TTL上加随机值。比如600 ± 60秒,让Key的过期时间均匀分布:
java
// 正确的随机过期时间设置
private int getRandomTtl() {
return 600 + ThreadLocalRandom.current().nextInt(-60, 61); // 540~660秒随机
}
public void loadProductsSafe(List<Product> products) {
try (Jedis jedis = jedisPool.getResource()) {
Pipeline pipeline = jedis.pipelined();
products.forEach(p -> {
pipeline.set("product:" + p.getId(), serialize(p));
pipeline.expire("product:" + p.getId(), getRandomTtl()); // 关键点
});
pipeline.sync();
}
}
实测对比:
| 方案 | 缓存失效时的DB QPS峰值 | 恢复时间 |
|---|---|---|
| 固定TTL | 12,000 | >5分钟 |
| 随机TTL | 2,300 | <30秒 |
3. 深层优化:多级缓存与热Key续命
光改TTL还不够。我们后来补充了两个措施:
- 本地缓存兜底:用Caffeine做一级缓存,过期时间比Redis短(比如3分钟+随机),扛住Redis瞬时失效
- 异步热Key续期:通过定时任务扫描,提前续期高频访问的Key
java
// 热Key续期示例(简化版)
@Scheduled(fixedDelay = 300_000) // 每5分钟执行
public void extendHotKeys() {
hotKeyTracker.getTopKeys(100).forEach(key -> {
jedis.expire(key, getRandomTtl()); // 续期时也要加随机
});
}
4. 避坑清单:雪崩防御三板斧
- 永远不用固定TTL批量设置过期时间:这是最隐蔽的坑,RDB持久化会抹平时间差
- 监控
expired_keys突增 :redis-cli info里的这个指标突然上涨就是前兆 - 慎用
keys *命令扫全库 :大Key扫描用scan增量式迭代,否则可能引发连锁反应
5. 终极防御:熔断与降级
当雪崩真的发生时,快速失败比雪崩蔓延更重要:
java
// 基于Hystrix的降级示例
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(String id) {
String key = "product:" + id;
String json = jedis.get(key);
if (json == null) {
// 这里可以加互斥锁防击穿,但篇幅限制略过
return db.getProduct(id);
}
return deserialize(json);
}
private Product getProductFallback(String id) {
return defaultProduct; // 返回兜底数据
}
写在最后
Redis雪崩的可怕之处在于:它总在你最忙的时候来一把大的。经过这次教训,我们现在对所有批量操作TTL的代码都会Code Review三次------因为人永远会在同一个地方跌倒两次。
你的项目里是怎么预防雪崩的?有没有更骚的操作?评论区见。