【Redis】缓存击穿问题及其解决方案
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
【Redis】缓存击穿问题及其解决方案
文章目录
1. 缓存击穿概念
缓存击穿缓存击穿也叫做热点Key问题就是少量被高并发访问并且缓存重建业务比较复杂的key突然失效了无数的请求访问会在瞬间给数据库带来巨大的压力。
如图所示
线程1缓存未命中去重建缓存在线程1重建缓存的时候线程2缓存又没命中线程2也去重建缓存和线程2同时来的线程3线程4…缓存都没命中都去重建缓存给数据库带来了巨大的压力。
2. 解决方案
缓存击穿的常见解决方案有两种
- 互斥锁
- 逻辑过期
2.1 互斥锁
互斥锁的实现思路就是在第一个线程到来的时候获取互斥锁后面的线程来到之后尝试去获取互斥锁获取失败于是进行休眠重试。直到第一个线程缓存重建成功之后释放互斥锁。之后其余线程在重试过程中就成功查询缓存命中了重建数据。
互斥锁的流程图如下
2.1.1 互斥锁的优缺点
优点
- 没有额外的内存消耗
- 保证一致性(数据库和redis数据一致)
- 实现简单
缺点
- 线程需要等待性能受影响
- 可能有死锁风险(一个方法里有多个查询操作另一个方法也有多个重合的查询操作)
2.1.2 互斥锁的代码实现
我们先设定一个场景假设这是一个电商平台我们通过id去查询店铺信息。
代码实现流程图如下
首先我们编写获取锁和释放锁的方法如下所示
//获取锁
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);
}
然后编写一个解决缓存击穿问题的方法最后写一个调用解决方法的业务方法
@Override
public Result queryById(Long id) {
//缓存空对象解决 缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁解决 缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) {
//1.从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//此时 shopJson 不是为null就是为""
if (shopJson != null) {
//为""直接返回错误信息为null查询数据库
return null;
}
//4.实现缓存重建
//4.1.获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2.判断是否获取成功
while (!isLock) {
//4.3.失败则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4.获取锁成功再次检测缓存释放存在(double check)
String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cacheShopJson)) {
//4.5.存在直接返回
return JSONUtil.toBean(cacheShopJson, Shop.class);
}
//5.缓存数据不存在根据id查询数据库
shop = getById(id);
//模拟重建的延时
Thread.sleep(200);
//6.不存在返回错误
if (shop == null) {
//缓存空值
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//7.存在写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//8.释放锁
unLock(lockKey);
}
return shop;
}
2.2 逻辑过期
逻辑过期就是给缓存的数据添加一个逻辑过期字段而不是真正的给它设置一个TTL。每次查询缓存的时候去判断是否已经超过了我们设置的逻辑过期时间如果未过期直接返回缓存数据如果已经过期则进行缓存重建。
逻辑过期的流程图如下
解释第一个线程到来之后发现逻辑过期于是获取互斥锁再开启一个新线程去进行缓存重建。当后续线程到来时发现缓存已过期尝试获取互斥锁也失败但是此时不进行等待重试而是直接返回过期数据。之后第一个线程成功缓存数据释放互斥锁之后后面线程继续来访发现命中缓存并且没有过期返回重建数据。
2.2.1 逻辑过期的优缺点
优点
- 线程无需等待性能较好
缺点
- 不保证一致性(因为会返回过期数据)
- 有额外的内存消耗(同时缓存了逻辑过期时间的字段)
- 实现复杂
2.2.2 逻辑过期的代码实现
我们先设定一个场景假设这是一个电商平台我们通过id去查询店铺信息。
代码实现流程图如下
1构建存储类
我们想要实现逻辑过期首先得清楚redis中到底要存储什么样的数据我们是不是要在每个类中都添加一个逻辑过期的字段这是不对的如果我们再每个类中都添加了一个逻辑过期时间字段这样对原代码就有了 侵入性
我们应该使整个系统具有可拓展性所以我们应该新建一个类来填充要存入redis的数据代码如下
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2创建线程池
由于我们需要开启独立线程去重建缓存所以我们可以选择创建一个线程池。
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
3)编写缓存重建的代码
缓存重建就是直接查询数据库将查询到的数据缓存到redis中。
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
//设置逻辑过期时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
4编写业务方法并调用缓存击穿方法
@Override
public Result queryById(Long id) {
//缓存空对象解决 缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁解决 缓存击穿
//Shop shop = queryWithMutex(id);
//逻辑过期解决 缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWithLogicalExpire(Long id) {
//1.从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//未命中直接返回空
return null;
}
//3.命中判断是否过期
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop cacheShop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//3.1未过期,直接返回店铺信息
return cacheShop;
}
//3.2.已过期缓存重建
//3.3.获取锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
if (flag) {
//3.4.获取成功
//4再次检查redis缓存是否过期做double check
shopJson = stringRedisTemplate.opsForValue().get(key);
//4.1.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//未命中直接返回空
return null;
}
//4.2.命中判断是否过期
redisData = JSONUtil.toBean(shopJson, RedisData.class);
cacheShop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
//4.3.未过期,直接返回店铺信息
return cacheShop;
}
CACHE_REBUILD_EXECUTOR.submit(() -> {
//5.重建缓存
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//7.获取失败,返回旧数据
return cacheShop;
}