本文目录导读:

下面是一个缓存雪崩的完整解决案例,包含问题背景、分析、核心解决方案(代码实现)以及优化后的架构图,方便你直接用于技术方案设计或面试讲解。
案例背景(业务场景)
业务场景:某电商平台首页,为了提升性能,将首页商品列表、轮播图、广告位等数据缓存在 Redis 中(缓存时间为固定 30 分钟)。 现象:某日凌晨 2 点,数据库突然遭受每秒上万次的查询请求,CPU 瞬间飙升至 100%,导致数据库连接池耗尽,服务不可用(宕机)。
事故原因分析
排查发现,凌晨 2 点是批处理任务统一更新数据的时间,所有缓存键都设置了相同的过期时间(30分钟)。 由于大量缓存同时过期(或 Redis 实例在某一刻重启崩溃),大量请求同时穿透缓存直达数据库,这就是典型的缓存雪崩。
核心解决方案(代码 + 架构级)
针对上述原因,我们采用了四种方案叠加,彻底解决了问题。
方案 1:过期时间加随机数(治标——防止同时失效)
核心思想:让缓存过期时间不是一个固定值,而是基础时间 + 随机值,这样缓存不会在同一秒集体失效。 代码示例(Java + Spring Boot):
@Service
public class HomePageService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
// 缓存键前缀
private static final String PRODUCT_KEY = "product:home:";
public List<Product> getHomeProducts() {
String key = PRODUCT_KEY + "list";
// 1. 从缓存读取
String json = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(json)) {
return JSON.parseArray(json, Product.class);
}
// 2. 缓存失效,加锁(防止并发击穿)
// 此处使用分布式锁,下文会提到,这里只展示缓存重建策略
List<Product> products = productMapper.getHomeList();
// 3. 【关键】设置缓存过期时间:基础 30 分钟 + 0~300 秒的随机数
int baseTime = 30 * 60; // 30分钟
int randomTime = new Random().nextInt(5 * 60); // 0 - 5分钟随机
redisTemplate.opsForValue().set(key, JSON.toJSONString(products),
baseTime + randomTime, TimeUnit.SECONDS);
return products;
}
}
方案 2:互斥锁(分布式锁——防止雪崩瞬间打垮数据库)
核心思想:如果缓存没命中,只有一个线程能去数据库查询并重建缓存,其他线程等待一段时间后重试,这能防止雪崩时的请求全部打到数据库。 代码示例(Redisson 分布式锁):
public List<Product> getHomeProductsWithLock() {
String key = "product:home:list";
String lockKey = "lock:" + key;
// 1. 先查缓存
String json = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(json)) {
return JSON.parseArray(json, Product.class);
}
// 2. 缓存不存在,获取分布式锁(只允许一个线程去查库)
RLock lock = redissonClient.getLock(lockKey);
List<Product> products = null;
try {
// 尝试加锁,等待 3 秒,锁 10 秒自动释放
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// 双重校验(防止在等待锁期间,其他线程已经重建了缓存)
json = redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(json)) {
return JSON.parseArray(json, Product.class);
}
// 真正去数据库查询
products = productMapper.getHomeList();
// 设置缓存过期时间(带随机数)
redisTemplate.opsForValue().set(key, JSON.toJSONString(products),
30*60 + new Random().nextInt(300), TimeUnit.SECONDS);
} else {
// 没拿到锁,说明其他线程在重建缓存,睡 50ms 后重试(递归调用自己)
Thread.sleep(50);
return getHomeProductsWithLock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return products;
}
方案 3:Redis 集群高可用(治本——防止 Redis 宕机)
解决方案:放弃单机 Redis,改为主从 + 哨兵模式,或者直接使用 Redis Cluster 或云厂商的 Redis 服务。
- 目的:即使一台 Redis 节点宕机,系统会自动切换到从节点或者通过分片机制保证缓存服务持续可用。
方案 4:服务熔断与降级(防线——兜底方案)
核心思想:万一数据库真的扛不住或者网络异常,主动降级,返回默认的兜底数据(如空列表或固定推荐商品),而不是让请求直接穿透到数据库。
public List<Product> getHomeProductsFallback() {
try {
// 正常查询缓存,超时时间设为 200ms
String json = redisTemplate.opsForValue().get("product:home:list", 200, TimeUnit.MILLISECONDS);
if (StringUtils.hasText(json)) {
return JSON.parseArray(json, Product.class);
}
} catch (Exception e) {
log.error("Redis 查询异常,触发降级逻辑");
}
// 数据库可用时,去查数据库;数据库也不可用时,返回兜底数据
try {
return productMapper.getHomeList();
} catch (Exception dbException) {
log.error("DB异常,返回兜底数据");
// 返回预设的静态热门商品列表(放在类变量中)
return DEFAULT_HOT_PRODUCTS;
}
}
优化后的解决方案总览(架构图)
结合以上方案,最终的流程如下:
用户请求
│
▼
【Controller】
│
▼
【缓存查询层】
├── 1. 查询 Redis (超时时间 200ms)
│
├── 命中? ──── Yes ────► 直接返回(走缓存,不查DB)
│
└── Miss (未命中)
│
▼
【互斥锁 + 二级缓存(本地缓存)】
│ (只有拿到锁的线程进入)
▼
查询 MySQL
│
▼
写入 Redis(并设置【基础时间+随机数】过期)
│
▼
返回结果
【边缘防护】(保障数据源安全)
├── Redis Cluster (高可用,避免单点故障)
├── 数据库连接池限流(如 HikariCP 设置最大连接数)
└── Sentinel 熔断降级(当错误率达到阈值,直接返回兜底数据)
解决后的效果
- 性能:数据库 QPS 从故障时的 10000+ 降为 0(正常时 <100),缓存命中率恢复至 99.9%。
- 稳定性:即使批量任务更新导致缓存数据刷新,由于过期时间分散,数据库永远只承受最低限度的查询压力。
- 容灾能力:即使 Redis 集群部分宕机,由于有本地缓存和兜底数据,用户在页面上依然能看到内容,体验无感知。
技术要点总结(面试/方案汇报可用)
- 散列过期时间(加随机数)是所有方案中成本最低、见效最快的。
- 互斥锁 是保证数据一致性和防止数据库瞬间被打垮的关键。
- 高可用集群 是底线,防止硬件层面的灾难。
- 降级策略 是最后一道防线,保证用户体验不中断。