四,redis商户查询缓存(缓存三大问题以及解决方法)

268 阅读6分钟

一,缓存概述

缓存就是数据交换的缓冲区(称作Cache)是临时存储数据的地方,一般读写性能较高。 image

  • 缓存的作用:
    1. 降低后端负载
    2. 提高读写效率,降低响应时间
  • 缓存的成本:
    1. 数据一致性成本
    2. 代码维护成本
    3. 运维成本

二,添加Redis缓存

image 代码实现:

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        String key="cache:shop:" + id;
        //1. 从redis查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //存在直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //3. 不存在,根据id查询数据库
        Shop shop = getById(id);
        if(shop == null){
            //不存在,返回error
            return Result.fail("商铺不存在!");
        }
        //4.存在,写入redis,并添加超时时间
        stringRedisTemplate.opsForValue()
        .set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
        //5.返回数据
        return Result.ok(shop);
    }

三,缓存更新策略

image


主动更新策略 image


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

  1. 删除缓存还是更新缓存

    • 更新缓存:每次更新数据库都更新缓存,无效的写操作较多(X)
    • 删除缓存:每次更新数据库都让缓存失效,查询时再更新缓存**(√)**
  2. 如何保证缓存与数据库的操作同时成功或失败

    • 单体系统:将缓存与数据库操作放到一个事务
    • 分布式系统:利用TCC等分布式事务方案
  3. 先操作缓存还是数据库 image

    • 先删缓存,再操作数据库: 线程1刚删除完缓存的间隙,线程2就查询数据库并再次写入缓存,然后线程1再更新数据库,会导致数据不一致。

    • 先操作数据库,再删缓存: 只有满足:

      1. 多个个线程并行执行
      2. 线程1查询时,缓存刚好失效
      3. 写入缓存的间隙其他线程完成更新数据库和删除缓存操作

      这种概率很小,因为缓存的操作是远小于数据库的操作的,即使发生了这种 情况,我们只需要给缓存加上存在时间即可解决。

我们在写的时候,针对数据库的删除,更新操作我们先操作数据库,然后删除缓存,在查询的时候给缓存设置一个超时时间即可。需要注意对缓存和数据库的操作需要加上事务注解。

四,缓存穿透

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

4.1 缓存空对象

优点:实现简单,维护方便

缺点

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

image

4.2 布隆过滤

优点:内存占用小,没有多余的key

缺点

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

image

4.3 其他

  • 增加id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

4.4 缓存空对象代码实现

image

public Result queryById(Long id) {
        String key="cache:shop:" + id;
        //1. 从redis查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //存在直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //判断命中的是否是空值
        if(shopJson != null){
            return Result.fail("店铺信息不存在!");
        }
        //3. 不存在,根据id查询数据库
        Shop shop = getById(id);
        if(shop == null){
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",2, TimeUnit.MINUTES);
            //不存在,返回error
            return Result.fail("商铺不存在!");
        }
        //4.存在,写入redis,并添加超时时间
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
        //5.返回数据
        return Result.ok(shop);
    }

五,缓存雪崩

缓存雪崩:同一段时间大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。 image 解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

六,缓存击穿

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

解决方法:

image

6.1 互斥锁解决缓存击穿问题

互斥锁

  • 优点
    • 没有额外内存消耗
    • 保证一致性
    • 实现简单
  • 缺点
    • 线程需要等待,性能受影响
    • 可能有死锁的风险

image 代码实现:

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public Result queryById(Long id) {
        Shop shop=queryWithMutex(id);
        if(shop == null){
            return Result.fail("店铺数据不存在!");
        }
        return Result.ok(shop);
    }

    public Shop queryWithMutex(Long id) {
        String key="cache:shop:" + id;
        String lockKey="lock:shop"+id;
        //1. 从redis查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //存在直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的是否是空值
        if(shopJson != null){
            return null;
        }
        //3. 没有命中,进行缓存重建
        Shop shop=null;
        try{
            boolean isLock = tryLock(lockKey);
            if(!isLock){
                //没有获取到锁,等待
                Thread.sleep(10);
                return queryWithMutex(id);
            }
            //获取到了锁
            //根据id查询数据
            shop = getById(id);
            //将数据写入redis
            if(shop == null){
                //将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",2, TimeUnit.MINUTES);
                //不存在,返回error
                return null;
            }
            //4.存在,写入redis,并添加超时时间
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
        }catch (Exception e){
            throw new RuntimeException(e);
        }finally {
            //解锁
            unLock(lockKey);
        }
        return shop;
    }

    //获取锁
    private boolean tryLock(String key){
        //即 setnx ""cache:shop:"+id "1" 来设置一个锁,且ttl为 10秒
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        //用BooleanUtil是为了防止包装类的null
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

6.2 逻辑过期解决缓存击穿问题

逻辑过期

  • 优点:
    • 线程无需等待,性能较好
  • 缺点:
    • 不保证一致性
    • 有额外内存消耗
    • 实现复杂

image 热点key一般会提前写入到redis里面,如果查不到说明这个key不是热点。 代码实现:

  1. 添加一个类,用来添加额外的逻辑过期时间字段

    @Data
    public class RedisData {
        private LocalDateTime expireTime;
        private Object data;
    }
    
  2. 编写一个保存逻辑过期时间key的方法

     @Autowired
    private StringRedisTemplate stringRedisTemplate;
       
    @Override
    public Result queryById(Long id) {
        Shop shop=queryWithMutex(id);
        if(shop == null){
            return Result.fail("店铺数据不存在!");
        }
        return Result.ok(shop);
    }
      
    public void saveShopToRedis(Long id,Long expireSeconds){
        //1.查询店铺数据
        Shop shop = getById(id);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        redisData.setData(shop);
        //3.写入redis
        stringRedisTemplate.opsForValue().set("cache:shop"+id,JSONUtil.toJsonStr(redisData));
    }
    
  3. 逻辑过期代码:

        public Shop queryWithLogicalExpire(Long id){
        String key="cache:shop:" + id;
        String lockKey="lock:shop"+id;
        //1. 从redis查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isBlank(shopJson)){
            //不存在直接返回null
            return null;
        }
        //3. 命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = (Shop) redisData.getData();
        LocalDateTime expireTime = redisData.getExpireTime();
        //4. 判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            //未过期,直接返回店铺信息
            return shop;
        }
        //5.已过期,进行缓存重建
        //5.1获取互斥锁
        boolean isLock = tryLock(lockKey);
        //5.2 判断是否获取互斥锁成功
        if(!isLock){
            //成功,开启独立线程,实现缓存重建
            //正确应该开线程池,我懒得开知道什么意思就行了
            new Thread(()->{
                try{
                    saveShopToRedis(id,30L);
                }catch (Exception e){
                    throw new RuntimeException(e);
                }finally {
                    //解锁
                    unLock(lockKey);
                }
            }).start();
        }
        //失败,返回过期信息
        return shop;
    }