4.Redis实战—缓存中的更新策略,缓存穿透,缓存雪崩,缓存击穿

缓存更新策略:

image-20220414151513530

image-20220414153441713


03方案 :

先是将数据缓存 , 在一个异步操作中 , 将这些数据保存到数据库 , 这么做的好处是 , 在两次异步操作之间 , 进行的数据增删改 , 不用频繁的对数据库进行操作 , 只用在下一次异步操作时 , 将最终的数据进行保存即可

但是 , 如果在两次异步操作之间 , 出现宕机 , 可能会造成数据的丢失 , 一致性和可靠性都会存在一定的问题 ,

02方案 : 开发和维护成本较高

01方案 : 在开发中经常使用


操作缓存和数据库是有三个问题需要考虑

  1. 删除缓存还是更新缓存
    • 更新缓存 : 每次更新数据库都要更新缓存 , 无效写操作较多
    • 删除缓存 : 更新数据库时让缓存失效 , 查询时在更新缓存 (推荐)
  2. 如何保证缓存与数据库的操作同时成功或失败?
    • 单体系统 : 将缓存与数据库操作放在一个事务
    • 分布式系统 : 利用TCC等分布式事务方案
  3. 先操作缓存还是操作数据库 (两种方案都有可能造成线程安全问题 )
    • 先删除缓存 , 再操作数据库 (出现的可能性较高)
    • 先操作数据库 , 再删除缓存 (出现的可能性极低)

总结 :

综上所述 : 选择缓存更新策略的最佳实践方案是 :

  1. 低一致性需求 : 使用Redis自带的内存淘汰机制

  2. 高一致性需求 : 主动更新 , 并以超时剔除作为兜底方案

    • 读操作 :

      • 缓存命中直接返回
      • 缓存未命中则查询数据库 , 并写入缓存 , 设定超时时间
    • 写操作 :

      • 先写数据库 , 然后在删除缓存

      • 要确保数据库与缓存操作的原子性

        • 在更新操作上加一个注解

          @Transactional 
          //TODO 添加事务处理 , 整个方法是一个事务
          
// TODO 数据的更新操作
@Override
@Transactional //TODO 添加事务处理 , 整个方法是一个事务
public Result update(Shop shop) {
    Long id = shop.getId();
    if (id == null) {
        return Result.fail("店铺id不能为空!");
    }
    // 1.更新数据库
    updateById(shop);
    // 2.删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}

缓存穿透 :

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

常见的解决方案 有两种 :

  1. 缓存空对象
    • 实现 :
      • 在查询时 , 缓存中没有 , 去查询数据库 , 数据库中也没有 , 返回一个 " " , 将这个" “缓存到缓存中 , 下次再请求的时候 , 直接从redis中返回这个” "
    • 优点 :
      • 实现简单 , 维护方便
    • 缺点 :
      • 额外的内存消耗
      • 可能造成短期的不一致
  2. 布隆过滤
    • image-20220414163414798
    • 优点 :
      • 内存占用较少 , 没有多于key
    • 缺点 :
      • 实现复杂
      • 存在误判可能 , 并不是百分百的准确
  3. 增强id的复杂度 , 避免被猜测id规律
  4. 做好数据的基础格式校验
  5. 加强用户权限校验
  6. 做好热点参数的限流

实现逻辑 :

  1. 从redis中查询商铺缓存
    1. 判断是否存在
      1. 存在 , 直接输出
      2. 不存在 , 判断是否是空字符串 " " ,
        1. 是 , 返回一个错误信息
        2. 不是 , 根据id查询数据库
          1. 数据库中不存在该店铺
            1. 以id加前缀 作为key , 以 " " 作为key值保存在缓存中 , 设置过期时间
            2. 返回错误信息
          2. 数据库中存在该店铺
            1. 将查询出来的数据转换 , 存储在Redis中
            2. 返回商铺信息

代码实现 :

// TODO 解决缓存穿透问题
public Shop queryWithPassThrough(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // TODO isNotBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    //TODO 判断命中是否是空值 ,
    if (shopJson != null) { //结果是空字符串
        // 返回一个错误信息
        return null;
    }

    // 4.不存在 , 根据id查询数据库
    Shop shop = getById(id);
    // 5.不存在返回错误
    if (shop == null) {
        // TODO 将空值写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回
    return shop;
}

缓存雪崩 :

  • 缓存雪崩 :
    • 是指在同一时间段大量的缓存key同时失效或者Redis服务宕机 , 导致大量请求到达数据库 , 带来巨大压力
  • 解决方案 :
    • 给不同的key的TTL添加随机值
    • 利用Redis集群提高服务的可用性
    • 给缓存业务添加降级限流策略
      • 添加机制 , 检测到redis服务出现宕机等严重事故时 , 牺牲一部分的业务来减少数据库的压力
    • 给业务添加多级缓存

缓存击穿:

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

image-20220414172640349

常用的解决方案 :

image-20220414173603697


基于互斥锁方式解决缓存击穿问题 :

互斥锁

  • image-20220414172846392
  • 问题 : 线程中只有一个线程 能够获取互斥锁 , 其他线程就只能进行等待 , 效率不高

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


获取锁 :
  • 使用的是Redis中的setnx , 这个操作在执行的时候 , 只有当它不存在的时候 , 才能执行成功 , 一旦创建成功之后 , 后边的所有setnx操作都会失败 ,

  • // TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
    private boolean tryLock(String key) {
        // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        // TODO 不要直接返回这个Boolean ,
        //  因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
        //  他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
        return BooleanUtil.isTrue(flag);
    }
    
删除锁 :
  • // TODO 删除锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
    
  • 缺陷 :

    • 如果设置锁之后 , 程序出现错误 , 导致没有人删除这个锁 , 那么后续所有的操作都会出错 ,
  • 解决 : 给锁设置一个有效期 , 来进行兜底


实现逻辑 :

image-20220415105035791

代码实现 :
// TODO 互斥锁解决缓存击穿 + 解决缓存穿透问题
public Shop queryWithMutex(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    //TODO 判断命中是否是空值 ,
    if (shopJson != null) { //结果是空字符串
        // 返回一个错误信息
        return null;
    }
    // TODO 开始缓存重建 , 获取互斥锁
    // TODO 给每一个店铺设置互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop;
    try {
        if (!tryLock(lockKey)) {
            // 获取互斥锁 失败 , 则休眠并重试
            Thread.sleep(50);
            // TODO 重试就是递归调用这个方法
            return queryWithMutex(id);
        }

        // 4. 获取互斥锁成功 , 根据id查询数据库
        shop = getById(id);
        // TODO 模拟重建的延时
        Thread.sleep(200);
        // 5.不存在返回错误
        if (shop == null) {
            // TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally { // 确保一定会释放锁
        // TODO 释放互斥锁
        unLock(lockKey);
    }
    // 7.返回
    return shop;
}

基于逻辑过期的方式解决缓存击穿问题:

逻辑过期

  • image-20220414173301267

image-20220414195355039


原有的实体类中并没有设置逻辑过期的时间 , 但是也不能直接在原有的实体类中加这个属性 , 不然会对其他的代码有影响 , 可以新建一个类 , 来单独存放这个逻辑过期时间

@Data
public class RedisData {
    // TODO 设置的逻辑过期时间
    private LocalDateTime expireTime;
    // TODO 用来封装想存入redis的实体类数据 ,
    private Object data;
}
封装逻辑过期时间 :缓存重建
public void saveShop2Redis(Long id , Long expireSeconds){
    // TODO 1.查询店铺数据
    Shop shop = getById(id);
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    // TODO 2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    // TODO  LocalDateTime.now() : 表示当前时间 ,
    //  plusSeconds : 表示添加多少时间
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // TODO 3.写入Redis , 不用设置过期时间 , 在redisData中已经封装了逻辑过期时间了
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

逻辑实现 :
  1. 从Redis中查询商铺数据 , 用商铺id加前缀作为唯一标识
    1. 判断是否命中 ,
      1. 未命中 , 直接返回错误信息
      2. 命中 , 将json格式反序列化为对象
        1. 判断逻辑时间是否过期
          1. 未过期 , 直接返回数据
          2. 过期 , 进行缓存重建
            1. 获取互斥锁 ,
              1. 成功 ,
                1. 开启一个新的线程 , 进行缓存重建
                2. 返回旧数据
              2. 未成功 , 返回旧数据
代码实现 :
// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

// TODO 逻辑过期解决缓存击穿问题
public Shop queryWithLogicalExpire(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否命中
    if (StrUtil.isBlank(shopJson)) {
        // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断为空 , 直接返回null
        return null;
    }

    // TODO 4.命中 , 需要先把json反序列化为对象 ,
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
    JSONObject data = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
    if (expireTime.isAfter(LocalDateTime.now())){
        // TODO 5.1未过期 , 直接返回信息
        return shop;
    }
    // TODO 5.2 过期 , 缓存重建
    // TODO 6.缓存重建 ,
    // TODO 6.1 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // TODO 6.2 判断是否获取互斥锁
    if (isLock){
        // TODO 6.3 成功 , 开启独立线程实现缓存重建 , 线程池在上边创建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShop2Redis(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally{
                // TODO 释放锁
                unLock(lockKey);
            }
        });
    }
    return shop;
}

缓存工具的封装 :

基于StringRedisTemplate封装一个缓存工具类 , 满足下列需求

  1. 方法1 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置TTL过期时间
  2. 方法2 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置逻辑过期时间 , 用于处理缓存击穿问题
  3. 方法3 : 根据指定的key查询缓存 , 并反序列换为指定类型 , 利用缓存空值的方式解决缓存穿透问题
  4. 方法3 : 根据指定的key查询缓存 , 并反序列化为指定类型 , 需要利用逻辑过期解决缓存击穿问题

代码实现 :

@Component
@Slf4j
public class CacheClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RedisData redisData;

    /**
     * 普通类型的缓存
     *
     * @param key   key
     * @param value value
     * @param time  TTL
     * @param unit  时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        // TODO 这里需要将value转为json类型的字符串
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 解决缓存穿透问题
     *
     * @param keyPrefix 前缀
     * @param id        id
     * @param type      数据类型
     * @param function  查询数据库函数
     * @param time      缓存时间
     * @param unit      时间单位
     * @param <R>       泛型 , 返回值
     * @param <ID>      泛型 , 主键
     * @return 返回的数据
     * TODO 解决缓存穿透问题 , 定义泛型 ,使用泛型 , 对于不确定的类型 , 统统使用泛型定义类型
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis中查询缓存 , 以id加key作为唯一标识
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            //  3.判断不为空 , 直接返回 , 将字符串转为bean
            return JSONUtil.toBean(json, type); // TODO 这里直接使用泛型的数据类型 ,
        }
        // 判断命中是否是空值 ,
        if (json != null) { //结果是空字符串
            // 返回一个错误信息
            return null;
        }

        //TODO 4.不存在 , 根据id查询数据库 ,
        // 这里不能直接调用数据库进行查询了 , 因为对应的类型不一致 , 查询的数据库也不一致 ,
        // 这里可以使用参数传递逻辑  , java中传递逻辑 , 使用的是Function<T,R> function , 可以传递一个函数到方法中
        // 调用函数形参中的方法 , apply , 将参数传递进去即可
        R r = function.apply(id);
        // 5.不存在返回错误
        if (r == null) {
            // TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", time, unit);
            return null;
        }
        //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
        this.set(key, r, time, unit);
        // 7.返回
        return r;
    }


    // TODO 创建一个线程池 , 里边有十个线程
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


    /**
     * 封装逻辑过期时间
     *
     * @param key key
     * @param value value
     * @param time 过期时间
     * @param unit 时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // TODO 这里需要将value转为json类型的字符串
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    // TODO 逻辑过期解决缓存击穿问题
    public <R , ID> R queryWithLogicalExpire(String keyPrefix, String lockPrefix ,ID id , Class<R> type , Function<ID ,R> function , Long time, TimeUnit unit) {
        redisData = new RedisData();
        String key = keyPrefix + id ;
        // TODO 1.从redis中查询缓存 , 以id作为唯一标识
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否命中
        if (StrUtil.isBlank(json)) {
            // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
            // TODO 3.判断为空 , 直接返回null
            return null;
        }

        // TODO 4.命中 , 需要先把json反序列化为对象 ,
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        // TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // TODO 5.1未过期 , 直接返回旧信息
            return r;
        }
        // TODO 5.2 过期 , 缓存重建
        // TODO 6.缓存重建 ,
        // TODO 6.1 获取互斥锁
        String lockKey = lockPrefix + id;
        boolean isLock = tryLock(lockKey);
        // TODO 6.2 判断是否获取互斥锁
        if (isLock) {
            // TODO 6.3 成功 , 开启独立线程实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R r1 = function.apply(id);
                    this.setWithLogicalExpire(key , r1 ,time , unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // TODO 释放锁
                    unLock(lockKey);
                }
            });
        }
        return r;
    }

    // TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
    private boolean tryLock(String key) {
        // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        // TODO 不要直接返回这个Boolean ,
        //  因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
        //  他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
        return BooleanUtil.isTrue(flag);
    }

    // TODO 删除锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值