基于单Redis节点的分布式锁

具体实现

获取锁:

1
SET resource_name my_random_value NX PX 30000

上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。

注意,在上面的SET命令中:

  • my_random_value是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
  • NX表示只有当resource_name对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000表示这个锁有一个30秒的自动过期时间。

释放锁:

1
2
3
4
5
6

if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

这段Lua脚本在执行的时候要把前面的my_random_value作为ARGV[1]的值传进去,把resource_name作为KEYS[1]的值传进去。

过期时间问题

这个锁必须要设置一个过期时间。否则的话,当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了。

获取锁命令需要一次请求完成

第一步获取锁的操作,不少文章把它实现成了两个Redis命令:

1
2
SETNX resource_name my_random_value
EXPIRE resource_name 30

虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。

my_random_value 字段的必要性

设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:

  • 客户端1获取锁成功。
  • 客户端1在某个操作上阻塞了很长时间。
  • 过期时间到了,锁自动释放了。
  • 客户端2获取到了对应同一个资源的锁。
  • 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。

之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。

Lua脚本保证原子性的必要性

释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

  • 客户端1获取锁成功。
  • 客户端1访问共享资源。
  • 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
  • 客户端1判断随机字符串的值,与预期的值相等。
  • 客户端1由于某个原因阻塞住了很长时间。
  • 过期时间到了,锁自动释放了。
  • 客户端2获取到了对应同一个资源的锁。
  • 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

实际上,在上述问题的分析中,如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生。

单点Redis的高可用问题

假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

  • 客户端1从Master获取了锁。
  • Master宕机了,存储锁的key还没有来得及同步到Slave上。
  • Slave升级为Master。
  • 客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

reference