前言

分布式锁就是在多个进程之间达到互斥的目的,常见的方案包括:基于DB的唯一索引、Zookeeper的临时有序节点、Redis的SETNX来实现;Redis因为其高性能被广泛使用,本文通过一问一答的方式来了解Redis如何去实现分布式锁的。

1.Redis怎么实现分布式锁

使用Redis提供的SETNX命令保证只有一次能写入成功

SETNX key value

当且仅当key不存在,则给key设值为value;若给定的key已经存在,则什么也不做;

127.0.0.1:6379> setnx lock 001
(integer) 1
127.0.0.1:6379> setnx lock 002
(integer) 0

当然也可以使用SET命令,并使用NX关键字

set <key> <value> NX

2.如果获取锁的节点挂了怎么办

如果仅仅使用SETNX命令,当某个节点抢占到锁,如果这时候当前节点挂了,那么导致这个锁无法释放,最终会导致死锁出现;这时候想到的是给key设置一个过期时间,这样就是节点挂了也会自动删除;

127.0.0.1:6379> expire lock 5
(integer) 1

以上使用expire命令设置过期时间;

3.如果Set执行完Expire未执行节点挂了

以上问题的原因是因为SETNX命令和Expire不是原子操作,所有有可能在执行完SETNX命令之后节点就挂了,这时候Expire还没来得及执行,同样会导致锁无法释放,出现死锁现象;

127.0.0.1:6379> set lock 001 ex 5 nx
OK

如上命令将SETNXExpire命令整合成一个原子操作,保证了同时成功同时失败;

4.没有获取锁的节点如何阻塞处理

没有获取到锁的节点需要处于阻塞状态,并且定时去重试,保证第一时间能获取锁;

while(true){
   set lock uuid ex 5 nx;   ## 抢占锁
   if(获取锁){
      break;
   }
   ......
   sleep(1);                ## 防止一直消耗CPU 
}

如果想功能更强大一点可以指定阻塞时间,超过指定阻塞时间就直接获取锁失败;

5.如果解决锁的可重入问题

可重入就是如果某个线程获取了锁,那么当前线程再次获取锁的时候,应该还是可以进入锁中的,每重入一次数量加一,出来时减一;本地可以使用threadId或者直接使用ThreadLocal来实现;当然最好是直接把相关信息保存在Redis中,Redisson使用lua脚本来记录threadId信息:

if (redis.call('exists', KEYS[1]) == 0) then            ## 如果锁不存在
redis.call('hincrby', KEYS[1], ARGV[2], 1);             ## 保存锁,同时设置threadId
redis.call('pexpire', KEYS[1], ARGV[1]);                ## 设置过期时间
return nil; 
end; 
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then  ## 如果锁存在并且threadId就是当前线程id
redis.call('hincrby', KEYS[1], ARGV[2], 1);             ## 给threadId自增
redis.call('pexpire', KEYS[1], ARGV[1]);                ## 设置过期时间
return nil; 
end; " 
return redis.call('pttl', KEYS[1]);

6.如果过期时间到了,任务刚好执行完会怎么样

正常来说我们预估的过期时间相对来说都比执行任务的时间长一些,所以当任务执行完之后会做删除操作

127.0.0.1:6379> del lock
(integer) 1

有没有可能A节点获取的锁过期时间到了,锁被删除,这时候B节点获取到锁,又重新执行了set ex nx命令;而刚好A节点任务执行完成,并且执行删除锁命令,把B节点的锁给删掉,出现锁被误删的情况;

这种情况就需要我们在删除锁的时候,检查当前被删除的锁是否就是我们之前获取的锁,可以在set的时候执行一个唯一的value,比如直接使用uuid;这样在删除的时候我们需要先获取锁对应的value值,然后和当前节点对象的value做比较,一致才可以删除;

string uuid = gen();     ## 生成一个唯一value
set lock uuid ex 5 nx;   ## 抢占锁
......                   ## 执行业务   
string value = get lock; ## 获取当前锁对应的value值
if(value == uuid) {      ## 对比获取的value值和uuid是否一致
   del lock              ## 一致执行删除操作
} else {
   return;               ## 否则不执行删除操作
}

7.如果过期时间到了,任务还没执行完怎么办

过期时间是一个预估的时间,如果真有某个任务执行的时间很长,而这时候刚好过期时间到了,锁就会被删除,导致其他节点又可以获取锁了,这样就出现了多个节点同时获取锁的情况;

这种情况一般会这么解决:

  • 过期时间设置的足够长,确保任务可以执行完;
  • 启动一个守护线程,为将要过期但未释放的锁增加时间,就是给锁续命;

我们常用的工具包Redisson,内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期;内部使用HashedWheelTimer作为定时器定期检查;

8.Redis主节点宕机,还未同步从节点怎么办

我们知道Redis主从同步是异步的,如果某个节点获取了锁,这时候锁信息还未同步到从节点,主节点宕机了,从节点升级为主节点,导致锁丢失;这种情况Redis作者提出了redlock算法,大致含义如下:

在Redis的分布式环境中,假设我们有N个Redis主机;这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统;

当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

Redisson提供了RedLock的支持,使用也很简单:

RLock lock1 = redissonClient1.getLock(resourceName); 
RLock lock2 = redissonClient2.getLock(resourceName); 
RLock lock3 = redissonClient3.getLock(resourceName); 
// 向3个redis实例尝试加锁 
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); 

更多:redlock

9.Redis出现集群脑裂会怎么样

集群脑裂指因为网络问题,导致主节点、从节点以及sentinel处于不同的网络分区,因为sentinel的存在会因为某些主节点不存在,而提升从节点为主节点,这时候就存在了不同的主节点,此时不同的客户端可能连接不同的主节点,两个客户端可以同时拥有同一把锁;

Redis 提供了两个配置项来限制主库的请求处理,分别是 min-slaves-to-writemin-slaves-max-lag

  • min-slaves-to-write:设置了主库能进行数据同步的最少从库数量
  • min-slaves-max-lag:设置了主从库间进行数据复制时,从库给主库发送ACK消息的最大延迟(以秒为单位)

配置项组合后要求主库连接的从库中至少有 N 个从库、主库进行数据复制时的 ACK 消息延迟不能超过N秒,否则主库就不会再接收客户端的请求。

10.如何实现一个公平锁

我们知道ReentrantLock通过AQS来公平锁,AQS内部通过双向队列来实现,Redis本身提供了多种数据结构包括列表、有序集合等;Redisson实现公平锁正是通过Redis内置的数据结构来实现的:

  • 使用列表作为线程的等待队列,新的等待队列添加到列表的尾部;
  • 使用有序集合存放等待线程的顺序,分数score是等待线程的超时时间戳;

总结

不管使用哪种方式去实现分布式锁,我们前提需要保证锁的功能包括:互斥性、可重入性、阻塞性;同时因为分布式的存在我们需要保证系统的高可用、高性能、杜绝一切出现死锁和同时获得锁的情况。


ksfzhaohui
398 声望70 粉丝