【分布式锁】由缓存到分布式锁到缓存一致性问题,万字长文一步步带你全部打通!

写在前面

本文使用Redis做缓存中间件,并以分布式为分析背景,从本文中你可以了解到:

  • 从本地缓存到引出分布式缓存
  • 高并发下,缓存失效的三大问题:缓存,击穿,雪崩的分析和解决
  • 使用分布式锁解决三大问题
  • Redisson专业分布式锁框架简化开发
  • Spring-cache由Spring整合缓存操作,一键式注解开发

各位看官可以按需所取

缓存的定义与使用

通常存在缓存中的数据要求实时性不高,常用于读多写少的数据,比如地图信息,电商中的三级分类信息等等

  • 使用前提:本地缓存本质上是在堆空间中申请空间创建一个Map对象,所以如果缓存数据比较大,会占据服务本身的内存空间,并且如果是单体架构,可以使用本地缓存,但如果是在分布式系统下,一个模块可能在多台服务器上,每一份服务器都要存一份缓存,大大浪费存储空间,最重要的是无法保证多台服务器上缓存数据的一致性问题

  • 本地缓存的简单使用样例

// 根据项目需求设置泛型,或者再使用中使用强转
Map<String,Object> cache = new HashMap<>();

      public Object getData(String key){
		Object data = cache.get("data");
		     if(data!=null){
		           return data;
		       }
		     data = getFromDb();
		     return data;
      } 
        
  • 所以由于本地缓存的局限性,我们尝试用缓存中间件Redis,这里关于Reids的基本使用以及Springboot整合不作为重点,接下来让我们步入正题:

高并发下,缓存失效的三大问题:缓存,击穿,雪崩的分析和解绝

面试标准八股文:我们先来用最通俗的语言讲解三大问题的定义和解决方案

缓存穿透

  • 定义:缓存穿透是指:查询一个缓存中不存在的数据,由于缓存不命中,大量并发请求同时打到数据库上,数据库被击垮,无法正常提供服务,例如:有人利用这个漏洞,恶意攻击去查询不存在的数据
  • 解决方案:通常我们将null空查询结果或者标志位比如0,1作为结果放入缓存中,并设置短暂的过期时间,就可防止缓存穿透的问题

缓存雪崩

  • 定义:由于我们设置过期时间过于集中,导致缓存中大部分的数据在同一个时间点失效,导致大量请求同时打到数据库,服务崩溃
  • 解决方案:在失效时间的基础上增加一个随机值,比如1-5分钟随机时间,这样过期时间不会大量重复,避免集体失效

缓存击穿

  • 定义:某一个高频key失效时刚好有大量请求同时进来,所有请求落到数据库上导致服务崩溃
  • 解决方案:加锁,大量并发只让一个线程去查数据库,其他线程等待,等线程查到数据后释放锁,其他线程去缓存中查

前两者问题好解决,一个设置标志位即可,一个是设置随机过期时间,对于第三种加锁方式我们来一步步解决

从本地锁到分布式锁解决缓存击穿

本地锁解决击穿

无论是我们熟悉的Sync或者JUC包下的各种锁,我们通常称为本地锁,只能锁住当前服务器的线程,现在我们假设是单体服务,使用Sync初步解决缓存击穿问题:只让一个线程去查数据库,其他线程阻塞等待,等线程查到数据后释放锁,其他线程去查缓存

// 这里假如我们想查询三级目录数据
public Map<String, List<Catelog2Vo>> getCatalog() {
        Map<String, List<Catelog2Vo>> catalog = null;
        synchronized (this){
            ValueOperations<String, String> ops = redisTemplate.opsForValue();
            String catalogJson = ops.get("catalogJson");
            if(!StringUtils.isEmpty(catalogJson)){
                catalog = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
                });
                return catalog;
            }
            // 从数据读取的方法
            catalog = getFromDb();
            // 在缓冲中设置
            ops.set("catalogJson", JSON.toJSONString(parentCid),1, TimeUnit.DAYS);
        }
        return catalog;
    }

代码中的需要注意的问题:

  • spring容器中所有的对象都是单例的,由于springboot容器中所有对象都是单例的,所以this指的impl是同一对象,所以使用this即可锁住
  • 同时注意要将查数据库和放缓存加入到同步代码块中,保证原子操作,否则可能造成数据库已经查过一遍还没来得放入缓存,又一个线程拿到锁发现缓存没有再次去数据库查

分布式锁解决击穿

上述代码有一个明显的问题,如果是单体应用,this可以解决问题,如果是分布式下,一个服务可能有多台机器,每台机器都有自己的springboot应用,此时this就失效了,所以我们必须引入分布式锁,简单来说:就是所有的服务去一个公共空间占锁即可,可以是Mysql,redis,zookeeper等,这里我们使用redis实现分布式锁

再redis中,有一个我们熟知的api:setnx(), 命令在指定的 key 不存在时,为 key 设置指定的值。我们可以使用setnx(“lock”,uuid),(lock可以自定是key值,uuid是为当前获取锁的线程分配一个唯一id,后序会用到)设置一条记录,只有设置成功了,才能执行后序操作相当于获取了锁,当我们执行完了业务逻辑再删除

// 还是获取目录的业务代码,代码中一些关键性地方在后文中解释
 public Map<String, List<Catelog2Vo>> getCatalog() {
        // 1.占分布式锁
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        // setnx占锁
        String uuid = UUID.randomUUID().toString();
        Boolean lock = ops.setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
        if(lock){
            Map<String, List<Catelog2Vo>> cateFromDb = null;
            try {
        		// 从数据库获取数据,也包括了将数据存到缓存的操作
                cateFromDb = getCateFromDb();
            }finally {
            	// lua脚本,删除key的操作,后文详细解释
                String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                        "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 cateFromDb;
        }else{
            // 加锁失败,自旋重试
            // 休眠100ms,防止自选频率过高
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatalogJsonFromDbWithRedis();
        }

    }

代码中的一些关键性解释

  • 为什么设置过期时间:假如我们执行完getCateFromDb()断电或者宕机了,就异常无法执行到删除逻辑,那么这个锁将永久留在redis服务器中,没有线程能够再去set成功,无法获取锁,图解
    在这里插入图片描述

  • 同时必须使用setIfAbsent("lock", uuid,300,TimeUnit.SECONDS)保证设置时间和set操作时原子性,否则假如set操作后,还没来的及设置过期时间就闪断,同样造成死锁
    在这里插入图片描述

  • UUID什么作用?假如我们的业务执行时间过长,导致业务代码还没执行完,锁就过期被删除了会造成:1.当前线程还没执行完,自己的锁就被删除了2.当锁删除后,其他线程会抢占到锁,这时原本的线程执行了删除锁的操作,导致把别人的锁删掉了,所以我们为每个线程分配一个uuid,却道加锁和删锁的是同一个线程,保证不会误删

  • lua脚本:上述说到setnx操作和设置过期时间必须是原子操作,假设我们使用普通的delete方法,首先去get(“lock”)并且还要equals进行比较
    在这里插入图片描述此时会产生如下问题:
    在这里插入图片描述所以lua脚本就是保证了删除锁的原子性操作

  • 总的来说就是保证加锁和解锁的原子性,前者使用setnx,后者使用lua脚本

Redission专业的人做专业的事

通过前文的分析你会发现,分布式锁的实现过程十分繁琐,同时有许多意想不到的bug,这时我们可以引入Redission专业的分布式锁框架,帮助我们解决分布式锁问题

由于Redission大部分类实现了JUC接口,所以大多数API无缝连接了JUC的操作,如果熟悉JUC操作,Redission也会快速上手

基本的整合与使用

  • 引入依赖
 <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.12.0</version>
</dependency>
  • 编写配置类
@Configuration
public class MyRedissonConfig {
    /**
     * 所有对Redisson地使用都是通过RedissonClient对象
     * @return
     * @throws IOException
     * @throws KrbException
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redission() throws IOException {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.28.137:6379");//redis服务地址
        return Redisson.create(config);
    }
}
  • 我们使用Redission简化上述代码
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisson() {
		// 拿锁
        RLock lock = redissonClient.getLock("Catalog-lock");
        Map<String, List<Catelog2Vo>> cateFromDb = null;
        try {
            lock.lock(3,TimeUnit.SECONDS);
            cateFromDb  = getCateFromDb();
        }catch (Exception e){

        }finally {
        	// 确保能够释放锁
            lock.unlock();
        }
        return cateFromDb;
    }

代码中的一些关键性解释:

  • Redission底层实现依托于Redis,当我们执行拿锁方法后,我们会发现Redis中存储了"Catalog-lock"的一条记录
    在这里插入图片描述
  • 关于过期时间的设置:当我们不去手动设置过期时间时,业务超长,超过锁的设置时间时,会自动给锁续30s的时间,不用担心业务时长超过设置时间(看门狗机制),只要被加锁的业务执行完毕,即使不手动unlock解锁,锁默认会在30s后过期
  • 如果我们执行lock方法时,指定过期时间,redisson无法帮助我们自动延长时间,但在业务开发中,我们仍然推荐使用指定时间的lock方式,省去续期操作带来的性能损耗

缓存一致性问题

一旦我们引入了缓存就要考虑缓存一致性问题,即数据库和缓存的数据一致性问题,通常我们使用以下模式解决一致性问题

双写模式(写数据库同时更新缓存)

在这里插入图片描述
如何解决双写模式的bug:

  • 加锁,保证写数据和写缓存为原子操作
  • 如果能够容忍一定的一致性误差时间,可以不用操作,等到缓存数据过期时,重新从数据库读取新数据即可,比如目录啊,实时性要求不高

失效模式(写数据库,并删除缓存,等下次读取更新缓存)

在这里插入图片描述问题在于
A:写数据db1,删缓存
B:在A删缓存之前开始写数据db2(卡顿,还没写完)
C:读缓存(db1),B写完数据并且删了缓存,C再更新缓存
此时读到的数据还是db1;

如何解决?

通常使用分布式读写锁实现,写操作加写锁,读操作加读锁,写写的时候排队,读读的时候无所谓

总结

  • 我们能放入缓存的数据不应该是实时性的,一致性要求超高的,所以缓存的时候加上过期时间,保证每日按拿到当时最新数据即可
  • 我们不应该过度设计,增加系统复杂性,实时数据就去数据库查
  • 实在遇到实时性比较高,又要加入缓存:使用失效模式+分布式读写锁解决

SpringCache专业的人做专业的事

简单使用

1.引入依赖(由于SpringCache基于redis所以同时引入redis的依赖)

		<dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        <!--        为了解决letture底层操作Netty导致的内存溢出,我们排除lettuce,使用Jedis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.letture</groupId>
                    <artifactId>letture-core</artifactId>
                </exclusion>
            </exclusions>

2.配置文件中设置缓存基于redis并在启动类上加上注解
在这里插入图片描述
在这里插入图片描述

3.使用注解简化开发操作

  • @Cacheable:触发将数据保存到缓存的操作,常用到查询操作上

使用@Cacheable注解,指名缓存的分区(value值,可以指名多个分区,也就是分个类,取个名),代表当前方法的结果需要缓存,如果缓存中有,方法不用调用,如果没有则会触发保存到缓存的操作
在这里插入图片描述当我们第一次调用方法时,Springcache会自动帮我们加入缓存,默认情况下如果缓存对象使用jdk的序列化方式,默认的ttl过期时间为-1
在这里插入图片描述
所以我们可以自定义行为
1.使用配置文件在配置文件中设置一些缓存属性,还可以设置前缀,等等

在这里插入图片描述
2.使用配置类一旦使用配置类,spring将不会帮我们读取properties配置文件,需要我们手动设置,可以在配置类中配置JSON的序列化方式

@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheProperties.class)
public class MyCacheConfiguration {
    @Bean
     RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 设置key,value的序列化机制
        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.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }
}
  • @CacheEvict:触发将数据从缓存删除的操作,此注解用在更新操作上

key为@Cacheable添加时指定的key
在这里插入图片描述使用失效模式,缓存中删除,下次查的使用从缓存中读

  • @CachePut:也为更新操作的注解,用于有返回值的方法

不同雨@CacheEvict,@CachePut使用双写模式,此次更新后,缓存中就有一份最新的数据,用于有返回值的方法

缺点与不足

上文分析到,缓存中的三大问题:击穿,雪崩,穿透,Springcache能不能帮我们解决呢?

  • 缓存击穿有没有解决?默认是无锁的,可以使用sync=true加本地锁,起到一定效果,但无法实现分布式锁的效果
    在这里插入图片描述

  • 缓存雪崩有没有解决?加上过期时间即可

  • 缓存穿透有没有解决?可以在配置文件中开启,缓存空值,防止击穿
    在这里插入图片描述

Springcache总结

常规数据(读多写少,即时性,一致性要求不高的数据完全可以使用spring-cache),写模式只要有过期时间就足够了,特殊数据特殊设计,只能自己使用redisson设计

最后

我们要明白一点,我们不应该过度设计,增加系统复杂性,采用合适的技术帮助我们简化开发,本文带你从缓存到分布式锁到缓存一致性问题,以及常用的框架手段都涉及到,能够带你走进分布式下的一大问题,如果觉得不错,点个免费的赞,就是对博主最大的鼓励
在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值