Redis的缓存穿透、缓存雪崩、缓存击穿问题及有效解决方案
目录
一、缓存穿透
1.简介
2.解决方案
3.修改前的代码
4.修改过后的代码
二、缓存雪崩
1.简介
2.解决方案
三、缓存击穿
1.简介
2.解决方案
3.用代码来实现互斥锁来解决缓存击穿
4.用代码来实现逻辑过期解决缓存击穿
四、缓存穿透和缓存击穿的区别
一、缓存穿透
1.简介
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
也就是有请求一请求到后端就一定会打到数据库(redis和MySQL都没有该数据),打一次不要紧,但要是有无数个请求同时打过来,那数据库大概率会崩溃,所以预防缓存穿透问题还是十分重要的。
2.解决方案
目前常见的解决方案有返回缓存空对象和布隆过滤两种方法,区别如下:
-
缓存空对象
-
优点:实现简单,维护方便
-
缺点:
-
额外的内存消耗
-
可能造成短期的不一致
-
-
-
布隆过滤
-
优点:内存占用较少,没有多余key
-
缺点:
-
实现复杂
-
存在误判可能
-
-
缓存空对象的解决思路如下:
当一个请求直接打到数据库的时候,后端直接在Redis中返回个空对象,之后多次相同的请求都会止步于Redis,通俗易懂的话来说,要打打Redis,别打MySQL。
布隆过滤的解决思路如下:
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突
3.修改前的代码
我们采用返回空对象的方法来缓解缓存穿透问题,修改前的代码如下:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 根据id查询店铺信息
* @param id
* @return
*/
@Override
public Result queryById(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 Result.ok(shop);
}
//4、不存在,根据id查询数据库
Shop shop = getById(id);
//5、判断数据库中是否有数据
if (shop == null) {
//6、没有,返回错误
return Result.fail("店铺不存在");
}
//7、有数据,返回,并将数据写入redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,jsonStr);
//8、返回结果
return Result.ok(shop);
}
}
这段代码的详细分析在以下文章可查看:给查询业务添加Redis缓存
4.修改过后的代码
@Override
public Result queryById(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 Result.ok(shop);
}
//判断命中的是否为空值------------------------------------------->修改后
if(shopJson != null){
return Result.fail("店铺不存在");
}
//4、不存在,根据id查询数据库
Shop shop = getById(id);
//5、判断数据库中是否有数据
if (shop == null) {
//6、没有,返回空字符串给redis------------------------------->修改后
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
//7、有数据,返回,并将数据写入redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,jsonStr,30L, TimeUnit.MINUTES);
//8、返回结果
return Result.ok(shop);
}
二、缓存雪崩
1.简介
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
2.解决方案
-
给不同的Key的TTL添加随机值
-
利用Redis集群提高服务的可用性
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存
三、缓存击穿
1.简介
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
2.解决方案
常见的解决方案有两种:
-
互斥锁
-
逻辑过期
互斥锁的解决思路就是在Redis进行缓存重建时,拿到一个互斥锁,其他请求拿不到这个锁就是乖乖等待锁的释放。
逻辑过期的解决思路如下:
在存入redis的value中增加一个字段,该字段为过期时间加上x分钟,通过计算就知道这个数据是否逻辑上过期,事实上没过期一直存在redis中。
在redis进行缓存重建的时候,会另开一个线程进行重建并拿到互斥锁,其他线程拿不到数据想要缓存重建时也拿不到锁,那就直接返回旧数据。
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
3.用代码来实现互斥锁来解决缓存击穿
其中这个互斥锁跟我们平时用的锁不太一样,不是synchronized等那些锁,这个互斥锁是我们自己编写的锁,其核心是利用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);
}
Service代码如下:
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("key");
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if(!isLock){
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
// 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_NULL_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
4.用代码来实现逻辑过期解决缓存击穿
流程图如下:
具体代码如下:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, 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 lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
四、缓存穿透和缓存击穿的区别
缓存穿透和缓存击穿之间的区别,可以说是既有质的区别也有量的区别:
-
质的区别:
- 数据存在性不同:缓存穿透是查询一个数据库中不存在的数据,而缓存击穿是查询一个数据库中确实存在且非常热门的数据。
- 触发原因不同:缓存穿透是由于查询的数据在数据库中不存在,导致缓存中也没有对应的记录;缓存击穿则是由于缓存中的数据到期,而此时有大量请求需要访问这个数据。
-
量的区别:
- 请求量不同:缓存穿透可能涉及到的请求量相对较小,因为通常不会有很多请求同时查询一个不存在的数据;而缓存击穿则可能涉及到大量的请求,因为热门数据的访问量通常很大。
- 影响范围不同:缓存穿透的影响范围可能较小,因为它只涉及到查询不存在的数据;而缓存击穿的影响范围可能较大,因为它涉及到的是热门数据,可能会对数据库造成较大压力。
总的来说,缓存穿透和缓存击穿在触发原因、数据存在性、请求量和影响范围等方面都存在明显的区别,这些区别既包括了质的不同,也包括了量的不同。因此,在设计缓存策略时,需要针对这两种情况分别采取不同的解决方案。