设计分布式锁需要哪些条件?
首先应该是互斥性,即无论任何情况下,只能有一个客户端能够获得分布式锁;
其次应该是安全性,不会发生死锁,即使是持有锁的客户端宕机;
最后要考虑的应该是容错机制,当Redis某个节点异常的时候,该如何保证分布式锁的性能?
用Redis来实现分布式锁最简单的方式就是在单实例里创建一个键值,创建出来的键值一般都是有一个超时时间的(这个是Redis自带的超时特性),所以每个锁最终都会释放(不会造成死锁)。而当一个客户端想要释放锁时,它只需要删除这个键值即可。
这样设计会有什么问题呢?没有任何容错机制,单实例节点宕机后,整个服务不可用。有的设计可能会增加slave节点,即典型的主备架构,但是同样会存在问题,原因就是Redis的 复制是异步的,请看下面的场景:
A、客户端A从master拿到锁后,master节点宕机,此时客户端A的锁并未复制到slave;
B、客户端B从slave拿到和客户端A一样的锁。
这种场景无法保证分布式锁的互斥性。
多实例的RedLock的实现是基于单实例,先看下单实例的实现:
要获得锁,要用下面这个命令: SET resource_name my_random_value NX PX 30000 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用) 这个key的值设为“my_random_value”。这个值必须在所有获取锁请求的客户端里保持唯一。 基本上这个随机值就是用来保证能安全地释放锁,我们可以用下面这个Lua脚本来告诉Redis:删除这个key当且仅当这个key存在而且值是我期望的那个值。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
这个很重要,因为这可以避免误删其他客户端得到的锁,举个例子,一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁,用上面这个脚本可以保证每个客户单都用一个随机字符串’签名’了,这样每个锁就只能被获得锁的客户端删除了。
这个随机字符串应该用什么生成呢?我假设这是从/dev/urandom生成的20字节大小的字符串,但是其实你可以有效率更高的方案来保证这个字符串足够唯一。比如你可以用RC4加密算法来从/dev/urandom生成一个伪随机流。还有更简单的方案,比如用毫秒的unix时间戳加上客户端id,这个也许不够安全,但是也许在大多数环境下已经够用了。
key值的超时时间,也叫做”锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其他客户端能抢占锁之前可以执行任务的时间,这个时间从获取锁的时间点开始计算。 所以现在我们有很好的获取和释放锁的方式,在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题。
下面说一下RedLock算法。
在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1.获取当前时间(单位是毫秒)。
2.轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
3.客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4.如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5.如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些它认为没有获取成功的锁。
我们可以看一下分析一下算法的实现,感兴趣的同学可以从redlock_py这个第三方PYPI包中得到RedLock的代码,本文仅仅分析一下获取锁的代码。在代码中,除了上述流程外,还有几点要说明一下:
1、客户端获取锁失败后,会延时一小段时间(防止重试失败),重试获取分布式锁。
2、self.quorum表示的是半数以上的节点。
3、注意一下这句:
drift = int(ttl * self.clock_drift_factor) + 2
这个算法是基于一个假设:虽然不存在可以跨进程的同步时钟,但是不同进程时间都是以差不多相同的速度前进,这个假设不一定完全准确,但是和自动释放锁的时间长度相比不同进程时间前进速度差异基本是可以忽略不计的。这个假设就好比真实世界里的计算机:每个计算机都有本地时钟,但是我们可以说大部分情况下不同计算机之间的时间差是很小的。 现在我们需要更细化我们的锁互斥规则,只有当客户端能在T时间内完成所做的工作才能保证锁是有效的(详见算法的第3步),T的计算规则是锁失效时间T1减去一个用来补偿不同进程间时钟差异的delta值(一般只有几毫秒而已) 。
4、在获取锁失败后,要把之前获取锁成功的Redis实例上的锁给删除,以便其它客户端能够获取到该锁。
def lock(self, resource, ttl): retry = 0 val = self.get_unique_id() # Add 2 milliseconds to the drift to account for Redis expires # precision, which is 1 millisecond, plus 1 millisecond min # drift for small TTLs. drift = int(ttl * self.clock_drift_factor) + 2 redis_errors = list() while retry < self.retry_count: n = 0 start_time = int(time.time() * 1000) del redis_errors[:] for server in self.servers: try: if self.lock_instance(server, resource, val, ttl): n += 1 except RedisError as e: redis_errors.append(e) elapsed_time = int(time.time() * 1000) - start_time validity = int(ttl - elapsed_time - drift) if validity > 0 and n >= self.quorum: if redis_errors: raise MultipleRedlockException(redis_errors) return Lock(validity, resource, val) else: for server in self.servers: try: self.unlock_instance(server, resource, val) except: pass retry += 1 time.sleep(self.retry_delay) return False