Loading

三、Redis企业实战 - 商户查询缓存

老大剑仙,你不收我为嫡传弟子,凭良心说,是不是怕我剑术超过你老人家?

缓存查询策略

标准的操作方式是查询数据库之前先查询缓存,如果缓存存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

    @Override
   public Object queryById(Long id) {
       // 从redis查询商铺缓存
       String key = CACHE_SHOP_KEY + id;
       String shopJson = stringRedisTemplate.opsForValue().get(key);
       // 判断redis是否存在
       if (StringUtils.isNotBlank(shopJson)) {
           // 存在直接返回
           Shop shop = JSONUtil.toBean(shopJson, Shop.class);
           return Result.ok(shop);
      }

       // 不存在则直接查询数据库
       Shop shop = this.getById(id);
       if (shop == null) {
           return Result.fail("找不到改店铺");
      }

       // 数据存入缓存
       stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));

       return Result.ok(shop);
  }

缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

在实际的项目开发中,要采用主动更新(手动解决缓存与数据库不一致的问题,做到可控),并用超时剔除作为兜底方案。

数据库和缓存不一致解决方案

解决数据库与缓存不一致需要考虑三个问题

  1. 如何保证缓存与数据库的操作的同时成功或失败
  • 单体系统,将缓存与数据库操作放在一个事务

  • 分布式系统,利用TCC等分布式事务方案

  1. 更新数据库数据库,是删除缓存还是更新缓存
  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多(如果都是更新10次,查询一次,中间九次更新缓存就是无效操作)

  • 删除缓存(选这个):更新数据库时让缓存失效,查询时再更新缓存

  1. 更新数据时,先操作数据库还是先操作缓存
  • 先删除缓存,再操作数据库

  • 先操作数据库(选这个),再删除缓存(操作数据库用的时间更长,在多并发的情况下,数据不一致的情况发生概率比上面的小)

实现商铺和缓存与数据库双写一致
  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。

  • 根据id修改店铺时,先修改数据库,再删除缓存。

    @Override
   public Object queryById(Long id) {
       // 从redis查询商铺缓存
       String key = CACHE_SHOP_KEY + id;
       String shopJson = stringRedisTemplate.opsForValue().get(key);
       // 判断redis是否存在
       if (StringUtils.isNotBlank(shopJson)) {
           // 存在直接返回
           Shop shop = JSONUtil.toBean(shopJson, Shop.class);
           return Result.ok(shop);
      }

       // 不存在则直接查询数据库
       Shop shop = this.getById(id);
       if (shop == null) {
           return Result.fail("找不到改店铺");
      }

       // 数据存入缓存
       stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

       return Result.ok(shop);
  }
    @Override
   @Transactional
   public Result update(Shop shop) {
       Long id = shop.getId();
       if (id == null) {
           return Result.fail("店铺id不能为空");
      }

       // 更新数据库并删除缓存
       stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
       return Result.ok();
  }

缓存穿透问题的解决思路

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

常用的解决方案:

  1. 缓存空对象,并设置过期时间

    • 优点:实现简单

    • 缺点:造成额外的内存消耗

  2. 布隆过滤

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

    • 缺点:实现复杂,存在误判(哈希冲突)

布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中。

布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。

编码解决商品查询的缓存穿透问题

代码示例

    /**
    * 用返回空字符串解决缓存击穿
    *
    * @param id 商户id
    * @return 商户信息
    */
   public Shop queryShopByIdSolveBreakWithWriteNull(Long id) {
       String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
       // 如果redis未命中查数据库
       if (StrUtil.isNullOrUndefined(shopJson)) {
           Shop shop = getById(id);
           // 数据库也没有则写入空数据到redis
           if (shop == null) {
               stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_SHOP_TTL, TimeUnit.MINUTES);
               queryShopByIdSolveBreakWithWriteNull(id);
          }
           // 数据库存在则将数据写进redis
           shopJson = JSONUtil.toJsonStr(shop);
           stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, shopJson, CACHE_SHOP_TTL, TimeUnit.MINUTES);
           // 重新查一遍,就可以从redis获得数据
           queryShopByIdSolveBreakWithWriteNull(id);
      }
       // 如果命中了redis,但是为空,直接返回空对象
       if (StrUtil.isBlank(shopJson)) {
           return null;
      }
       // 如果命中了redis且不为空,重置时间
       stringRedisTemplate.expire(CACHE_SHOP_KEY + id, CACHE_SHOP_TTL, TimeUnit.MINUTES);

       return JSONUtil.toBean(shopJson, Shop.class);
  }

缓存雪崩问题的解决思路

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

常用解决方案

  • 给不同的key的TTL添加随机值

  • 利用Redis集群提高服务可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

缓存击穿问题的解决思路

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然

失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到

缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设

在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这

些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接

着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大。

常见的解决方案:

  1. 互斥锁

    • 优点:实现简单、只需要加一把锁

    • 缺点:可能会发生死锁,性能受影响

  2. 逻辑过期

    • 优点:额外的锁重构数据,性能好

    • 缺点:有一段时间的脏数据

利用互斥锁解决缓存击穿问

代码示例

    /**
    * 用互斥锁解决缓存击穿
    *
    * @param id 商户id
    * @return 商户信息
    */
   public Shop queryShopByIdSolvePenetrateWithWriteMutex(Long id) {
       String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
       // 如果redis未命中则查询数据库
       if (StrUtil.isNullOrUndefined(shopJson)) {
           // 在查询数据库之前先获取锁
           String lockKey = LOCK_SHOP_KEY + id;
           try {
               boolean isLock = tryLock(lockKey);
               if (!isLock) {
                   // 如果获取锁失败了休眠一段时间后重试
                   Thread.sleep(50);
                   return queryShopByIdSolvePenetrateWithWriteMutex(id);
              }
               // 获取锁成功之后的操作
               Shop shop = getById(id);
               // 解决缓存穿透问题
               // 如果数据库中也不存在则写入空数据到redis
               if (shop == null) {
                   stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_SHOP_TTL, TimeUnit.MINUTES);
                   queryShopByIdSolveBreakWithWriteNull(id);
              }
               // 数据库中存在则将数据写入redis
               shopJson = JSONUtil.toJsonStr(shopJson);
               stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, shopJson, CACHE_SHOP_TTL, TimeUnit.MINUTES);
          } catch (InterruptedException e) {
               e.printStackTrace();
          } finally {
               // 释放锁
               unLock(lockKey);
          }
           // 重新查一遍,就可以从redis中获取数据
           return queryShopByIdSolveBreakWithWriteNull(id);
      }
       // 如果命中了redis,但是为空,直接返回空对象
       if (StrUtil.isBlank(shopJson)) {
           return null;
      }
       // 如果命中了redis且不为空,重置时间
       stringRedisTemplate.expire(CACHE_SHOP_KEY + id, CACHE_SHOP_TTL, TimeUnit.MINUTES);

       return JSONUtil.toBean(shopJson, Shop.class);
  }

/**
    * 解决缓存击穿加锁
    *
    * @param key 标识id
    * @return true/false
    */
   private boolean tryLock(String key) {
       Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);

       return BooleanUtil.isTrue(flag);
  }

   /**
    * 解决缓存击穿解锁
    *
    * @param key 标识id
    */
   private void unLock(String key) {
       stringRedisTemplate.delete(key);
  }
利用逻辑过期解决缓存击穿问题

代码示例

    /**
    * 用逻辑过期时间解决缓存穿透
    *
    * @param id 商户id
    * @return 商户信息
    */
   public Shop queryShopByIdSolvePenetrateWithWriteLogicalExpireTime(Long id) {
       String redisDataJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
       // 如果redis未命中查数据库
       if (StrUtil.isEmpty(redisDataJson)) {
           // 未命中说明不是热点数据,转入处理缓存击穿的函数
           return null;
      }
       // 如果命中了redis且不为空,将其反序列化成对象,拿到逻辑过期时间
       RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
       Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
       LocalDateTime expireTime = redisData.getExpireTime();
       // 如果时间已经过期,直接返回旧数据并开启新线程修改数据
       if (expireTime.isBefore(LocalDateTime.now())) {
           // 在开启新线程之前获得锁,锁的名称要与锁的id关联,修改不同的id可以并行,一样的id才需要加锁
           String lockKey = LOCK_SHOP_KEY + id;
           try {
               boolean isLock = tryLock(lockKey);
               // 获取锁成功
               if (isLock) {
                   // 开启新线程用线程池,性能比自己创建线程好
                   CACHE_REBUILD_EXECUTOR.execute(() -> {
                       // 重建缓存
                       saveShopWithExpireTimeToRedis(id, LOGICAL_EXPIRE_TIME);
                  });
              }
          } catch (Exception e) {
               e.printStackTrace();
          } finally {
               unLock(lockKey);
          }
      }
       return shop;
  }

   /**
    * 将带有逻辑过期的Shop数据写入redis
    *
    * @param id           id
    * @param expireMinutes 逻辑过期时间增加值
    */
   public void saveShopWithExpireTimeToRedis(long id, Long expireMinutes) {
       Shop shop = getById(id);
       RedisData redisData = new RedisData();
       redisData.setData(shop);
       redisData.setExpireTime(LocalDateTime.now().plusMinutes(expireMinutes));
       String redisDataJson = JSONUtil.toJsonStr(redisData);
       // 存入数据库
       stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, redisDataJson);
  }

总结

 

posted @ 2022-11-17 10:59  你比从前快乐;  阅读(172)  评论(0编辑  收藏  举报