redis缓存常见问题
一、缓存三剑客(穿透、雪崩、击穿)
1.redis穿透
(1)什么是redis缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求就都打到数据库里面了。
穿透穿透,顾名思义,就是请求穿过了redis又穿过了数据库(Mysql)就像下面的图片一样,请求就是子弹,redis就是防弹衣,Mysql就是身体,当我们请求redis缓存没有命中时,就会打到数据库上。这就是穿透。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili)
(2)解决方法
(2.1)缓存空对象
当数据库中没有查询到数据时,可以将这个请求存储到Redis中,并设置一个较短的过期时间(如5分钟)。这样,在发送这样的请求就打到redis上,不会打到数据库上了(在这设置的5分钟里)。
其实就是将请求过来在redis缓存和数据库里都没有的数据存储到redis中,防止这个请求在打到数据库。
如下代码的CACHE_SHOP_TTL是一个常量,设置的过期时间。
java
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从Redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 缓存命中(包含空值)直接返回
if (shopJson != null) {
// 反序列化空字符串为null
return shopJson.equals("") ? null : JSONUtil.toBean(shopJson, Shop.class);
}
// 3. 缓存未命中,查询数据库
Shop shop = getById(id);
// 4. 数据库中不存在,缓存空值
if (shop == null) {
// 缓存空字符串(或特定标识),并设置较短过期时间
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. 数据库中存在,写入Redis缓存(正常TTL)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 6. 返回结果
return shop;
}
(2.2)布隆过滤
在缓存和数据库之间加入一个布隆过滤器,它可以预存储一些可能存在的键。如果查询的键不在布隆过滤器中,直接返回不存在,避免查询数据库。布隆过滤器通过哈希函数实现,误判率可以通过调整其大小和哈希函数的数量来控制。(有黑名单与白名单两种)(其实都是判断数据库里,有没有,没有就存入布隆过滤器,这只是我个人的理解)

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili)
2.缓存雪崩
(1)什么是缓存雪崩
缓存雪崩是指同一时段大量的缓存的key同时失效或redis服务宕机,导致大量请求到达数据库,带来巨大压力。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili)
(2)解决办法
(2.1)给不同的key添加随机时间(防止大量key同时过期问题)
实现方式 :
在设置缓存时,为每个 key 的过期时间增加一个随机偏移量。基础过期时间保证数据不会长时间不更新,随机范围则确保各个 key 不会集中失效。
给不同的 key 添加随机时间
java
public class RandomExpireTimeCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 基础过期时间(秒)
private static final long BASE_EXPIRE_TIME = 60 * 30; // 30分钟
// 随机范围(秒)
private static final long RANDOM_RANGE = 60 * 15; // 15分钟
private final Random random = new Random();
/**
* 设置带有随机过期时间的缓存
* @param key 缓存键
* @param value 缓存值
*/
public void setWithRandomExpireTime(String key, Object value) {
// 生成随机过期时间:基础时间 + 随机时间
long expireTime = BASE_EXPIRE_TIME + random.nextInt((int) RANDOM_RANGE);
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
}
}
(2.2)利用redis集群(如哨兵模式,有效防止redis宕机带来的问题)
核心思想:通过部署 Redis 集群提高可用性,避免因单点故障导致的缓存服务整体不可用,从而引发雪崩。
哨兵模式工作原理:
- 哨兵节点 (Sentinel) 监控主从节点状态
- 当主节点故障时,自动进行主从切换
- 客户端通过哨兵获取 Redis 服务地址
利用 Redis 集群(哨兵模式)配置
java
@Configuration
public class RedisSentinelConfig {
@Bean
public RedisSentinelConfiguration sentinelConfiguration() {
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
.master("mymaster") // 主节点名称
.sentinels(
Arrays.asList(
new RedisNode("sentinel1.host", 26379),
new RedisNode("sentinel2.host", 26379),
new RedisNode("sentinel3.host", 26379)
)
);
return sentinelConfig;
}
@Bean
public JedisConnectionFactory jedisConnectionFactory() {
return new JedisConnectionFactory(sentinelConfiguration());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(jedisConnectionFactory());
return template;
}
}
(2.3)限流降级
核心思想:当系统负载过高时,通过限制流量和降级非核心服务,确保核心功能的可用性,防止整个系统被拖垮。
- 限流:控制请求的访问速率,防止过多请求进入系统
- 降级:当检测到系统异常时,自动返回预设的默认值或错误信息
java
// 使用Sentinel注解定义受保护的资源
@SentinelResource(value = "protectedResource", blockHandler = "handleBlock")
public String process(String param) {
// 正常业务逻辑
}
// 限流降级处理方法
public String handleBlock(String param, BlockException ex) {
// 资源被限流或降级时的处理逻辑
return "系统繁忙,请稍后再试";
}
(2.4)添加多级缓存
核心思想:通过组合本地缓存和分布式缓存,减少对 Redis 的访问频率,提高系统响应速度,同时增强系统容错能力。
java
@Service
public class MultiLevelCacheService {
// Caffeine本地缓存
private final Cache<String, Object> localCache;
// Redis分布式缓存
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 缓存默认过期时间(分钟)
private static final long DEFAULT_EXPIRE_TIME = 30;
public MultiLevelCacheService() {
// 初始化本地缓存
this.localCache = Caffeine.newBuilder()
.maximumSize(1000) // 最大缓存条目数
.expireAfterWrite(DEFAULT_EXPIRE_TIME, TimeUnit.MINUTES) // 写入后过期时间
.build();
}
/**
* 从多级缓存中获取数据
* @param key 缓存键
* @param dataLoader 数据加载器,当缓存未命中时用于加载数据
* @param expireTime 缓存过期时间(分钟)
* @return 缓存值
*/
public <T> T get(String key, Supplier<T> dataLoader, long expireTime) {
// 1. 先从本地缓存获取
T value = (T) localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 本地缓存未命中,从Redis获取
value = (T) redisTemplate.opsForValue().get(key);
if (value != null) {
// 将数据写入本地缓存
localCache.put(key, value);
return value;
}
// 3. Redis未命中,从数据源加载数据
value = dataLoader.get();
if (value != null) {
// 将数据写入Redis
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);
// 将数据写入本地缓存
localCache.put(key, value);
}
return value;
}
/**
* 从多级缓存中获取数据(使用默认过期时间)
* @param key 缓存键
* @param dataLoader 数据加载器,当缓存未命中时用于加载数据
* @return 缓存值
*/
public <T> T get(String key, Supplier<T> dataLoader) {
return get(key, dataLoader, DEFAULT_EXPIRE_TIME);
}
/**
* 更新缓存
* @param key 缓存键
* @param value 缓存值
* @param expireTime 过期时间(分钟)
*/
public void update(String key, Object value, long expireTime) {
// 更新Redis缓存
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);
// 更新本地缓存
localCache.put(key, value);
}
/**
* 删除缓存
* @param key 缓存键
*/
public void delete(String key) {
// 删除Redis缓存
redisTemplate.delete(key);
// 删除本地缓存
localCache.invalidate(key);
}
}
3、缓存击穿
(1)什么是缓存击穿
缓存击穿
问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili)
(2)解决方案
(2.1)互斥锁
使用互斥锁确保只有一个线程可以查询数据库并更新缓存。其他线程等待锁释放后直接从缓存中获取数据。可以使用Redis的SETNX
命令实现分布式锁。
注意:可能发送死锁问题,需要设置有效期。(当一个线程获取锁成功之后,程序出问题了,没有释放锁,就可能发生死锁)

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili)
相关代码(通过redis的SETNX
命令实现)
java
//写一个自定义锁
//获取锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
使用锁的代码块(主要是第4部分,实现缓存重建)
java
//互斥锁解决缓存击穿代码块
private Shop queryWithMutex(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中是否是空值
if (shopJson != null) {
//返回错误信息
return null;
}
// 4.实现缓存重建
String lock = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
// 4.1 获取互斥锁
boolean isLock = tryLock(lock);
// 4.2判断获取锁是否成功
if (!isLock){
// 4.3不成功,休眠一段时间重试
Thread.sleep(50);
}
// 4.成功,查询数据库
shop = getById(id);
if (shop == null) {
//将空值存入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL);
return null;
}
// 5.写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 6.释放互斥锁
unLock(lock);
}
// 7.返回
return shop;
}
(2.2)逻辑过期
逻辑过期并非真正在缓存层面设置过期时间,而是在缓存数据结构中增加一个代表过期时间的字段 。当应用程序读取缓存时,通过判断该字段与当前时间的关系,来确定数据是否 "过期"。如果数据被判定为 "过期",应用程序会在后台异步地对数据进行更新,而在更新完成前,仍然返回旧数据给请求方。

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili)
定义一个 RedisData
类,用于将实际缓存数据和逻辑过期时间封装在一起,方便后续操作和判断。
java
// 用于封装缓存数据及其逻辑过期时间
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 实际缓存的数据
}
重建缓存代码(设置过期时间)
java
//逻辑过期解决缓存击穿代码块
private void saveShop2Redis(Long id, Long expireSeconds) {
//1查询店铺数据
Shop shop = getById(id);
//2封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
通过逻辑过期实现缓存击穿代码块
java
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//逻辑过期解决缓存击穿代码块
private Shop queryWithLogicalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1.从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.不存在,返回
return null;
}
// 4.命中,把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1未过期,直接返回信息
return shop;
}
// 5.2 已过期,需要重建缓存
// 6.缓存重建
// 6.1获取互斥锁
String lock = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lock);
// 6.2判断获取锁是否成功
if (!isLock){
// 6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 6.3.1缓存重建
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 6.3.2释放锁
unLock(lock);
}
});
}
// 7.失败,返回过期信息
return shop;
}