一. 什么是缓存

1.简介

  • 缓存是数据交换的缓冲区,是存储数据的临时位置,缓存数据存储于 代码 中,而代码运行在 内存 中,内存的读写性能远远高于磁盘,因此使用缓存可以大大降低 用户访问并发量 带来的服务器读写压力

2.缓存的作用和成本

  1. 缓存的作用:降低后端负载(存在缓存里就不用去数据库查),提高读写效率(用缓存不进磁盘读写)

  2. 缓存的成本:数据一致性成本,需要维护代码,运维(部署集群)

二. 添加商户缓存

1.根据id查询商户

  1. 流程图:

随着查询次数越来越多,Redis的命中率就会越来越高,就不用每次都查数据库了,形成良性循环

  1. 功能逻辑:

  • 根据id从Redis里查询商户缓存,存在直接返回,不存在查询数据库---数据库不存在返回404,存在--将数据添加到Redis缓存--返回结果

  1. 代码:

//根据id查询商户信息
@Override
public Result queryById(Long id) {
    String key=CACHE_SHOP_KEY+id;

    //1.从缓存(Redis)中查询数据
    String shopJson=stringRedisTemplate.opsForValue().get(key);

    //2.如果有直接返回
    if(StrUtil.isNotEmpty(shopJson)){
        Shop shop= JSONUtil.toBean(shopJson,Shop.class);
        return Result.ok(shop);
    }

    //3.如果缓存中没有,到数据库查询数据
    Shop shop=getById(id);

    //4.如果数据库没有,返回404
    if(shop==null){
        return Result.fail("店铺不存在呵呵!!");
    }

    //5.如果数据库有,将数据添加写入Redis中
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));

    //6.返回结果
    return Result.ok(shop);
}

2.查询店铺列表

  1. 代码:添加缓存后,第一次访问接口速度1.8s,第二次就20ms了,速度大大提升

    @Override
    public Result queryTypeList() {
        // 1、从Redis中查询店铺类型
        String key = CACHE_SHOP_TYPE_KEY + UUID.randomUUID().toString(true);
        //将店铺类型在Redis的数据类型设置为String
        String shopTypeJSON = stringRedisTemplate.opsForValue().get(key);

        List<ShopType> typeList = null;
        // 2、判断缓存是否命中
        if (StrUtil.isNotBlank(shopTypeJSON)) {
            // 2.1 缓存命中,直接返回缓存数据
            typeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
            return Result.ok(typeList);
        }
        // 2.1 缓存未命中,查询数据库
        typeList = this.list(new LambdaQueryWrapper<ShopType>()
                .orderByAsc(ShopType::getSort));

        // 3、判断数据库中是否存在该数据
        if (Objects.isNull(typeList)) {
            // 3.1 数据库中不存在该数据,返回失败信息
            return Result.fail("店铺类型不存在");
        }
        // 3.2 店铺数据存在,写入Redis,并返回查询的数据
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList),
                CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
        return Result.ok(typeList);
    }

}

三. 缓存的问题

  • 既然Redis做缓存这么好,既能减轻数据库压力,也能大大减少查询用时,那是不是我们在所有查询接口都加缓存就万事大吉了呢?并不是,Redis在带来方便的同时,也有它的问题

1.数据一致性问题

  • 假如我们更新了数据库中的数据,Redis里的数据并不会更新,由此带来了数据一致性问题,为了解决这个问题,我们必须将缓存和数据库中的数据做同步,也就是要更新缓存

  1. 缓存更新策略:主动更新策略胜出

  2. 主动更新策略

  3. 解决问题:我们采取给缓存中的店铺数据增加 超时时间 的方案

添加过期时间

四. 缓存穿透,缓存雪崩,缓存击穿

1.缓存穿透

  1. 概念:缓存穿透指的是 客户端请求的数据缓存和数据库中都不存在,这样请求一定会到达数据库,如果有人大量查询不存在的数据,会给数据库造成巨大压力

2.解决方法:缓存空对象 和 布隆过滤

  1. 缓存空对象解决方案

  • 既然缓存穿透是 查询的对象在缓存和数据库中都不存在,那么我们就无论查询的对象在数据库中存在与否,都把它添加到缓存中,这样下一次查询相同的对象,请求就不会到达数据库

  1. 业务逻辑

  • 和之前我们按照id查询商铺的逻辑基本一致,只不过这次无论数据库中是否有这个数据,我们都把它添加到缓存中

空对象也添加进缓存中

  1. 代码

@Override
public Result queryById(Long id) {
    String key=CACHE_SHOP_KEY+id;

    //1.从缓存(Redis)中查询数据
    String shopJson=stringRedisTemplate.opsForValue().get(key);

    //2.如果缓存命中直接返回
    if(StrUtil.isNotEmpty(shopJson)){
        Shop shop= JSONUtil.toBean(shopJson,Shop.class);
        return Result.ok(shop);
    }
     //判断命中的是否是空值
    if(shopJson != null){
        //返回错误信息
        return Result.fail("店铺不存在呵呵!!");
    }

    //3.如果缓存中没有,到数据库查询数据
    Shop shop=getById(id);

    //4.如果数据库没有,把空值写入到Redis中 并设置短的过期时间
    if(shop==null){
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("店铺不存在呵呵!!");
    }

    //5.如果数据库有,将数据添加写入Redis中
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

    //6.返回结果
    return Result.ok(shop);
}

2.缓存雪崩

  1. 概念

  • 缓存雪崩指的是在同一时间 大量key同时失效 或者 Redis崩了导致大量请求到达数据库

  1. 一些解决方案

  • 给不同Key的 TTL添加随机值----防止大量KEY同时过期

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

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

3.缓存击穿

  1. 概念:缓存击穿也叫热点key问题,指的是某些 高并发访问 并且缓存重建过程复杂的KEY突然失效,无数的请求访问会给数据库带来巨大冲击

  2. 解决方案:互斥锁 逻辑过期

五. 互斥锁和逻辑过期解决缓存击穿

1.互斥锁

  1. 互斥锁的逻辑

  • 让一个线程获取锁后,只由它来查询数据库,完成缓存重建,其他线程重复查询缓存,获取锁的过程,直到缓存完成重建,其他线程就可以查询缓存了

  1. 互斥锁的功能

  • 1)获取锁和释放锁必须是原子操作,不能被分割 ---原子性

  • 2)同一把锁只能被一个线程持有 ---唯一性

  • 3)同一线程可多次获取同一把锁 ---可重入性(可选)

  • 4)锁必须有一个过期时间,防止死锁

  1. 为什么不用本地锁synchronized?

  • 多台Tomcat服务器时,本地锁不起作用。两个请求分别由两个Tomcat发出时,两个请求各会获取一把锁,都到达数据库

  1. 最终解决方案 ---Redis的 SETNX(setIfAbsent)命令

// 获取锁
Boolean flag = stringRedisTemplate.opsForValue()
    .setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    //      ↑
    //   只有 key 不存在时,才能设置成功!

//释放互斥锁
private void unlock(String key){
    stringRedisTemplate.delete(key);
}
  1. 互斥锁解决缓存击穿

    //互斥锁解决缓存击穿
    private Shop queryWithMutex(Long id)   {
        String key=CACHE_SHOP_KEY+id;

        //1.从缓存(Redis)中查询数据
        String shopJson=stringRedisTemplate.opsForValue().get(key);

        //2.如果缓存命中直接返回
        if(StrUtil.isNotEmpty(shopJson)){
            Shop shop= JSONUtil.toBean(shopJson,Shop.class);
            return shop;
        }
        //3.判断命中的是否是空值
        if(shopJson != null){
            //返回错误信息
            return null;
        }

        //4.实现缓存重建
        //4.1获取互斥锁
        String lockKey="lock:shop"+id;
        Shop shop= null;
        try {
            boolean isLock=tryLock(lockKey);
            //4.2判断是否获取成功,这里为了代码简洁优雅,我们依旧选择反逻辑写
            if(!isLock){
                //4.3失败则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }

            //5.成功,则到数据库查询数据
            shop = getById(id);

            //6.如果数据库没有,把空值写入到Redis中 并设置短的过期时间
            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);
        }

        //8.释放互斥锁
        unlock(lockKey);

        //9.返回结果
        return shop;
    }
  1. 用互斥锁的情况

  • 在代码中我们可以看到,没获取到互斥锁的线程,会一直重复获取直到缓存重建完成,这就会导致查询变慢,但查询到的数据一定是准确的,所以 互斥锁解决缓存击穿 适合 数据必须最新,数据变更频繁的业务

2.逻辑过期

  1. 概念

  • 既然缓存击穿的发生原因是 热点KEY 突然在Redis中过期 那我们直接将它设置成永不过期不就行了吗??!!但有一个问题,难道这个缓存数据就不更新了吗?不可能,所以我们要给它额外设置一个逻辑过期的字段,当时间超过逻辑过期的时间,就代表这条数据需要更新了

  1. 用什么数据结构?

  • 因为我们需要给数据额外添加过期时间,就需要把过期时间和原对象打包到一起,我们新建一个类,不但可以实现“打包”,还可以逻辑复用给 其他所有对象

public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

然后我们把RedisData存进Redis里就行了,还用String

  1. 逻辑过期解决缓存击穿

    //逻辑过期解决缓存击穿
    private Shop queryWithLogicalExpire(Long id)   {
        String key=CACHE_SHOP_KEY+id;

        //1.从缓存(Redis)中查询数据
        String shopJson=stringRedisTemplate.opsForValue().get(key);

        //2.如果缓存未命中直接返回
        if(StrUtil.isEmpty(shopJson)){
            return null;
        }

        //3.命中,需要先把Json反序列化为对象
        RedisData redisData=JSONUtil.toBean(shopJson,RedisData.class);
        Shop shop=JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
        LocalDateTime expiretime=redisData.getExpireTime();

        //4.判断是否过期
        if(expiretime.isAfter(LocalDateTime.now())){
            //4.1未过期,直接返回店铺信息
            return shop;
        }

        //4.2已过期,需要缓存重建
        //5.缓存重建
        //5.1 拿互斥锁
        String lockKey="lock:shop"+id;
        boolean islock=tryLock(lockKey);

        //5.2 判断是否拿到锁
        //5.3 成功则开启独立线程,实现缓存重建
        if(islock){
            //用线程池
            CACHE_REBUILD_EXECUTOR.submit(() ->{
                //重建缓存
                try {
                    this.saveShop(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(lockKey);
                }

            });
        }

        //5.4 失败直接返回过期的商铺信息
        return shop;
    }

  1. 什么时候用逻辑过期?

  • 在代码中我们可以发现,对于没拿到锁的线程,直接返回了过期的旧数据,甚至拿到锁的线程,进入if的true块后,我们用线程池异步 重建缓存,这样甚至拿到锁的这个线程也直接拿了过期数据返回,性能很高,所以逻辑过期适用的情况是 访问量大,需要高并发,数据更新不频繁,对数据准确不做高要求的情况

六. 逻辑抽离和复用

  • 我们将刚才解决缓存穿透 和 缓存击穿的逻辑全部抽出 封装到一个工具类,这样就可以对所有对象,而不但是我们的查询商铺功能使用了

  • public class CacheClient {
    
        private final StringRedisTemplate stringRedisTemplate;
    
        //线程池,用来开启线程
        private static final ExecutorService CACHE_REBUILD_EXECUTOR=java.util.concurrent.Executors.newFixedThreadPool(10);
    
        public CacheClient(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        //1.将任意Java对象转为JSON字符串并存储到String类型的key中,并可以设置过期时间
        public void set(String key, Object value, long time, TimeUnit Unit) {
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, Unit);
        }
    
        //2.将任意Java对象转为JSON字符串并存储到String类型的key中,并可以设置逻辑过期时间,用于解决缓存击穿
        public void setWithLogicalExpire(String key, Object value, long time, TimeUnit unit) {
            //设置逻辑过期
            RedisData redisData = new RedisData();
            redisData.setData(value);
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
            //写入Redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
        }
    
        //3.根据指定的key查询缓存,并反序列化为指定类型,用缓存空值的方式解决缓存穿透问题
        public <R,ID> R queryWithPathThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit) {
            String key=keyPrefix+id;
    
            //1.从缓存(Redis)中查询数据
            String json=stringRedisTemplate.opsForValue().get(key);
    
            //2.如果缓存命中直接返回
            if(StrUtil.isNotEmpty(json)){
                return JSONUtil.toBean(json,type);
            }
            //判断命中的是否是空值
            if(json != null){
                //返回错误信息
                return null;
            }
    
            //3.如果缓存中没有,到数据库查询数据
            R r=dbFallback.apply(id);
    
            //4.如果数据库没有,把空值写入到Redis中 并设置短的过期时间
            if(r==null){
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
    
            //5.如果数据库有,将数据添加写入Redis中
            this.set(key,r,time,unit);
    
            //6.返回结果
            return r;
        }
    
        //4.根据指定的key查询缓存,并反序列化为指定类型,用逻辑过期的方式解决缓存击穿问题
    
        //获取互斥锁
        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);
        }
        }

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐