缓存击穿
缓存击穿问题也叫热点key问题,指的是一个被 高并发访问 并且 缓存重建业务复杂 的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
如图,当某个线程查询缓存未命中时,则会进行查询数据库且重建缓存数据的操作,但是由于该key是热点数据,被访问的频率非常高。所以我们可以看到,很多线程都重复进行查询数据库并且重建缓存的操作,这将会给数据库带来巨大的压力。
解决方案
- 互斥锁
- 逻辑过期
互斥锁
如图所示,线程1获取锁成功之后,进行查询数据库并且重建缓存数据。假如此时有其他线程想要获取锁的话,必然是获取不到的。获取失败后,线程将会休眠并且重试查询缓存,如果仍然未命中且获取不到锁的话,则继续休眠,重试这个过程。本质上就是一个简单粗暴的解决方案,有一个线程获取锁并进行缓存重建,其余线程阻塞等待。
互斥锁的缺点在于只有一个线程获取锁,其余线程只能阻塞等待。假如重建缓存的时长相对较久,达到200ms-300ms的话,性能会显得比较差。
逻辑过期
逻辑过期并不是使用Redis的TTL机制进行过期,所以存入数据时并不用设置TTL。
由于产生击穿的原因是大量Redis的热点key发生过期,要进行缓存重建。要解决这个问题,逻辑过期处理方式在存入数据时同时存入一个逻辑上的过期时间。如果逻辑上过期了,仍然会返回过期数据给前端同时进行缓存重建,由于Redis的数据仍然有效并不会发生击穿。
两种方案对比
基于互斥锁方式解决缓存击穿问题的代码实现
需求
修改根据id查询商铺的业务,基于互斥锁方式来解决击穿问题
流程图
此处的互斥锁并不是我们日常使用的lock或者是synchorized那种重量级的锁,因为java的锁没拿到的话是会阻塞的,所以这里需要自定义未获取锁的线程行为而不是直接挂起阻塞。
这里我们使用了Redis的setnx命令来自定义一个锁。
Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。且无法更改
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
获取锁的命令就是setnx,那释放锁就是delete这个key。但是如果发生了线程中断或者服务失败等情况无法正常释放锁,那就需要给锁一个过期时间。
代码实现
首先进行获取锁和释放锁的操作
prvate boolean trylock(String key){
//获取锁
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
//释放锁
stringRedisTemplate.delete(key);
}
逻辑操作
private Shop queryWithMutex(Long id){
//尝试从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断是否存在
if (StrUtil.isNotBlank(shopJson)){
//存在,返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是"",也就是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
//实现缓存重建
//获取互斥锁
boolean trylock = trylock(RedisConstants.LOCK_SHOP_KEY + id);
//判断是否获取成功
Shop shop = null;
try {
if (!trylock) {
//失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//获取锁之后要进行一次doublecheck,确认是否有redis缓存重建
shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)){
//存在,返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是"",也就是空值
if(shopJson != null){
//返回一个错误信息
return null;
}
//进入数据库
shop = getById(id);
//模拟重建的延时
Thread.sleep(200);
//不存在,返回错误
if (shop == null) {
//将空值写入redis,解决缓存穿透问题
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, ""
, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//存在, 写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),
RedisConstants.CACHE_SHOP_TTL,TimeUnit.MINUTES);
//返回
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放互斥锁
unlock(RedisConstants.LOCK_SHOP_KEY+id);
}
return shop;
}
基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务。基于逻辑过期方式来解决缓存击穿问题
流程图
代码实现
首先进行RedisData类的创建,封装数据类并且引入过期时间
@Data
public class RedisData {
//逻辑过期时间
private LocalDateTime expireTime;
private Object data;
}
编写封装数据类和RedisData类的方法,引入过期时间到对象中
public void saveShopToRedis(Long id, Long expireSeconds){
//查询店铺数据
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
执行后Redis如图所示,在data之外还有个expireTime
主要的方法逻辑如下
//开启独立线程池进行缓存重建
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
private Shop queryWithLogicalExpire(Long id){
//尝试从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//判断是否存在
if (StrUtil.isBlank(shopJson)){
//不存在,返回null
return null;
}
//命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return shop;
}
//已过期,需要缓存重建
//开始缓存重建
//获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY+id;
//判断是否获取锁成功
boolean isLock = trylock(lockKey);
if (isLock){
//注意获取锁成功之后应该再次检测redis是否过期,做doublecheck,如果存在则无需重建缓存
//成功,开启独立显存,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存
this.saveShopToRedis(id,20L);
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁
unlock(lockKey);
}
});
}
//失败,直接返回过期的商铺信息
return shop;
}