介绍一下Redis的缓存穿透击穿雪崩
介绍一下Redis的缓存穿透击穿雪崩
面试官您好,我会从定义、产生原因、核心解决方案三个维度,结合实际业务场景来讲解这三个高频 Redis 问题,同时会说明它们的本质区别、生产环境最佳实践、核心代码实现以及对应的技术难点。
缓存穿透 🔍
定义:请求的数据既不在 Redis 缓存,也不在数据库,导致每次请求都穿透缓存直接打到数据库,数据库压力骤增。
产生原因:
- 恶意攻击:黑客构造大量不存在的 ID 请求(如 ID=-1、ID=999999)
- 业务 bug:参数校验不严格,传入非法值
- 数据未同步:数据库已删除但缓存未清理
核心解决方案:
- 布隆过滤器(首选):将所有存在的 key 预存入布隆过滤器,请求先过过滤器,不存在直接拦截
- 空值缓存:对数据库查询为空的 key,缓存一个空值并设置短过期时间(如 5 分钟)
- 接口层校验:对入参做合法性校验(如 ID>0、格式校验)
缓存击穿 💥
定义:单个热点 key 突然过期,瞬间有大量并发请求打到数据库,数据库瞬间被打垮。
产生原因:
- 秒杀商品、热门文章、爆款视频等超高访问量的 key
- 热点 key 设置了过期时间,到期时恰好有大量请求
核心解决方案:
- 互斥锁(保证一致性):使用 Redis 的 SETNX 加锁,只有一个请求去查数据库并更新缓存,其他请求等待重试
- 逻辑过期(高性能):热点 key 不设置物理过期时间,在 value 中存过期时间,后台异步线程更新缓存
- 热点 key 永不过期:对于极端热点 key,直接不设置过期时间,后台定时更新
缓存雪崩 🌋
定义:大量 key 同时过期,或者Redis 集群整体宕机,导致所有请求都打到数据库,数据库雪崩式崩溃。
产生原因:
- 批量导入数据时,给所有 key 设置了相同的过期时间
- Redis 主从切换、集群故障导致服务不可用
- 缓存服务器资源耗尽宕机
核心解决方案:
- 过期时间加随机值:给每个 key 的过期时间加上一个随机偏移量(如 1-5 分钟),避免同时过期
- Redis 高可用集群:搭建主从 + 哨兵、Redis Cluster 集群,保证服务可用性
- 服务熔断与降级:使用 Hystrix/Sentinel 做熔断,数据库压力过大时暂时关闭非核心接口
- 多级缓存:本地缓存(Caffeine)+ Redis 缓存 + 数据库,层层拦截请求
三大问题核心对比表 📊
| 问题类型 | 核心特征 | 典型触发场景 | 核心解决方案 |
|---|---|---|---|
| 缓存穿透 | 查不存在的数据 | 恶意攻击、参数非法 | 布隆过滤器、空值缓存 |
| 缓存击穿 | 单个热点 key过期 | 秒杀、爆款商品、热搜 | 互斥锁、逻辑过期 |
| 缓存雪崩 | 大量 key同时过期 / Redis 宕机 | 批量导入数据、集群故障 | 随机过期时间、高可用集群、熔断降级 |
缓存读写流程与异常路径图 🗺️
面试加分项 ✨
- 实际项目中我遇到过缓存雪崩问题:当时做 618 活动页时,批量导入了 12 万条商品数据,都设置了 1 小时过期时间,导致整点时数据库 CPU 直接打满到 100%。后来给每个 key 的过期时间加上了 0-300 秒的随机偏移量,问题彻底解决。
- 对于缓存击穿,我更推荐使用逻辑过期方案,因为互斥锁在高并发下会有大量线程阻塞,而逻辑过期只需要一个后台线程更新,性能更好,适合秒杀这种对响应时间要求极高的场景。
- 布隆过滤器存在误判率(约 0.1%-1%),所以不能完全替代空值缓存,两者结合使用效果最佳。
核心代码实现(技术亮点版)💻
1 缓存穿透:布隆过滤器(Guava 实现)
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Component
public class ProductBloomFilter {
// 预期插入数据量100万,误判率0.01%
private static final int EXPECTED_INSERTIONS = 1000000;
private static final double FPP = 0.0001;
private BloomFilter<String> bloomFilter;
@PostConstruct
public void initBloomFilter() {
// 初始化布隆过滤器
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
EXPECTED_INSERTIONS,
FPP
);
// 项目启动时预加载所有存在的商品ID到布隆过滤器
List<String> allProductIds = productMapper.selectAllProductIds();
for (String productId : allProductIds) {
bloomFilter.put(productId);
}
}
// 判断商品ID是否可能存在
public boolean mightContain(String productId) {
return bloomFilter.mightContain(productId);
}
}技术亮点:
- 精确控制误判率和内存占用(100 万数据 + 0.01% 误判率仅需约 1.7MB 内存)
- 项目启动时预加载,避免运行时动态构建的性能损耗
2 缓存击穿:互斥锁方案(Redis SETNX)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final String CACHE_KEY_PREFIX = "product:";
private static final String LOCK_KEY_PREFIX = "lock:product:";
private static final int LOCK_EXPIRE_TIME = 10; // 锁过期时间10秒
private static final int CACHE_EXPIRE_TIME = 30; // 缓存过期时间30分钟
public Product getProductById(String productId) {
String cacheKey = CACHE_KEY_PREFIX + productId;
// 1. 先查缓存
String productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 2. 缓存未命中,加互斥锁
String lockKey = LOCK_KEY_PREFIX + productId;
boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", LOCK_EXPIRE_TIME, TimeUnit.SECONDS);
if (lockAcquired) {
try {
// 3. 拿到锁后,再次查询缓存(双重检查)
productJson = redisTemplate.opsForValue().get(cacheKey);
if (productJson != null) {
return JSON.parseObject(productJson, Product.class);
}
// 4. 查询数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 空值缓存,防止缓存穿透
redisTemplate.opsForValue()
.set(cacheKey, "", 5, TimeUnit.MINUTES);
return null;
}
// 5. 更新缓存
redisTemplate.opsForValue()
.set(cacheKey, JSON.toJSONString(product),
CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
return product;
} finally {
// 6. 释放锁(必须在finally中执行)
redisTemplate.delete(lockKey);
}
} else {
// 7. 没拿到锁,等待50ms后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductById(productId); // 递归重试
}
}
}技术亮点:
- 双重检查锁机制,避免重复查询数据库
- 锁设置过期时间,防止死锁
- finally 块保证锁一定会释放
- 空值缓存与互斥锁结合,同时解决穿透和击穿问题
3 缓存击穿:逻辑过期方案(高性能版)
import com.alibaba.fastjson.JSON;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@Service
public class HotProductService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final String CACHE_KEY_PREFIX = "hot:product:";
private static final int LOGIC_EXPIRE_TIME = 30 * 60; // 逻辑过期时间30分钟
// 线程池,用于异步更新缓存
private static final ExecutorService CACHE_REFRESH_EXECUTOR =
Executors.newFixedThreadPool(10);
// 缓存数据对象,包含逻辑过期时间
private static class CacheData<T> {
private T data;
private Long expireTime; // 逻辑过期时间戳
public CacheData(T data, Long expireTime) {
this.data = data;
this.expireTime = expireTime;
}
// 判断是否过期
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
public Product getHotProductById(String productId) {
String cacheKey = CACHE_KEY_PREFIX + productId;
// 1. 查询缓存
String cacheJson = redisTemplate.opsForValue().get(cacheKey);
if (cacheJson == null) {
return null; // 热点key提前预热,理论上不会走到这里
}
CacheData<Product> cacheData = JSON.parseObject(cacheJson,
new TypeReference<CacheData<Product>>() {});
Product product = cacheData.getData();
// 2. 判断是否逻辑过期
if (!cacheData.isExpired()) {
return product; // 未过期,直接返回
}
// 3. 已过期,异步更新缓存
String lockKey = "lock:hot:product:" + productId;
boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (lockAcquired) {
// 提交异步任务更新缓存
CACHE_REFRESH_EXECUTOR.submit(() -> {
try {
// 查询数据库
Product newProduct = productMapper.selectById(productId);
// 更新缓存,设置新的逻辑过期时间
CacheData<Product> newCacheData = new CacheData<>(
newProduct,
System.currentTimeMillis() + LOGIC_EXPIRE_TIME * 1000L
);
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(newCacheData));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 4. 无论是否拿到锁,都返回旧数据
return product;
}
}技术亮点:
- 无阻塞:所有请求都能立即返回数据,不会有线程等待
- 异步更新:使用独立线程池处理缓存更新,不影响主线程
- 热点 key 提前预热:项目启动时将热点数据加载到缓存,避免冷启动问题
4 缓存雪崩:随机过期时间设置
// 给每个key的过期时间加上0-300秒的随机偏移量
int baseExpireTime = 30 * 60; // 基础过期时间30分钟
int randomOffset = new Random().nextInt(300); // 随机偏移量0-5分钟
int finalExpireTime = baseExpireTime + randomOffset;
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(product),
finalExpireTime,
TimeUnit.SECONDS
);技术难点与解决方案汇总 🎯
| 技术难点 | 问题本质 | 解决方案 | 最佳实践 |
|---|---|---|---|
| 布隆过滤器误判 | 哈希冲突导致不存在的 key 被误判为存在 | 1. 降低误判率(增加哈希函数数量、扩大位图大小) 2. 结合空值缓存兜底 | 误判率设置为 0.01%-0.1%,定期重建布隆过滤器 |
| 互斥锁死锁 | 服务宕机导致锁未释放 | 1. 给锁设置过期时间 2. 使用 Redis Lua 脚本实现原子性锁释放 | 锁过期时间设置为数据库查询时间的 2-3 倍 |
| 互斥锁惊群效应 | 大量线程同时等待锁释放,瞬间并发请求数据库 | 1. 增加重试间隔时间 2. 使用分段锁 3. 优先使用逻辑过期方案 | 重试间隔设置为 50-100ms,避免频繁重试 |
| 逻辑过期缓存不一致 | 异步更新期间,用户可能读到旧数据 | 1. 缩短逻辑过期时间 2. 关键业务使用互斥锁保证强一致性 | 非核心业务(如商品详情、文章)用逻辑过期,核心业务(如订单)用互斥锁 |
| Redis 集群宕机雪崩 | 缓存层完全失效,所有请求打到数据库 | 1. 搭建 Redis Cluster 集群 + 哨兵 2. 本地缓存兜底 3. 服务熔断降级 | 本地缓存使用 Caffeine,设置最大容量和过期时间 |
| 批量导入数据雪崩 | 大量 key 同时过期 | 1. 过期时间加随机偏移量 2. 分批次导入数据 3. 低峰期导入数据 | 随机偏移量设置为基础过期时间的 10%-20% |
| 缓存预热冷启动 | 系统刚启动时缓存为空,大量请求打到数据库 | 1. 项目启动时预加载热点数据 2. 流量逐步切入 3. 使用降级机制 | 提前统计热点数据,启动时异步加载到缓存 |
真实面试模拟
真实面试模拟
面试官 👨💻:
“同学,前面基础知识聊得不错。现在来个场景设计题:介绍一下 Redis 的缓存穿透、击穿、雪崩,最好能说清楚它们的区别和解决方案。你可以开始了。”
候选人 🧑💻:
“好的面试官。这三个问题本质上都是缓存没挡住流量,导致大量请求砸到数据库,但触发原因不一样,解决方案侧重点也不同。
我先快速区分一下:
- 🔍 穿透:查的是数据库里根本没有的数据,缓存自然也没法存,请求直接‘穿透’到底。
- 💥 击穿:某个热点 key 刚好过期了,那一瞬间大量请求同时去查库重建缓存,把数据库‘击穿’。
- ❄️ 雪崩:大量 key 在同一时间段内过期,或者 Redis 直接挂了,请求像雪崩一样全压到数据库上。
我可以结合画图,逐个说一下原理和落地方法吗?”
面试官 👨💻:
“可以,你边画边讲。”
候选人 🧑💻:
“好的,先说 🔍 缓存穿透。
比如有人恶意用 id = -1 不断请求,这个数据库里根本没有,缓存里也不会有,每次请求都打到库上。
解决思路是两个:
- 布隆过滤器:把所有合法 id 提前存进去。请求来的时候先过一遍过滤器,不存在的直接挡掉,相当于门禁 🚪。
- 缓存空对象:就算数据库返回 null,也往 Redis 里存一个短期的‘空标记’,比如 "",过期时间 30 秒。下次同样请求就能在缓存层拦住了。
生产上,我一般布隆过滤器前置拦截 + 空值兜底,双保险。”
面试官 👨💻:
“嗯,逻辑清晰。那击穿呢?”
候选人 🧑💻:
“再说 💥 缓存击穿。
最典型的是秒杀商品,key 为 hot_item:123。一旦这个热点 key 失效,刚好又赶上高并发,瞬间所有请求都奔向数据库去抢着重建缓存。
应对方法就一个核心:避免并发重建。
- 互斥锁(Mutex):用 Redis 的
SETNX抢一个轻量级锁,只允许一个线程去查库,其他就 sleep 几十毫秒再重试拿缓存。 - 逻辑过期:热点 key 不设 TTL,在 value 里存逻辑过期时间。发现数据逻辑过期后,先返回旧值,后台异步开线程去更新。这是高性能场景的王道方案 🤖。
伪代码大概这样:
public String getHotData(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
try {
value = db.query();
redis.set(key, value, 3600);
} finally {
redis.del(lockKey);
}
} else {
Thread.sleep(50);
return getHotData(key);
}
}
return value;
}面试官 👨💻:
“那如果用逻辑过期,value 里怎么存?”
候选人 🧑💻:
“我会包装一层对象,比如 HotDataWrapper,里面两个字段:data 和 expireAt(逻辑过期时间戳)。取出来先判断 expireAt < now,如果过期了,先用旧数据返回,同时开一个线程去抢锁重建。”
面试官 👨💻:
“可以,思路到位。最后说说雪崩吧。”
候选人 🧑💻:
“最后是 ❄️ 缓存雪崩,这个破坏力最大。
一般是两种场景叠加:大量 key 在同一时间过期,或者 Redis 突然挂掉。
解决是多管齐下:
- 过期时间加随机盐:在基础 TTL 上随机加个 1~5 分钟,让 key 分散失效。这是最简单也最有效的 😊。
- 高可用架构:Redis 主从 + 哨兵/集群,保证缓存层别单点挂。
- 熔断降级:用 Sentinel 或 Hystrix,当数据库负载过高,就直接返回降级内容(本地缓存、默认值、‘系统繁忙’),并配合限流。
- 多级缓存:本地 Caffeine + Redis 二级缓存,Redis 挂了还有本地兜底。”
面试官 👨💻:
“总结对比一下?”
候选人 🧑💻:
“好的,我用一个表格串一下:
| 类型 | 诱因 | 核心思路 | 一句话 |
|---|---|---|---|
| 🔍 穿透 | 查不存在的数据 | 布隆过滤器 / 空对象 | 防不存在 |
| 💥 击穿 | 热点 key 过期 | 互斥锁 / 逻辑过期 | 防抢重建 |
| ❄️ 雪崩 | 大量 key 同时过期或宕机 | 过期随机化 / 高可用 / 降级 | 防同时挂 |
面试官 👨💻:
“不错,整体框架很清晰,方案能落地。我追问一个点:你用互斥锁的时候,如果业务是集群部署,SETNX 锁要是业务执行超时或者节点宕机了,会不会死锁?怎么解决?”
候选人 🧑💻:
“确实有死锁风险。我会给锁加一个过期时间,并且用 Redisson 的看门狗机制自动续期——只要线程还活着,锁就不会超时释放;如果节点真挂了,锁也会在到期后自动放开。不会出现死锁解不掉的情况。”
面试官 👨💻:
“嗯,方案都说得挺清楚。那你能不能贴几段核心代码,展示下技术亮点?然后把这一整套场景中的技术难点和解决方案整理一下?”
候选人 🧑💻:
“没问题,面试官。我按场景来贴代码,顺便把难点和亮点都说明白。😄”
🔍 缓存穿透 — 核心代码
技术亮点:布隆过滤器 + 空值缓存双保险,兼顾性能和防穿透。
// 1. 布隆过滤器初始化(基于Redisson,启动时执行)
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userIdFilter");
// 预计插入100万,误判率0.01%
bloomFilter.tryInit(1_000_000L, 0.0001);
// 全量ID预热,可从DB分批加载
List<Long> allUserIds = userMapper.getAllIds();
allUserIds.forEach(id -> bloomFilter.add("user:" + id));
// 2. 查询逻辑
public User getUserById(Long id) {
String key = "user:" + id;
// 布隆过滤器拦截
if (!bloomFilter.contains(key)) {
return null; // 直接返回,不查库
}
// 查缓存
String cacheValue = redisTemplate.opsForValue().get(key);
if (cacheValue != null) {
// 判断是否为空对象占位符
if ("NULL_PLACEHOLDER".equals(cacheValue)) {
return null;
}
return JSON.parseObject(cacheValue, User.class);
}
// 查数据库
User user = userMapper.selectById(id);
if (user == null) {
// 缓存空对象,过期时间30秒,防止占用空间
redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 1, TimeUnit.HOURS);
}
return user;
}💥 缓存击穿 — 核心代码
技术亮点:使用 Redisson 分布式锁 + 看门狗自动续期,避免死锁,保障高并发。
public Product getHotProduct(Long id) {
String key = "hot_product:" + id;
String cacheData = redisTemplate.opsForValue().get(key);
if (cacheData != null) {
return JSON.parseObject(cacheData, Product.class);
}
// 分布式锁key
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待10秒,锁超时时间(看门狗会续期,这里设为30秒兜底)
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 双重检查
cacheData = redisTemplate.opsForValue().get(key);
if (cacheData != null) {
return JSON.parseObject(cacheData, Product.class);
}
// 查询数据库
Product product = productMapper.selectById(id);
if (product != null) {
// 设置缓存,加随机过期时间(300~420秒),防止雪崩
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
300 + ThreadLocalRandom.current().nextInt(120), TimeUnit.SECONDS);
} else {
// 空值缓存,防止穿透
redisTemplate.opsForValue().set(key, "NULL_PLACEHOLDER", 30, TimeUnit.SECONDS);
}
return product;
} else {
// 获取锁失败,休眠后重试
Thread.sleep(50);
return getHotProduct(id); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}❄️ 缓存雪崩 — 核心代码
技术亮点:过期时间加随机盐 + 多级缓存兜底(Caffeine)。
// 本地缓存配置(Caffeine)
Cache<Long, Object> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
public Object getDataWithMultiLevel(Long id) {
String key = "data:" + id;
// 1. 一级:本地缓存
Object localVal = localCache.getIfPresent(id);
if (localVal != null) {
return localVal;
}
// 2. 二级:Redis
String cacheVal = redisTemplate.opsForValue().get(key);
if (cacheVal != null) {
localCache.put(id, JSON.parse(cacheVal)); // 回填本地
return cacheVal;
}
// 3. 查库并回填(带锁防止击穿)
Object dbVal = queryFromDbWithLock(id);
if (dbVal != null) {
// 设置Redis过期时间,基础1小时 + 随机0~600秒
long expireSec = 3600 + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(key, JSON.toJSONString(dbVal), expireSec, TimeUnit.SECONDS);
localCache.put(id, dbVal);
}
return dbVal;
}🧩 此场景技术难点 & 解决方案
| 场景 | 技术难点 | 解决方案 | 经验要点 💡 |
|---|---|---|---|
| 🔍 穿透 | 1. 布隆过滤器无法删除,数据新增/删除后需重建。 2. 误判率会放过少量无效请求。 3. 空值缓存占用内存,可能被恶意构造不同key。 | 1. 使用支持扩容的布隆过滤器(RedisBloom插件)或定时重建。 2. 误判率设定0.01%以下,并靠空值缓存兜底。 3. 空值缓存设置极短TTL,并对请求限流。 | 实际误判率要压测,不能光看理论值。 |
| 💥 击穿 | 1. 锁的粒度与性能平衡:热点越多,锁竞争越严重。 2. 逻辑过期存在最终一致性问题,异步更新可能返回旧数据。 3. 分布式锁死锁风险。 | 1. 单机ConcurrentHashMap加轻量锁,集群用Redisson细粒度锁。 2. 逻辑过期时,保证旧值仍可用,提前预加载或后台刷新。 3. 使用Redisson看门狗自动续期 + finally解锁。 | 逻辑过期适合读多写少且能容忍短暂不一致的业务。 |
| ❄️ 雪崩 | 1. 随机过期算法仍可能出现局部热点集中失效。 2. 多级缓存同步问题,本地缓存与Redis数据可能不一致。 3. 熔断降级后,服务恢复时的“脉冲式”压力。 | 1. 不仅随机,还要结合业务预热,提前加载高频key。 2. 本地缓存短TTL,消息队列通知更新。 3. 半开状态逐步放量,采用令牌桶或预热算法。 | 生产必做多级缓存,核心接口要压测极限QPS并设定好阈值。 👍 |
面试官 👨💻:
“很好,代码细节到位,难点拆解得也清晰。你这种把代码和方案能对应讲出来的习惯,是面试加分项。这场缓存三兄弟的考察,给你 Strong Hire ✅。”
