众所周知
Redis是款高性能的非关系数据库中间件,具备缓存读写的强大能力
但是归根结底还是以身后的数据库为托底。
然而在高并发或有坏比攻击的情况下,可能会干碎你的缓存系统,导致请求直接穿透至数据库进而引发严重问题。常见的失效场景比如有缓存穿透、缓存击穿和缓存雪崩。
我们这来逐一列举一下这些问题的根源和解决方案
1.缓存穿透:
根本原因:查询的数据在缓存和数据库中根本不存在,导致每次请求都会直达数据库,而造成数据库压力。
解决方案:核心思路就是即使是不存在的数据,也在缓存中留下记录而防止重复查询数据库,也就是缓存空对象或者布隆过滤器。
这里还没深入了解布隆过滤器所以就先说说缓存空对象的方法
缓存空对象:即使数据库查询结果为NULL,也把这个空结果(例如空字符串或特定标识)写入缓存,并设置一个较短的过期时间(例如5分钟)。这样后续请求在有效期内将直接命中缓存中的空值,从而保护数据库。
以下是一种代码实现方案(缓存空对象):
//R为泛型参数类型,ID是传入对象的类型, 后面的R返回值类型
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit) {
//Function<传入参数,返回值> //定义一个有参数有返回值的泛型方法
String key=keyPrefix+id;
String json=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(json)){
R bean = JSONUtil.toBean(json, type);
return bean;
}
if(json!=null){//判断是否为空值
return null;
}
R r= dbFallback.apply(id);//这里就比如调用MP的查询方法
//Function<ID,R>调用时只需要传入一个ID参数,意为输入为ID,输出为R
if(r==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);//注意值为“ ”
return null;
}
this.set(key,r,time,unit);//这个this相当于调用set方法的实例化对象
|
// |-> stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(r),time,unit);
//这里是我事先写好了set的工具方法,所以直接this.set,set工具方法里封装的就是这个
return r;
}
代码说明:
- 关键点在于区分前两个if到底是查询的有效值还是。
redisTemplate.get(key)
对于不存在的 key 返回null
。redisTemplate.set对于存入""
的 key 返回空字符串。
2.缓存击穿:
定义:某个热点Key在缓存中Key过期的瞬间,海量的并发请求同时无法命中缓存,并去数据库查询数据并载入缓存。这个瞬间的并发压力可能会压垮数据库。
特别是页面首页这种高并发的地方最容易有缓存击穿的问题
方案一:设置逻辑时间过期:
核心思想:
手动设置一个TLL而不依赖Redis的TTL(自动物理过期时间),而是在缓存值中额外储存一个手动设置的逻辑过期时间. 当发现数据逻辑过期时,异步更新缓存,当前请求返回旧数据。
(手动设置的TLL到了之后不会删除缓存,而普通的物理过期时间会自动删除缓存)
会标记为过期数据但不删除缓存,访问缓存会返回旧数据,但可以防止直接访问数据库
代码中会申请互斥锁来保护数据的更新,但是高并发的情况下就会来不及更新而返回旧数据
/为了可用性而牺牲了一致性的策略。防止缓存击穿/
代码如下:(逻辑过期)
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){
String key=keyPrefix+id;
String JSON = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(JSON)) {return null;}
RedisData redisData = JSONUtil.toBean(JSON, RedisData.class);//1.若命中,需要先把JSON反序列化为对象
//data封装了封装进这个属性的类的所有属性
JSONObject data = (JSONObject) redisData.getData();//这里要用JSONObject接收,我也不知道为什么,用Object会报错
R r=JSONUtil.toBean(data,type);//再把JSONObject转成对应类型对象
LocalDateTime expireTime=redisData.getExpireTime();
//2.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){return r;}//2.1 若未过期,直接返回店铺信息
//2.2 若过期,需要缓存重建
//3.缓存重建
//3.1获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
boolean isLock=tryLock(lockKey);
if(isLock){//3.2判断是否获取锁成功
CACHE_REBUILD_EXECUTOR.submit(()->{//3.3 若成功,开启独立线程,实现缓存重建
try {//重建缓存
R r1=dbFallback.apply(id);//函数式编程,传入ID到数据库进行查找来更新数据
this.setWithLogicalExpire(key,r1,time,unit);//更新缓存,这个r1只是做到更新缓存的作用,用完就废了,因为逻辑过期时间是TLL到了之后不删缓存而返回旧数据
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
//3.4返回过期的商铺信息
return r;
}
设置互斥锁:
并发线程一个一个等待锁的开启释放,就不会同时请求并发直击数据库
但带来的问题就是性能的浪费,甚至于导致死锁
以下是代码实例(互斥锁):
public <R,ID> R queryWithMutex(String keyPrefix,ID id,Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){
//从Redis中查询缓存
String key=keyPrefix+id;
String JSON = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(JSON)){
//此处要转存json格式才能被前端正确的接受
R r= JSONUtil.toBean(JSON,type);
return r;
}
//判断是否为空字符串 (三种情况:有值,无值,空字符串,下面有一个判断为null的了)
if(JSON!=null){
return null;
}
/**
* ————————————若数据未命中————————————
*/
//实现缓存重建
// 1.获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
R r=null;
try {
boolean isLock=tryLock(lockKey);
//2.判断是否获取互斥锁
if(!isLock){
//3.若失败,则进入短期休眠状态
Thread.sleep(50);
return queryWithMutex(keyPrefix,id,type,dbFallback,time,unit);// 递归,休眠时间到后重新尝试加锁
}
//4.若加锁成功,则根据id查询数据库
r = dbFallback.apply(id);
//5.若不存在则返回失败
if(r==null){
//这是返回redis的空内容缓存,用于防止无限访问导致的缓存穿透
stringRedisTemplate.opsForValue().set("cache:shop+id","",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set("cache:shop+id",JSONUtil.toJsonStr(r),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//7.释放互斥锁
unlock(lockKey);
}
//8.返回
return r;
}
private boolean tryLock(String key){//添加锁
Boolean flag=stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);//用这个布尔工具类更好一点,可以防止拆箱导致的空指针
}
这里有一个难点就是Redis是怎么进行加解锁操作的
其实是先设置了一个LOCK:SHOP+ID的表层级
然后把表层级传入tryLock方法(加锁操作)
tryLock方法中的关键字setIfAbsent的作用是如果不存在就set
setIfAbsent(key,”1″,10,TimeUnit.SECONDS); 会设置值(value)为“1”
并且设置10秒的TTL时间以防止死锁
这个”1“相当于标志,存在时阻止同名缓存
tryLock方法是Boolean类型的
所以当boolean isLock=tryLock(lockKey);时
会去Redis中设置一个新的锁,
然后会被IfAbsent卡住进而返回false,加锁失败
后续对isLock是True还是False进行判断即可
若为false就sleep以下然后在return递归的调用函数本身
必须要是return,是返回到上一层级。不加return只会继续执行if内的语句然后报错
最后释放互斥锁
3. 缓存雪崩
- 定义:缓存中大量的数据在同一时间点或极短时间内集中过期,或者缓存服务(如Redis集群)直接宕机。导致所有对这些数据的请求全部转向数据库,数据库无法承受瞬间的巨大压力而崩溃,进而导致整个系统瘫痪。
这种的解决方式主要是一种思想习惯而不是代码层面能直接解决的,有错开过期时间、构建高可用的缓存集群、提前预热等手段,这里就不展开细说。