分布式锁(一)-Redis

431 阅读7分钟

为了保证一个方法或属性在高并发的情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用并发处理相关的功能进行互斥控制( jvm 锁),但是随着业务发展的需要,原来单体单机部署的系统被演化成分布式集全系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这时原来的jvm锁失效,单纯的应用并不能提供分布式锁的能力,为了解决这个问题,这个时候就需要 分布式锁了。现在主流的分布式锁就是用redis实现的

1. Redis分布式锁的基本实现

首先,我们写一个简单的加锁和释放锁的redis 语句。

setnx 'lock' 'value'
(程序执行...)
del key

我们在方法执行前去执行setnx...,执行完后释放锁,看起来很完美,没有问题。

但是我们考虑下极端情况,如果程序执行的时候,这个服务挂了,这个key就没有机会删除,导致其他服务没办法拿到锁,就会一直阻塞,这肯定是有问题的。所以我们改进下,加个过期时间 10 s。

setnx 'lock' 'value'
expire 'lock' 10
(程序执行...)
del key

这个时候还是存在问题,因为setnx和expire是两条语句,如果setnx成功了后,redis挂了,expire没机会执行,这个时候还是存在阻塞其他服务的情况,我们可以用lua脚本来保证setnx 和 expire的原子性。这里暂不讨论具体实现细节(更新:从redis 2.6.12开始,支持set nx px 命令)

setnx 'lock' 'value' expire 'lock' 10 #使用lua脚本执行这一条
(程序执行...)
del key

就算这样,还是不能保证万无一失。

如果进程A拿到了分布式锁并开始执行,操作锁内资源超过了redis锁的过期时间,那么就会导致进程B拿到该分布式锁,等进程A执行结束会把进程B的锁删了,等到进程B也执行结束,准备释放锁的时候就会发现自己加的锁已经没了,锁没了其实问题不大,如果进程B在操作锁内资源的时候,锁没了,然后进程C发现锁没了就会再次加锁,这个时候进程B和进程C同时执行,这也就不满足分布式锁的要求了。

所以解决这个问题的办法就是每个进程只能释放自己加的锁。

在setnx 的时候,value值就派上了用场,我们可以给value值设置一个uuid随机数。

在解锁的时候,先获取value判断是否是当前进程加的锁,然后再去删除。伪代码:

String uuid = "xxxxx";
String distributedLockName = "lock-key-name"
set 'lock' uuid nx px 10000;
try{
    //业务代码
}finally{
    if(uuid.equals(redisTool.get(distributedLockName))){
        redisTool.del(distributedLockName);
    }
}

这个时候看起来是不是稳了?

相反,问题更严重了, 在释放锁的时候,get 和del 不是原子操作,还是会有进程安全问题。

同样这里get 和del使用lua脚本来保证原子操作。

至此,这个分布式锁看起来是没有问题的了。

2. 超时问题

上面的分布式锁还是会存在超时问题的,比如客户端A拿到了分布式锁,锁过期时间是10s,如果客户端A执行了10s还是没有结束,这个时候锁会过期,然后客户端B重新拿到了分布式锁,此时客户端A和客户端B都在执行临界区的代码,这显然是不符合我们串行执行代码的要求的。

常见的解决办法是调大锁的过期时间,但是这并不是完美的解决办法,因为业务逻辑的执行时间是不可控的,所以还是可能会出现超时,当前客户端的代码还没有执行完,其他客户端乘虚而入。而如果我们把过期时间设置过长,当持有锁的客户端宕机,释放锁将依靠redis,这将导致业务在一个超时时间内不可用。

基本上,如果在执行计算期间发现锁要超时了,客户端可以给redis服务实例发送一个lua脚本让redis服务延长锁的时间(启动一个守护线程 watch dog),推荐使用redission来实现。

3. 单点故障问题

我们说到现在的都是单节点的redis,如果说节点挂了,那么分布式锁也就实现不了了,为了实现redis的高可用,我们会使用redis的哨兵模式来实现主从切换,而redis的主从间的数据同步是异步的,考虑一种极端情况,如果有两个节点,一个主,一个从,如果说 A 线程setnx在主节点上面成功了,还没来得及把数据同步到从节点,主节点就挂了,这个时候其实 A 线程是拿到了分布式锁的,现在这个从节点就变成了主节点,B 线程也来请求分布式锁的时候,就会setnx到新的主节点,而这个主节点是没有 A 线程setn的数据的,B 线程也拿到了分布式锁,这个时候分布式锁就有问题了。不满足互斥的条件了。 这时 RedLock就闪亮登场了。
话说什么是RedLock?

image.png

redLock的中文是直译过来的,就叫红锁。

红锁并非是一个工具,而是redis官方提出的一种分布式锁的算法。 红锁算法认为,只要过半个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。 流程为:

  1. 顺序向五个节点请求加锁
  2. 根据一定的超时时间来推断是不是跳过该节点
  3. 三个节点加锁成功并且花费时间小于锁的有效期
  4. 认定加锁成功 这样看起来是不是就是没有问题了?未完待续~

2. Redis的其他实现方式

2.1 getset()的方式

getset命令:getset(key,value), 这个名会给key设置value,如果key存在旧的value则返回旧value 假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:

  1. getset(key, "value1") 返回nil 此时key的值会被设置为value1

  2. getset(key, "value2") 返回value1 此时key的值会被设置为value2

使用步骤:

  1. setnx lock_key currentTimestamp+锁超时时间 如果是1说明拿到锁,否则转向2

  2. get(lock_key)后拿到 oldTimeStamp,判断 oldTimeStamp 是否小于 currentTimeStamp ,如果小于说明锁过期了,可以上锁,转向步骤3

  3. 计算newTimeStamp = (currentTimeStamp+锁超时时间),然后 getset(lockKey,newExpireTime)后,判断返回的 value 和 oldTimeStamp 是否相同,如果相同,说明拿到了锁,如果不相同,说明锁被其他线程拿去了。重试

  4. 业务执行结束后,再get下,判断value是不是自己的value,如果是的话,删除key,不是的话,不管了。

存在问题:在“get(lockkey)获取值oldExpireTime ”这个操作与“getset(lockkey, newExpireTime) ”这个操作之间,如果有N个线程在get操作获取到相同的oldExpireTime后,然后都去getset,假设第1个线程获取锁成功,其他锁获取失败,但是获取锁失败的线程它发起的getset命令确实执行了,这样会不会造成第一个获取锁的线程设置的锁超时时间一直在延长???
解答: 确实存在这个问题,锁超时时间被延长,即使是业务执行结束后,拿到锁的线程也不会删除这个key,导致锁资源浪费。解决办法getset()更换为lua脚本,判断get(lock_key)返回的value和oldTimeStamp相同后,再set