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分布式锁工作原理以及用法分享,有需要的朋友参考学习下吧。
    2017-12-12
  • Redis列表类型的常用命令小结

    Redis列表类型的常用命令小结

    这篇文章给大家整理了在操作Redis列表类型中的常用命令,文章总结的很全面,对大家学习Redis具有一定的参考借鉴价值,下面来一起看看吧。
    2016-09-09
  • 如何通过redis减库存的秒杀场景实现

    如何通过redis减库存的秒杀场景实现

    本文通过解决秒杀系统中的一个场景即数据预加载,即把库存数据事先加载到缓存,然后通过缓存来更新库存,简单介绍了如何通过redis减库存的秒杀场景实现,感兴趣的可以了解一下
    2022-06-06
  • mac下设置redis开机启动方法步骤

    mac下设置redis开机启动方法步骤

    这篇文章主要介绍了mac下设置redis开机启动,本文详细的给出了操作步骤,需要的朋友可以参考下
    2015-07-07
  • Redis缓存穿透、缓存击穿与缓存雪崩实战指南

    Redis缓存穿透、缓存击穿与缓存雪崩实战指南

    本文介绍Redis在高并发分布式系统中的应用及可能引发的问题,包括缓存穿透、缓存击穿和缓存雪崩,针对这些问题,提出了布隆过滤器、分布式锁、随机过期时间、高可用集群、本地缓存和预热策略等多种解决方案,通过合理配置和组合使用这些方案,可以有效提升系统的稳定性和性能
    2026-05-05
  • Redis内存碎片率调优处理方式

    Redis内存碎片率调优处理方式

    Redis集群因内存碎片率超过1.5触发告警,分析发现内因与外因导致内存碎片,内因为操作系统内存分配机制,外因为Redis操作特性,使用Redis内置内存碎片清理机制可有效降低碎片率,但需注意可能影响性能,建议使用MEMORY命令诊断内存使用情况,合理配置参数以优化性能
    2024-09-09
  • Redis序列化转换类型报错的解决

    Redis序列化转换类型报错的解决

    本文主要介绍了Redis序列化转换类型报错的解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • 基于Redis实现分布式锁的方法(lua脚本版)

    基于Redis实现分布式锁的方法(lua脚本版)

    这篇文章主要介绍了基于Redis实现分布式锁的方法(lua脚本版),本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-05-05
  • redis数据结构之intset的实例详解

    redis数据结构之intset的实例详解

    这篇文章主要介绍了redis数据结构之intset的实例详解的相关资料, intset也即整数集合,当集合保存的值数量不多时,redis使用intset作为其底层数据保存结构,希望通过本文能帮助到大家,需要的朋友可以参考下
    2017-09-09
  • Spring redis使用报错Read timed out排查及解决过程

    Spring redis使用报错Read timed out排查及解决过程

    项目使用spring集成redis,偶尔会出现read timed out的情况,刚开始以为是网络不稳定引起的,后面发现影响业务测试的准确性,这篇文章主要给大家介绍了关于Spring redis使用报错Read timed out排查及解决过程的相关资料,需要的朋友可以参考下
    2024-02-02

最新评论