前言
分布式锁就是在多个进程之间达到互斥的目的,常见的方案包括:基于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 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
如上命令将SETNX
和Expire
命令整合成一个原子操作,保证了同时成功同时失败;
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-write
和 min-slaves-max-lag
:
- min-slaves-to-write:设置了主库能进行数据同步的最少从库数量
- min-slaves-max-lag:设置了主从库间进行数据复制时,从库给主库发送
ACK
消息的最大延迟(以秒为单位)
配置项组合后要求主库连接的从库中至少有 N 个从库、主库进行数据复制时的 ACK
消息延迟不能超过N秒,否则主库就不会再接收客户端的请求。
10.如何实现一个公平锁
我们知道ReentrantLock
通过AQS
来公平锁,AQS
内部通过双向队列来实现,Redis本身提供了多种数据结构包括列表、有序集合等;Redisson
实现公平锁正是通过Redis内置的数据结构来实现的:
- 使用列表作为线程的等待队列,新的等待队列添加到列表的尾部;
- 使用有序集合存放等待线程的顺序,分数score是等待线程的超时时间戳;
总结
不管使用哪种方式去实现分布式锁,我们前提需要保证锁的功能包括:互斥性、可重入性、阻塞性;同时因为分布式的存在我们需要保证系统的高可用、高性能、杜绝一切出现死锁和同时获得锁的情况。