缓存与分布式锁

缓存与分布式锁

背景

随着分布式架构的发展,集群部署成为必不可缺少的一部分,相比以往的单应用部署,复杂业务中衍生出诸多需要解决的问题,例如在分布式系统中,要解决分布式事务,在集群部署中,要解决分布式缓存和分布式锁等问题

缓存

(What)定义

From WiKi缓存是在计算机上的一个原始数据的复制集,以便于访问

(Why)为什么使用缓存

  • 对于用户:提升用户体验,加快访问速度,降低响应时间
  • 对于服务:提升系统性能,提高并发数量、吞吐量及资源利用率,减少DB及I/O过程,让DB更多的承担数据落盘工作

(Which)哪些数据适合放入缓存

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

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

    举例:inf_vin_photo_source

(Where)缓存类型

  • 客户端缓存:浏览器缓存、页面缓存…
  • 网络中缓存:Web代理缓存(Nginx)、边缘缓存(CDN)…
  • 服务端缓存核心
    • 服务器本地缓存:性能最高,位于内存中,对Java程序而言,本地缓存数据直接保存在JVM中,需要考虑缓存数据的大小、JVM的垃圾回收性能消耗,ConcurrentHashMap.class,EhCache, Caffeine。单服务是集群部署的时候,应该考虑是否需要做集群中本地缓存的数据同步
    • 分布式缓存:当本地缓存被穿透的时候就会去查询分布式缓存,当在分布式缓存中查询到数据的时候,直接将查询结果放到本地缓存中。对于分布式缓存主要是使用NoSQL数据库来实现,常用的NoSQL数据库有Redis、Memcached、MongoDB等。目前比较流行的Redis来说,支持Slava/Master模式和Cluster
    • 数据库缓存:数据库在设计的时候也有缓存操作,更改相关参数开启查询缓存
    • 文件缓存:应用在启动时,读取文件写入内存中

分布式缓存示意图

PhotoApplication3
PhotoApplication2
PhotoApplication1
Cache
集中式缓存中间件Redis...
方法
方法
方法

(How)读模式缓存使用流程

流程图

返回查询结果
请求
读取缓存中数据
是否命中
返回结果
结束
查询数据库
将数据放入缓存

伪代码

data = cache.get(key);
if(data == null) {
    data = db.get();
    cache.set(key, data);
}
return data;

❗❗❗在实际开发中,凡是放入缓存中的数据我们都必须指定过期时间(例如Redis的过期时间expire)或这定期清除策略(例如Map的value设置为时间戳),使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程,避免业务崩溃导致的数据永久不一致问题

整合SpringBoot

  1. pom.xml可参阅SpringBoot官方Reference

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  2. application.yml可参阅RedisProperties.class

    spring:
    	redis:
            # redis数据库索引(默认为0),我们使用索引为3的数据库,避免和其他数据库冲突
            database: 1
            # redis服务器地址(默认为loaclhost)
            host: 121.4.91.174
            # redis端口(默认为6379)
            post: 6379
            # redis访问密码(默认为空)
            password: jiubugaosuni
            # redis连接超时时间(单位毫秒)
            timeout: 0
            # redis连接池配置
            pool:
              # 最大可用连接数(默认为8,负数表示无限)
              max-active: 8
              # 最大空闲连接数(默认为8,负数表示无限)
              max-idle: 8
              # 最小空闲连接数(默认为0,该值只有为正数才有用)
              min-idle: 0
              # 从连接池中获取连接最大等待时间(默认为-1,单位为毫秒,负数表示无限)
              max-wait: -1
    
  3. 使用RedisTemplate或StringRedisTemplate操作Redis

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    
    @Test
    public void testStringRedisTemplate(){
        //操作对象
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        //保存
        ops.set("hello", "world_"+ UUID.randomUUID().toString());
        //查询
        String hello = ops.get("hello");
        System.out.println("hello = " + hello);
    }
    
    • 存在问题:Redis在SpringBoot中默认最底层客户端是lettuce,因为Redis底层基于netty,且netty直接操作堆外内存, netty底层在对内存计数时,当超过默认的容量限制虚拟机参数-Dio.netty.maxDirectMemory,不设置默认为-Xmx,就会抛出堆外溢出异常。netty统计内存使用量,操作完了就会减内存使用量,一定是lettcure客户端,在哪一块操作的时候,没有及时调用掉减内存PlatformDependent.class,导致堆外内存溢出
    • 解决办法:升级lettuce或者更换实现方式改为jedisSpringBoot都支持,见RedisAutoConfig.class,@Import注解
  4. 缓存使用过程中存在的问题

    • 缓存穿透

      • What 指查询一个一定不存在的数据,由于缓存没有命中,去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,导致这个不存在的数据每次请求都要到DB去查询,失去了缓存的意义
      • Risk 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃
      • Resolve null结果缓存,并加入短暂过期时间
    • 缓存雪崩

      • What 指我们设置缓存时key采用了相同的过期时间,导致缓存在某一时同时失效,请求全部转发到DB,DB瞬时压力过重雪崩
      • Resolve 原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件
    • 缓存击穿

      • What 指对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常热点的数据,如果这个key在大量请求进来前正好失效,那么所有对这个key的数据查询都落到DB
      • Resolve 加锁,大量并发只让一个人去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存, 就会有数据,不用去DB

分布式锁

分布式锁演进

Redis-set

private List<PhotoSourceEntity> step1() {
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "...");
    if (lock) {
        //处理业务逻辑
        List<PhotoSourceEntity> photoSourceList = photoSourceService.getPhotoSourceList();
        //解锁
        redisTemplate.delete("lock");

        return photoSourceList;
    } else {
        return step1();
    }
}

private List<PhotoSourceEntity> step2() {
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "...");
    if (lock) {
        //设置过期时间
        redisTemplate.expire("lock", 30, TimeUnit.SECONDS);

        //处理业务逻辑
        List<PhotoSourceEntity> photoSourceList = photoSourceService.getPhotoSourceList();
        //解锁
        redisTemplate.delete("lock");

        return photoSourceList;
    } else {
        return step2();
    }
}

private List<PhotoSourceEntity> step3() {
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "...", 300, TimeUnit.SECONDS);
    if (lock) {
        //处理业务逻辑
        List<PhotoSourceEntity> photoSourceList = photoSourceService.getPhotoSourceList();
        //解锁
        redisTemplate.delete("lock");

        return photoSourceList;
    } else {
        return step3();
    }
}

 private List<PhotoSourceEntity> step4() {
     String uuid = UUID.randomUUID().toString();
     Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
     if (lock) {
         //处理业务逻辑
         List<PhotoSourceEntity> photoSourceList = photoSourceService.getPhotoSourceList();

         String lockValue = redisTemplate.opsForValue().get("lock");
         if (uuid.equals(lockValue)) {
             //删除自己的锁
             redisTemplate.delete("lock");
         }

         return photoSourceList;
     } else {
         return step4();
     }
 }

private List<PhotoSourceEntity> stepFinal() {
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
    if (lock) {
        //处理业务逻辑
        List<PhotoSourceEntity> photoSourceList = photoSourceService.getPhotoSourceList();

        String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
            "then\n" +
            "    return redis.call(\"del\",KEYS[1])\n" +
            "else\n" +
            "    return 0\n" +
            "end";
        //RedisScript<T> script, List<K> keys, Object... args
        RedisScript<Long> luaScript = RedisScript.of(lua, Long.class);
        //删除锁
        Long lockResult = redisTemplate.execute(luaScript, Collections.singletonList("lock"), uuid);

        return photoSourceList;
    } else {
        return stepFinal();
    }
}

Redisson

  1. pom.xml

    <!-- 以后要使用redission作为所有分布式锁,分布式对象等功能 -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.13.6</version>
    </dependency>
    
  2. 配置方法

    @Configuration
    public class MyRedissonConfig {
        /**
         * 所有对Redisson的使用都是对RedissonClient对象的操作
         */
        @Bean(destroyMethod="shutdown")
        public RedissonClient redisson() throws IOException {
            //1、创建配置
            Config config = new Config();
            config.useSingleServer().setAddress("192.168.218.128:6379");
            //2、根据Config创建出RedisClient实例
            return Redisson.create(config);
        }
    }
    
  3. 普通锁测试

    public String redissonLock() {
        //1、获取同一把锁,只要锁的名字一样,就是同一把锁,
        RLock lock = redisson.getLock("my-lock");
        //2、加锁
        //阻塞式等待
        lock.lock();
        try {
            System.out.println("加锁成功,执行业务" + Thread.currentThread().getId());
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //3、解锁
            System.out.println(Thread.currentThread().getId() + "释放锁");
            lock.unlock();
        }
    
        return "hello";
    }
    
  4. 看门狗解决死锁

    //看门狗默认ttl
    private long lockWatchdogTimeout = 30 * 1000;
    //看门狗刷新ttl时机
    internalLockLeaseTime / 3, TimeUnit.MILLISECONDS
    
  5. 读写锁测试

    保证一定能读到最新数据, 修改期间, 写锁是一个排他锁(互斥锁,共享锁), 读锁是一个共享锁

    写锁没释放,读就必须等待

    读 + 读 :相当于无锁, 并发读, 只会在redis中记录所有当前的读锁, 他们都会同时加锁成功

    写 + 读 :等待写锁释放

    写 + 写 :阻塞方式

    读 + 写 :有读锁, 写也需要等待

    只要有写的存在, 都必须等待

SpringCache

(What)定义

  • Spring从3.1开始定义了org.springframework.cache.Cache和org.springframework.cache.CacheManager接口来统一不同的缓存技术,并支持使用JCache(JSR-107)注解简化开发
  • Cache接口为缓存的组件规范定义,包含缓存的各种操作集合,Cache接口下Spring提供了各种XxxCache的实现,如RedisCache,EhCache,ConcurrentMapCache等

(How)整合

  1. pom.xml

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    
  2. 注解说明

    SpringJSR-107Remark
    @Cacheable@CacheResultFairly similar. @CacheResult can cache specific exceptions and force the execution of the method regardless of the content of the cache.
    @CachePut@CachePutWhile Spring updates the cache with the result of the method invocation, JCache requires that it be passed it as an argument that is annotated with @CacheValue. Due to this difference, JCache allows updating the cache before or after the actual method invocation.
    @CacheEvict@CacheRemoveFairly similar. @CacheRemove supports conditional eviction when the method invocation results in an exception.
    @CacheEvict(allEntries=true)@CacheRemoveAllSee @CacheRemove.
    @CacheConfig@CacheDefaultsLets you configure the same concepts, in a similar fashion.
  3. 开启@EnableCaching注解

  4. 示例

    • @Cacheable

      //因为spel动态取值,所有需要额外加''表示字符串
      @Cacheable(value = {"photoSource"}, key = "'AllPhotoSource'")
      @Cacheable(value = {"photoSource"}, key = "#root.method.name")
      //解决缓存击穿问题
      @Cacheable(value = {"photoSource"}, key = "#root.method.name", sync = true)
      
    • @CacheEvict

      @CacheEvict(value = {"photoSource"}, key="'AllPhotoSource'")
      //同时对多个缓存操作, 见@Caching
      //指定删除某个分区下的所有数据
      @CacheEvict(value = {"photoSource"}, allEntries = true)
      
    • @CachePut

    • @Caching

      @Caching(evict={
          @CacheEvict(value = {"photoSource"}, key = "'AllPhotoSource1'"),
          @CacheEvict(value = {"photoSource"}, key = "'AllPhotoSource2'")
      })
      
  5. 分析

    1. 读模式:
      • 缓存穿透:cache-null-values: true
      • 缓存击穿:sync = true
      • 缓存雪崩:time-to-live: 3600000
    2. 写模式(缓存与数据库一致)
      • 读写加锁
      • 引入Canal,感知MySQL的更新
      • 读多写多,直接去数据库查询

缓存一致性

写和写的并发问题

写和读的并发问题

最终解决方案

总结

  • 常规数据,读多写少,即时性、一致性要求不高的数据,完全可以使用SpringCache(只要设置ttl)开发
  • 所有的缓存数据都有过期时间,数据过期下一次查询触发主动更新
  • 读写共享数据等特殊数据时的时候,考虑加上分布式的读写锁
  • 特殊数据,特殊设计

问题补充

  1. Redis实现分布式锁使用单节点还是集群部署?
    集群部署。
    使用 Redis 单机实现分布式锁时比较简单,大多数时候能满足需求;因为是单机单实例部署,如果Redis服务宕机,那么所有需要获取分布式锁的地方均无法获取锁,将全部阻塞,需要做好降级处理。
    为了防止锁因为自动过期已经解锁,执行任务的进程还没有执行完,可能被其它进程重新加锁,这就造成多个进程同时获取到了锁,这需要额外的方案来解决这种问题,或者把自动释放时间加长。
    而Redis 集群下部分节点宕机,依然可以保证锁的可用性。当某个节点宕机后,又立即重启了,可能会出现两个客户端同时持有同一把锁,如果节点设置了持久化,出现这种情况的几率会降低。

  2. 缓存雪崩(大量key同时失效)同时去查数据库是否会造成问题?
    不会。
    加分布式锁或者本地锁均可避免雪崩问题,对于null值也可以缓存。

  3. 为什么对于高并发服务只需要加本地锁或者SpringCache的@Cacheable中sync置为true即可?
    分布式锁可以解决所有问题,对于占用共享资源用分布式锁更合适或者只能用分布式锁;但是对于单纯的高并发场景,例如查库,我们的单服务集群可能不足十台机器,假设有一万条请求同时访问所有服务,也只会查库十次,这是绝对可以接受的情况,这种单纯高并发场景如果使用分布式锁还可能使代码复杂化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值