redis缓存常见问题
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是一个常量,设置的过期时间。
public Shop queryWithPassThrough(Long id) {String key = CACHE_SHOP_KEY + id;// 1. 从Redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2. 缓存命中(包含空值)直接返回if (shopJson != null) {// 反序列化空字符串为nullreturn 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 添加随机时间
public class RandomExpireTimeCacheService {@Autowiredprivate 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 集群(哨兵模式)配置
@Configuration
public class RedisSentinelConfig {@Beanpublic 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;}@Beanpublic JedisConnectionFactory jedisConnectionFactory() {return new JedisConnectionFactory(sentinelConfiguration());}@Beanpublic RedisTemplate<String, Object> redisTemplate() {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(jedisConnectionFactory());return template;}
}
(2.3)限流降级
核心思想:当系统负载过高时,通过限制流量和降级非核心服务,确保核心功能的可用性,防止整个系统被拖垮。
- 限流:控制请求的访问速率,防止过多请求进入系统
- 降级:当检测到系统异常时,自动返回预设的默认值或错误信息
// 使用Sentinel注解定义受保护的资源
@SentinelResource(value = "protectedResource", blockHandler = "handleBlock")
public String process(String param) {// 正常业务逻辑
}// 限流降级处理方法
public String handleBlock(String param, BlockException ex) {// 资源被限流或降级时的处理逻辑return "系统繁忙,请稍后再试";
}
(2.4)添加多级缓存
核心思想:通过组合本地缓存和分布式缓存,减少对 Redis 的访问频率,提高系统响应速度,同时增强系统容错能力。
@Service
public class MultiLevelCacheService {// Caffeine本地缓存private final Cache<String, Object> localCache;// Redis分布式缓存@Autowiredprivate 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) {// 将数据写入RedisredisTemplate.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
命令实现)
//写一个自定义锁//获取锁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部分,实现缓存重建)
//互斥锁解决缓存击穿代码块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) {//将空值存入redisstringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL);return null;}
// 5.写入redisstringRedisTemplate.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
类,用于将实际缓存数据和逻辑过期时间封装在一起,方便后续操作和判断。
// 用于封装缓存数据及其逻辑过期时间
public class RedisData {private LocalDateTime expireTime; // 逻辑过期时间private Object data; // 实际缓存的数据
}
重建缓存代码(设置过期时间)
//逻辑过期解决缓存击穿代码块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写入redisstringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}
通过逻辑过期实现缓存击穿代码块
//线程池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;}