redis实现分布式锁的迭代演进

1. redis分布式锁主要是由以下4个命令实现的:

a. setnx:是set if not exists的缩写,也就是当该key在redis中不存在的时候才放入redis中,这个步骤分为两步:首先判断该key是否存在,若不存在,则放入;这两步操作是个原子操作,所以这个命令能够实现锁的效果;

b. getset:这个命令先根据key执行get操作,再执行set操作,这个命令的作用是:先获取原来的旧值,再设置一个新值;这个命令也具有原子性;

c. expire:设置键的有效期;

d. del:根据key,删除对应的value;

2. Redis分布式锁流程图--基础版(不防死锁)

redis实现分布式锁的迭代演进_第1张图片

这个版本的redis分布式锁是有缺陷的,考虑一个这样的应用场景:
当业务量增加到一定程度后,一台服务器无法应付这么大的业务量了,需要再启动一台服务器,用两台服务器同时运行;假设我们使用tomcat运行项目的war包,用nginx做负载均衡;这两个war包里都有定时任务,根据业务需要,只能在一台服务器上执行定时任务,这时候就用到redis分布式锁了。即在同一时刻,哪个tomcat获取到redis锁,哪个tomcat就执行定时任务,另一个tomcat则提前执行结束。假设tomcat-A在执行完setnx后,获取到redis锁,在即将执行expire(lockkey)时,tomcat-A突然宕机了,那么后果就是该lockkey在redis中将永久存在;在下一个时间点,要执行该定时任务时,任何一个tomcat都无法执行,因为这个lockkey在redis中永久存在了。

3. Redis分布式锁--优化版(双重防死锁)--setnx命令和expire分开执行

redis实现分布式锁的迭代演进_第2张图片

上述图片对应代码如下:

优化版本的redis分布式锁与基础版的redis分布式锁相比较而言,lockKey对应的value是currentTime + timeOut--当前时间戳 + 超时时间(这个超时时间要比定时任务执行的时间略长)

假设我们用5个tomcat才能支撑现在的业务量,每个tomcat上运行的功能代码都是一样的,每个tomcat都可以运行这个定时任务,但同一时间点只能有一个tomcat执行该定时任务;

//每分钟执行一次
@Scheduled(cron="0 */1 * * * ?")
public static void borrowcash() throws InterruptedException {
    logger.info("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】,执行定时任务");
    //lockTimeout:这个时间要比定时任务执行时间要长一些,因为在setnx命令后,若是没有获取到锁,
    //那么在第一个else分支下,我们会判断这个lockTimeout是否超时,若是超时了,那么就认为这个锁失效了 
    long lockTimeout = 50l;
    Long setnxResult = RedisShardedPoolUtil.setnx(BORROWCASH_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
    if(setnxResult != null && setnxResult.intValue() == 1){
        /*同一时间点只有一个tomcat能获取到锁,从而进入到这里---执行业务逻辑,其余4个tomcat只能走else分支*/
        sendMsgToUps(BORROWCASH_LOCK);
    }else{
        /*其余未获取到锁的4个tomcat都执行这个else分支下的代码*/

        //这4个tomcat先根据key取出旧值,然后根据当前时间戳,看是否可以重置并获取到锁
        String oldLockValue = RedisShardedPoolUtil.get(BORROWCASH_LOCK);
        if(oldLockValue != null && System.currentTimeMillis() > Long.parseLong(oldLockValue)){
            //能进入到这里,说明这把锁已经失效了,既然失效了,这个锁为什么还没有被释放掉:
            //a. 其中一个有可能的原因是:获取锁的tomcat正在执行该定时任务,实际完成定时任务所用的时间比预设的lockTimeout的值要长;

            //这里使用getset命令重新设置key的时间戳,并获取到旧值
            /*两个疑问:
             * 1. 为什么使用getset命令,而不是使用两个命令get--获取旧值和set命令--设置新值?
             *    因为getset命令在执行的过程中是个原子操作,而get和set这两个命令连在一起使用时,不是原子操作。
             * 2. 不使用getset命令,而使用get命令,会出现什么后果?
             *    同一时刻,只有一个tomcat获取到锁,那么其余4个tomcat都会执行get操作,它们    
             *    获取到的值是一样的,接下来如果满足这个条件
             *    (newLockValue!= null && StringUtils.equals(oldLockValue ,newLockValue)),
             *    那么这4个tomcat将都会执行该定时任务,这样一来,就失去了分布式锁的意义;如果不
             *    满足这个条件,这4个tomcat都不执行该定时任务,而获取到锁的tomcat在执行完setnx
             *    后,突然宕机了,那么此时,该定时任务在这个时间点将不会被执行,即5个tomcat在这
             *    一时间点都不会执行该定时任务;在下一时间点,这5个tomcat都会执行该定时任务;
             *    为了防止这种情况的发生,所以使用getset命令设置新值,而不使用get命令只获取旧值;
             */
            String newLockValue= RedisShardedPoolUtil.getSet(BORROWCASH_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
            if(newLockValue!= null && StringUtils.equals(oldLockValue ,newLockValue)){
                //真正获取到锁
                /*
                 * 当这个条件newLockValue!= null && StringUtils.equals(oldLockValue ,newLockValue)满足时,即新值和旧值相等,
                 * 则说明这个锁并没有被其它的tomcat操作,那么此时就可以执行该定时任务了;
                 * 假设获取到锁的tomcat刚执行完setnx后,就宕机了,那么其余的4个tomcat就算同时
                 * 执行getset命令,也没关系,因为redis是单线程的,这4个tomcat的请求会串行执
                 * 行;总有一个tomcat获取到的新值和旧值相等,那么该tomcat就会执行该定时任务;
                 */
                sendMsgToUps(BORROWCASH_LOCK);
            }else{
                System.out.println("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】没有获取到分布式锁 = 【" + BORROWCASH_LOCK + "】");
            }
        }else{
            //a. 当旧值不存在时(要么是key过期了--redis自动将该key删除了,要么是定时任务执行完了,执行定时任务的tomcat使用del命令把该key给删除了),这时候获取到锁的tomcat很可能已经执行完该定时任务了,此时执行到这里的tomcat就执行结束了;
            //b. 当旧值存在,且当前时间戳小于旧值时,说明该锁还在有效期内,这时候获取到锁的tomcat很可能正在执行该定时任务,此时执行到这里的tomcat也执行结束了;
            System.out.println("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】没有获取到分布式锁 = 【" + BORROWCASH_LOCK + "】");
        }
    }
    System.out.println("给用户打款结束");
}
private static void sendMsgToUps(String lockName) throws InterruptedException {
    RedisShardedPoolUtil.expire(lockName,5);//有效期50秒,防止死锁
    System.out.println("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】获取 = 【" + BORROWCASH_LOCK + "】");
    //模拟处理业务所花的时间  2019-06-06
    Thread.sleep(3000);
    RedisShardedPoolUtil.del(BORROWCASH_LOCK);
    System.out.println("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】释放 = 【" + BORROWCASH_LOCK + "】");
    System.out.println("当前tomcat = 【" + PropertiesUtil.getProperty("tomcat_platform") + "】===============================");
}

4. Redis分布式锁--优化版

redis实现分布式锁的迭代演进_第3张图片

与版本3相比,有一下改进

4.1  setnx命令和expire命令作为一个原子操作执行

       解决的问题:防死锁时,代码逻辑更加简单;

4.2   每个分布式锁,由当前线程加锁,同时也由当前线程根据value的值(value的值不再是时间戳,而是本线程自己的标识,比如是UUID)解锁;

        解决的问题:

        a. 在异常情况下,执行业务逻辑的时间超过了key的有效期;

        b. 当前线程加的锁,由其它线程释放了;

         这两个问题都会导致线程安全的问题;

上述图片对应代码如下:

        // 分布式锁和Synchronized关键字、ReentrantLock显示锁相同点和不同点:
        // 相同点:都是为了保证线程安全;
        // 不同点:
        //    a.适用场景不一样:
        //      分布式锁一般情况下是在不同的进程之间来保证线程安全;
        //      Synchronized关键字和ReentrantLock显示锁是在同一个进程内来保证线程安全;
        //    b.释放锁的方式不一样:
        //      b.1 使用redis作为分布式锁,释放锁有两种方式:
        //          设置key的有效期,过期自动失效;
        //          由加锁成功的线程来执行del(key)命令释放锁;
        //      b.2 使用Synchronized关键字,jvm自动释放锁;
        //      b.3 使用ReentrantLock显示锁,由加锁线程来释放锁;


        // 使用Redis作为分布式锁,有两个需要注意的点:
        // a. 如何避免死锁;
        //  a1. 业务代码没有执行完之前,服务器宕机,由key的有效期释放锁;
        //  a2. 业务代码执行完之后,由加锁成功的线程来释放锁;
        // b. 如何做到真正的线程安全:
        //  b1. 在同一个时间点,只能由一个线程来执行代码块中的业务逻辑代码--由setnx来保证;
        //  b2. 要保证释放锁的操作,只能由加锁成功的线程释放(在不宕机的情况下,不应该由key自动失效来释放锁);
        //   b.2.1 防止key失效的解决方案:在执行业务代码时,另起一个线程,动态设置key的有效期,防止出现业务代码还没执行完毕,key失效了,后果是:多个线程同时同步代码块,造成线程不安全;
        //   b.2.2 保证每个分布式锁只能由自己的线程来释放的解决方案:每个线程加锁时,value中存储的是自己的标识(UUID生成的字符串),在执行完业务逻辑后,删除分布式锁时,要判断value值是否是自己的;


        //当前线程或进程竞争锁时,设置自己的唯一标识---其目的是,当前线程只能释放自己加的锁;
        String value = UUID.randomUUID().toString();
        try {
            //多线程或多进程竞争分布式锁
            boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(Const.TESTKEY, value, Const.TIMEOUT, TimeUnit.SECONDS);
            if (false == flag) {
                //获取锁失败时,直接返回
                return Const.ERRORSTR;
            }
            // 获取锁成功后,执行业务逻辑;
            // 同时另起一个线程B,只要主线程A没有执行结束,
            // 则在线程B中,将当前分布式锁的有效时间重置--其目的是防止分布式锁的有效期过了,而业务代码还没执行完毕;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //只能释放本线程加的锁
            if (value.equals(stringRedisTemplate.opsForValue().get(Const.TESTKEY))) {
                stringRedisTemplate.delete(Const.TESTKEY);
            }
        }

5. 由Redission开源框架实现分布式锁

        RLock lock = redissonClient.getLock(Const.TESTKEY);
        boolean getLock = false;
        try {
            if(getLock = lock.tryLock(0,5, TimeUnit.SECONDS)){
                log.info("Redisson获取到分布式锁:{},ThreadName:{}",Const.TESTKEY,Thread.currentThread().getName());
                //扣减库存
                subStore();
            }else{
                log.info("Redisson没有获取到分布式锁:{},ThreadName:{}",Const.TESTKEY,Thread.currentThread().getName());
            }
        } catch (Exception e) {
            e.printStackTrace();
            log.error("Redisson分布式锁获取异常",e);
        } finally {
            //如果该线程没有加锁成功,则直接返回
            if(false == getLock){
                return Const.ERRORSTR;
            }
            lock.unlock();
            log.info("Redisson分布式锁释放锁");
        }

版本5的实现方案只是把版本4的逻辑给封装了一下。

你可能感兴趣的:(redis,redis,分布式锁,redis分布式锁)