Redis缓存穿透与缓存击穿
缓存穿透
在默认情况下,用户请求数据时,会先在缓存(Redis)中查找,若没找到即缓存未命中,再在数据库中进行查找,数量少可能问题不大,可是一旦大量的请求数据(例如秒杀场景)缓存都没有命中的话,就会全部转移到数据库上,造成数据库极大的压力,就有可能导致数据库崩溃。网络安全中也有人恶意使用这种手段进行攻击被称为洪水攻击。
解决方案
1)缓存空对象
简单的来说,就是请求之后,发现数据不存在,就将null值打入Redis中。
优点:实现简单,维护方便
缺点:额外的内存消耗
可能造成短期的不一致
思路分析:
当我们客户端访问不存在的数据时,先请求 redis,但是此时 redis 中没有数据,
此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,
我们都知道数据库能够承载的并发不如 redis 这么高,如果大量的请求同时过来访问这种不存在的数据,
这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,
我们也把这个数据存入到 redis 中去,这样,下次用户过来访问这个不存在的数据,
那么在 redis 中也能找到这个数据就不会进入到数据库了。
2)布隆过滤
在客户端与Redis之间加了一个布隆过滤器,对请求进行过滤。
布隆过滤器的大致原理:布隆过滤器中存放二进制位。
数据库的数据通过hash算法计算其hash值并存放到布隆过滤器中,
之后判断数据是否存在的时候,就是判断该hash值是0还是1。
但是这是一种概率上的统计,当其判断不存在的时候就一定是不存在;
当其判断存在的时候就不一定存在。所以有一定的穿透风险
优点:内存占用较少,没有多余 key
缺点:实现复杂 存在误判可能
编码解决用户查询的缓存穿透问题
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在 mysql 中不存在,直接就返回 404 了,
这样是会存在缓存穿透问题的
现在的逻辑中:
如果这个数据不存在,我们不会返回 404 ,还是会把这个数据写入到 Redis 中,
并且将 value 设置为空,当再次发起查询时,我们如果发现命中之后,判断这个 value 是否是 null,
如果是 null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。
@Override
public Result queryById(Long id) {
// 从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String userJson = stringRedisTemplate.opsForValue().get(key);
// 判断是否存在
if (StrUtil.isNotBlank(userJson )) {
// 存在,直接返回
User user = JSONUtil.toBean(userJson , User.class);
return Result.ok(user );
}
// 1.检查缓存中是否有空值
if (userJson == null) {
// 返回一个错误信息
return Result.fail("用户不存在!");
}
// 不存在,根据id查询数据库
User user = getById(id);
// 不存在,返回错误
if (user == null) {
// 2.防止穿透问题,将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("用户不存在!");
}
// 存在,写入Redis
// 把shop转换成为JSON形式写入Redis
// 同时添加超时时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(user), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(user);
}
缓存击穿
缓存击穿是部分key过期导致的严重后果。
为什么大量key过期会产生问题而少量的key也会有问题?
缓存击穿问题也叫热点Key问题,就是⼀个被高并发访问并且缓存重建业务较复杂的key突然失效了,
无数的请求访问会在瞬间给数据库带来巨大的冲击。
上述:假设此时该热点key的TTL时间到(失效了),则查询缓存未命中,会继续查询数据库,并进行缓存重建工作。但是由于查询SQL逻辑比较复杂、重建缓存的时间较久,并且该key又是热点key,短时间内有大量的线程对其进行访问,所以请求会直接到数据库中,数据库就有可能垮掉!
缓存击穿解决方案
通过互斥锁解决缓存击穿方案
简单的来说:
并不是所有的线程都有 “ 资格 ” 去访问数据库,只有持有锁的线程才可以对其进行操作。
不过该操作有一个很明显的问题,就是会出现相互等待的情况。
核心思路:
相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,
如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有得到,
则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入 redis,再释放锁,返回数据,
利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿。
public User queryByMutex(Long id) {
// 1.从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String userJson= stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopString )) {
return JSONUtil.toBean(shopString, User.class);
}
// 判断空值
if (userJson!= null) {
// 返回一个错误信息
return null;
}
String lockKey = "lock:user:" + id;
User user= null;
try {
// 4.实现缓存重建
// 4.1获取互斥锁
boolean isLock = tryLock(lockKey);
// 4.2判断是否成功
if (!isLock) {
// 4.3失败,则休眠并重试
Thread.sleep(50);
// 递归
return queryByMutex(id);
}
// 4.4成功,根据id查询数据库
user= getById(id);
// 模拟延迟
Thread.sleep(200);
// 5.不存在,返回错误
if (user== null) {
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(user),
CACHE_SHOP_TTL,TimeUnit.MINUTES);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
} finally {
// 7.释放锁
unLock(lockKey);
}
// 8.返回
return user;
}