阅读 1197

缓存与分布式锁

image.png

本文约4500字,建议阅读时间30~45分钟

背景

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

缓存

(What)定义

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

(Why)为什么使用缓存

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

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

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

(Where)缓存类型

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

分布式环境缓存示意图

graph TD
	subgraph Cache
		cache[缓存中间件Redis集群]
	end
	subgraph 服务集群
		method1[Application1] --> cache
	end
	subgraph 服务集群
		method2[Application2] --> cache
	end
	subgraph 服务集群
		method3[Application3] --> cache
	end

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

流程图

graph LR
	request(请求) --> readCache[读取缓存中数据]
	readCache --> condition{是否命中}
	condition --是--> return(返回结果)
	return --> stop(结束)
	condition --否--> queryDB[查询数据库]
	queryDB --> writeCache[将数据放入缓存]
	writeCache --返回查询结果--> return

伪代码

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: 3
             # redis服务器地址(默认为loaclhost)
             host: 121.4.91.174
             # redis端口(默认为6379)
             port: 6379
             # redis访问密码(默认为空)
             password: nicai
             # 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,可以去数据库,可以去任何大家都能访问的地方,等待可以自旋的方式。

分布式锁演进 —— 阶段一

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

解决: 设置锁的自动过期,即便没有删除,会自动删除

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();
    }
}
复制代码

分布式锁演进 —— 阶段二

问题: setnx设置好,正要去设置过期时间,宕机,进一步导致死锁

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

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();
    }
}
复制代码

分布式锁演进 —— 阶段三

问题: 如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了

解决: 占锁的时候,值指定为UUID,每个人匹配是自己的锁才删除

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();
    }
}
复制代码

分布式锁演进 —— 阶段四

问题: 如果正好判断是当前值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的是别人的锁

解决: 删除锁必须保证原子性。使用Redis+Lua脚本完成

 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();
     }
 }
复制代码

分布式锁演进 ——阶段五

保证加锁和删除锁的原子性,但这里还是为解决锁的自动续期问题,下面介绍Redisson

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的更新
      • 读多写多,直接去数据库查询

缓存一致性

写和写的并发问题

  1. 单线程写入

    读到最新数据有延迟:实现最终一致性

    image.png

  2. 多并发写入

    由于网络等原因,导致写缓存2在最前,写缓存1在后面就出现了不一致。导致脏数据问题,这是暂时性的脏数据问题,但是在数据稳定,缓存过期以后,又能得到最新的正确数据

写和读的并发问题

总结

  • 常规数据,读多写少,即时性、一致性要求不高的数据,完全可以使用SpringCache开发
  • 所有的缓存数据都有过期时间,数据过期下一次查询触发主动更新
  • 读写共享数据等特殊数据时的时候,考虑加上分布式的读写锁
  • 特殊数据,特殊设计,不应该过度设计,增加系统的复杂性
  • 遇到实时性、一致性要求较高的数据,就应该使用数据库,抛弃性能

问题补充

  1. Redis实现分布式锁使用单节点还是集群部署?

    集群部署。

    使用 Redis 单机实现分布式锁时比较简单,大多数时候能满足需求;因为是单机单实例部署,如果Redis服务宕机,那么所有需要获取分布式锁的地方均无法获取锁,将全部阻塞,需要做好降级处理。

    为了防止锁因为自动过期已经解锁,执行任务的进程还没有执行完,可能被其它进程重新加锁,这就造成多个进程同时获取到了锁,这需要额外的方案来解决这种问题,或者把自动释放时间加长。

    而Redis 集群下部分节点宕机,依然可以保证锁的可用性。当某个节点宕机后,又立即重启了,可能会出现两个客户端同时持有同一把锁,如果节点设置了持久化,出现这种情况的几率会降低。

  2. 缓存雪崩(大量key同时失效)同时去查数据库是否会造成问题?

    不会。

    加分布式锁或者本地锁均可避免雪崩问题,对于null值也可以缓存。

  3. 为什么对于高并发服务只需要加本地锁或者SpringCache的@Cacheable中sync置为true即可?

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


南京三百云信息科技有限公司(车300)成立于2014年3月27日,是一家扎根于南京的移动互联网企业,目前坐落于南京、北京。经过7年积累,累计估值次数已达52亿次,获得了国内外多家优质投资机构青睐如红杉资本、上汽产业基金等。
三百云是国内优秀的以人工智能为依托、以汽车交易定价和汽车金融风控的标准化为核心产品的独立第三方的汽车交易与金融SaaS服务提供商。

欢迎加入三百云,一起见证汽车行业蓬勃发展,期待与您携手同行!
官网:www.sanbaiyun.com/
邮箱:hr@che300.com

文章分类
后端
文章标签