本文目录导读:

- 什么是缓存击穿?先看一个案例
- 核心解决思路
- 方案一:加互斥锁(Mutex Lock) ⭐️⭐️⭐️⭐️⭐️
- 方案二:逻辑过期 + 异步重建 ⭐️⭐️⭐️⭐️
- 方案三:热点 key 永不过期(物理层面) ⭐️⭐️⭐️
- 方案四:缓存预热 + 打散过期时间 ⭐️⭐️
- 实战建议(按场景选择)
- 避坑指南(经验之谈)
- 面试话术总结
这是一个非常经典的面试题,也是线上高并发场景中容易踩的坑,我会从一个真实的业务案例入手,再给出从简单到复杂的完整解决方案。
什么是缓存击穿?先看一个案例
场景假设
假设你有一个电商系统,商品详情页的缓存策略是:
- 用户请求商品ID为
9527的详情。 - Redis 中查询 key =
product:9527。 - 如果缓存中有,直接返回;如果没有,去 MySQL 查询,然后回写 Redis,并设置过期时间(1 小时)。
问题爆发
某天,运营在后台手动删除了 product:9527 的缓存(或者该缓存恰好到了过期时间)。
- 突然涌入 10 万个用户 同时请求该商品详情。
- 10万个请求全部发现缓存中没有
product:9527。 - 于是这 10万个请求同时涌入 MySQL 去查询数据库。
- MySQL 瞬间连接打满、CPU 飙升,导致数据库崩溃。
这就是缓存击穿
定义:一个热点 key 在缓存失效的瞬间,大量并发请求直接打到数据库,导致数据库压力暴增甚至宕机。
核心解决思路
核心就一句话:不让大量请求同时通过缓存缺口直达数据库。
具体有以下几种主流方案:
方案一:加互斥锁(Mutex Lock) ⭐️⭐️⭐️⭐️⭐️
这是最常用、最有效的方法,保证同一时刻,只有一个线程去数据库查询并重建缓存,其他线程等待。
实现方式(基于 Redis SETNX 或 Redisson)
public String getProductDetail(String productId) {
String cacheKey = "product:" + productId;
String value = redis.get(cacheKey);
if (value != null) {
return value; // 缓存命中
}
// 开始:缓存未命中,尝试加锁
String lockKey = "lock:product:" + productId;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS); // 加锁并设置过期时间
if (locked) {
try {
// 二次检查:可能别的线程已经重建了缓存
value = redis.get(cacheKey);
if (value != null) {
return value;
}
// 去数据库查询
value = queryDB(productId);
redis.set(cacheKey, value, 3600, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 未获得锁的线程,等待后重试(或者自旋)
Thread.sleep(100);
return getProductDetail(productId); // 递归重试
}
}
优缺点
| 优点 | 缺点 |
|---|---|
| 简单可靠,保证数据库安全 | 牺牲了一点可用性(少量等待) |
| 适用于绝大多数场景 | 如果锁实现不好,可能死锁 |
逻辑过期 + 异步重建 ⭐️⭐️⭐️⭐️
不设置物理过期时间,而是缓存一个逻辑过期时间,当发现逻辑过期时,只让一个线程去重建,其他线程继续返回旧数据。
实现方式
public String getProductDetail(String productId) {
String cacheKey = "product:" + productId;
String cacheData = redis.get(cacheKey);
if (cacheData == null) {
return null; // 或者直接查DB返回(理论上不会发生)
}
// 解析出业务数据和过期时间
ProductCache cache = parseCache(cacheData);
if (cache.getExpireTime() > System.currentTimeMillis()) {
return cache.getData(); // 未过期,直接返回
}
// 逻辑过期:尝试获取锁,异步重建
String lockKey = "rebuild:product:" + productId;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
// 异步线程池去重建缓存
executorService.submit(() -> {
String newData = queryDB(productId);
ProductCache newCache = new ProductCache(newData, System.currentTimeMillis() + 3600*1000);
redis.set(cacheKey, JSON.toJSONString(newCache));
redis.del(lockKey);
});
}
// 无论是否获得锁,都返回旧数据
return cache.getData();
}
优缺点
| 优点 | 缺点 |
|---|---|
| 极高性能,不阻塞任何请求 | 数据短暂不一致(旧数据) |
| 抗高并发 | 实现稍复杂 |
热点 key 永不过期(物理层面) ⭐️⭐️⭐️
对于明确知道的热点 key,不给它设置过期时间,但为了避免数据永久不更新,可以后台定时更新。
实现方式
// 1. 初始化时,永久缓存热点数据
redis.set("product:9527", queryDB("9527")); // 不设过期时间
// 2. 后台定时任务,每隔一段时间更新缓存
@Scheduled(fixedDelay = 10 * 60 * 1000) // 每10分钟
public void refreshHotProduct() {
String productId = "9527";
String newData = queryDB(productId);
redis.set("product:" + productId, newData);
}
优缺点
| 优点 | 缺点 |
|---|---|
| 绝对不会击穿 | 占用内存 |
| 实现极简单 | 热点 key 需要人工识别 |
| 如果定时任务挂了,数据会陈旧 |
缓存预热 + 打散过期时间 ⭐️⭐️
在系统启动或活动开始前,手动将热点数据提前加载到缓存,并给过期时间增加随机偏移。
// 不设置统一的3600秒,而是随机波动 int baseExpire = 3600; int randomDelta = new Random().nextInt(300); // 0~300秒随机 redis.set(key, value, baseExpire + randomDelta, TimeUnit.SECONDS);
目的
避免大量热点 key 同时过期,从而降低击穿概率。
实战建议(按场景选择)
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 高一致性要求(如余额、库存) | 互斥锁 | 保证数据最新、最准确 |
| 并发极高,可接受短暂脏读(如首页、商品列表) | 逻辑过期+异步重建 | 性能最好,不阻塞 |
| 已知的极少热点 key | 永不过期+定时更新 | 最简单,最可靠 |
| 一般业务系统,不确定热点 | 随机过期时间 | 低成本防范 |
避坑指南(经验之谈)
- 别用 Java 内置锁(synchronized) → 分布式环境下无效,需要用 Redis 分布式锁。
- 加锁一定要设置过期时间 → 防止死锁(SETNX + EXPIRE要原子操作或用 Redisson)。
- DB 查询要加熔断/限流 → 即使有缓存保护,也要防止查询把 DB 打满(比如使用 Hystrix / Sentinel)。
- 监控热点 key → 提前识别哪些 key 访问量最高,可以主动做缓存预热或者手动设置永不过期。
面试话术总结
“针对缓存击穿,我一般首选互斥锁方案,当缓存失效时,只会有一个线程去数据库查询并重建缓存,其他线程等待或自旋重试。
如果业务对少量数据不一致不敏感且并发极高,我会用逻辑过期+异步重建方案,性能更好。
同时我会配合缓存预热和过期时间随机化来降低击穿概率。
会用 Redisson 实现分布式锁,避免死锁,并配合 Sentinel 做 DB 限流保护。”
这样的回答既有深度又有实战经验,面试官通常会很满意。