
在Redis的实战场景中,"缓存三大问题"------穿透、击穿、雪崩是中大厂面试的必问项。面试官不仅会问"什么是缓存穿透",更会追问"怎么解决?、代码怎么实现?、生产环境选哪种方案?"
本文将从"问题本质→解决方案→代码实现→场景选择→踩坑点"五个维度,系统拆解这三大问题,每个方案都附Java实战代码(Spring Boot+Redis),帮你既懂原理又能落地。
一、缓存穿透:查不到的数据"穿透"到数据库
1. 问题定义与危害
定义 :客户端频繁请求"不存在的数据"(如查询ID=-1的用户、不存在的商品ID),由于缓存中没有这些数据,请求会直接穿透到数据库,导致数据库压力骤增,甚至宕机。
本质:缓存只缓存"存在的key",对"不存在的key"无防护,形成"缓存真空"。
2. 解决方案1:缓存空值(简单有效,推荐中小场景)
(1)原理
第一次查询不存在的数据时,不仅返回空结果,还会往缓存中存入一个"空值"(如""、null),并设置短期过期时间(避免长期占用内存)。后续请求会直接命中缓存的空值,不再访问数据库。
(2)Java代码实现(Spring Boot)
java
@Service
public class UserService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
// 空值过期时间:5分钟(根据业务调整,不宜过长)
private static final long NULL_VALUE_EXPIRE = 5 * 60 * 1000L;
public User getUserById(Long id) {
String key = "user:id:" + id;
// 1. 先查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
// 2. 缓存命中:若为null(空值标记),返回null;否则返回用户
return user instanceof NullValue ? null : user;
}
// 3. 缓存未命中:查数据库
user = userMapper.selectById(id);
if (user == null) {
// 4. 数据库也不存在:缓存空值(用自定义NullValue标记,避免与真实null混淆)
redisTemplate.opsForValue().set(key, new NullValue(), NULL_VALUE_EXPIRE, TimeUnit.MILLISECONDS);
return null;
}
// 5. 数据库存在:缓存真实数据(设置合理过期时间,如1小时)
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
return user;
}
// 自定义空值标记类(避免与真实null混淆,防止缓存穿透)
static class NullValue implements Serializable {}
}
(3)适用场景与踩坑点
- 适用场景:数据量不大(如十万级)、不存在的key请求频率不高的场景。
- 踩坑点 :
- 空值需设置短期过期时间(如5-10分钟),避免"真实数据新增后,缓存的空值导致查询不到";
- 用自定义
NullValue类标记空值,避免与真实null混淆(RedisTemplate默认不存储null)。
3. 解决方案2:布隆过滤器(大数据量场景,推荐)
(1)原理
布隆过滤器是一种"概率性数据结构",能快速判断"一个元素是否存在于集合中"。提前将所有"存在的key"(如数据库中所有用户ID)存入布隆过滤器,请求先经过过滤器:
- 若过滤器判断"不存在",直接返回空,不访问缓存和数据库;
- 若过滤器判断"可能存在"(允许一定误判率),再走"缓存→数据库"流程。
(2)Java代码实现(基于Guava布隆过滤器)
java
@Configuration
public class BloomFilterConfig {
// 预计数据量(如100万用户ID)
private static final long EXPECTED_INSERTIONS = 1000000;
// 误判率(推荐0.01-0.001,越小占用内存越大)
private static final double FPP = 0.01;
// 初始化用户ID布隆过滤器
@Bean
public BloomFilter<Long> userBloomFilter() {
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(),
EXPECTED_INSERTIONS,
FPP
);
// 从数据库加载所有存在的用户ID,放入过滤器(实际应异步加载)
List<Long> allUserIds = userMapper.selectAllIds();
allUserIds.forEach(filter::put);
return filter;
}
}
@Service
public class UserService {
@Autowired
private BloomFilter<Long> userBloomFilter;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
// 1. 先过布隆过滤器:若不存在,直接返回null
if (!userBloomFilter.mightContain(id)) {
return null;
}
// 2. 过滤器判断可能存在,再查缓存和数据库(同缓存空值方案的后续流程)
String key = "user:id:" + id;
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user instanceof NullValue ? null : user;
}
user = userMapper.selectById(id);
if (user == null) {
redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
return user;
}
}
(3)适用场景与踩坑点
- 适用场景:数据量大(百万级以上)、不存在的key请求频率极高的场景(如爬虫恶意攻击)。
- 踩坑点 :
- 存在"误判率"(无法完全避免穿透):需配合"缓存空值"兜底;
- 不支持删除数据:若数据库数据删除,布隆过滤器无法同步删除,需定期重建过滤器(如每天凌晨);
- 内存占用:100万数据、0.01误判率约占1.5MB,可接受。
二、缓存击穿:热点key失效瞬间,请求全冲库
1. 问题定义与危害
定义 :某个"热点key"(如秒杀商品ID、热门文章ID)缓存突然过期,瞬间大量并发请求未命中缓存,全部穿透到数据库,导致数据库过载。
本质:热点key的"缓存失效时间点"与"高并发请求"重叠,形成"流量尖峰"。
2. 解决方案1:互斥锁(控制并发,推荐通用场景)
(1)原理
缓存失效时,不是所有请求都去查数据库,而是让"第一个请求"获取锁(如Redis的SET NX),去数据库查询并更新缓存;其他请求获取锁失败后,等待一段时间再重试,直到缓存更新完成。
(2)Java代码实现(基于Redis分布式锁)
java
@Service
public class GoodsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private GoodsMapper goodsMapper;
// 锁过期时间:3秒(需大于数据库查询+缓存更新的耗时)
private static final long LOCK_EXPIRE = 3 * 1000L;
// 重试间隔:100毫秒
private static final long RETRY_INTERVAL = 100L;
public Goods getGoodsById(Long id) {
String key = "goods:id:" + id;
// 1. 先查缓存
Goods goods = (Goods) redisTemplate.opsForValue().get(key);
if (goods != null) {
return goods;
}
// 2. 缓存失效:尝试获取锁
String lockKey = "lock:goods:" + id;
boolean locked = false;
try {
// 2.1 用SET NX获取锁(仅当锁不存在时成功)
locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE, TimeUnit.MILLISECONDS);
if (locked) {
// 2.2 获取锁成功:查数据库
goods = goodsMapper.selectById(id);
if (goods == null) {
// 数据库不存在:缓存空值(短期)
redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
return null;
}
// 数据库存在:更新缓存(设置合理过期时间,如30分钟)
redisTemplate.opsForValue().set(key, goods, 30, TimeUnit.MINUTES);
return goods;
} else {
// 2.3 获取锁失败:等待后重试(最多重试5次)
int retryCount = 0;
while (retryCount < 5) {
Thread.sleep(RETRY_INTERVAL);
goods = (Goods) redisTemplate.opsForValue().get(key);
if (goods != null) {
return goods;
}
retryCount++;
}
// 重试多次仍未获取缓存:返回默认兜底数据(如"系统繁忙")
return new Goods().setName("系统繁忙,请稍后再试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 3. 释放锁(仅释放自己的锁,避免误删)
if (locked) {
redisTemplate.delete(lockKey);
}
}
}
}
(3)适用场景与踩坑点
- 适用场景:热点key更新频率不高、数据库查询耗时较短的场景(如商品详情)。
- 踩坑点 :
- 锁过期时间需大于"数据库查询+缓存更新"的耗时,避免"锁提前释放,多个线程同时查库";
- 重试次数和间隔需合理(如5次、100ms),避免线程长时间阻塞;
- 释放锁需判断"是否是自己的锁"(复杂场景可用Lua脚本,本例简化处理)。
3. 解决方案2:热点key永不过期(彻底避免失效,推荐超高并发场景)
(1)原理
两种实现方式:
- 物理上不设置过期时间:缓存中的热点key永远不过期;
- 逻辑上永不过期:设置过期时间,但用异步线程定期(如每隔29分钟)更新过期时间,保证缓存"逻辑上不过期"。
核心是"不让热点key在高并发时失效"。
(2)Java代码实现(异步线程续期)
java
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private SeckillMapper seckillMapper;
// 初始化定时线程池(用于更新过期时间)
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
public Seckill getSeckillById(Long id) {
String key = "seckill:id:" + id;
// 1. 先查缓存
Seckill seckill = (Seckill) redisTemplate.opsForValue().get(key);
if (seckill != null) {
return seckill;
}
// 2. 缓存未命中:查数据库并初始化缓存(设置30分钟过期)
seckill = seckillMapper.selectById(id);
if (seckill == null) {
return null;
}
redisTemplate.opsForValue().set(key, seckill, 30, TimeUnit.MINUTES);
// 3. 启动异步线程:每隔29分钟更新一次过期时间(逻辑上永不过期)
scheduler.scheduleAtFixedRate(() -> {
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
}, 29, 29, TimeUnit.MINUTES);
return seckill;
}
// 服务关闭时关闭线程池
@PreDestroy
public void destroy() {
scheduler.shutdown();
}
}
(3)适用场景与踩坑点
- 适用场景:超高并发的热点key(如秒杀、热门活动),且数据更新频率低(避免缓存与数据库不一致)。
- 踩坑点 :
- 需保证"异步线程池"的稳定性(避免线程泄露);
- 若数据更新,需主动更新缓存(如发布消息通知缓存更新),否则会出现"缓存脏数据"。
三、缓存雪崩:大量key同时过期,数据库被冲垮
1. 问题定义与危害
定义 :缓存中大量key在同一时间过期,或缓存集群宕机,导致所有请求瞬间落到数据库,数据库直接被压垮。
本质:"缓存失效"或"缓存不可用"与"高并发请求"叠加,形成"流量洪峰"。
2. 解决方案1:过期时间随机化(避免同时过期,基础方案)
(1)原理
给每个key的过期时间加一个"随机值"(如30分钟 ± 5分钟),避免大量key在同一时间点过期,将过期时间分散到不同时间段。
(2)Java代码实现(封装Redis工具类)
java
@Component
public class RedisCacheUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 基础过期时间(如30分钟)
private static final long BASE_EXPIRE = 30 * 60 * 1000L;
// 随机值范围(±5分钟)
private static final long RANDOM_RANGE = 5 * 60 * 1000L;
private static final Random random = new Random();
// 存储缓存并添加随机过期时间
public void setWithRandomExpire(String key, Object value) {
// 计算随机过期时间:BASE_EXPIRE ± RANDOM_RANGE
long expire = BASE_EXPIRE + (random.nextLong() % (2 * RANDOM_RANGE + 1) - RANDOM_RANGE);
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MILLISECONDS);
}
}
// 使用示例
@Service
public class ProductService {
@Autowired
private RedisCacheUtil redisCacheUtil;
public void saveProduct(Product product) {
// 存储缓存时自动添加随机过期时间
redisCacheUtil.setWithRandomExpire("product:id:" + product.getId(), product);
}
}
3. 解决方案2:缓存集群高可用(避免缓存不可用,核心方案)
(1)原理
通过"主从复制+哨兵"或"Redis Cluster"部署缓存集群,避免单节点宕机导致整个缓存不可用:
- 主从复制:主节点处理写请求,从节点同步数据并处理读请求,主节点宕机后从节点可切换为主;
- 哨兵:监控主从节点健康状态,自动完成故障转移(主节点宕机后选新主);
- Redis Cluster:分片存储数据,支持多主多从,单个节点宕机不影响整体可用。
(2)核心配置(Redis Cluster示例)
yaml
# Spring Boot配置Redis Cluster
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
- 192.168.1.104:6379
- 192.168.1.105:6379
- 192.168.1.106:6379
max-redirects: 3 # 最大重定向次数
4. 解决方案3:服务熔断降级(保护数据库,兜底方案)
(1)原理
当数据库压力过大(如QPS超过阈值),通过熔断工具(如Sentinel、Resilience4j)暂时"熔断"缓存到数据库的请求,返回降级数据(如"系统繁忙,请稍后再试"),避免数据库被压垮。
(2)Java代码实现(基于Sentinel)
java
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private OrderMapper orderMapper;
// 用Sentinel注解设置熔断规则:QPS>1000时降级
@SentinelResource(
value = "getOrderById",
blockHandler = "getOrderByIdBlockHandler" // 熔断时执行的方法
)
public Order getOrderById(Long id) {
String key = "order:id:" + id;
Order order = (Order) redisTemplate.opsForValue().get(key);
if (order != null) {
return order;
}
// 缓存未命中:查数据库(熔断时不会执行到这里)
order = orderMapper.selectById(id);
if (order != null) {
redisTemplate.opsForValue().set(key, order, 1, TimeUnit.HOURS);
}
return order;
}
// 熔断降级处理方法(参数和返回值需与原方法一致)
public Order getOrderByIdBlockHandler(Long id, BlockException e) {
return new Order().setId(id).setMessage("系统繁忙,请稍后再试");
}
}
(3)Sentinel控制台配置
在Sentinel控制台为getOrderById资源设置规则:
- 阈值类型:QPS;
- 单机阈值:1000;
- 流控模式:直接;
- 流控效果:快速失败(直接返回降级数据)。
四、场景化选择:不同场景怎么选方案?
| 问题类型 | 场景特点 | 推荐方案 |
|---|---|---|
| 缓存穿透 | 数据量小(<10万)、无效请求少 | 缓存空值 |
| 缓存穿透 | 数据量大(>100万)、无效请求多 | 布隆过滤器+缓存空值(兜底) |
| 缓存击穿 | 热点key更新频率低、并发中等 | 互斥锁 |
| 缓存击穿 | 热点key超高并发(如秒杀) | 热点key永不过期+异步更新 |
| 缓存雪崩 | 预防key同时过期 | 过期时间随机化 |
| 缓存雪崩 | 预防缓存集群不可用 | Redis Cluster(主从+哨兵) |
| 缓存雪崩 | 数据库压力过大时兜底 | 服务熔断降级(Sentinel) |
五、面试高频踩坑题&标准答案
- 问:缓存空值会导致什么问题?如何避免?
答:问题:若真实数据新增,缓存的空值会导致"查不到新数据"。避免:设置短期过期时间(如5分钟),或新增数据时主动删除缓存的空值。 - 问:布隆过滤器为什么不能删除数据?
答:布隆过滤器通过"多个哈希函数映射到位数组"实现,删除一个元素会影响其他元素的映射结果(可能导致误判"不存在"),因此不支持删除。解决方案:定期重建过滤器。 - 问:互斥锁的过期时间设置过短会怎样?
答:若锁过期时间小于"数据库查询+缓存更新"的耗时,会导致"锁提前释放,多个线程同时查库",重新引发缓存击穿。需根据实际耗时设置(如3-5秒),并预留冗余。 - 问:过期时间随机化的随机范围怎么定?
答:随机范围=基础过期时间的10%-20%(如基础1小时,随机±6-12分钟),范围太小仍可能集中过期,太大可能导致缓存数据过期时间过长(脏数据)。
六、总结与下一篇预告
缓存三大问题的核心是"流量控制"和"风险隔离":
- 穿透:用"缓存空值"或"布隆过滤器"拦截无效请求;
- 击穿:用"互斥锁"或"永不过期"控制热点key的并发流量;
- 雪崩:用"随机过期""集群高可用""熔断降级"分散风险。
掌握这些方案的代码实现和场景选择,就能应对中大厂的实战面试题。
下一篇将聚焦"Redis分布式锁"------从基础实现到Redisson高级版的演进,包括锁续期、可重入性、集群场景优化等核心考点,帮你彻底搞懂分布式锁的实战落地,敬请关注。
如果觉得本文有用,欢迎收藏+转发,后续会持续更新Redis面试核心系列,帮你系统攻克Redis考点~