一、基于单Redis节点的分布式锁
1、获取锁和释放锁
获取锁:SET resource_name my_random_value NX PX 30000
释放锁:if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else return
0
end
2、基于单Redis节点的分布式锁为什么锁必须要设置一个过期时间?
当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了。
3、第一步获取锁的操作SET resource_name my_random_value NX PX30000能实现成了两个Redis命令吗?
SETNX resource_name my_random_value
EXPIRE resource_name 30
不能。虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。
4、为什么设置一个随机字符串my_random_value是很有必要的?
它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:
1、客户端1获取锁成功。
2、客户端1在某个操作上阻塞了很长时间。
3、过期时间到了,锁自动释放了。
4、客户端2获取到了对应同一个资源的锁。
5、客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。
5、释放锁的操作为什么必须使用Lua脚本来实现?
释放锁其实包含三步操作:'GET'、判断和'DEL',用Lua脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:
1、客户端1获取锁成功。
2、客户端1访问共享资源。
3、客户端1为了释放锁,先执行'GET'操作获取随机字符串的值。
4、客户端1判断随机字符串的值,与预期的值相等。
5、客户端1由于某个原因阻塞住了很长时间。
6、过期时间到了,锁自动释放了。
7、客户端2获取到了对应同一个资源的锁。
8、客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。
6、单Redis节点的分布式锁存在的问题
A、主从复制延迟导致锁安全性打破
1、客户端1从Master获取了锁。
2、Master宕机了,存储锁的key还没有来得及同步到Slave上。
3、Slave升级为Master。
4、客户端2从新的Master获取到了对应同一个资源的锁。
于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。
B、锁的有效时间(lock validity time),设置成多少合适
如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。
二、分布式锁Redlock
1、获取锁和释放锁
运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作:
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脚本)。
上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
2、节点发生崩溃重启,对锁的安全性有影响吗?
有。影响程度跟Redis对数据的持久化程度有关。
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
1、客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
2、节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
3、节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。
方案:
延迟重启(delayed restarts)。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。
3、如果客户端长期阻塞导致锁过期,那么它接下来访问共享资源就不安全了(没有了锁的保护)。这个问题在Redlock中是否有所改善呢?
(redis分布式锁过期时间内没执行完怎么解决?)
原文:At this point we need to better specify our mutual exclusion rule: it is guaranteed only as long as the client holding the lock will terminate its work within the lock validity time (as obtained in step 3), minus some time (just a few milliseconds in order to compensate for clock drift between processes).
这样的问题在Redlock中是依然存在的。
方案:
A、续租锁的时间(redlock原文):
If the work performed by clients is composed of small steps, it is possible to use smaller lock validity times by default, and extend the algorithm implementing a lock extension mechanism. Basically the client, if in the middle of the computation while the lock validity is approaching a low value, may extend the lock by sending a Lua script to all the instances that extends the TTL of the key if the key exists and its value is still the random value the client assigned when the lock was acquired.
The client should only consider the lock re-acquired if it was able to extend the lock into the majority of instances, and within the validity time (basically the algorithm to use is very similar to the one used when acquiring the lock).
B、Martin给出了一种方法,称为fencing token。fencing token是一个单调递增的数字,当客户端成功获取锁的时候它随同锁一起返回给客户端。而客户端访问共享资源的时候带着这个fencing token,这样提供共享资源的服务就能根据它进行检查,拒绝掉延迟到来的访问请求(避免了冲突)。如下图: