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缓存雪崩的物种解决方案

    Redis缓存雪崩的物种解决方案

    在高并发系统中,Redis作为核心缓存组件,通常扮演着重要的"守门员"角色,当大量缓存同时失效时,会导致请求如洪水般直接涌向数据库,造成数据库瞬间压力剧增甚至宕机,这种现象被形象地称为"缓存雪崩",本文给大家介绍了Redis缓存雪崩的5种应对措施,需要的朋友可以参考下
    2025-04-04
  • Redis集群Lettuce主从切换问题解决方案

    Redis集群Lettuce主从切换问题解决方案

    这篇文章主要为大家介绍了Redis集群Lettuce主从切换问题解决方案,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-07-07
  • Redis 哨兵与集群脑裂问题及其解决

    Redis 哨兵与集群脑裂问题及其解决

    本文主要介绍了Redis 哨兵与集群脑裂问题及其解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2025-04-04
  • Redis源码阅读:Redis字符串SDS详解

    Redis源码阅读:Redis字符串SDS详解

    这篇文章主要介绍了Redis源码阅读:Redis字符串SDS,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-07-07
  • Redis如何一键部署脚本

    Redis如何一键部署脚本

    这篇文章主要介绍了Redis如何一键部署脚本,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-04-04
  • 浅谈Redis 缓存的三大问题及其解决方案

    浅谈Redis 缓存的三大问题及其解决方案

    Redis 经常用于系统中的缓存,这样可以解决目前 IO 设备无法满足互联网应用海量的读写请求的问题。本文主要介绍了浅谈Redis 缓存的三大问题及其解决方案,感兴趣的可以了解一下
    2021-07-07
  • Redis分布式锁之红锁的实现

    Redis分布式锁之红锁的实现

    本文主要介绍了Redis分布式锁之红锁的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-08-08
  • Redis缓存和数据库的数据一致性的问题解决

    Redis缓存和数据库的数据一致性的问题解决

    随业务增长,直接操作数据库性能下降,引入缓存提高读性能常见,但缓存和数据库的双写操作会引发数据不一致问题,本文讨论几种常用同步策略,感兴趣的可以了解一下
    2024-09-09
  • 关于Redis库存超卖问题的分析

    关于Redis库存超卖问题的分析

    在高并发场景下进行优惠券秒杀测试时,发现由于并发操作导致了超卖问题,即理论上只能卖出100个优惠券,实际卖出了102个,分析原因,是因为在高并发环境下,多个线程同时操作库存,导致数据不一致,提出了两种解决方案:悲观锁和乐观锁
    2024-11-11
  • jedis配置含义详解

    jedis配置含义详解

    这篇文章主要介绍了jedis配置含义详解的相关资料,需要的朋友可以参考下
    2020-04-04

最新评论