缓存和分布式锁

7 篇文章 0 订阅

缓存

缓存流程

在这里插入图片描述

缓存分类

  1. 本地缓存
    在这里插入图片描述
    在分布式环境中,本地缓存就会存在数据冗余和效率不高的问题,从而需要使用分布式缓存

  2. 分布式缓存
    在这里插入图片描述

  3. 整合redis
    添加依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    

    修改配置文件

    spring:
      redis:
    	host: 192.168.101.130
    	port: 6379
    

    使用

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public Map<String, List<CategoryEntity>> getCatelog2JSON() {
        String categoryData = redisTemplate.opsForValue().get("categoryData");
        if (categoryData != null) {
            System.out.println("缓存命中了");
            return JSONObject.parseObject(categoryData, Map.class);
        }
        System.out.println("查询数据库");
        List<CategoryEntity> allCategoryEntity = this.list();
        // 获取所有的一级分类的数据
        List<CategoryEntity> levelOneOfCategory = this.getLevelOneOfCategory();
        Map<String, List<CategoryEntity>> categoryMap = levelOneOfCategory.stream().map(levelOne -> {
            levelOne.setChildrens(getChildren(levelOne, allCategoryEntity));
            return levelOne;
        }).collect(Collectors.toMap(levelOne -> levelOne.getCatId().toString(), levelOne -> levelOne.getChildrens()));
        redisTemplate.opsForValue().set("categoryData", JSONObject.toJSONString(categoryMap), 10, TimeUnit.MINUTES);
        return categoryMap;
    }
    
  4. 存在的问题
    通过jmeter在50并发的情况下会发现控制台输出如下
    在这里插入图片描述
    这个结果并不是像我们期望的那样只查询一次数据库,因为在高并发的情况下,第一个请求从查询数据库到放入缓存的过程中,其他请求就不会命中缓存,那么也会去查询数据库,这就是缓存击穿

  5. 缓存穿透/雪崩/击穿

    • 缓存穿透
      描述: 查询一个数据库一定不存在的数据,由于缓存是不命中,那么请求就会去查询数据库,但数据库也查询不到对应的记录,由于没有将查询的结果null写入缓存,那么后续的请求都会去查询数据库,从而失去了缓存的意义
      解决方案: 将查询出的null写入缓存
      实现:
      在这里插入图片描述
    • 缓存雪崩
      描述: 缓存雪崩是指在我们设置缓存时不同key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩
      解决方案: 在设置的过期时间基础上增加一个随机值
      实现: 在这里插入图片描述
    • 缓存击穿
      描述: 在大量的请求访问同一个key的缓存数据时,此时缓存数据不存在或者失效时,前面n个请求都没有命中缓存,从而导致这n个请求需要请求DB
      解决方案: 加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db
      实现:
      在这里插入图片描述
    • 三者之间的区别:
      击穿和穿透的区别在于,数据是否存在于数据库中;
      击穿和雪崩的区别在于,是否是同一个key的数据失效
  6. 本地锁可以解决单节点的缓存穿透问题,但在分布式环境下,是没有办法锁住其他节点的操作的,这种情况肯定是有问题的
    在这里插入图片描述
    针对本地锁的问题,我们需要通过分布式锁来解决,那么是不是意味着本身锁在分布式场景下就不需要了呢?
    在这里插入图片描述
    显然不是这样的,因为如果分布式环境下的每个节点不控制请求的数量,那么分布式锁的压力会非常大,这时我们需要本地锁来控制每个节点的同步,来降低分布式锁的压力,所以实际开发中我们都是本地锁和分布式锁结合使用的。

分布式锁

分布式锁的原理

分布式锁或者本地锁的本质其实是一样的,都是将并行的操作转换为了串行的操作
在这里插入图片描述

分布式锁的常用解决方案

  • 数据库
    数据库隔离性:唯一索引
  • Zookeeper
    临时有序节点
  • Redis
    setNx命令
  • Redisson

Redis分布式锁的实现

  1. 基础代码
    获取数据:
    public Map<String, List<CategoryEntity>> getData() {
        String key = "categoryData";
        String categoryData = redisTemplate.opsForValue().get(key);
        if (categoryData != null) {
            System.out.println("缓存命中了");
            return JSONObject.parseObject(categoryData, Map.class);
        }
        System.out.println("查询数据库");
        List<CategoryEntity> allCategoryEntity = this.list();
        // 获取所有的一级分类的数据
        List<CategoryEntity> levelOneOfCategory = this.getLevelOneOfCategory();
        Map<String, List<CategoryEntity>> categoryMap = levelOneOfCategory.stream().map(levelOne -> {
            levelOne.setChildrens(getChildren(levelOne, allCategoryEntity));
            return levelOne;
        }).collect(Collectors.toMap(levelOne -> levelOne.getCatId().toString(), levelOne -> levelOne.getChildrens()));
        //categoryMap = null; 模拟缓存穿透
        if (categoryMap == null) {
            //数据库中也不存在,在缓存中也存一分,防止缓存穿透
            redisTemplate.opsForValue().set(key, "{}", 5, TimeUnit.MINUTES);
        } else {
            redisTemplate.opsForValue().set(key, JSONObject.toJSONString(categoryMap), Long.valueOf(new Random().nextInt(10)), TimeUnit.MINUTES);
        }
        return categoryMap;
    }
    
    加锁:
    public Map<String, List<CategoryEntity>> getCatelog2JSONByRedisLock() {
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "lock");
        if (lock) {
            //抢到锁则获取数据
            Map<String, List<CategoryEntity>> data = getData();
            //释放锁
            redisTemplate.delete("lock");
            return data;
        } else {
            //自旋获取锁
            return getCatelog2JSONByRedisLock();
        }
    }
    
  2. 上面的代码其实是存在问题的,如果在getData()方法中出现了异常,那么就不会删除key也就是不会释放锁,从而造成了死锁,针对这个问题我们可以在获取锁后加上一个过期时间
    在这里插入图片描述
  3. 上面虽然解决了getData()中的异常造成死锁的问题,但是如果在执行expire之前就已经发生了异常,那么同样会出现死锁问题,所以需要获取锁和设置过期时间的操作能够保持原子性,可以在获取锁的同时指定过期时间
    在这里插入图片描述
  4. 上面的代码看似很完美了,但是如果getData()执行的时间很长,超过了设置的30s的过期时间,那么就会存在业务代码还没执行完,锁已经释放了
    比如线程A获取到了锁,执行getData()用了35秒,在30秒时锁已经释放了,被线程B获取到,那么在35秒时线程A还是会执行delete操作,这个时候删除的其实是线程B的锁,从而对线程B的数据处理造成数据不安全的问题
    针对这个问题,我们可以查询锁的value通过UUID来区分,释放锁的时候判定当前锁的value是不是自己的,具体代码如下:
    在这里插入图片描述
  5. 上面的代码查询key和删除key不是一个原子操作,那么就会出现查询出来key后key过期了,那么又会出现删除其他线程key的情况,那么针对这种情况就需要保证查询key和删除key是一个原子操作。
    public Map<String, List<CategoryEntity>> getCatelog2JSONByRedisLock() {
        String uuid = UUID.randomUUID().toString();
        //加锁,设置key的同时设置过期时间
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "lock" + uuid,30, TimeUnit.SECONDS);
        if (lock) {
            //设置过期时间
            //redisTemplate.expire("lock", 30, TimeUnit.SECONDS);
            //抢到锁则获取数据
            Map<String, List<CategoryEntity>> data;
            try {
                data = getData();
            } finally {
                String srcipts = "if redis.call('get',KEYS[1]) == ARGV[1]  then return redis.call('del',KEYS[1]) else  return 0 end ";
                // 通过Redis的lua脚本实现 查询和删除操作的原子性
                redisTemplate.execute(new DefaultRedisScript<Integer>(srcipts,Integer.class)
                        ,Arrays.asList("lock"),uuid);
            }
            return data;
        } else {
            //自旋获取锁
            return getCatelog2JSONByRedisLock();
        }
    }
    
  6. 总结
    redis实现分布式锁的过程非常繁琐并且很容易出错,对此可以使用更加高效的Redisson实现分布式锁

Redisson

  1. redisson整合
    添加依赖
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.16.1</version>
    </dependency>
    
    配置类
    @Configuration
    public class MyRedisConfig {
    
        @Bean
        public RedissonClient redissonClient(){
            Config config = new Config();
            // 配置连接的信息
            config.useSingleServer()
                    .setAddress("redis://192.168.101.130:6379");
            RedissonClient redissonClient = Redisson.create(config);
            return  redissonClient;
        }
    }
    
  2. 可重入锁
    /**
     * 1.锁会自动续期,如果业务时间超长,运行期间Redisson会自动给锁重新添加30s,不用担心业务时间,锁自动过去而造成的数据安全问题
     * 2.加锁的业务只要执行完成, 那么就不会给当前的锁续期,即使我们不去主动的释放锁,锁在默认30s之后也会自动的删除
     * @return
     */
    @ResponseBody
    @GetMapping("/hello")
    public String hello(){
        RLock myLock = redissonClient.getLock("myLock");
        // 加锁
        myLock.lock();
        try {
            System.out.println("加锁成功...业务处理....." + Thread.currentThread().getName());
            Thread.sleep(30000);
        }catch (Exception e){
    
        }finally {
            System.out.println("释放锁成功..." +  Thread.currentThread().getName());
            // 释放锁
            myLock.unlock();
        }
        return "hello";
    }
    
  3. 读写锁
    @GetMapping("/writer")
    @ResponseBody
    public String writerValue(){
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
        // 加写锁
        RLock rLock = readWriteLock.writeLock();
        String s = null;
        rLock.lock(); // 加写锁
        try {
            s = UUID.randomUUID().toString();
            stringRedisTemplate.opsForValue().set("msg",s);
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            rLock.unlock();
        }
        return s;
    }
    
    @GetMapping("/reader")
    @ResponseBody
    public String readValue(){
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
        // 加读锁
        RLock rLock = readWriteLock.readLock();
        rLock.lock();
        String s = null;
        try {
            s = stringRedisTemplate.opsForValue().get("msg");
        }finally {
            rLock.unlock();
        }
    
        return s;
    }
    
  4. 闭锁
    @GetMapping("/lockDoor")
    @ResponseBody
    public String lockDoor(){
        RCountDownLatch door = redissonClient.getCountDownLatch("door");
        door.trySetCount(5);
        try {
            door.await(); // 等待数量降低到0
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "关门熄灯...";
    }
    
    @GetMapping("/goHome/{id}")
    @ResponseBody
    public String goHome(@PathVariable Long id){
        RCountDownLatch door = redissonClient.getCountDownLatch("door");
        door.countDown(); // 递减的操作
        return id + "下班走人";
    }
    
  5. 信号量
    @GetMapping("/park")
    @ResponseBody
    public String park(){
        RSemaphore park = redissonClient.getSemaphore("park");
        boolean b = true;
        try {
            // park.acquire(); // 获取信号 阻塞到获取成功
            b = park.tryAcquire();// 返回获取成功还是失败
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "停车是否成功:" + b;
    }
    
    @GetMapping("/release")
    @ResponseBody
    public String release(){
        RSemaphore park = redissonClient.getSemaphore("park");
        park.release();
        return "释放了一个车位";
    }
    

缓存一致性

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
针对于上的两种解决方案我们怎么选择?

  1. 缓存的所有数据我们都加上过期时间,数据过期之后主动触发更新操作
  2. 使用读写锁来处理,读读的操作是不相互影响的

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  4. 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略)

总结:

  1. 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  2. 我们不应该过度设计,增加系统的复杂性
  3. 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

SpringCache

  1. 基本使用
    引入依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    

    添加配置

    spring:
    	cache:
    		type: redis
    

    放开缓存
    启动类添加 @EnableCaching

  2. 常用注解
    @Cacheable:放入缓存
    key:缓存的key
    value:key的前缀
    sync:是否同步,设置为true是可以防止缓存击穿

    @CacheEvict:清空缓存

    @CachePut

    @Caching

    @CacheConfig

  3. 注解使用
    @Cacheable

    @Cacheable(cacheNames = "category", key = "'info'")
    //spel表达式
    //@Cacheable(cacheNames = "category", key = "root.methodName")
    //参数 id作为key
    //@Cacheable(cacheNames = "category", key = "#id")
    public JSONObject testCacheable(int id) {
        System.out.println("查询数据库了。。。。");
        JSONObject info = new JSONObject();
        info.put("name", "aaa");
        info.put("age", 24);
        JSONObject address = new JSONObject();
        address.put("provice", "浙江");
        address.put("city", "杭州");
        info.put("address", address);
        return info;
    }
    

    (1) redis中存储的key的结构为:@Cacheable的 key-prefix + cacheNames::key,key-prefix在配置文件中指定
    (2) cacheNames是数组,必须指定(否则报错),可以指定多个,对应的会在redis中生成多条数据;
    (3) key的值为spel表达式,如果是字符串的话需要添加'',key如果不指定,redis中key的结构为:cacheNames::SimpleKye[]
    (4)sync默认为false,为true时会调用RedisCache的synchronized <T> T get(Object key, Callable<T> valueLoader),这种方式可以防止缓存击穿,类似于redis中使用本地同步锁

    @Cacheable

    @Override
    @CacheEvict(cacheNames = "category", key = "'info'")
    public void testCacheEvict() {
        System.out.println("更新数据库了。。。。");
    }
    

    (1)删除redis中的key为key-prefix + cacheNames::key的数据
    (2)删除所有key:allEntries=true
    (3)删除多个key:

    @Caching( evict = {
            @CacheEvict(cacheNames = "category", key = "'info'"),
            @CacheEvict(cacheNames = "category", key = "'xxx'")
    })
    
  4. 总结
    缓存穿透:查询一个null的数据。可以解决 cache-null-values=true
    缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:分布式锁 sync=true 本地锁
    缓存雪崩:大量的key同一个时间点失效。解决方案:添加过期时间 time-to-live=60000 指定过期时间

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值