Redis实现分布式锁

一、使用Redis中的SETNX指令实现分布式锁

命令SET resource-name anystring NX EX max-lock-time是一种用 Redis 来实现锁机制的简单方法

  • EX seconds – 过期时间,单位秒
  • PX milliseconds – 过期时间,单位分钟
  • NX – 只有key不存在才设置key

设计思路:根据SET resource-name anystring NX EX max-lock-time设置分布式锁,如果上述命令返回OK,那么就可以获得锁(如果返回Nil,那么在一段时间之后重新尝试),从数据库中获取数据操作成功后,通过DEL命令来释放锁。简单实现代码如下:

    Boolean isSet = redisTemplate.opsForValue().setIfAbsent("lock",uuid);
    if(isSet){
        dataFromDb = getDataFromDb();
        redisTemplate.delete("lock");//删除锁
	}else{
		//等待一段时间后重试上述操作,即自旋锁操作
	}
}

上述代码会出现三个问题:

  • 问题一:执行getDataFromDb()出现异常,那么后续的删除锁操作将无法执行,所以需要将redisTemplate.delete("lock")使用try-catch-finally将异常抛出,并在finally中执行删除锁操作
    try{
    	dataFromDb = getDataFromDb();
    }finally {
      	redisTemplate.delete("lock");//删除锁
    }
    
  • 问题二:如果执行完setIfAbsent()即加锁完成后该进程中断导致后面代码不再执行,那么该锁将永远不会被删除,可以给锁设置一个过期时间避免这种情况。注意加锁设置过期时间必须同时进行,即必须保证这两步是一个原子操作,不然当加完锁进程中断但过期时间还没设置时还是会出现问题二。加锁设置过期时间在Spring可以通过setIfAbsent()同时传入锁名和过期时间这两个参数实现。
    //加锁的同时设置过期时间,都设在setIfAbsent中可以保证原子性
    Boolean isSet = redisTemplate.opsForValue().setIfAbsent("lock",uuid,300,TimeUnit.SECONDS);
    
  • 问题三:设置了过期时间后,如果获得锁后当前进程还没执行完时过期时间正好到了导致当前进程的锁没了,其他进程占用锁,这样当前进程执行到删除锁步骤时会把其他进程占用的锁给删了。
    1) 在删除锁前可以通过value判断锁是不是当前线程的。但是获取value删除锁两个操作中间如果有时间间隔还是会有可能出现问题三的情况,所以必须保证这两步的原子性。这两步的原子性可以通过lua脚本来实现。
    2) 不过上述方法1)解决的是当前线程删除其他线程的锁的情况,没有从根本上解决问题,根本问题是当前线程还在执行,但是执行时锁由于过期被删,其他线程占用锁,这本身就很不合理,没有起到锁的作用,同一时间有两个线程处理业务了。所以要解决这个问题需要通过设置一个守护线程,在当前线程还没执行完业务时且过期时间快到时定期更新过期时间,这样才能防止上述问题。
  • 解决上述三个问题的简单实现的代码如下(守护线程定期更新过期时间没有实现):
    /**
     * 从数据库获取并封装分类数据
     */
    public Map<String, List<Catalog2VO>> getCatalogJsonFromDbWithRedisLock() {
    
        //1.占分布式锁。去Redis占坑
        //1.1 setIfAbsent方法等同于SETNX命令
        //1.2 注意:加锁和设置过期时间必须是一个原子操作,在调用setIfAbsent传入锁名的同时必须传入过期时间
        String uuid = UUID.randomUUID().toString();
        Boolean isSet = redisTemplate.opsForValue().setIfAbsent("lock",uuid,300,TimeUnit.SECONDS);
        if(isSet){
            //加锁成功
            System.out.println("获取分布式锁成功...");
            Map<String, List<Catalog2VO>> dataFromDb;
            try{
                dataFromDb = getDataFromDb();
            }finally {
                /**
                 * 获取值对比+对比成功删除 这两步必需是原子操作,
                 * 不然还是存在获取到lockValue但还没删除时由于时间过期被其他进程拿到锁的可能
                 * 所以下面这段代码可以使用lua脚本来实现
                 */
    //            String lockValue = redisTemplate.opsForValue().get("lock");
    //            if(uuid.equals(lockValue)){
    //                //删除我自己的锁
    //                redisTemplate.delete("lock");//删除锁
    //            }
                String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
                Long deleteFlag = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
    
            }
            return dataFromDb;
        }else{
            //加锁失败..重试
            System.out.println("获取分布式锁失败...重试ing");
            try{
                Thread.sleep(200);
            }catch (Exception e){
    
            }
            //尽量不要使用递归来进行重试,容易出现栈溢出
            return getCatalogJsonFromDbWithLocalLock();
        }
    }
    

二、使用Redisson实现分布式锁

SpringBoot中简单使用Redisson实例展示

导入redisson依赖


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

配置RedissonClient,将RedissonClient交由Spring管理

/**
 * 所有对Redisson的使用都是通过redissonClient对象
 * @return
 * @throws IOException
 */
@Bean(destroyMethod="shutdown")
RedissonClient redisson() throws IOException {
    // 默认连接地址 127.0.0.1:6379

    Config config = new Config();
    //注意地址前要加redis://,ssl连接要加rediss://,不然会报错
    config.useSingleServer().setAddress("redis://192.168.56.10:6379");

    //根据configuration创建redissonClient实例
    RedissonClient redisson = Redisson.create(config);

    return redisson;
}

简单调用Redisson的重入锁

@ResponseBody
@GetMapping("/hello")
public String hello(){
    //1.获取一把锁,锁名一样就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    //2.加锁
    lock.lock();//阻塞式等待,默认都是30s过期时间
    try{
        System.out.println("加锁成功,执行业务"+Thread.currentThread().getId());
        Thread.sleep(10000);

    }catch (Exception e){

    }finally {
        System.out.println("释放锁..."+Thread.currentThread().getId());
        lock.unlock();
    }
    return "hello";
}

Redisson的加锁机制

  • 线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。
  • 线程去获取锁,获取失败: 一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
  • 注意:redisson是通过lua脚本来保证操作的原子性的

watch dog自动延期机制

防止服务器突然宕机,导致锁无法被释放,所以需要设置过期时间。但是这样又会引出另一种问题,一个进程获得锁后还没处理完业务,但是此时正好过期时间到了,导致当前进程的锁被释放给另一个进程获取执行业务,这样是不合理的,没有起到加锁的作用,因为加锁的本意就是为了让某个被锁的资源在同一时间内只能被一个进程或一个线程访问。所以就需要使用watch dog机制来设置一个后台线程,用来延长锁的过期时间。

总结

  • 锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
  • 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁也会默认在30s后自动删除

注意
如果加锁时自动指定过期时间lock.lock(10, TimeUnit.SECONDS),那么自动续期机制将会失效,过期时间一到锁将被删除,所以指定过期时间时一定得保证该时间大于业务执行时间。为什么执行lock.lock()可以自动续期,而指定参数就不行呢?

  • 查看lock()源码可知,因为lock的第一个参数是leaseTime,即执行lock.lock(10, TimeUnit.SECONDS)时leaseTime=10,满足leaseTime>0的条件,当执行tryLockInnerAsync(...)方法通过lua脚本设置过期时间后,还会判断一次leaseTime是否>0,只有当leaseTime<=0(不设置过期时间leaseTime默认为-1),才会调用scheduleExpirationRenewal()方法,方法名的意思时计划更新到期时间。
    Redis实现分布式锁_第1张图片
  • 继续查看scheduleExpirationRenewal()源码
    Redis实现分布式锁_第2张图片
  • scheduleExpirationRenewal()中调用了renewExpiration()方法,最后查看renewExpiration()源码,renewExpiration()方法中新建了一个定时器,用于定期调用renewExpirationAsync(threadId)来定期设置过期时间。(至于为什么Redisson为什么不给自定义过期时间的情形也设置定时器来更新呢?可能是为了缓存数据的一致性考虑吧,双写模式和失效模式中可能发生缓存中存的是脏数据问题,有方案是靠着缓存过期时间到后再更新缓存数据,如果设置了过期时间自动更新那么如果一直有访问的话将一直无法更新脏数据)
    Redis实现分布式锁_第3张图片

优缺点

优点

  • Redisson 通过 Watch Dog机制可以解决锁的续期问题;
  • 和Zookeeper相比较,Redisson基于Redis性能更高,适合对性能要求高的场景。
  • Redisson 实现分布式可重入锁,比原生的 SET mylock userId NX PX milliseconds + lua 实现的效果更好些,虽然基本原理都一样,但是它帮我们屏蔽了内部的执行细节。在等待申请锁资源的进程等待申请锁的实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率;
  • Redison实现了jucLock接口,完整的实现了超时,中断,可重入等各种特性。

缺点

  • Redisson没有办法解决节点宕机问题,不能达到ZK的一致性;
  • Redisson的机制比较复杂,如果对其底层实现不是很熟悉会出现很多预期外的问题(这是Redisson的缺点吗?不!是你自己的缺点!)

三、缓存的一致性问题

双写模式

更新数据库时同时更新缓存
双写模式可能出现两个线程同时写缓存导致一个线程写的缓存被覆盖,缓存中保存的是脏数据,只能等缓存过期后才能更新(redisson的锁最好自己指定过期时间,自己指定过期时间的话redisson不会自动更新过期时间,这样不会出现一直有访问缓存的操作导致此处由于过期时间一直更新导致脏数据一直不能更新)。
Redis实现分布式锁_第4张图片

失效模式

更新数据库时删除缓存,同样会出现类似双写模式中的问题,比如有三个线程,1号线程更新数据为db-1,2号线程更新数据为db-2,如果此时3号线程读取的是db-1的数据(因为db-2还没保存到数据库),并且在2号线程执行删除操作后3号线程才更新缓存,这样的话db-2的更新没有在缓存中,缓存中存的是脏数据。
Redis实现分布式锁_第5张图片

缓存数据不一致的解决方案

  • 可以通过加锁解决缓存数据的更新丢失问题,比如加读写锁就挺符合缓存数据读多写少的特点。读少写多的数据用来做缓存不是折磨人么。
  • 数据一致性要求不高的或者并发不高的情况等待缓存过期就行,下次访问数据层会更新缓存,反正一般也只有数据一致性要求不高的数据才保存到缓存。
  • 使用阿里开源的中间件canal,canal会监控数据库,如果数据库相关数据发生变化,就会同步到缓存中。跟主从数据库的思想有点类似,不过从数据库换成了redis缓存

你可能感兴趣的:(学习,redis,分布式,java)