谷粒商城-分布式高级篇-分布式锁与缓存

  1. 谷粒商城-分布式基础篇【环境准备】
  2. 谷粒商城-分布式基础【业务编写】
  3. 谷粒商城-分布式高级篇【业务编写】持续更新
  4. 谷粒商城-分布式高级篇-ElasticSearch
  5. 谷粒商城-分布式高级篇-分布式锁与缓存
  6. 项目托管于gitee


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

哪些数据适合放入缓存?

  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多,写少)



一、整合Redis测试

1.1、整合Redis

第一步、引入依赖


<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

第二步、配置redis主机地址

spring:
  redis:
    host: 124.222.223.222
    port: 6379

1.2、测试Redis


@Autowired
StringRedisTemplate redisTemplate;

@Test
public void testStringRedisTemplate() {
     // 存入一个 hello world
    ValueOperations<String, String> ops = redisTemplate.opsForValue();
    // 保存数据
    ops.set("hello", "world_"+ UUID.randomUUID().toString());
    // 查询数据
    System.out.println("之前保存的数据:" + ops.get("hello"));
}





二、改造三级分类业务

2.1、编写 从缓存中查询并封装分类数据的方法

优化菜单获取业务getCatalogJson,使用 从缓存中查询并封装分类数据

在 CategoryServiceImpl 实现类中 将原来的从数据库中查询并封装分类数据 的方法名改为 getCatalogJsonFromDb() 供调用!,并编写 从缓存中查询并封装分类数据的方法

/**
 * 从缓存中查询并封装分类数据
 * 给换从中放JSON字符串,拿出来的JSON字符串,还要逆转为能用的对象类型【序列化与反序列化】
 * @return
 */
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    // 1、加入缓存逻辑,缓存中存放的数据是json字符串
    // JSON跨语言,跨平台兼容
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        // 2、缓存中没有数据,则查询数据库并保存
        Map<String, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
        // 3、查到的数据放入缓存,将查出的对象转为json放在缓存中
        redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJsonFromDb));
        return catalogJsonFromDb;
    }
    // 4、将读到的JSON字符串转为我们想要的
    Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
    return result;
}

2.2、lettuce堆外内存溢出bug

测试爆出 lettuce堆外内存溢出bug

谷粒商城-分布式高级篇-分布式锁与缓存_第1张图片

当进行压力测试时后期后出现堆外内存溢出OutOfDirectMemoryError

产生原因:

  1. springboot2.0以后默认使用lettuce作为操作redis的客户端,它使用netty进行网络通信

  2. lettuce的bug导致netty堆外内存溢出。netty如果没有指定堆外内存,默认使用Xms的值,可以使用-Dio.netty.maxDirectMemory进行设置

解决方案:由于是lettuce的bug造成,不要直接使用-Dio.netty.maxDirectMemory去调大虚拟机堆外内存,治标不治本。

  • 1)、升级lettuce客户端。但是没有解决的
  • 2)、切换使用jedis

2.3、切换使用jedis

lettuce和jedis是操作redis的底层客户端,RedisTemplate是再次封装

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettucegroupId>
            <artifactId>lettuce-coreartifactId>
        exclusion>
    exclusions>
dependency>
<dependency>
    <groupId>redis.clientsgroupId>
    <artifactId>jedisartifactId>
dependency>

lettuce 和 jedis 都是操作Redis的底层客户端。Spring 再次封装 redisTemplate ;



三、缓存击穿、穿透、雪崩

3.1、高并发缓存失效问题-缓存穿透(查不到)

缓存穿透

缓存失效 :缓存没有命中到,没有使用

  • 缓存穿透
    缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求(指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录),我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要去存储层去查询,失去了缓存的意义
  • 风险
    利用不存在的数据进行攻击,数据库瞬间压力增大,最终导致崩溃
  • 结局
    null结果缓存,并加入短暂过期时间

### 3.2、高并发缓存失效问题-==缓存击穿== (量太大)

缓存击穿 (量太大)

  • 缓存击穿

    • 缓存击穿 , 是指一个key非常热点 , 在不停的扛着大并发 , 大并发中对这一个点进行访问 , 当这个key在失效的瞬间 , 持续的大并发就穿破缓存 , 直接请求数据库 , 就像在一个屏幕上凿开了一个洞 .

    • 当某个key在过期的瞬间 , 有大量的请求并发访问 , 这类数据一般是热点数据 , 由于缓存过期 , 会同时访问数据库来查询最新数据 , 并且会写缓存 , 会导致数据库瞬间压力过大 .

      比如热搜排行上,一个热点新闻被同时大量访问就可能导致缓存击穿。

  • 解决

    • 设置热点数据永远不过时
      • 这样就不会出现热点数据过期的情况,但是当Redis内存空间满的时候也会清理部分数据,而且此种方案会占用空间,一旦热点数据多了起来,就会占用部分空间。
    • 加互斥锁(分布式锁)
      • 在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。保证同时刻只有一个线程访问。这样对锁的要求就十分高。

3.3、高并发缓存失效问题-缓存雪崩

缓存雪崩

  • 缓存雪崩
    大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。
  • 解决
    原油的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。


四、优化三级分类业务

  • 1、空结果缓存:解决缓存穿透

    2、设置过期事件(加随机值):解决缓存雪崩

    3、加锁:解决缓存击穿

  • 方法一本地锁 在代码块上加synchronized(this) ,SpringBoot所有的组件在容器中都是单例的。

    • 查缓存没有,然后取竞争锁查数据库。
    • 竞争到锁之后再次查询缓存中有没有
      • 有:则直接返回缓存中的数据
      • 没有:则查询数据库返回
    • 缺点:单体架构下可以,但在分布式的时候多个服务相当于多个实例。本地锁,只能锁住当前进程,所以我们需要分布式锁!故此方法不附代码
  • 方法二分布式锁

4.1、本地锁


方法一本地锁 在代码块上加synchronized(this) ,SpringBoot所有的组件在容器中都是单例的。

  • 查缓存没有,然后取竞争锁查数据库。
  • 竞争到锁之后再次查询缓存中有没有
    • 有:则直接返回缓存中的数据
    • 没有:则查询数据库返回
  • 缺点:单体架构下可以,但在分布式的时候多个服务相当于多个实例。本地锁,只能锁住当前进程,所以我们需要分布式锁!

4.1.1、锁-时序问题

锁-时序问题

谷粒商城-分布式高级篇-分布式锁与缓存_第2张图片

这里进行压力测试的时候出现了查询多次数据库没锁住,即锁-时序问题,因为我们将结果放入缓存这一步写在了锁外面,比如1号请求在数据库中查到了数据之后就释放了锁,此时还没将结果放入缓存,此时2号请求竞争到锁由于此时1号请求在数据库中查到的数据还没有放入到缓存,故缓存中没有再次查询数据库

解决:
将结果放入缓存这一步写到锁里面去,不要放在释放锁之后做
谷粒商城-分布式高级篇-分布式锁与缓存_第3张图片

4.1.2、本地锁在分布式下的问题

本地锁在分布式下的问题

复制配置创建多个进程服务


和我们预想的一样在分布式情况下,每个服务都要查询一次数据库。虽然没有完全锁住,但确实优化了。

4.1.3、附上代码


CategoryServiceImpl 实现类:

@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    /*
    * 1、空结果缓存:解决缓存穿透
    * 2、设置过期事件(加随机值):解决缓存雪崩
    * 3、加锁:解决缓存击穿
    * */

    // 1、加入缓存逻辑,缓存中存放的数据是json字符串
    // JSON跨语言,跨平台兼容
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        System.out.println("缓存不命中__查询数据库");
        // 2、缓存中没有数据,则查询数据库并保存(保存缓存的代码写在了getCatalogJsonFromDb()方法里面)
        Map<String, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
    }
    System.out.println("缓存命中__直接返回");
    // 4、将读到的JSON字符串转为我们想要的
    Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
    return result;
}

/**
 * 从数据库中查询并封装分类数据
 * @return
 */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDb() {
    // 方法一、用线程安全方法
    // 只要是同一把锁,就能锁住这个锁的所有线程
    // 1、synchronized (this):SpringBoot所有的组件在容器中都是单例的
    // TODO 本地锁:synchronized,JUC(Lock),在分布式情况下想要锁住所有,必须使用分布式锁
    synchronized (this) {
        // 得到锁以后,我们应该再去缓存中确定一次,如果没有才需要继续查询
        String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
        if (!StringUtils.isEmpty(catalogJSON)) {
            // 如果缓存不为空,直接返回
            Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference< Map<String, List<Catelog2Vo>>>(){});
            return result;
        }
        System.out.println("查询数据库");
        /**
         * 1、将数据库的多次查询变为一次
         */
        List<CategoryEntity> selectList = baseMapper.selectList(null);

        // 1、查出所有分类
        List<CategoryEntity> level1Categorys = getParent_cid(selectList,0L);

        // 2、封装数据
        Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            // 2.1、每一个一集分类,查到这个一集分类的所有二级分类
            List<CategoryEntity> categoryEntities = getParent_cid(selectList,v.getCatId());
            // 2.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());

                    // 2.3、找当前二级分类的三级分类封装成vo
                    List<CategoryEntity> level3Catelog = getParent_cid(selectList,l2.getCatId());
                    if (level3Catelog!=null) {
                        List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.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放在缓存中
        redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(parent_cid),1, TimeUnit.DAYS);
        return parent_cid;
    }
}


### 4.2、分布式锁

4.2.1、分布式锁原理与使用

分布式锁原理与使用

谷粒商城-分布式高级篇-分布式锁与缓存_第4张图片

4.2.1.1、分布式锁演进-阶段一

谷粒商城-分布式高级篇-分布式锁与缓存_第5张图片

谷粒商城-分布式高级篇-分布式锁与缓存_第6张图片

  • 问题

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

  • 解决
    设置锁的自动过程,即使没有删除,会自动删除

4.2.1.2、分布式锁演进-阶段二

谷粒商城-分布式高级篇-分布式锁与缓存_第7张图片

  • 问题

    setnx设置好,正要去设置过期时间,宕机,又成死锁了

  • 解决
    设置过期时间和占位必须是原子的。redis支持使用 setnx ex命令

4.2.1.3、分布式锁演进-阶段三

谷粒商城-分布式高级篇-分布式锁与缓存_第8张图片

  • 问题
    • 删除锁直接删除?
      如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了
  • 解决
    设置过期时间和占位必须是原子的。redis支持使用 setnx ex命令
    • 占锁的时候,值指定为uuid,每个人匹配是自己的锁才删除。
4.2.1.4、分布式锁演进-阶段四

谷粒商城-分布式高级篇-分布式锁与缓存_第9张图片
谷粒商城-分布式高级篇-分布式锁与缓存_第10张图片

4.2.1.5、分布式锁演进-阶段五

谷粒商城-分布式高级篇-分布式锁与缓存_第11张图片

最终代码:

/**
 * 从数据库中查询并封装分类数据(Redis分布式锁)
 * @return
 */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    // 1、占分布式锁,去Redis占坑 设置过期时间,必须和加锁是同步的、原子的
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300,TimeUnit.SECONDS);
    if (lock) {
        System.out.println("获取分布式锁成功...");
        // 2、加锁成功 ...执行业务
        Map<String, List<Catelog2Vo>> dataFromDb;
        try{
            dataFromDb = getDataFromDb();
        }finally {
            // 3、删锁
            String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
                    "    return redis.call(\"del\",KEYS[1])\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";
            redisTemplate.execute(new DefaultRedisScript<Long>(script,Long.class),
                    Arrays.asList("lock"),uuid);
        }
        return dataFromDb;
    } else {
        // 加锁失败...重试
        // 休眠100ms重试
        System.out.println("获取分布式锁失败...等待重试");
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
    }
}


五、分布式锁-Redisson

5.1、Redisson 简介


https://github.com/redisson/redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

本文我们仅关注分布式锁的实现,更多请参考官方文档

5.2、Redisson 整合


第一步、导入依赖


<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
    <version>3.12.0version>
dependency>

第二步、编写配置类 RedissonClient

com/atguigu/gulimall/product/config 路径下

@Configuration
public class MyRedissonConfig {

    /**
     * 所有对Redisson的使用都是通过RedissonClient对象
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod="shutdown")
    public RedissonClient redisson() throws IOException {
        // 1、创建配置
        Config config = new Config();
        // 可以使用 "rediss://"来启用SSL安全连接
        config.useSingleServer().setAddress("redis://124.222.223.222:6379");

        // 2、根据Config创建出实例
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}

### 5.3、分布式锁-Redisson-lock锁测试

5.3.1、可重入锁(Reentrant Lock)

分布式锁:github.com/redisson/redisson/wiki/8.-分布式锁和同步器

A调用B。AB都需要同一锁,此时可重入锁就可以重入,A就可以调用B。不可重入锁时,A调用B将死锁

  • 1、如果我们传递了锁的超时时间,就发送给redis执行脚本 进行占锁,默认超时就是我们指定的时间
  • 2、如果我们未指定锁的超时时间,就是使用 30 * 1000【看门狗的超时间 lockWatchdogTimeout】,只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是开门狗的默认时间】,每隔1/3的看门狗时间10s再次续期
  1. 默认
RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();

锁的续期:大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个**监控锁的看门狗,**它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟(每到20s就会自动续借成30s,是1/3的关系),也可以通过修改Config.lockWatchdogTimeout来另行指定。

  1. 指定锁的超时时间

另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       lock.unlock();
   }
}
  1. Redisson同时还为分布式锁提供了异步执行的相关方法:
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);

RLock对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore 对象.

测试代码附上:

// 测试接口
@ResponseBody
@GetMapping("/hello")
public String  hello() {
    // 1、获取一把锁,只要锁的名字一样,就是同一把锁
    RLock lock = redisson.getLock("my-lock");

    // 2、加锁
    //lock.lock(); // 阻塞式等待,默认加的锁都是30s时间
    // 1)、锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务长锁自动过期被删掉
    // 2)、加锁的业务只要运行完成就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除
    lock.lock(10, TimeUnit.SECONDS);    // 10s自动解锁,自动解锁时间一定要大于业务的执行时间
    // lock.lock(10, TimeUnit.SECONDS);  锁时间到了以后不会自动续期
    // 1、如果我们传递了锁的超时时间,就发送给redis执行脚本 进行占锁,默认超时就是我们指定的时间
    // 2、如果我们未指定锁的超时时间,就是使用 30 * 1000【看门狗的超时间 lockWatchdogTimeout】
    // 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是开门狗的默认时间】,每隔1/3的看门狗时间10s再次续期
    // internallockLeaseTime【看门狗时间】/3,10s

    // 最佳实战
    // 指定锁的超时时间,省掉了整个续期操作。手动解锁
    try {
        System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
        Thread.sleep(30000);
    } catch (Exception e){

    } finally {
        // 3、解锁 假设解锁代码没有运行,redisson会不会出现死锁
        lock.unlock();
        System.out.println("释放锁..." + Thread.currentThread().getId());
    }
    return "hello";
}

5.3.2、公平锁(Fair Lock)


  • 基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
  • 它保证了当多个Redisson客户端线程同时请求加锁时,
    1. 优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当
    2. 某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();

使用方法都同上可重入锁一样,可重入锁是不公平锁!

5.3.3、读写锁(ReadWriteLock)


  • 保证一定能读到最新数据,修改期间写锁是互斥锁(排他锁)。读锁是一个共享锁
  • 写锁没释放读就必须等待
    • 读 + 读:相当于无锁,并发读只会在Redis中记录好,所有当前的读锁。它们都会同时加锁成功
    • 写 + 读:等待写锁释放
    • 写 + 写:阻塞方式
    • 读 + 写:有读锁,写也需要等待
    • 只要有写锁的时候,都得等待

分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();

另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();

测试代码:

    @ResponseBody
    @GetMapping("/write")
    public String writeValue() {

        RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
        RLock rLock = lock.writeLock();
        String s = "";
        try {
            // 1、该数据加写锁,读数据加读锁
            rLock.lock();
            System.out.println("写锁加锁成功..."+Thread.currentThread().getId());
            s = UUID.randomUUID().toString();
            Thread.sleep(30000);
            redisTemplate.opsForValue().set("writeValue", s);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rLock.unlock();
        }

        return s;
    }

    @ResponseBody
    @GetMapping("/read")
    public String readValue() {
        RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
        // 加读锁
        RLock rLock = lock.readLock();
        String s = "";
        try {
            rLock.lock();
            System.out.println("读锁加锁成功..."+Thread.currentThread().getId());
            Thread.sleep(30000);
            s = (String) redisTemplate.opsForValue().get("writeValue");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            rLock.unlock();
        }
        return s;
    }
}

5.3.4、信号量(Semaphore)


信号量为存储在redis中的一个数字,当这个数字大于0时,即可以调用acquire()方法增加数量,也可以调用release()方法减少数量,但是当调用release()之后小于0的话方法就会阻塞,直到数字大于0

RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();

测试代码:

@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore park = redisson.getSemaphore("park");
    //park.acquire(); // 获取一个信号,获取一个值, 占一个车位
    boolean b = park.tryAcquire();  // 尝试获取一下,有就执行没就不执行
    return "ok=>"+b;
}

@GetMapping("/go")
@ResponseBody
public String gp() throws InterruptedException {
    RSemaphore park = redisson.getSemaphore("park");
    park.release(); // 释放一个车位

    return "ok";
}

5.3.5、闭锁(CountDownLatch)


等待其他都完成之后,才关闭

基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。

RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();

// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();

测试代码:

    /**
     * 放假、锁门
     * 1班没人了
     * 5个班全部走完,我们才可以锁大门
     */
    @GetMapping("/lockDoor")
    @ResponseBody
    public String lockDoor() throws InterruptedException {
        RCountDownLatch door = redisson.getCountDownLatch("door");
        door.trySetCount(5);
        door.await();  // 等待闭锁都完成

        return "放假啦...";
    }

    @GetMapping("/gogo/{id}")
    public String goGo(@PathVariable("id") Long id){
        RCountDownLatch door = redisson.getCountDownLatch("door");
        door.countDown();   // 计数减1
        return id+"班的人都走了...";
    }

5.4、使用Redisson 来优化 从数据库中查询并封装分类数据方法


/**
* Redisson优化分布式锁
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedissonLock() {
  // 1、锁的名字。锁的粒度,越细越快。
  // 锁的粒度:具体缓存的是某个数据。比如:11号商品;product-11-lock
  RLock lock = redisson.getLock("catalogJson-lock");
  lock.lock();
  Map<String, List<Catelog2Vo>> dataFromDb;
  try{
    dataFromDb = getDataFromDb();
  }finally {
    lock.unlock();
  }
  return dataFromDb;
}

这个时候来解决我们一直思考的问题:缓存里面的数据如何和数据库保持一致


六、缓存数据一致性

6.1、缓存数据一致性【问题】

问题:缓存里面的数据如何和数据库保持一致

缓存数据一致性

  • 双写模式 :修改数据库的时候同时修改缓存中的数据

    • 问题:并发时,存在脏数据!
    • 解决方案:
      1. 双写的时候加锁,写数据库和写缓存封装成原子性操作
      2. 允许暂时的脏读,设置缓存的过期时间,最终一致性

谷粒商城-分布式高级篇-分布式锁与缓存_第12张图片

  • 失效模式 :袖该数据库的时候删除缓存中的数据,等待下次主动查询进行更新

    • 问题:还没存入数据库呢,线程2又读到旧的DB了
    • 解决:
      1. 缓存设置过期时间,定期更新
      2. 写数据写时,加分布式的读写锁。

谷粒商城-分布式高级篇-分布式锁与缓存_第13张图片

6.2、缓存数据一致性【解决方案】

缓存数据一致性——解决方案

  • 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
    1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间出触发读的主动更新即可
    2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式
    3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求
    4. 通过加锁保证并发读写,并发写的时候按顺序排好队。并发读的时候无所谓。所以适合使用读写锁(业务不关心脏数据,允许临时脏数据可省略)
  • 总结
    • 我们能放入缓存的根本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
    • 我们不应该过度设计,增加系统的复杂性
    • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点

谷粒商城-分布式高级篇-分布式锁与缓存_第14张图片

6.3、本项目的缓存数据一致性的解决方案

本系统的一致性解决方案:

1、缓存的所有数据都有过期时间,数据过期下次查询触发主动更新

2、读写数据的时候,加上分布式的读写锁。(经常读写会有影响、偶尔写经常读没多大影响)



七、SpringCache

7.1、SpringCache简介

SpringCache简介

  • Spring 从 3.1 开始定义了
    org.springframework.cache.Cacheorg.springframework.cache.CacheManager接口来统一不同的缓存技术;并支持使用 JCache(JSR-107)注解简化我们开发;
  • Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合;
    Cache 接口下 Spring 提供了各种 xxxCache 的实现;如 RedisCache、EhCacheCache、ConcurrentMapCache等;
  • 每次调用需要缓存功效的方法时,Spring 会检查检查指定参数的指定的目标方法是否已经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户。下次调用直接从缓存中获取。
  • 使用 Spring 缓存抽象时我们需要关注以下两点:
    • 1、确定方法需要被缓存以及它们的缓存策略
    • 2、从缓存中读取之前缓存存储的数据

谷粒商城-分布式高级篇-分布式锁与缓存_第15张图片

7.2、整合SpringCache


第一步、引入依赖

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-cacheartifactId>
dependency>

第二步、加入配置

spring:
  cache:
    type: redis     #指定缓存类型为redis
    redis:
      time-to-live: 3600000     # 指定缓存的数据的存活时间(毫秒为单位)
      key-prefix: CACHE_        # 设置key的前缀,用来区分和reids其他键不同的.如果制定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
      use-key-prefix: true      # 设置是否使用前缀
      cache-null-values: true   # 是否缓存空值,防止缓存穿透

2.1、自动配置了哪些

  •          CacheAutoCOnfiguration 会导入 RedisCacheConfiguration
    
  •          RedisCacheConfiguration :自动配好了缓存管理器(RedisCacheManager)
    

2.2、我们需要配置哪些

  •          配置使用redis作为缓存
    

第三步、给主类加上开启缓存注解@EnableCaching

@EnableCaching
@EnableFeignClients(basePackages = "com.atguigu.gulimall.product.feign")
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.product.dao")
@SpringBootApplication
public class GulimallProductApplication {

    public static void main(String[] args) {
        SpringApplication.run(GulimallProductApplication.class, args);
    }

}

7.3、使用缓存


  • @Cacheable : 触发缓存填充(将数据保存到缓存的操作)
  •      `@CacheEvict` : 触发缓存逐出(将数据从缓存中删除的操作)
    
  •      `@CachePut` : 在不干扰方法执行的情况下更新缓存(不影响方法执行更新缓存)
    
  •      `@Caching` : 重新组合要应用于一个方法的多个缓存操作(组合以上多个操作)
    
  •      `@CacheConfig` : 在类级别共享一些常见的缓存相关设置(在类级别共享缓存的相同配置)
    

7.3.1、@Cacheable【读】


7.3.1.1、使用@Cacheable

给查找所有的一级分类方法加上@Cacheable,将该数据保存到缓存中去

@Cacheable

  • 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
  • 每一个需要缓存的数据我们都来指定放到那个名字的缓存。【缓存的分区(按照业务类型分)】

查找所有的一级分类

  • 1、每一个需要缓存的数据我们都来指定要放到那个名字的缓存。【缓存的分区(按照业务类型分)】
  • 2、@Cacheable
    • 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
    • 每一个需要缓存的数据我们都来指定放到那个名字的缓存。【缓存的分区(按照业务类型分)】CategoryServiceImpl实现类
  • 3、默认行为

    • 如果缓存中有,方法不用调用
    • hey默认自动生成:缓存的名字::SimpleKey [] (自主生成的key值)如:category::SimpleKey []
    • 缓存的value的值。默认使用jdk序列化机制,将序列化后的数据存到redis
    • 默认ttl时间:-1
  • 4、自定义

    • 指定生成的缓存使用的key key属性指定,接收一个SpEL SpEL的详细语法

    • 指定缓存的数据的存活时间 在配置文件中指定(spring.cache.redis.time-to-live=num毫秒)

    • 将数据保存为json格式 需要自定义缓存配置

    @Cacheable(value = {"category"}, key = "#root.methodName")
    @Override
    public List<CategoryEntity> getLevel1Categorys() {
        System.out.println("getLevel1Categorys...");
        List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
        return categoryEntities;
    }

测试,之后每次刷新首页都不再进入getLevel1Categorys()方法了!

7.3.1.2、自定义缓存配置

原理:

  •      CacheConfigurations 缓存的自动配置帮我们导入了 ->  RedisCacheConfiguration
    
  •      -> 自动配置了RedisCacheManager -> 初始化所有的缓存 -> 每个缓存决定使用什么配置
    
  •      ->如果 redisCacheConfiguration 有就有已有的,没有就用默认配置
    
  •      -> 想改缓存的配置,只需要给容器中放一个 RedisCacheConfiguration 即可
    
  •      -> 就会应用到当前缓存 RedisCacheManager 管理的所有缓存分区中
    

编写自定义的缓存机制

默认使用jdk进行序列化(可读性差),默认ttl为-1永不过期,自定义序列化方式需要编写配置类

com.atguigu.gulimall.product.config包下

@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {
    
    /**
     * 配置文件中的配置没有用上
     * 1、原来和配置文件绑定的配置类是这样子的
     *      @ConfigurationProperties(prefix = "spring.cache")
     *      public class CacheProperties {
     * 2、要让他生效
     *      @EnableConfigurationProperties(CacheProperties.class)
     * @return
     */
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 每个方法执行完都是返回了一个新对象,得覆盖原对象得值
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        // 将配置文件中的所有配置都生效
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

}

7.3.2、@CacheEvict【写】

触发缓存逐出(将数据从缓存中删除的操作)

  • @CacheEvict:缓存失效模式(当修改数据库的时候删除指定key的缓存)
    删除指定某个分组下的所有数据 :@CacheEvict(value = “category”, allEntries = true)
  • @Caching:同时进行多个缓存操作
    • @Caching(evict = {
    • ​ @CacheEvict(value = {“category”}, key = “‘getLevel1Categorys’”),
    • ​ @CacheEvict(value = {“category”}, key = “‘getCatalogJson’”)
    • })
7.3.2.1、修改(级联更新所有关联数据)的方法

@CacheEvict(value = "category", allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
  this.updateById(category);
  categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
  // 双写
  // 失效:redis.del("catalogJSON"); 等待下次主动查询进行更新
}
7.3.2.2、修改(从数据库中查询并封装分类数据)的方法

/**
 * 修改(从数据库中查询并封装分类数据)的方法【SpringCache】
 * @return
 */
@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    System.out.println("进行查询数据库");
    List<CategoryEntity> selectList = baseMapper.selectList(null);
    List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
    // 2、封装数据
    Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
        // 2.1、每一个一集分类,查到这个一集分类的所有二级分类
        List<CategoryEntity> categoryEntities = getParent_cid(selectList, v.getCatId());
        // 2.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());

                // 2.3、找当前二级分类的三级分类封装成vo
                List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId());
                if (level3Catelog != null) {
                    List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.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;
    }));
    return parent_cid;
}

7.4、SpringCache不足


1)、读模式

  • 缓存穿透:查询一个null数据; 解决:缓存空数据:cache-null-values: true
  • 缓存击穿:大量并发进来同时查询一个正好过期的数据; 解决:加锁 ?sync = true本地锁
  • 缓存雪崩:大量的key同时过期。 解决:加随机时间。加上过期时间 time-to-live: 3600000

2)、写模式:(缓存与数据库一致)

  • 读写加锁
  • 引入Canal,感知到MySQL的更新去更新数据库
  • 读多写多,直接去数据库查询就行

原理:

  • CacheManager(createRedisCacheManager)->Cache(createRedisCache)->Cache负责缓存的读写

总结:

  • 常规数据(读多写少,及时性和一致性要求不高的数据)完全可以使用SpringCache;邪魔恶事(只要缓存的数据有过期时间就足够了)
  • 特殊数据:特殊设计

你可能感兴趣的:(谷粒商城,redis,缓存,分布式,spring,boot)