缓存穿透 :客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这样的请求都会访问到数据库,这样的大量请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,对数据库造成巨大压力。
解决方案 :
缓存空对象
布隆过滤
解决缓存穿透 :如果客户端请求的数据缓存和数据库中都不存在,则将这个数据也写入缓存中,并将其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问题) :是指一个被高并发访问并且缓存重建业务比较复杂的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
返回结果
修改根据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 redisData = JSONUtil.toBean(shopString, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
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 = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
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多平台发布