前言
众所周知,在面试时,面试官总会问到Redis缓存的一系列问题。如何更好的理解并且区分 Reids 穿透、击穿和雪崩之间的区别,一直以来都是困扰着大家的问题。特别是穿透和击穿,过一段时间就稀里糊涂的分不清了。
那么,本篇文章会通过三个关键词来区分并理解缓存穿透、雪崩、击穿。
一、缓存穿透
关键词:无中生有
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样的缓存永远不会生效,这些请求都会直接打到数据库中,从而给数据库带来巨大压力。
举个例子: 恶意用户传入负的商品ID或者不存在的商品ID来查询商品,这样由于缓存中没有,每次都会去查询缓存。
缓存穿透发生的场景
黑客的恶意攻击行为,利用不存在的Key或者恶意尝试导致产生大量不存在的业务数据请求。
常见解决方案
1.缓存空对象:把不存在的key值所对应的value值设置为null并存入Redis缓存中,那么再有不存在的key进行访问时,直接把缓存中的null返回。简单粗暴,维护方便。但是会产生额外的内存消耗。
2.使用布隆过滤器:我们可以将查询的数据条件都存到一个足够大的布隆过滤器中,用户发送的请求会先被布隆过滤器拦截,一定不存在的数据就直接拦截返回了,从而避免下一步对数据库的压力。
3.增强id的复杂性,避免被猜测id规律
我们使用一段查询商铺的代码例子来完成第一种解决方案 ,根据第一种解决方案的逻辑,来对原缓存业务逻辑进行修改。在未查询到数据库数据时,将空值写入Redis缓存,并在下一次非法id传来时直接把Redis缓存中的空值返回。
具体代码:
@Override
public Result queryById(Long id) {
// 根据商店id提前生成商店的key值
String key="cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 新增逻辑解决穿透问题:判断命中是否为空值
if("".equals(shopJson)){
return Result.fail("商铺不存在!");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.若数据库中不存在,返回错误
if (shop==null) {
// 新增逻辑解决穿透问题:将空值写入Redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回店铺信息
return Result.ok(shop);
}
二、缓存雪崩
关键词:万箭齐发
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求打到数据库,数据库危在旦夕。
举个例子:在某宝的双十一活动时,上万人同时抢购各种生活用品,可是存储生活物品的多条缓存数据由于设置了过期时间同时消失了。上万条请求直达数据库,某宝崩溃了。
常见解决方案
1.给不同的key设置的过期时间添加随机值,使不同的key在不同的时间自动消失。
2.利用Redis集群提高服务的可用性
3.给缓存业务添加降级限流策略
4.给业务添加多级缓存,如浏览器缓存等。(我如果穿了五层防弹衣,什么子弹都打不穿我)
解决方案演示
给不同的key设置的过期时间添加随机值具体代码如下图,我们还是使用上一个例子所使用的商铺查询的代码来做修改:
@Override
public Result queryById(Long id) {
// 生成一个从1到10的随机的长整型变量
long random=1+(long)(Math.random()*10);
// 根据商店id提前生成商店的key值
String key="cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.若数据库中不存在,返回错误
if (shop==null) {
return Result.fail("店铺不存在!");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL+random, TimeUnit.MINUTES);
// 7.返回店铺信息
return Result.ok(shop);
}
三、缓存击穿
关键词:定点爆破
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
举个例子:在某宝的双十二活动时,上万人同时抢购华为pro,可是存储华为pro的那条缓存数据由于设置了过期时间同时消失了。上万条请求直达数据库,某宝崩溃了。
常见解决方案
1.互斥锁
2.逻辑过期
解决方案演示
互斥锁方案就是在第一个线程未命中缓存时,利用互斥锁来对它进行限制,仅允许这一条线程去查询数据库并缓存数据,查到数据后再重建缓存。当缓存被重新创建后再释放锁。而在重构缓存的过程中其他线程就不允许查询数据库,也就是不让它们获取互斥锁,直至命中缓存。
互斥锁流程图
互斥锁代码实现
使用setnx命令模拟互斥锁的实现,如果成百上千条线程都来执行setnx命令 那么只有一条线程会执行成功直到这个key值被删除!
首先定义两个方法分别用于获取锁与释放锁。
// 获取锁
private Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
boolean aTrue = BooleanUtil.isTrue(flag);
return aTrue;
}
// 释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
然后封装互斥锁的方法
// 封装缓存击穿代码
public Shop queryWithMutex(Long id){
// 根据商店id提前生成商店的key值
String key="cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 新增逻辑解决 穿透 问题:判断命中是否为空值
if("".equals(shopJson)){
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockkey="lock:shop:"+id;
try {
Boolean isLock = tryLock(lockkey);
//4.2判断是否获取成功
if(!isLock){
//4.3失败,则休眠并重试(递归)
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功,则根据id查询数据库
shop = getById(id);
//模拟重建时间
Thread.sleep(200);
// 5.若数据库中不存在,返回错误
if (shop==null) {
// 新增逻辑解决穿透问题:将空值写入Redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放互斥锁
unLock(lockkey);
}
// 8.返回店铺信息
return shop;
}
互斥锁这个方案虽然操作简单、便于维护,但也存在着一些问题,比如热点key重构缓存时间过长,在这段时间内,所有涌入的线程只能等待,性能较差。那我们来看第二种方案:
逻辑过期方案:顾名思义就是永不过期,缓存雪崩、缓存击穿问题的根源就是key值的TTL(过期时间)造成的。那我们就可以不为这个key设置过期时间,但如果数据库中的数据更新了怎么办?这时我们要在这个key存入缓存中时为它加上一条缓存的逻辑过期时间字段去代替设置缓存过期时间:
具体逻辑如下:当线程1访问key缓存时发现这条数据的逻辑时间已过期,它就会获取互斥锁并开启新线程,利用线程2去查询数据库并重构缓存。在这段时间内线程1会直接返回过期的数据。在重构缓存的时间内若有其他线程来访问key时就不会获取互斥锁并返回过期数据。直到线程2重构缓存成功后其他线程才能命中缓存并拿到新数据。
逻辑过期流程图
逻辑过期代码实现:
private static final ExecutorService CAHE_REBUID_EXECUTOR= Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id){
// 根据商店id提前生成商店的key值
String key="cache:shop:" + id;
// 1.从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.不存在,直接返回
return null;
}
//命中,将json反序列化为对象
RedisDate redisDate = JSONUtil.toBean(shopJson, RedisDate.class);
Shop shop = JSONUtil.toBean((JSONObject) redisDate.getData(), Shop.class);
LocalDateTime expireTime = redisDate.getExpireTime();
//判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return shop;
}
//已过期,需要缓存重建
//获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
Boolean isLock = tryLock(lockKey);
if(isLock){
//获取成功,开启独立线程,实现缓存重建
CAHE_REBUID_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unLock(lockKey);
}
});
}
//获取失败,直接返回过期商铺信息
return shop;
}
// 获取锁
private Boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
boolean aTrue = BooleanUtil.isTrue(flag);
return aTrue;
}
// 释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
两种解决方案的优缺点
互斥锁操作便捷,可以保证一致性但会使性能受到影响。而逻辑过期可以保证性能,但是存在数据的一致性问题并且产生了额为的内存消耗。
创作不易,感觉对你有帮助的话,来一个点赞收藏~~~