redis分布式锁的版本演进

目录

1、最简版redis分布式锁

2、基于SETNX+GETSET实现的redis分布式锁

3、基于SETNX实现的redis分布式锁 

4、Redlock实现的redis分布式锁 

5、关于锁续期和锁重入的问题


分布式锁我们能想到什么?

  • 从锁的本质说起,锁的意义便是锁定资源,限制资源在某个时间点操作权限;限制资源只需要在针对某个方法、代码块进行锁定标记,分布式锁和synchronized、lock锁的区别在哪里,解决什么问题(单机多线程 和 多机多线程 共享资源同步问题)?
  • 获取分布式锁我们一般会采用setnx命令,指定key\value\expire ,主要是它具备什么特性(锁的演变过程setnx+expire->getset+setnx→setnx+lua)?参量值如何指定,各作何考虑(例如expire过长、过短都会有什么问题)?
  • 如何去释放锁(业务执行的finally中主动释放、过期自动释放)?怎么保证业务异常、宕机锁依然被释放(expire)?怎么保证业务执行完之前锁过期提前释放会有什么问题,如何解决(锁续  期,在获取锁的时候设定定时,主动监测key的过期时间,保证业务执行完之前锁不会过期)?
  • Synchronized和ReentrantLock都是可重入的,那么setNx分布式锁如何进行锁重入控制?

1、最简版redis分布式锁

//加锁
tryLock(){
  setnx key 1
  expire key seconds
}
//释放锁
release(){
  delete key
}

设计:

       给锁加一个过期时间的目的 是为了避免服务重启、应用异常导致锁没有执行释放,锁会一直被持有,无法过期;

缺陷:

       方案缺陷:加锁和为锁赋过期时间分两个操作,如果在设置过期时间成功之前发生了中断、应用服务重启,会导致锁无法释放;

       使用缺陷:在释放分布式锁的过程中,无论锁是否获取成功,都在finally中释放锁,这样是一个锁的错误使用;(未解决)

改善方案:

       使用lua脚本,可以包含setnx额expire两条命令。但是如果redis仅执行了一条命令后crash或者发生主从切换,依然会出现锁没有过期时间,导致锁没法被释放;所以出现了getset命令版本和Redis2.6.12版本后增加过期时间参数的SETNX命令,主要解决两条指令无法保证原子性的问题。

2、基于SETNX+GETSET实现的redis分布式锁

value设置时间戳作为过期时间

//加锁
tryLock(){
   newExpireTime=CurrentTimeStamp + expireSeconds   //新过期时间
   if(!Setnx key newExpireTime 秒){                 //setNx获取锁失败
      oldExpireTime = GET(key);                     //GET(key)返回时间戳,检查锁是否过期
      if(oldExpireTime<currentTimeStamp){
           currentExpireTime=GETSET(Key,newExpireTime)   //锁过期,GETSET延长锁过期时间为newExpireTime,并返回原值
           if(currentExpireTime==oldExpireTime){    //检查是CAS是否成功,多个client执行GETSET竞争设置时间,只能有一个能对oldExpireTime做修改
              return 1;
           }else{
			  return 0;
           }
      }
      return 0;  //锁未过期
   }
   return 1;
}
//释放锁
release(){
  delete key
}

 设计:

  • 这个版本去掉了EXPIRE命令,改为通过设置value时间戳值来判断过期
  • setnx(key,expireTime)获取锁
  • 如果获取锁失败,通过GET(key)返回的时间戳检查锁是否已经过期
  • GETSET(key,expireTime)命令是原子的,修改value为newExpireTime,并返回原值currentExpireTime

缺陷:

  • 在锁竞争较高的情况下,会出现value不断被覆盖,没有一个client获取到锁
  • 在获取锁的过程中不断修改原有锁的数据,假设一种场景C1,C2竞争锁,C1获取到了锁,C2执行了GETSET修改了C1锁的过期时间,如果C1正确没有释放锁,锁的过期时间被延长,其他Client需要等待更长的时间      

3、基于SETNX实现的redis分布式锁 

3.1、为SETNX设置过期时间,value为1

//加锁
tryLock(){
  setnx key 1 seconds
}
//释放锁
release(){
  delete key
}

 设计:

        redis2.6.12版本后的setnx命令带有过期时间参数,可以保证设置key-value值和设置过期时间是一个原子操作;

缺陷:

  • 假设场景:C1成功获取到了锁,之后C1因为GC进入等待或者其他原因导致任务执行过长,最后在锁失效前C1没有主动释放锁;这时如果C2在C1超时失效后获取到锁,并且开始执行,这个时候C1和C2同时执行,会因重复执行造成数据不一致等未知情况;而如果C1先执行完毕,则会释放C2的锁,此时可能另外一个C3进程获取到了锁;
  • 问题总结:a.由于C1的停顿导致C1 和C2同都获得了锁并且同时在执行,在业务实现间接要求必须保证幂等性   b.C1释放了不属于C1的锁

3.2、为SETNX设置过期时间,value设置时间戳   

//加锁
tryLock(){
  setnx key UnixTimeStamp seconds
}
//释放锁
release(){
	EVAL(
		//LuaScript
		if redis.call("get",KEYS[1]) == ARGV[1] then
       		return redis.call("del",KEYS[1])
    	else
       		return 0
    	end
	)
}

       通过指定value为时间戳,并在释放锁的时候检查锁的value是否为获取锁的value,避免3.1中提到的C1释放了C2持有的锁的问题; 

设计:

  • 考虑释放锁的时候存在多个redis操作和CAS模型的并发问题,所以使用Lua脚本来避免并发问题;

缺陷:

  • 如果在并发极高的场景下,比如抢红包场景,可能存在UnixTimestamp重复问题,另外由于不能保证分布式环境下的物理时钟一致性,也可能存在UnixTimestamp重复问题,只不过极少情况下会遇到。

3.3、为SETNX设置过期时间,value设置为UniqId

//加锁
tryLock(){
  setnx key UniqId seconds
}
//释放锁
release(){
	EVAL(
		//LuaScript
		if redis.call("get",KEYS[1]) == ARGV[1] then
       		return redis.call("del",KEYS[1])
    	else
       		return 0
    	end
	)
}

设计:

  • 使用自增的唯一UniqId代替时间来避免 分布式环境下物理时钟一致&抢红包等极高并发场景下UnixTimeStamp重复的问题;
  • 相对最优了

缺点:

  • redis集群环境下依然存在问题:redis集群数据同步为异步,假设在master节点获取锁后未完成数据同步的情况下master节点crash,此时在新的master依然可以获取到锁,所以多个client同时获取到了锁;依然不安全,所以出现了redlock;

4、Redlock实现的redis分布式锁 

      为了解决3.3版本在redis集群中的问题,它只在单实例的场景下是安全的,分布式专家antirez提出了分布式锁算法redlock,在distlock话题下可以看到对Redlock的详细说明,下面是Redlock算法的一个中文说明(引用)

假设有N个独立的Redis节点

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。
  6. 释放锁:对所有的Redis节点发起释放锁操作

然而Martin Kleppmann针对这个算法提出了质疑,提出应该基于fencing token机制(每次对资源进行操作都需要进行token验证)

1. Redlock在系统模型上尤其是在分布式时钟一致性问题上提出了假设,实际场景下存在时钟不一致和时钟跳跃问题,而Redlock恰恰是基于timing的分布式锁
2. 另外Redlock由于是基于自动过期机制,依然没有解决长时间的gc pause等问题带来的锁自动失效,从而带来的安全性问题。

       接着antirez回复了Martin Kleppmann的质疑,给出了过期机制的合理性,以及实际场景中如果出现停顿问题导致多个Client同时访问资源的情况下如何处理。

针对Redlock的问题,基于Redis的分布式锁到底安全吗给出了详细的中文说明,并对Redlock算法存在的问题提出了分析。

5、关于锁续期和锁重入的问题

释放锁的情况有两种,一种是业务正常执行完毕,主动执行delete key 进行释放;另一种是 当锁的过期时间到了,自动释放(一般为了解决);

        正常情况锁的过期时间要大于业务的执行时间(但也不能过大),当业务正常执行完毕都会主动delete key释放锁;只有当业务服务宕机时,才会通过锁的过期时间保证锁的释放;还有一种情况,当业务正常耗时超过锁的过期时间时,锁被提前释放了,没有起到访问限制的作用;这个时候就需要对锁进行续期;锁续期一般会在获取锁的时候设定一个定时任务,根据 key 主动去设置 key 的过期时间,保证业务在正常执行完成前,不会出现锁被释放的问题。参考下Redisson的实现:tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId)

public class RedissonLock{

    ......

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {

        ......

        scheduleExpirationRenewal(threadId);

        ......

    }

    private void scheduleExpirationRenewal(long threadId) {

        ......

        renewExpiration();

        ......

    }

    private void renewExpiration() {

        ......

        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {

            @Override

            public void run(Timeout timeout) throws Exception {

                ......

                   // 真正执行 续期的操作方法

                  RFuture<Boolean> future = renewExpirationAsync(threadId);  

                  // 只有续期成功才会继续递归调用

                   future.onComplete((res, e) -> {

                    if (e != null) {

                        log.error("Can't update lock " + getName() + " expiration", e);

                        return;

                    }

                    // reschedule itself

                    renewExpiration();

                });

                ......

            }

        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        ee.setTimeout(task);

    }

    .....

}

到这里我们可以知道, Redisson 在进行获取到锁之后会有一个 递归调用续期的操作方法,该方法中有一个定时器时间间隔为 锁过期时间的 1/3 。 之后我们再来看看,续期操作究竟做了些什么;

public class RedissonLock{

    ......

     protected RFuture<Boolean> renewExpirationAsync(long threadId) {

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,

                "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +

                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +

                    "return 1; " +

                "end; " +

                "return 0;",

            Collections.<Object>singletonList(getName()),

            internalLockLeaseTime, getLockName(threadId));

    }

    ......

}

//通过 lua 的脚本进行操作:根据 业务key ,线程id 进行查询 数据是否存在,如果存在更新 过期时间。


       Synchronize锁和ReentrantLock锁都是可重入的,使用redis的setNx做的分布式锁也应该保证锁重入,从而避免锁的竞争;锁重入的控制,比较好的方式就是在维护一个数据放 [(业务key + 业务线程id): 锁定的数量 ]。也就是说需要通过维护两条数据,才能实现锁重入的问题;

发布了12 篇原创文章 · 获赞 1 · 访问量 774

猜你喜欢

转载自blog.csdn.net/a1290123825/article/details/102845980