【Redis】缓存击穿问题及其解决方案
文章目录
- 【Redis】缓存击穿问题及其解决方案
- 1. 缓存击穿概念
- 2. 解决方案
- 2.1 互斥锁
- 2.1.1 互斥锁的优缺点
- 2.1.2 互斥锁的代码实现
- 2.2 逻辑过期
- 2.2.1 逻辑过期的优缺点
- 2.2.2 逻辑过期的代码实现
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;
}