本文目录导读:

这是一个非常经典的分布式系统高并发问题,缓存雪崩发生时,大量请求直接打到数据库,极易导致数据库崩溃,进而引发整个系统不可用。
下面从原因分析、预防措施、事后处理三个层面,结合具体案例和Java代码实现,来拆解如何应对缓存雪崩。
什么是缓存雪崩?核心原因是什么?
定义:缓存中的数据在同一时刻大面积过期,导致大量请求无法命中缓存,直接涌入数据库,数据库瞬间压力过大甚至宕机。
核心原因:
- 缓存数据同时失效:大量key设置了相同的过期时间(如统一设置为0点过期)。
- 缓存节点宕机:Redis集群中部分节点挂掉,导致部分热点key不可用。
典型案例
场景:电商APP首页的商品分类、推荐列表、热门活动等数据,缓存在Redis中,过期时间统一设为24小时(凌晨0点过)。
现象:
- 凌晨0点0分0秒,所有缓存同时失效。
- 此时百万用户刷新首页 -> 所有请求穿透到MySQL -> 数据库连接池打满 -> 请求堆积 -> MySQL崩溃 -> 整个首页不可用。
预防措施(核心)
预防的思路是避免大量key同时失效并保护数据库。
设置随机过期时间(最基础且有效)
给缓存过期时间增加一个随机值,避免集中失效。
import redis.clients.jedis.Jedis;
import java.util.Random;
public class CacheService {
private Jedis jedis = new Jedis("localhost");
private Random random = new Random();
private static final int BASE_EXPIRE = 3600; // 基础过期时间:1小时
public void setRandomExpire(String key, String value) {
// 基础过期时间 + 0~600秒的随机偏移
int expireTime = BASE_EXPIRE + random.nextInt(600);
jedis.setex(key, expireTime, value);
}
}
优点:实现简单,适合大多数场景。
使用二级缓存(本地缓存 + 分布式缓存)
使用本地缓存(如Caffeine、Guava Cache)作为一级缓存,Redis作为二级缓存,本地缓存不集中失效,能有效拦截第一波流量。
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
public class MultiLevelCache {
// 一级:本地缓存(过期时间短,且随机)
private Cache<String, Object> localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS) // 本地缓存10秒过期
.maximumSize(10000)
.build();
// 二级:Redis
private CacheService redisService = new CacheService();
public Object get(String key) {
// 1. 查本地缓存
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return localValue;
}
// 2. 查Redis
Object redisValue = redisService.get(key);
if (redisValue != null) {
// 写入本地缓存
localCache.put(key, redisValue);
return redisValue;
}
// 3. 查数据库(加互斥锁保护)
return loadFromDB(key);
}
private Object loadFromDB(String key) {
// ... 数据库查询逻辑(建议配合分布式锁)
return null;
}
}
缓存永不过期 + 后台异步更新(热点数据利器)
只设置逻辑过期时间,物理上永不过期,后台线程定期检查并刷新缓存。
public class HotCacheManager {
private Jedis jedis;
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
public void setHotData(String key, String value, int logicalExpireSeconds) {
// 存储数据 + 逻辑过期时间戳
String cacheValue = value + "|" + (System.currentTimeMillis() + logicalExpireSeconds * 1000);
jedis.set(key, cacheValue); // 物理上永不过期
// 启动后台线程,逻辑过期前主动刷新(比如提前10秒)
scheduler.schedule(() -> refreshCache(key), logicalExpireSeconds - 10, TimeUnit.SECONDS);
}
public String getHotData(String key) {
String cacheValue = jedis.get(key);
if (cacheValue == null) {
return null;
}
// 解析逻辑过期时间
String[] parts = cacheValue.split("\\|");
long expireTime = Long.parseLong(parts[1]);
// 如果逻辑过期,触发异步更新(但不阻塞当前请求)
if (System.currentTimeMillis() > expireTime) {
scheduler.submit(() -> refreshCache(key));
}
return parts[0]; // 返回旧数据
}
private void refreshCache(String key) {
// 1. 加分布式锁,防止重复刷新
// 2. 从数据库加载最新数据
// 3. 更新缓存(重新设置逻辑过期时间)
System.out.println("Refreshing cache for key: " + key);
}
}
互斥锁(缓存重建时保护数据库)
当缓存失效时,只允许一个线程去查数据库,其他线程等待。
public class CacheWithMutex {
private Jedis jedis;
private static final String LOCK_KEY_PREFIX = "LOCK:";
public String get(String key) {
String value = jedis.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = LOCK_KEY_PREFIX + key;
String lockValue = UUID.randomUUID().toString();
// SET NX EX 10:不存在才设置,过期10秒(防止死锁)
String result = jedis.set(lockKey, lockValue, "NX", "EX", 10);
if ("OK".equals(result)) {
// 成功获取锁
try {
// 双重检查:可能其他线程已经重建了缓存
value = jedis.get(key);
if (value != null) {
return value;
}
// 查数据库
value = queryDB(key);
// 写入缓存
jedis.setex(key, 3600, value);
return value;
} finally {
// 释放锁(用Lua脚本保证原子性)
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(luaScript, 1, lockKey, lockValue);
}
} else {
// 未获取到锁,等待后重试
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return get(key); // 递归重试(注意可能栈溢出,建议用循环)
}
}
private String queryDB(String key) {
// 模拟数据库查询
return "data_from_db";
}
}
缓存节点宕机怎么办?
如果是因为Redis集群节点宕机,不是key同时过期。
Redis集群高可用
- 主从架构 + Sentinel:自动故障转移。
- Redis Cluster:数据分片,部分节点宕机不影响整体。
本地缓存兜底(更上一层保险)
即使Redis完全不可用,本地缓存还能扛住一段时间。
限流熔断(保护数据库的最后防线)
- Sentinel(Hystrix/Sentinel限流组件):在数据库访问层设置限流阈值。
- 当请求量超过阈值时,直接返回降级数据(如“系统繁忙,请稍后重试”)或默认数据。
// 使用Sentinel做限流示例(伪代码)
@SentinelResource(value = "queryDB", blockHandler = "blockHandler")
public String queryDB(String key) {
// 实际数据库查询
return "real_data";
}
public String blockHandler(String key, BlockException e) {
// 降级返回默认值
return "default_data";
}
最佳实践组合
针对缓存雪崩,推荐按以下优先级组合使用:
| 场景 | 方案 | 复杂度 | 效果 |
|---|---|---|---|
| 通用预防 | 设置随机过期时间 | 低 | 有效避免集中失效 |
| 热点key | 永不过期 + 异步刷新 | 中 | 彻底解决过期问题 |
| 高安全要求 | 二级缓存(本地+Redis) | 中 | 多一层缓冲 |
| 并发大时 | 互斥锁重建缓存 | 中 | 保护数据库 |
| 硬件故障 | Redis高可用(Cluster/Sentinel) | 高 | 应对宕机 |
| 极限保护 | 限流、降级、熔断 | 中 | 防止系统崩溃 |
日常开发建议:
- 新项目:优先采用
随机过期时间+本地缓存组合,简单可靠。 - 高并发核心链路:引入
后台异步刷新+互斥锁,保证缓存一直存在且数据最终一致。 - 最终防线:所有数据库访问层必须配置
限流和熔断,避免缓存失效时系统被拖垮。
通过以上措施,可以有效将缓存雪崩的概率降到极低,即使发生,系统也能平稳降级,不会直接崩溃。