7.分布式锁
7.1 单机Redis的分布式锁
分布式锁的终极奥义就是在一个地方有一个唯一资源,当多个客户端过来时,谁先抢到这个资源,谁就获得了赢取其他公共资源的资格。常见的分布式锁可以拿这些技术来实现:redis、mysql、linux file、zookeeper。这些技术各有利弊,我就不在这分析了。
用redis做分布式锁其实有三个阶段,也是我自己在开发过程中亲身经历过的。
-
最早的版本
redis2.8之前一般是用setnx命令和expire命令来实现分布式锁,但是这是两个命令,无论我们用pipeline还是redis事务,都解决不了它们是两条命令的事实,既然是两条命令,那么就有可能第一条执行成功,第二条执行失败。给个场景:客户端A连续发送了setnx和expire给redis,redis执行setnx成功并返回success,但是执行expire失败。客户端拿到setnx的回执继续往下执行代码,但是到某处报错了,导致代码中最后的释放锁未生效。这时,若无人工干预,这个分布式锁将无法被其他客户端获得,进入了死锁状态...
-
中间的版本
在redis2.8之后,set命令加入了nx和ex一起配置的功能,也就是说,上述的两个命令变成了一个命令。也就很好的解决了上述提到的死锁问题。不过这时还有其他的问题暴露出来。还是给个场景:当客户端A设置好了分布式锁并且设置了3秒的有效时间,但3秒过后A并未执行完代码,分布式锁被释放。这时客户端B获得了分布式锁,B还未执行完代码,但是A执行完了业务代码然后del了分布式锁。这时我们看见A把B的锁给删了...这里就存在了两个隐患,一是A未执行完的时候B拿到了锁,二是A把B的锁给删了。
-
后来的版本
上述的第一个问题只能通过合理的设置ex时间来规避。所以焦点就到了上述的第二个问题,如果我们在设置lock的时候将value设置成一个唯一值,当删除命令触发时,先进行value是否相等的判断,如果相等则删除,如果不相等则不执行删除,这样就解决了A把B的锁给删了的情况。不过获取value,比较value,然后执行del这些步骤并不是原子执行的,如果不是原子执行的就有可能发生资源线程安全问题。这时就想起了在redis中嵌套lua脚本,一个lua脚本中执行的命令对redis来说是原子的。
这里给一个用Python2.7实现的分布式锁:
#!/usr/bin/env python # -*- coding:utf-8 -*- """ 使用lua脚本解决redis分布式锁存在的错误释放问题 """ import redis import time import hashlib class DistributedLock: def __init__(self, redis_instance, lock_key, value): """ :param redis_instance: :param lock_key: :param value: A unique identifier """ self.redis_instance = redis_instance self.lock_key = lock_key self.value = value def set_lock(self, ex=3): """ :param ex: sets an expire flag on key ``name`` for ``ex`` seconds. :return: True -> success or None -> fail """ return self.redis_instance.set(self.lock_key, self.value, ex=ex, nx=True) def del_lock(self): """ :return: 1 -> success or 0 -> fail """ try: return self.redis_instance.evalsha(self.script_sha1_str(), 1, self.lock_key, self.value) except redis.exceptions.NoScriptError: return self.redis_instance.eval(self.lua_script(), 1, self.lock_key, self.value) @staticmethod def lua_script(): lua_str = """ if redis.call("GET",KEYS[1]) == ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end """ return lua_str def load_script(self): return self.redis_instance.script_load(self.lua_script()) def script_sha1_str(self): script_sha1 = hashlib.sha1(self.lua_script()) return script_sha1.hexdigest() def is_script_exist(self): """ :return: True or False """ return self.redis_instance.script_exists(self.script_sha1_str())[0] def flush_script(self): return self.redis_instance.script_flush() @classmethod def unique_value(cls): return int(time.time() * 1000) if __name__ == '__main__': def redis_cache(): connection_pool = redis.ConnectionPool( host='127.0.0.1', port='6379', db=0, ) return redis.Redis(connection_pool=connection_pool) distributed_lock = DistributedLock( redis_instance=redis_cache(), lock_key='distributed_lock', value=DistributedLock.unique_value() ) print(distributed_lock.set_lock(ex=30)) print(distributed_lock.del_lock())
7.2 Redlock
贴一个官网地址:Distributed locks with Redis
其原理大致就是给定两个时间,一个是设置锁的超时时间,一个是分布式锁的超时时间。前者用来在多台机器上设置锁,当设置时间超出,那么这台机器就算失败,后者就是我们设置释放分布式锁的超时时间。当过半的机器设置成功,那么这个分布式锁就算成功设置上了,它的超时时间为用户设置的超时时间减去在每台机器上设置所花的时间。
详细的说明(tielei公众号的文章):
- 获取当前时间(毫秒数)。
- 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串
my_random_value
,也包含过期时间(比如PX 30000
,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。 - 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
- 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
- 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。
当然,上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。我们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟Redis对数据的持久化程度有关。
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
- 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
- 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
- 节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。
在默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。
关于Redlock还有一点细节值得拿出来分析一下:在最后释放锁的时候,antirez在算法描述中特别强调,客户端应该向所有Redis节点发起释放锁的操作。也就是说,即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。这是为什么呢?设想这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET
操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。