缓存穿透
缓存穿透 :客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这样的请求都会访问到数据库,这样的大量请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,对数据库造成巨大压力。
解决方案 :
-
缓存空对象
-
优点:实现简单,维护方便
-
缺点:需要额外的内存消耗,可能造成短期数据不一致的情况
当客户端请求不存在的数据时,请求先经过redis,redis中没有该数据,则进行数据库访问,但是发现数据库中也没有该数据,因为数据库能够承受的并发量有限,若大量的请求访问的都是这种不存在的数据,则都会访问到数据库,对数据库的压力很大,而缓存空对象的解决方案就是不管数据库中被访问的数据存在还是不存在,都将该数据写入缓存中,不存在的写入缓存时,其value置空即可,这样,下次用户过来访问这个不存在的数据时,redis缓存中也能找到该数据,不会访问到数据库。
-
-
布隆过滤
-
优点:内存占用较少
-
缺点:实现复杂,存在误判的可能性
布隆过滤器其实是采用的哈希思想来解决缓存穿透的问题,通过一个庞大的二进制数组,通过哈希思想去判断当前这个需要访问的数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会访问到redis缓存,若缓存中不存在,则去数据库中查询,并将结果写入缓存,并进行返回;如果布隆过滤器判断不存在,则直接返回。但是布隆过滤器会因为
哈希冲突
产生误判。
-
缓存空对象解决缓存穿透问题
解决缓存穿透 :如果客户端请求的数据缓存和数据库中都不存在,则将这个数据也写入缓存中,并将其value设置为空,若当再次发送请求查询该数据时,如果在缓存中命中,判断这个value是否是空,如果是空,则是之前写入的缓存穿透数据。
-
修改根据id查询商铺信息
@Override
public Shop getShopById(Long id) {
//组装redis中的key
String cacheShopKey = CACHE_SHOP_KEY + id;
//根据ID在redis中查询商铺信息
String shopString = stringRedisTemplate.opsForValue().get(cacheShopKey);
//redis中查询到商铺信息
if (StrUtil.isNotBlank(shopString)){
Shop shop = BeanUtil.toBean(shopString, Shop.class);
return shop;
}
//根据商铺id查询商铺信息
Shop shop = this.getById(id);
//数据库中没查询到该商铺信息,则将空值写入缓存,并设置一个较短的TTL
if (ObjectUtil.isNull(shop)){
//数据库中查询到了该商铺信息,写入缓存,并设置有效时间为30分钟
stringRedisTemplate.opsForValue().set(cacheShopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//数据库中查询到了该商铺信息,写入缓存,并设置有效时间为30分钟
stringRedisTemplate.opsForValue().set(cacheShopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回给商铺信息
return shop;
} -
增加常量
/**
* redis中缓存一个空值的有效时间
*/
public static final Long CACHE_NULL_TTL = 2L;
总结
-
缓存穿透
:用户请求的数据在缓存和数据库中都不存在,但用户还是不断的发送这样的请求,给数据库带来巨大压力 -
解决方案
:-
①缓存null值
-
② 布隆过滤器
-
③ 增强查询条件复杂度,避免被猜出
-
④ 完善数据的基础格式校验等
-
⑤ 加强用户权限校验
-
⑥ 做好热电参数的限流
-
缓存雪崩
-
缓存雪崩 :是指在同一时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库。
-
解决方案 :
-
给不同key的TL添加随机值,避免大量数据TTL相同导致的同时过期 -
搭建redis集群提高服务的可用性 -
给缓存业务添加降级限流策略 -
给业务添加多级缓存
-
缓存击穿
缓存击穿问题(热点key问题) :是指一个被高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大压力。
线程1发送请求进行数据查询,查询缓存没有查询到数据,则去访问数据库,然后再将查询结果写入缓存,然而在线程1在访问数据库阶段,大量的线程也发送了该请求,因为缓存里没有数据,导致都去访问数据库,导致数据库压力过大。
-
解决方案 :
-
互斥锁
根据锁的互斥性,来决定只有获得锁的访问请求才能够访问数据库,从而避免数据库访问压力过大,但互斥锁会影响查询性能, 一般采用的方案如下:线程1发送请求,查询缓存未命中,获取锁,然后查询数据库,而其他线程发送该请求,在线程1没有释放锁的情况下,其他线程只能等待(休眠),等待结束后,他们继续请求,先去查询缓存,再去获取锁,如果线程1释放了锁,那么其他发送该请求的线程在等待结束后都能在缓存中获取数据,如果线程1没释放锁,那么其他发送该请求的线程因为获取不到锁,会再次进入等待状态。
-
逻辑过期
逻辑过期指的是把过期时间设置在redis的value中,该时间不会作用于redis,而是后续通过逻辑去处理,假设线程1请求访问查询,先查询缓存,从查询的缓存value中判断当前数据是否逻辑过期,如果逻辑过期了,线程1获取互斥锁,并开启一个新的线程(线程2),线程2进行数据库访问,以及将数据重置逻辑过期时间写入缓存,该步骤完成后,进行锁的释放,假设其他线程在新数据没更新完成时访问,发现缓存数据已过期时,进行互斥锁的获取,如果获取不到互斥锁,则返回过期数据,或者线程2已将缓存进行更新,其他线程访问到的数据已是未过期数据。
-
优点 | 缺点 | |
---|---|---|
互斥锁 | 没有额外的内存消耗; 保证数据一致性; 实现简单 | 线程需要等待,性能受到一定影响; 有死锁风险 |
逻辑过期 | 异步构建缓存,线程无需等待,性能良好 | 不保证数据一致性,因为在异步构建缓存完成之前,返回的都是旧数据 有额外的内存消耗 实现复杂 |
互斥锁解决缓存击穿
-
修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
/**
* 获取锁
* @param key
* @return
*/
public boolean tryLock(String key){
return stringRedisTemplate.opsForValue().setIfAbsent(key,"1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
}
/**
* 释放锁
* @param key
* @throws Exception
*/
public void unlock(String key) throws Exception {
stringRedisTemplate.delete(key);
}
@Override
public Shop getShopById(Long id) {
//组装redis中的key
String cacheShopKey = CACHE_SHOP_KEY + id;
//根据ID在redis中查询商铺信息
String shopString = stringRedisTemplate.opsForValue().get(cacheShopKey);
//redis中查询到商铺信息
if (StrUtil.isNotBlank(shopString)){
Shop shop = BeanUtil.toBean(shopString, Shop.class);
return shop;
}
//redis中没有获取到数据
//获取互斥锁
String lockShopKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean b = tryLock(lockShopKey);
//获取失败 休眠 重新请求
if (!b){
Thread.sleep(30);
return getShopById(id);
}
//获取成功
//获取锁成功应该进行再次检查redis缓存是否存在,即做DoubleCheck,如果存在则无需重建缓存
//根据ID在redis中查询商铺信息
shopString = stringRedisTemplate.opsForValue().get(cacheShopKey);
//redis中查询到商铺信息
if (StrUtil.isNotBlank(shopString)){
shop = BeanUtil.toBean(shopString, Shop.class);
return shop;
}
//根据商铺id查询商铺信息
shop = this.getById(id);
//数据库中没查询到该商铺信息,则将空值写入缓存,并设置一个较短的TTL
if (ObjectUtil.isNull(shop)){
//数据库中查询到了该商铺信息,写入缓存,并设置有效时间为30分钟
stringRedisTemplate.opsForValue().set(cacheShopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//数据库中查询到了该商铺信息,写入缓存,并设置有效时间为30分钟
stringRedisTemplate.opsForValue().set(cacheShopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
// 释放锁
try {
unlock(lockShopKey);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
//返回给商铺信息
return shop;
}核心思路 :利用redis中的
SETNX
方法来表示获取锁,在stringRedisTemplate中,该方法被封装为setIfAbsent
,当setIfAbsent
方法返回true,则表明该key没有被存储在redis中,并且现在已经存储,此时成功存储key的线程就认为是获取锁的线程。-
SETNX
方法用于将一个键值对存储到redis中,但只有在指定的键不存在时才执行,如果键不存在,则不执行任何操作, -
SETNX
返回结果-
如果键名不存在并且存储成功,返回1 -
如果键名已经存在,则返回0
-
-
逻辑过期解决缓存击穿
-
修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
/**
* 将数据库查询到的数据设置逻辑过期时间 存储到redis中
* @param id
* @param expireTime
*/
private void saveShop2Redis(Long id, Long expireTime){
// 查询商铺信息
Shop shop = this.getById(id);
// 封装逻辑过期时间
RedisData<Shop> redisData = new RedisData<>();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//写入缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
//Executors.newFixedThreadPool(10);也能创建线程池 但是不推荐
//private static final ExecutorService a = Executors.newFixedThreadPool(10);
// 创建线程池
int corePoolSize = 10; // 核心线程数
int maxPoolSize = 20; // 最大线程数
long keepAliveTime = 60; // 非核心线程的空闲时间
TimeUnit unit = TimeUnit.SECONDS; // 时间单位
LinkedBlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(); // 任务队列
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue
);
@Override
public Shop getShopById(Long id) {
//组装redis中的key
String cacheShopKey = CACHE_SHOP_KEY + id;
//根据ID在redis中查询商铺信息
String shopString = stringRedisTemplate.opsForValue().get(cacheShopKey);
// 如果redis中不存在,则直接返回null
if (StrUtil.isBlank(shopString)){
return null;
}
// 不为空 判断是否逻辑过期
RedisData<Shop> redisData = JSONUtil.toBean(shopString, RedisData.class);
Shop shop = redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
// 如果expireTime晚于当前时间,则返回true。
// 如果expireTime早于或等于当前时间,则返回false。
if (expireTime.isAfter(LocalDateTime.now())){
//未过期 直接返回商铺信息
return shop;
}
//过期
//获取互斥锁
String lockShopKey = LOCK_SHOP_KEY + id;
try {
boolean b = tryLock(lockShopKey);
//互斥锁获取成功
if (b) {
//注意此处最好做彩瓷redis缓存是否过期的检查,即DoubleCheck,如果缓存没过期,则无需重建,主要防止他在获取锁的时候刚好有人重建完成导致的再次重建
//根据ID在redis中查询商铺信息
shopString = stringRedisTemplate.opsForValue().get(cacheShopKey);
// 如果redis中不存在,则直接返回null
if (StrUtil.isBlank(shopString)){
return null;
}
// 不为空 判断是否逻辑过期
redisData = JSONUtil.toBean(shopString, RedisData.class);
shop = redisData.getData();
expireTime = redisData.getExpireTime();
//如果expireTime晚于当前时间,则返回true。
//如果expireTime早于或等于当前时间,则返回false。
if (expireTime.isAfter(LocalDateTime.now())){
//未过期 直接返回商铺信息
return shop;
}
//开启新的线程
//重建缓存
executor.execute( ()->this.saveShop2Redis(id,20L));
}
}catch (Exception e){
throw new RuntimeException();
}finally {
//释放锁
try {
unlock(lockShopKey);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
//互斥锁获取失败,返回旧数据
return shop;
}
/**
* 获取锁
* @param key
* @return
*/
public boolean tryLock(String key){
return stringRedisTemplate.opsForValue().setIfAbsent(key,"1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
}
/**
* 释放锁
* @param key
* @throws Exception
*/
public void unlock(String key) throws Exception {
stringRedisTemplate.delete(key);
}核心思路 :请求查询redis缓存,判断redis缓存中是否存在,如果不存在,则直接返回空数据,不查询数据库,如果存在,则判断数据中的逻辑过去时间是否已经过期,如果没有过期,直接返回redis中的数据,如果过期,则开启独立线程去重构数据。该方式需要
缓存预热
。-
缓存预热是指在系统启动或负载较低的时候,提前将一些常用的数据加载到缓存中,以减少后续请求的响应时间和系统压力。
-
缓存预热的过程可以在系统启动时自动触发,也可以定期执行。下面是一种常见的缓存预热的实现方式:
-
确定需要预热的数据:根据系统的特点和需求,确定哪些数据是频繁访问的、耗时较长的,可以考虑将这些数据预热到缓存中。
-
在系统启动时或定期执行:在系统启动时或者定期触发预热任务,将需要预热的数据加载到缓存中。可以使用定时任务或者后台线程来实现。
-
数据加载到缓存:根据对应的缓存方案,使用相应的接口将数据加载到缓存中。例如,使用Redis作为缓存,可以使用Redis的相关命令将数据写入缓存。
-
预热完成标识:在预热完成后,可以设置一个标识来表示预热过程已经完成,以便系统其他部分知道缓存已经可用。
-
通过缓存预热,可以提前将常用的数据加载到缓存中,在实际请求到来时直接从缓存中获取数据,减少了数据库或其他数据源的访问,提高了系统的性能和响应速度。
需要注意的是,缓存预热可能会有一定的成本,包括预热数据的加载时间和系统资源消耗。因此,在选择需要预热的数据时,需要权衡数据的访问频率和成本,并根据实际情况评估是否值得进行缓存预热。
-
本文由 mdnice 多平台发布