Redis的缓存问题(三)缓存穿透、缓存雪崩、缓存击穿

缓存穿透

什么是缓存穿透?

解决方案(2种)

(1)缓存空对象

(2)布隆过滤

综上所述

代码实现

思路图解

运行测试

扩展

缓存雪崩

什么是缓存雪崩?

解决方案(4种)

(1)给不同的Key的TTL添加随机值(推荐)

(2)利用Redis集群提高服务的可用性

(3)给缓存业务添加降级限流策略

(4)给业务添加多级缓存  

缓存击穿

什么是缓存击穿?

解决方案(2种)

(1)互斥锁

(2)逻辑过期

互斥锁与逻辑过期的对比分析

具体实现

(1)关于互斥锁的实现

需求描述

代码实现

(2)逻辑过期的实现

需求描述

代码实现

完整代码


Redis的缓存问题(三)缓存穿透、缓存雪崩、缓存击穿

缓存穿透

什么是缓存穿透?

缓存穿透是指客户端请求的数据在缓存中和数据库都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

解决方案(2种)

(1)缓存空对象

简单的来说,就是请求之后,发现数据不存在,就将null值打入Redis中。

优点:

  • 实现简单,维护方便

缺点:

  • 额外的内存消耗
  • 可能造成短期的不一致

分析:

当请求第一次来时,数据库中没有该数据,数据库向Redis写入一个null;此时正好数据库中被插入了该数据,又有一个请求来访问,但是刚刚向Redis中插入的null来没有过期,就出现了不一致(该请求从Redis拿到的结果就是null,而数据库中其实是有实际数据的)

当然我们也有许多的解决办法,例如:将TTL的时间设置的足够短;每次向数据库新增数据的时候主动将其插入缓存中去覆盖那个null。

(2)布隆过滤

在客户端与Redis之间加了一个布隆过滤器,对于请求进行过滤。 

布隆过滤器的大致的原理:布隆过滤器中存放二进制位。数据库的数据通过hash算法计算hash值并存放到布隆过滤器中,之后判断数据是否存在的时候,就是判断该hash值是0还是1。

但是这个玩意是一种概率上的统计,当其判断不存在的时候就一定是不存在;当其判断存在的时候就不一定存在所以有一定的穿透风险!!!

优点:

  • 内存占用较少,没有多余key

缺点:

  • 实现复杂
  • 存在误判可能

综上所述

我们可以两种方案一起用,这样子最为保险。据统计使用布隆过滤器一般可以避免90%的无效请求。但是黑马程序员这里的视频是使用方案一(缓存空对象)

代码实现

思路图解

显然我们在这里只要做两件事:

  • 当查询数据在数据库中不存在时,将空值写入 redis
  •  判断缓存是否命中后,再加一个判断是否为空值
@Override
public Result queryById(Long id) {

    // 从redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    // 判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // 存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    // 1.判断空值
    if (shopJson != null) {
        // 返回一个错误信息
        return Result.fail("店铺不存在!");
    }


    // 不存在,根据id查询数据库
    Shop shop = getById(id);

    // 不存在,返回错误
    if (shop == null) {
        
        // 2.防止穿透问题,将空值写入redis!!!
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
        return Result.fail("店铺不存在!");
    }

    // 存在,写入Redis
    // 把shop转换成为JSON形式写入Redis
    // 同时添加超时时间
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}

运行测试

我们可以查看后台接口数据

扩展

上述所提到的两种方案其实都是被动的,(即缓存穿透已经发生了) 

在此之前我们应该先行避免这种现象的发生。如何避免???

  • 我们可以增加 id 设计时的复杂度,避免被本人猜到 id 的规律
  • 做好基础数据格式校验(将不符合我们定义规范的 id 先行剔除
  • 加强用户权限的校验
  • **做好限流(SpringCloud~~~)

缓存雪崩

什么是缓存雪崩?

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。情况大致如下图所示:

解决方案(4种)

(1)给不同的Key的TTL添加随机值(推荐)

操作简单,当我们在做缓存预热的时候,就有可能在同一时间批量插入大量的数据,那么如果它们的TTL都一样的话就可能出现大量key同时过期的情况!!!所以我们需要在设置过期时间TTL的时候,定义一个范围,追加该范围内的一个随机数

(2)利用Redis集群提高服务的可用性

使用集群提高可靠性,后续讲解~~~之后写了会在这里贴上链接。

(3)给缓存业务添加降级限流策略

也是后续的微服务的知识~~~SpringCloud中有提!!!

(4)给业务添加多级缓存  

请求到达浏览器,nginx可以做缓存,未命中找Redis,再未命中找JVM,最后到数据库......

SpringCloud中有多级缓存的实现方案,Redis后期也会提到,之后也会更新。

缓存击穿

什么是缓存击穿?

缓存雪崩是因为大量的key同时过期所导致的问题,而缓存击穿则是部分key过期导致的严重后果。

为什么大量key过期会产生问题而少量的key也会有问题呢???

这是因为这一部分的key不简单!!!

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

具体情况如下图所示:

上述:此时假设该热点key的TTL时间到(失效了),则查询缓存未命中,会继续查询数据库,并进行缓存重建工作。但是由于查询SQL逻辑比较复杂重建缓存的时间较久,并且该key又是热点key,短时间内有大量的线程对其进行访问,所以请求会直接 “打到” 数据库中,数据库就有可能崩掉!!!

解决方案(2种)

(1)互斥锁

简单的来说就是,并不是所有的线程都有 “ 资格 ” 去访问数据库,只有持有的线程才可以对其进行操作。

不过该操作有一个很明显的问题,就是会出现相互等待的情况。

(2)逻辑过期

不设置TTL,之前所说导致缓存击穿的原因就是该key的TTL到期了,所以我们在这就不设置TTL了,而是使用一个字段例如:expire表示过期时间(逻辑上的)。当我们想让它 “ 过期 ” 的时候,我们可以直接手动将其删除(热点key,即只是在一段时间内,其被访问的频次很高)。

互斥锁与逻辑过期的对比分析

具体实现

(1)关于互斥锁的实现

需求描述

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

之前在“判断缓存是否命中”,如果是未命中,则会直接查数据库;但是现在要先判断一下是否可以拿到! 

代码实现

(1)首先,我们声明一下获取锁、释放锁的方法,tryLock()、unLock()

/**
  * 获取锁
  * @param key
  * @return
*/
private boolean tryLock(String key) {
    // setnx 就是 setIfAbsent 如果存在
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
    // 装箱是将值类型装换成引用类型的过程;拆箱就是将引用类型转换成值类型的过程
    // 不要直接返回flag,可能为null
    return BooleanUtil.isTrue(flag);
}

/**
 * 释放锁
 * @param key
 */
private void unLock(String key) {
    stringRedisTemplate.delete(key);
}

注意:这里的锁不是真正的线程锁,而是redis里面的一个特殊的key。 

(2)互斥锁解决缓存击穿 queryWithMutex()

/**
 * 互斥锁解决缓存击穿 queryWithMutex()
 * @param id
 * @return
 */
public Shop queryWithMutex(Long id) {
    // 1.从redis查询商铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        return JSONUtil.toBean(shopJson, Shop.class);
    }

    // 判断空值
    if (shopJson != null) {
        // 返回一个错误信息
        return null;
    }

    String lockKey = "lock:shop:" + id;
    Shop shop = null;
    try {
        // 4.实现缓存重建
        // 4.1获取互斥锁
        boolean isLock = tryLock(lockKey);

        // 4.2判断是否成功
        if (!isLock) {
            // 4.3失败,则休眠并重试
            Thread.sleep(50);
            // 递归
            return queryWithMutex(id);
        }
        // 4.4成功,根据id查询数据库
        shop = getById(id);

        // 模拟延迟
        Thread.sleep(200);

        // 5.不存在,返回错误
        if (shop == null) {
            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 ex) {
        throw new RuntimeException(ex);
    } finally {
        // 7.释放锁
        unLock(lockKey);
    }

    // 8.返回
    return shop;
}

(2)逻辑过期的实现

需求描述

修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

注意:这里的key是否过期,不是由redis控制的,而是由我们自己去手动编写逻辑去控制的。 

代码实现

(1)添加逻辑过期时间的字段

由于我们之前的Shop中是没有逻辑过期的字段,那么我们要如何让它带有这个属性,又不修改之前的代码呢?

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    private String name;
    private Long typeId;
    private String images;
    private String area;
    private String address;
    private Double x;
    private Double y;
    private Long avgPrice;
    private Integer sold;
    private Integer comments;
    private Integer score;
    private String openHours;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
    @TableField(exist = false)
    private Double distance;
}

新建一个RedisData对象,里面的data指的是Shop对象,而expireTime是逻辑过期时间。

即:我们可以使用 JSONUtil.toBean 将Shop对象通过序列化、反序列化到RedisData类的data属性中。

@Data
public class RedisData {
    // LocalDateTime : 同时含有年月日时分秒的日期对象
    // 并且LocalDateTime是线程安全的!
    private LocalDateTime expireTime;
    private Object data;
}

(2)逻辑过期解决缓存击穿问题 queryWithLogicalExpire()

缓存重建

/**
 * 重建缓存,先缓存预热一下,否则queryWithLogicalExpire() 的expire为null
 * @param id
 * @param expireSeconds
 */
public void saveShopRedis(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));
}

先使用测试方法运行一下saveShopRedis(),否则redis里面没有expireTime !

/**
 * 逻辑过期解决缓存击穿问题 queryWithLogicalExpire()
 * 测试前要先缓存预热一下!不然 data 与 expireTime 的缓存值是null!
 * @param id
 * @return
 */
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;
    }

    // 4.命中,需要将json反序列化为对象
    // redisData没有数据
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), 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) {
        // 6.3.成功,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit( () -> {
            try {
                // 重建缓存,过期时间为20L
                saveShopRedis(id,20L);
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            } finally {
                unLock(lockKey);
            }
        });
    }
    // 6.4.返回过期店铺信息
    return shop;
}

我们可以看到在测试的时候,name的值为:“100XXXX”

我们现在来修改一下数据库,将值改为:“900XXXX”,看看并发情况下缓存重建能否正确

通过Jmeter做压力测试

再查看Redis中的数据,可以看到name的值已经被修改了,而且上面的jmeter的每一个http都是正常的!

完整代码 

分支 v5_cache_breakdown

最新的代码见master主分支(代码还未写完,视频还未看完,所以master是最新的分支,而v5_cache_breakdown就是上述的这些功能实现!不会再做修改了!) ​​​​​​​

https://gitee.com/Harmony_TL/redis_heima_projecticon-default.png?t=M7J4https://gitee.com/Harmony_TL/redis_heima_project

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐