【Redis】分别从互斥锁与逻辑过期两个方面来解决缓存击穿问题
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
文章目录
前言
身逢乱世未雨绸缪
一.什么是缓存击穿
说直白点就是一个被非常频繁使用的key突然失效了请求没命中缓存而因此造成了无数的请求落到数据库上瞬间将数据库拖垮。而这样的key也被叫做热key!
可以直观地看到要想解决缓存击穿绝对不能让这么多线程的请求在某一时段大量去访问到数据库。
以此为基础针对访问数据库的限制有两种解决方案
二.基于互斥锁解决缓存击穿
对于一个访问频繁的id查询接口可能会发生缓存击穿问题下面通过互斥锁的方式来解决
在以前id查询信息的接口里一般将查询的信息写到缓存里针对是否命中缓存再去做对应的处理。而在并发的情况下对于热Key失效的情况大量的请求则会直接打到数据库上并试图重建缓存很有可能打停数据库导致服务中断。对于这样的情况往往是在未命中缓存时最佳的处理点就在于业务中判断缓存是否命中之后的那一步操作即“多余”的请求对数据库的访问与否。
其他线程的请求能不能去访问数据库什么时候才能去访问数据库
其他的线程能不能去访问数据库——加锁有锁才能
什么时候才能去访问数据库——等主线程释放锁
那其他线程拿不到锁的时间该干嘛——睡吧等会再来
为了实现在多个线程并行的情况下只能有一个线程获得锁我们可以使用Redis自带的setnx
他可以保证在key不存在时可以进行写的操作key存在时无法进行写的操作这就完美地保证了在并发情况下只有第一个拿到锁的线程才能去写并且他写完了之后在不释放的前提下别人就写不了了。
如何去获取写个Key—Value进去
如何释放把Key删了 del lock (通常设置一个有效期避免长时间未释放的情况)
这样我就可以以此为条件封装两个方法一个写key来尝试获取锁另一个删key来释放锁。就像这样
/**
* 尝试获取锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
在并行情况下每当其他线程想要获取锁来访问缓存都要通过将自己的key写到tryLock方法里setIfAbsent返回false则说明有线程在在更新缓存数据锁未释放。若返回true则说明当前线程拿到锁了可以访问缓存甚至操作缓存。
我们在下面一个热门的查询场景中用代码用代码来实现互斥锁解决缓存击穿
/**
* 解决缓存击穿的互斥锁
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key); //JSON格式
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) { //不为空就返回 此工具类API会判断""为false
//存在则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
//return Result.ok(shop);
return shop;
}
//3.判断是否为空值
if (shopJson != null) {
//返回一个空值
return null;
}
//4.缓存重建
//4.1获得互斥锁
String lockKey = "lock:shop"+id;
Shop shopById=null;
try {
boolean isLock = tryLock(lockKey);
//4.2判断是否获取成功
if (!isLock){
//4.3失败则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功根据id查询数据库
shopById = getById(id);
//5.不存在则返回错误
if (shopById == null) {
//将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//return Result.fail("暂无该商铺信息");
return null;
}
//6.存在写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
return shopById;
}
三.基于逻辑过期解决缓存击穿
逻辑过期不是真正的过期对于对应的Key我们并不需要去设置TTL而是通过业务逻辑来达到一个类似于“过期”的效果。其本质还是限制落到数据库的请求数量但前提是牺牲一致性保证可用性还是上一个业务的接口通过使用逻辑过期来解决缓存击穿
这样一来缓存基本是会被命中的因为我没有给缓存设置任何过期时间并且对于Key的set都是事先选择好的如果出现未命中的情况基本可以判断他不在选择之内,这样我就可以直接返回错误信息。那么对于命中的情况就需要先判断逻辑时间是否过期根据结果再来决定是否进行缓存重建。而这里的逻辑时间就是减少大量请求落到数据库的一个“关口”
看完上面这一段相信大家还很迷惑。既然没有设置过期时间那你为什么还要判断逻辑过期时间怎么还存在过不过期的问题
其实这里所谓的逻辑过期时间只是一个类的属性字段根本没有上升到Redis上升到缓存的层面是用来辅助判断查询对象的也就是说所谓的过期时间与缓存数据是剥离开的所以根本不存在缓存过期的问题自然数据库也不会有压力。
代码阶段
为了尽可能地贴合开闭原则不采用继承的方式来扩展原实体的属性而是通过组合的形式。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data; //这里用Object是因为以后可能还要缓存别的数据
}
封装一个方法用来模拟更新逻辑过期时间与缓存的数据在测试类里运行起来达到数据与热的效果
/**
* 添加逻辑过期时间
*
* @param id
* @param expireTime
*/
public void saveShopRedis(Long id, Long expireTime) {
//查询店铺信息
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//将封装过期时间和商铺数据的对象写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
查询接口
/**
* 逻辑过期解决缓存击穿
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) throws InterruptedException {
String key = CACHE_SHOP_KEY + id;
Thread.sleep(200);
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key); //JSON格式
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//不存在则直接返回
return null;
}
//3.判断是否为空值
if (shopJson != null) {
//返回一个空值
//return Result.fail("店铺不存在");
return null;
}
//4.命中
//4.1将JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//4.2判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//5.未过期则返回店铺信息
return shop;
}
//6.过期则缓存重建
//6.1获取互斥锁
String LockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(LockKey);
//6.2判断是否成功获得锁
if (isLock) {
//6.3成功开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(LockKey);
}
});
}
//6.4返回商铺信息
return shop;
}
四.接口测试
可以看到通过APIfox模拟并发场景进行接口测试平均耗时还是很短的控制台的日志也没有频繁的去访问数据库的记录
由于ApiFox不支持大量线程我又用jmeter拿1550个线程测试了一下接口依然都可以跑通
看来接口在并发场景下性能还不错QPS也挺理想
五.两者对比
可以看到互斥锁的方式代码层面更加简单只需要封装两个简单的方法来操作锁。而逻辑过期的方式更加复杂需要额外增添实体类封装方法之后还要去测试类里模拟数据预热。
相比之下前者没有消耗额外的内存不开新线程数据一致性强但是线程需要等待性能可能不好并且有死锁的风险。后者开辟了新的线程有额外的内存消耗牺牲一致性保证可用性但是不要需等待性能比较好。