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

 更新时间:2023年12月19日 09:20:33   作者:嗯mua.  
缓存击穿是指在高并发环境下,大量请求同时访问缓存中不存在的数据,导致这些请求穿透到数据库,本文主要介绍了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;
}

到此这篇关于Redis 缓存击穿问题及解决方案的文章就介绍到这了,更多相关Redis 缓存击穿内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家! 

相关文章

  • Redis SCAN命令详解

    Redis SCAN命令详解

    SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程,这篇文章给大家介绍了Redis SCAN命令的相关知识,感兴趣的朋友一起看看吧
    2022-07-07
  • 基于redis.properties文件的配置及说明介绍

    基于redis.properties文件的配置及说明介绍

    今天小编就为大家分享一篇基于redis.properties文件的配置及说明介绍,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2018-05-05
  • Redis链表底层实现及生产实战

    Redis链表底层实现及生产实战

    Redis 的 List 是一个双向链表,链表中的每个节点都包含了一个字符串。是redis中最常用的数据结构之一,本文主要介绍了Redis链表底层实现及生产实战,文中通过示例代码介绍的非常详细,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • redis实现session共享的方法

    redis实现session共享的方法

    本文主要介绍了redis实现session共享的方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • Redis常见限流算法原理及实现

    Redis常见限流算法原理及实现

    这篇文章主要介绍了Redis常见限流算法原理及实现,限流简称流量限速(Rate Limit)是指只允许指定的事件进入系统,超过的部分将被拒绝服务、排队或等待、降级等处理
    2022-08-08
  • 浅谈redission锁的默认失效时间

    浅谈redission锁的默认失效时间

    Redisson是一个基于Redis的Java驻留库,提供了许多分布式对象和服务,包括分布式锁,本文主要介绍了浅谈redission锁的默认失效时间, 具有一定的参考价值,感兴趣的可以了解一下
    2024-02-02
  • Redis如何统计用户访问量

    Redis如何统计用户访问量

    这篇文章主要介绍了Redis如何统计用户访问量问题,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-07-07
  • 关于使用Redisson订阅数问题

    关于使用Redisson订阅数问题

    本文主要介绍了关于使用Redisson订阅数问题,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-01-01
  • Redis命令使用技巧之Keys的相关操作

    Redis命令使用技巧之Keys的相关操作

    Redis KEYS命令用于搜索具有匹配模式的键。下面这篇文章主要给大家介绍了关于Redis命令使用技巧之Keys的相关资料,文中通过示例代码介绍的非常详细,需要的朋友可以参考借鉴,下面随着小编来一起学习学习吧
    2018-10-10
  • 基于redis实现世界杯排行榜功能项目实战

    基于redis实现世界杯排行榜功能项目实战

    前段时间,做了一个世界杯竞猜积分排行榜。对世界杯64场球赛胜负平进行猜测,猜对+1分,错误+0分,一人一场只能猜一次。下面通过本文给大家分享基于redis实现世界杯排行榜功能项目实战,感兴趣的朋友一起看看吧
    2018-10-10

最新评论