Redis缓存击穿问题及解决方案

964 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 6 天,点击查看活动详情

1.什么是缓存击穿

缓存击穿问题是redis中某个key过期了,而这时有大量的请求同时来访问这个key并且这个key的缓存重建业务比较复杂(意味着将新缓存存进redis中需要较长时间),无数请求最后只能先去访问数据库,给数据库带来巨大的冲击。

2.缓存击穿解决方案

解决缓存击穿的常见方案有两种:

  1. 互斥锁
  2. 逻辑过期

2.1 互斥锁

image.png 互斥锁的逻辑可以简化为上图所示。线程1先发来请求,并且获取到了互斥锁。这时线程1就可以去查询数据库,接着将查询到的数据缓存到redis中,最后记得释放锁,不然以后别的线程无法访问数据库。如果在线程1获取互斥锁成功并且还未重建缓存、释放互斥锁的时候,线程2的请求到达,那么线程2无法在redis中获取到缓存,无法获取到互斥锁,那么我们就让线程休眠一会,比方休眠50毫秒,再让线程2去查询缓存,如果还是查询不到,那么再重新休眠,再重新查询。

2.2 逻辑过期

逻辑过期,意味着永不过期。缓存击穿问题产生的原因是某个热点key过期了,请求都打到数据库了,造成数据库压力过大。因此我们可以提前准备一个不过期的热点key (比如参加活动的商品),不设置它的过期时间,将这个key保存到redis中,这样理论上总能命中redis。那是怎么判断这个key逻辑上过期了?答案是这个key的value存储一个过期时间,我们判断这个key是否过期的依据,就是这个key的value保存的过期时间。

image.png

2.3 互斥锁和逻辑过期两种方案对比

解决方案优点缺点
互斥锁1.没有额外的内存消耗 2.保证一致性 3.实现简单1.线程需要等待
逻辑过期线程无需等待,性能较好1.不保证一致性 2.有额外内存消耗 3.实现复杂

两种方案没有孰优孰劣之分,试开发者的需求来决定采用何种方式。如果你看重数据的一致性,那么显然是采用互斥锁;如果你对数据的一致性要求不高,看重性能,显然逻辑过期方案会更好。

3.实践

下面的例子是黑马点评,查询店铺的代码。

3.1 利用互斥锁解决缓存击穿问题

image.png

  1. 我们将利用互斥锁的主要业务逻辑写在一个叫queryWithMutex(Long id)的方法里。这里对它做一点介绍。首先我们去redis里查询是否有可以返回的缓存,如果有就直接返回;如果没有可以返回的缓存,判断是否是redis保存的空值,如果是空值的话,那就说明数据库并没有对应的数据可以缓存到redis中,返回null即可。接着就是获取互斥锁,如果获取到互斥锁,那么就可以去查询数据库了,这里又有两种情况,分别是:1.在数据库能够查询到数据,那么将数据缓存到redis,并将查询到的数据返回(记得释放互斥锁);2.在数据库当中查询不到数据,那么往redis中写入空字符串(空值),以防缓存穿透问题。
  2. 这部分的重点在于互斥锁是怎么做的。这个互斥锁其实是利用了redis本身的特性。大家回忆下redis的String数据结构,里面有一个setnx,它的作用是在设置key的时候,必须保证这个key在redis中是不存在的,如果这个key在redis中早已存在,那么是无法正常使用setnx来设置key和value的。我们的互斥锁就是利用了这个特性,如果某个进程先用setnx设置了key (所有线程都应该使用setnx来设置同一个key,才能成功设置互斥锁),那么其它线程是无法用setnx的,也就无法拿到互斥锁。我们在这里写了两个方法tryLock(String key)和unlock(String key),前者是用来设置互斥锁,后置是用来释放互斥锁。stringRedisTemplate是没有setnx方法的,而是setIfAbsent方法。
  3. 设置互斥锁的时候,记得给这个key一个过期时间,以防某些原因这个key没有被删除掉。
  4. 获取到互斥锁的时候,记得再次检测redis中缓存是否存在。因为有可能有线程重建好了缓存后,释放了锁,而当前进程恰好拿到了锁。
  5. 通过这个例子的学习,我们应该摒弃以前只考虑缓存穿透的问题,我们发现在写了避免缓存穿透的代码之后,还可以更少避免缓存击穿的代码。缓存穿透和缓存击穿的原因都是请求直接访问数据库,导致数据库压力过大,所以这两个问题的解决办法就一点,不要让过多的请求去访问数据库。
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
    //缓存穿透
    //Shop shop = queryWithPassThrough(id);

    // 互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    //Shop shop = queryWithLogicalExpire(id);
    if (shop == null){
        return Result.fail("店铺不存在!");
    }
    //7.返回
    return Result.ok(shop);
}

public Shop queryWithMutex(Long id){
    //String key = "cache:shop:" + id;
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        //3.redis存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //判断命中的是否是redis缓存的空值
    if (shopJson != null){
        //返回错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String locKey = "lock:shop" + id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(locKey);
        // 4.2.判断是否获取成功
        if (!isLock){
            // 4.3.失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }

        /**
         * 获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck,
         * 如果存在则无需重建缓存。为什么要doublecheck?原因是以防刚刚
         * 有线程才写好缓存,释放锁。另一个线程又获取到了锁,有要再去重建缓存
         */
        String shopJsonCheck = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJsonCheck)){
            return JSONUtil.toBean(shopJsonCheck, Shop.class);
        }

        // 4.4.成功,根据id查询数据库
        shop = getById(id);
        // 模拟重建的延时
        //Thread.sleep(200);
        //5.数据库不存在,返回错误
        if (shop == null){
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }
        //6.数据库存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        //7.释放互斥锁
        unlock(locKey);
    }

    //8.返回
    return shop;
}

//生成互斥锁
private boolean tryLock(String key){
    //这里是执行redis的setnx value其实设置成什么都无所谓,这里设置1 为了避免这个值无法被删除,所以设置10秒有效期。毕竟通常业务1秒即可完成
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

//释放锁
private void unlock(String key){
    stringRedisTemplate.delete(key);
}

3.2 利用逻辑过期解决缓存击穿问题

image.png

  1. 采用逻辑过期解决缓存击穿的情况下,一般会有一个管理系统,在那里输入预热的key,所以理论上这个可以是会被命中的。如果在redis中没有查到缓存,直接返回null即可,因为这个key并不是我们要预热的。我们这里没有管理系统,是写了一个测试类,往redis里面写入预热的key
  2. 因为是采用逻辑过期的方式,所以过期时间是写在实体类里面的。为了不改变原有实体类的结构,我们创建了一个新类RdisData来封装原有的实体类Shop。当然最简单的是用这个新类去继承实体类,例子中我们不是采用继承而是封装。后面将JSON序列化为对象采用的是hutool提供的工具类。
  3. 获取锁后,重建完缓存,要释放锁。
  4. 获取锁后,还需要重新判断是否有缓存已经建立,如果已经建立缓存,直接返回即可。这么做是因为有可能前面有线程已经重建好缓存了,刚释放锁,就被当前进程获取到了锁。
  5. 采用互斥锁解决缓存穿透问题时,当我们发现缓存和数据库都没有对应的数据时,我们会往redis写入空值,避免以后的请求都去访问数据库,给数据库造成太大的压力。采用逻辑过期解决缓存穿透问题,我们不用写空值到redis中。因为采用这种方案的时候,我们是知道哪些数据会被高频访问,所以我们提前用管理系统在redis中存入了对应的key(预热),所以redis中的数据会被命中。如果没命中的话,直接返回null,因为显然这个请求不重要。
  6. 我们这里在添加key的逻辑时间时,代码是这么写的LocalDateTime.now().plusSeconds(expireSeconds),就是当前时间的基础上,增加expireSeconds秒。以后比对时间是否过期就用当前时间和我们现在添加到key的时间比对,如expireTime.isAfter(LocalDateTime.now()),如果值是true,说明key的时间还在当前时间之后,key没过期,其中expireTime就是我们存到key里的时间。添加时间的方法是saveShop2Redis(Long id,Long expireSeconds),LocalDataTime这个类的有些方法比较少用,大家可以自行搜索。

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
    //缓存穿透
    //Shop shop = queryWithPassThrough(id);

    // 互斥锁解决缓存击穿
    //Shop shop = queryWithMutex(id);
    Shop shop = queryWithLogicalExpire(id);
    if (shop == null){
        return Result.fail("店铺不存在!");
    }
    //7.返回
    return Result.ok(shop);
}

//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire(Long id){
    //String key = "cache:shop:" + id;
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
        //3.redis缓存不存在,直接返回null
        return null;
    }
    //4.命中,需要先把JSON反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    JSONObject data = (JSONObject)redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    //5.判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())){
        //5.1 未过期,直接返回店铺信息
        return shop;
    }
    //5.2 已过期,需要缓存重建
    //6.缓存重建
    //6.1 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    //6.2 判断是否获取锁成功
    if (isLock){
        /**
         * 获取锁成功后应该再次检测redis缓存是否过期,做DoubleCheck。
         * 如果缓存存在,则无需重建缓存。为什么需要doublecheck?因为
         * 有肯能已经有线程重建好了缓存,释放了锁,另一个线程刚好拿到了锁
         */
        String shopJsonCheck = stringRedisTemplate.opsForValue().get(key);
        RedisData redisData2 = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data2 = (JSONObject)redisData.getData();
        Shop shop2 = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime2 = redisData.getExpireTime();
        //5.判断是否过期
        if (expireTime2.isAfter(LocalDateTime.now())){
            //5.1 未过期,直接返回店铺信息
            return shop2;
        }

        // 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;
}
@Data
public class RdisData {
    private LocalDateTime expireTime;
    private Object data;
}
//测试类
@SpringBootTest
class HmDianPingApplicationTests {

    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSaveShop() {
        shopService.saveShop2Redis(1L,10L);
    }


}
public void saveShop2Redis(Long id,Long expireSeconds){
    //1.查询店铺数据
    Shop shop = getById(id);
    //2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    //3.写入Redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}