黑马Redis项目笔记 缓存击穿问题和解决方案

332 阅读5分钟

缓存击穿

缓存击穿问题也叫热点key问题,指的是一个被 高并发访问 并且 缓存重建业务复杂 的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

image.png

如图,当某个线程查询缓存未命中时,则会进行查询数据库且重建缓存数据的操作,但是由于该key是热点数据,被访问的频率非常高。所以我们可以看到,很多线程都重复进行查询数据库并且重建缓存的操作,这将会给数据库带来巨大的压力。

解决方案

  • 互斥锁
  • 逻辑过期

互斥锁

image.png

如图所示,线程1获取锁成功之后,进行查询数据库并且重建缓存数据。假如此时有其他线程想要获取锁的话,必然是获取不到的。获取失败后,线程将会休眠并且重试查询缓存,如果仍然未命中且获取不到锁的话,则继续休眠,重试这个过程。本质上就是一个简单粗暴的解决方案,有一个线程获取锁并进行缓存重建,其余线程阻塞等待。

互斥锁的缺点在于只有一个线程获取锁,其余线程只能阻塞等待。假如重建缓存的时长相对较久,达到200ms-300ms的话,性能会显得比较差。

逻辑过期

image.png

逻辑过期并不是使用Redis的TTL机制进行过期,所以存入数据时并不用设置TTL。

由于产生击穿的原因是大量Redis的热点key发生过期,要进行缓存重建。要解决这个问题,逻辑过期处理方式在存入数据时同时存入一个逻辑上的过期时间。如果逻辑上过期了,仍然会返回过期数据给前端同时进行缓存重建,由于Redis的数据仍然有效并不会发生击穿。

image.png

两种方案对比

image.png

基于互斥锁方式解决缓存击穿问题的代码实现

需求

修改根据id查询商铺的业务,基于互斥锁方式来解决击穿问题

流程图

image.png

此处的互斥锁并不是我们日常使用的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查询商铺的业务。基于逻辑过期方式来解决缓存击穿问题

流程图

image.png

代码实现

首先进行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

image.png

主要的方法逻辑如下


//开启独立线程池进行缓存重建
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;
}