2.商城业务-缓存与分布式锁

156 阅读11分钟

本文我们讲解一下项目中用到的缓存与分布式锁。

1.缓存使用

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而数据库承担数据落盘工作。

哪些数据适合放入缓存?

  • 即时性、数据一致性要求不高的

    像商品数量就属于即时性很高的,需要去数据库中获取;保证最终一致性即可;

  • 访问量大且更新频率不高的数据(读多,写少)

举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要 5分钟才能看到新的商品一般还是可以接受的。

缓存使用流程 image-20230103163654881.png

伪代码

data = cache.load(id);//从缓存加载数据
if(data == null){
    data = db.load(id);//从数据库加载数据
    cache.put(id,data);//保存到 cache中
}
return data;

注意:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致问题

2.本地缓存

缓存方式我们可以使用Map来做,将一些符合缓存条件的数据放到map中,每当使用的时候按照上述的流程使用,如果是单体项目,Map是合适的,并且效率还很高。

使用Map做缓存的方式就属于是本地缓存

image-20230103164133247.png

本地缓存存在的问题

  • 每一个微服务项目放一个缓存cache,会导致缓存一致性问题。

    nginx做负载均衡,分配到不同的服务中,如果对应cache都没有对应数据,都需要存入一份,并且如果某个服务的数据有变化,其它服务中的cache不晓得。

  • 针对上述问题,出现了分布式缓存;

image-20230103164211188.png

3.分布式缓存

  • redis也可以搭建集群,完成容量提升的限制。

image-20230103164528520.png

4.整合Redis

  • 引入spring-boot-starter-data-redis
  • application.yml配置redis远程服务器地址,端口(默认就是6379),密码(建议使用阿里云服务器的设置密码,不然可能会被当作矿机)
  • 使用SpringBoot自动配置好的StringRedisTemplate[继承了RedisTemplate,并且(k,v)的泛型都是 String 类型]来操作Redis

5.使用Redis改进业务

  • 首先将之前从数据库中获取分类数据的业务逻辑封装成一个方法getCatelogJsobnFromDb()

  • 新增一个Redis优化的方法

    public Map<String, List<Catelog2Vo>> getCatelogJson() {
        // TODO:注意:给缓存中放json字符串,拿出的json字符串,还得逆转为能用的对象类型【序列化与反序列化】
        // 1.加入缓存逻辑,缓存中存的数据是json字符串
        // JSON跨语言,跨平台兼容
        String catelogJson = redisTemplate.opsForValue().get("catelogJson");
        if(StringUtils.isEmpty(catelogJson)) {
            // 2.缓存中没有数据,查询数据库
            Map<String, List<Catelog2Vo>> catelogJsobnFromDb = getCatelogJsobnFromDb();
            // 3.将查到的数据转为json字符串再放入缓存
            String json = JSON.toJSONString(catelogJsobnFromDb);
            redisTemplate.opsForValue().set("catelogJson", json);
            // 将数据库中查到的数据直接返回即可
            return catelogJsobnFromDb;
        }
        // 转为指定的对象
        // TypeReference构造器是受保护的,需要写一个实现类,这里我们直接使用内部类的方式
        Map<String, List<Catelog2Vo>> result = JSON.parseObject(catelogJson, new TypeReference<Map<String, List<Catelog2Vo>>>(){});
        return result;
    }
    

在开发中遇到的问题:产生堆外内存溢出:OutOfDirectMemoryError

  • springboot2.0以后默认使用lettuce作为操作redis客户端,使用netty进行通信,而lettuce的bug会导致堆外内存溢出,netty如果没有指定堆外内存,默认使用-Xmx100m,内存不足,自然而然就会产生堆外内存溢出了

  • 解决方案:

    1.升级lettuce客户端

    2.切换使用jedis

问题:jedis,lettuce与RedisTemplate的区别

  • lettuce,jedis是操作redis的底层客户端

    jedis作为老牌的redis客户端,采用同步阻塞式IO,采用线程池时是线程安全的。优点是简单、灵活、api全面,缺点是某些redis高级功能需要自己封装。

    lettuce作为新式的redis客户端,基于netty采用异步非阻塞式IO,是线程安全的,优点是提供了很多redis高级功能,例如集群、哨兵、管道等,缺点是api抽象,学习成本高。

  • RedisTemplate是基于某个具体实现的再封装,比如说springBoot1.x时,具体实现是jedis;而到了springBoot2.x时,具体实现变成了lettuce。封装的好处就是隐藏了具体的实现,使调用更简单,但是有人测试过jedis效率要10-30倍的高于redisTemplate的执行效率,所以单从执行效率上来讲,jedis完爆redisTemplate。redisTemplate的好处就是基于springBoot自动装配的原理,使得整合redis时比较简单。

  • redission作为redis的分布式客户端,同样基于netty采用异步非阻塞式IO,是线程安全的,优点是提供了很多redis的分布式操作和高级功能,缺点是api抽象,学习成本高。

6.高并发下缓存失效的问题

1)缓存穿透

  • 指查询一个一定不存在的数据,发送的请求传进来的key是不存在Redis中的,那么就查不到缓存,查不到缓存就会去数据库查询(数据库中也是查不到的)
  • 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃
  • 解决方法:缓存null;使用布隆过滤器

2)缓存雪崩

  • 缓存雪崩是指某一个时刻出现大规模的缓存失效的情况,请求全部转发到数据库,数据库瞬时压力过重雪崩。
  • 解决方法:原有的失效时间基础上增加一个随机值,比如1-5分钟随机;使用熔断机制

3)缓存击穿

  • 缓存击穿是一个热点Key,有大并发集中对其进行访问,突然间这个Key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。
  • 解决方法:对于热点的key可以设置永不过期的key;使用互斥锁:只让一个去查,其他等待,查到以后立刻放入缓存,再释放锁

7.本地锁&分布式锁解决具体业务

1)本地锁

  • 使用synchronized(this)将从数据库获取二&三级分类的数据的业务逻辑包裹起来

    this指当前实例,当前实例在容器中是单实例的,没有问题;但在分布式情况下,一个服务一个容器,也即一个服务就是一个实例,导致锁不唯一;在分布式情况下会出现同时最多有开启服务个数的实例来访问[这其实也是没有问题的,像本项目共8个服务,那么某一时刻最多有8个线程来访问,最多访问8次数据库,是可以接受的],而如果我们想在某一时刻只允许一个服务中的一个线程进入,就需要使用分布式锁

  • 注意锁时序问题:如果我们加锁-释放锁之后再将查询到的数据放到缓存中,期间某个间隙会存在某个线程在另一个线程将数据放入缓存之前,跨过了判断缓存中没有重复查询数据库的情况,因此需要把从数据库查询并将结果放入缓存的操作设置为原子操作。

image-20230104173609635.png

@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
    /**
      * 解决缓存失效的三个问题:
      * 1.空结果缓存:解决缓存穿透
      * 2.设置过期时间(加随机值):解决缓存雪崩
      * 3.加锁:解决缓存击穿
     */
    String catelogJson = redisTemplate.opsForValue().get("catelogJson");
    if(StringUtils.isEmpty(catelogJson)) {
        // 2.缓存中没有数据,查询数据库
        System.out.println("缓存没有命中,查询数据库。。。。。。");
        Map<String, List<Catelog2Vo>> catelogJsobnFromDb = getCatelogJsobnFromDb();
        // 将数据库中查到的数据直接返回即可
        return catelogJsobnFromDb;
    }
    System.out.println("缓存命中,直接返回。。。");
    Map<String, List<Catelog2Vo>> result = JSON.parseObject(catelogJson, new TypeReference<Map<String, List<Catelog2Vo>>>(){});
    return result;
}

/**
  * 从数据库中查询并封装分类数据
  * @return
*/
public Map<String, List<Catelog2Vo>> getCatelogJsobnFromDb() {

    // 加锁,只要是同一把锁,就能锁住需要这个锁的所有线程
    // SpringBoot所有组件在容器中都是单例的
    synchronized (this) {

        // 得到锁之后,仍需要再次查询一次缓存,如果没有才需要继续查询
        String catelogJson = redisTemplate.opsForValue().get("catelogJson");
        if(!StringUtils.isEmpty(catelogJson)) {
            // 缓存不为null,直接返回
            Map<String, List<Catelog2Vo>> result = JSON.parseObject(catelogJson, new TypeReference<Map<String, List<Catelog2Vo>>>(){});
            return result;
        }

        System.out.println("查询了数据库");

        /**
             * 优化策略:将对数据库的多次查询变为一次
             */
        List<CategoryEntity> selectList = baseMapper.selectList(null);

        // 1.先查出所有1级分类
        List<CategoryEntity> level1Categorys = getParentCid(selectList, 0L);
        // 2.封装数据,将对应的k封装成对应的catelogid,v封装称对应的Catelog2Vo
        Map<String, List<Catelog2Vo>> getCatelogJson = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            //1.根据对应的1级分类id查找到对应的二级分类
            List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
            // 2.继续将上面的得到的结果封装
            List<Catelog2Vo> catelog2Vos = null;
            if (categoryEntities != null) {
                catelog2Vos = categoryEntities.stream().map(l2 -> {
                    Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
                    // 1.找当前二级分类的三级分类封装成vo
                    List<CategoryEntity> level3Categorys = getParentCid(selectList, l2.getCatId());
                    if(level3Categorys != null) {
                        List<Catelog2Vo.Catelog3Vo> collect = level3Categorys.stream().map(l3 -> {
                            Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                            return catelog3Vo;
                        }).collect(Collectors.toList());
                        catelog2Vo.setCatalog3List(collect);
                    }

                    return catelog2Vo;
                }).collect(Collectors.toList());
            }

            return catelog2Vos;
        }));
        // 3.将查到的数据转为json字符串立刻放入缓存,之后再释放锁
        String json = JSON.toJSONString(getCatelogJson);
        redisTemplate.opsForValue().set("catelogJson", json, 1, TimeUnit.DAYS);
        return getCatelogJson;
    }
}

2)分布式锁

接下来我们模仿多个服务压测域名:80(注意域名解析需要配置到hosts文件中)网关负载均衡到各个服务;jmeter设置每个线程的循环次数,这样更容易出现想要的结果

image-20230104174026784.png

结果:上述三个服务中都出现了一次查询数据库的操作

前置知识:分布式锁的原理

分布式锁三种实现方式:

  • 基于数据库实现分布式锁;
  • 基于缓存(Redis等)实现分布式锁;
  • 基于Zookeeper实现分布式锁;

这里我们使用redis实现分布式锁

版本一:未加过期时间

  • setnx占好了位,业务代码异常或者程序在页面过程 中宕机。没有执行删除锁逻辑,这就造成了死锁

  • 解决方案:设置锁的自动过期,即使没有删除,会自动删除(须原子操作set ex nx

    ex保证不死锁,nx保证互斥性

// 1. 抢占分布式锁(原子操作)
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", ”111“);
if(lock) {
	Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
    redisTemplate.delete("lock");
    return dataFromDb;
}else {
    System.out.println("获取分布式锁失败...等待重试");
    // 加锁失败,重试
    // 自旋方式
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return getCatelogJsobnFromDbWithRedisLock();
}

image-20230104185404026.png

版本二:误删锁

  • 如果业务时间过长,导致在删除锁的时候,锁自己已经过期了,直接删除,相当于把其它线程上的锁删除了。
  • 解决方案:占锁的时候,值指定为uuid,每个人匹配是自己的锁才删除(判断一下)。
// 1. 抢占分布式锁(原子操作)
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1111", 300, TimeUnit.SECONDS);;
if(lock) {
	Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
    redisTemplate.delete("lock");
    return dataFromDb;
}else {
    System.out.println("获取分布式锁失败...等待重试");
    // 加锁失败,重试
    // 自旋方式
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return getCatelogJsobnFromDbWithRedisLock();
}

image-20230104190014365.png

版本三:存在原子性问题

  • 如果正好判断是当前值,正要删除锁的时候,锁已经过期, 别人已经设置到了新的值。那么我们删除的是别人的锁
  • 解决方案比较锁、删除锁必须保证原子性。使用redis+Lua脚本完成
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
if(lock) {
    System.out.println("获取分布式锁成功");
    Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
    // 删除锁(不能直接删除,防止误删)
    // 获取值对比+对比成功删除=原子操作 ------>  lua脚本
    String lockVal = redisTemplate.opsForValue().get("lock");
    if(uuid.equals(lockVal)) {
        // 删除我自己的锁
        redisTemplate.delete("lock");
    }
    return dataFromDb;
}

image-20230104190540353.png

最终版本:lua+redis

  • 保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性,并且不能误删其它线程加的锁。
  • 仍然存在一个问题:锁的自动续期【锁仍然存在过期,可以将过期时间设置的长一些】,那么续期问题怎么解决呢,可以依赖我们接下来即将学习的redission!
// 1. 抢占分布式锁(原子操作)
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
if(lock) {
    System.out.println("获取分布式锁成功");
    Map<String, List<Catelog2Vo>> dataFromDb;
    try {
        // 加锁成功,执行业务
        dataFromDb = getDataFromDb();
    } finally {
        // lua脚本
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return  redis.call('del', KEYS[1]) else return 0 end";
        Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class)
                                           , Arrays.asList("lock"), uuid);
    }
    return dataFromDb;
} else {
    System.out.println("获取分布式锁失败...等待重试");
    // 加锁失败,重试
    // 自旋方式
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return getCatelogJsobnFromDbWithRedisLock();
}

image-20230104192838540.png

声明

  1. 若存在错误的内容,请在评论区留言,及时修改。
  2. 笔记内容来自尚硅谷项目--谷粒商城。
  3. 欢迎各位大佬指正,共同进步!!