美文网首页
闲聊Redis分布式锁

闲聊Redis分布式锁

作者: 落落的博客 | 来源:发表于2019-11-10 16:16 被阅读0次

    引言

    目前很多系统都是使用redis作为分布式锁,如果redis是单节点部署,基本上不会出现什么问题。但如果redis是多节点的集群部署,那么使用redis集群作为分布式锁就会存在一些问题。这两篇文章进行了详细的讲解。http://zhangtielei.com/posts/blog-redlock-reasoning.html http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html

    一、基于单节点的redis锁

    客户端获取锁

    SET resource_name my_random_value NX PX 30000
    

    如果返回成功,则说明客户端获取锁成功,然后就可以访问公共资源了,如果失败则获取锁失败。对于这条命令,需要注意

    • my_random_value:必须是一个随机字符,并且唯一。如果不唯一,可能会出现以下情况:
      1.客户端1获取资源成功
      2.客户端1阻塞超时,锁自动释放
      3.客户端2获取锁成功
      4.客户端1从阻塞中醒来,释放了客户端2的锁

    • 必须设置NX,表示只有resource_name不存在时才会设置成功,保证只有第一个请求的客户端获取锁成功

    • PX 30000 表示过期时间为30s,为了保证原子操作必须在SET时设置过期时间

    客户端释放锁
    释放锁时使用下面的redis lua脚本执行来保证原子性

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

    只有当resource_name的值和客户端持有的数据相等时才能够调用del删除resource_name,否则不进行删除操作。从而防止一个客户端释放另一个客户端持有的锁。

    安全性和可靠性
    分析一下redis锁的原理,我们在redis实例中创建一个键值,同时设置该键值的超时时间。创建该键值的客户端获取锁成功,访问公共资源。同时如果客户端宕机则锁会自动释放。客户端需要释放锁时只需要删除该键即可。但一旦单节点的Redis宕机则不能再提供服务,即使是基于Master-Slave模式的故障切换也是不安全的,例如下面场景

    1. 客户端1从Master获取锁
    2. Master宕机,但锁key还没有同步到Slave上
    3. Slave升级为Master
    4. 客户端2从新的Master上获取锁成功

    二、分布式Redlock

    分布式Redlock是基于多个redis示例实现的锁服务

    算法实现

    1. 客户端获取当前时间start_time
    2. 客户端按照顺序依次向N个Redis节点获取锁操作,这个过程类似于上述单个节点获取锁过程。为了防止在获取某个Redis节点锁超时,客户端会设置一个很小的超时时间(timeout),timeout要远远小于锁本身超时时间。
    3. 当向所有Redis节点发送获取锁操作完成后,记录当前时间endtime。并且获取锁总消耗时间elapsed_time = (endtime-starttime),可用时长:validity = ttl - elapsed_time - drift,获取锁成功数n。当n > (N/2+1) && validity > 0(或其他值) 获取锁成功,并修改占有锁时长为validity
    4. 如果获取锁失败,则需要向所有客户端发起释放锁的操作

    python源码:

    import logging 
    import string 
    import random 
    import time 
    from collections import namedtuple 
     
    import redis 
    from redis.exceptions import RedisError 
     
    # Python 3 compatibility 
    string_type = getattr(__builtins__, 'basestring', str) 
     
    try: 
        basestring 
    except NameError: 
        basestring = str 
     
     
    Lock = namedtuple("Lock", ("validity", "resource", "key")) 
     
     
    class CannotObtainLock(Exception): 
        pass 
     
     
    class MultipleRedlockException(Exception): 
        def __init__(self, errors, *args, **kwargs): 
            super(MultipleRedlockException, self).__init__(*args, **kwargs) 
            self.errors = errors 
     
        def __str__(self): 
            return ' :: '.join([str(e) for e in self.errors]) 
     
        def __repr__(self): 
            return self.__str__() 
     
     
    class Redlock(object): 
     
        default_retry_count = 3     //默认重试次数,指客户端获取锁重试次数,并不是向单个redis master加锁请求重试次数 
        default_retry_delay = 0.2   //默认重试间隔(实际应用中应该设置为随机值)
        clock_drift_factor = 0.01   //不同服务器时间漂移比例因子 
         
        //释放锁的lua脚本 
        unlock_script = """          
        if redis.call("get",KEYS[1]) == ARGV[1] then 
            return redis.call("del",KEYS[1]) 
        else 
            return 0 
        end""" 
     
        def __init__(self, connection_list, retry_count=None, retry_delay=None): 
            self.servers = [] 
            for connection_info in connection_list: 
                try: 
                    if isinstance(connection_info, string_type): 
                        server = redis.StrictRedis.from_url(connection_info) 
                    elif type(connection_info) == dict: 
                        server = redis.StrictRedis(**connection_info) 
                    else: 
                        server = connection_info 
                    self.servers.append(server) 
                except Exception as e: 
                    raise Warning(str(e)) 
            self.quorum = (len(connection_list) // 2) + 1 
     
            if len(self.servers) < self.quorum: 
                raise CannotObtainLock( 
                    "Failed to connect to the majority of redis servers") 
            self.retry_count = retry_count or self.default_retry_count 
            self.retry_delay = retry_delay or self.default_retry_delay 
     
            //向单个redis服务器加锁请求 
        def lock_instance(self, server, resource, val, ttl): 
            try: 
                assert isinstance(ttl, int), 'ttl {} is not an integer'.format(ttl) 
            except AssertionError as e: 
                raise ValueError(str(e)) 
            return server.set(resource, val, nx=True, px=ttl) 
     
        def unlock_instance(self, server, resource, val): 
            try: 
                server.eval(self.unlock_script, 1, resource, val) 
            except Exception as e: 
                logging.exception("Error unlocking resource %s in server %s", resource, str(server)) 
     
            //获取锁机制 
        def get_unique_id(self): 
            CHARACTERS = string.ascii_letters + string.digits 
            return ''.join(random.choice(CHARACTERS) for _ in range(22)).encode() 
     
        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. 
            //表示不同服务器之间时间飘移 默认加锁时长的1% + 2ms 
            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) 
     
                //剩余时长 > 0 && 加锁成功 >= n/2+1 
                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 
     
            //解锁,向所有服务器发送解锁请求 
        def unlock(self, lock): 
            redis_errors = [] 
            for server in self.servers: 
                try: 
                    self.unlock_instance(server, lock.resource, lock.key) 
                except RedisError as e: 
                    redis_errors.append(e) 
            if redis_errors: 
                raise MultipleRedlockException(redis_errors) 
    

    安全性
    1.访问共享资源期间所有锁未过期

    1. 根据redlock算法可知,每个redis实例锁的过期时间为ttl
    2. 客户端获取锁成功后将锁时间修改为validity = (ttl - elapsed_time - drift)
    3. 此时距离第一个实例加锁已经过去了elapsed_time,第一个实例锁释放剩余时间为 ttl - elapsed_time
    4. 由于drift的存在,所以在客户端占有资源的时间内第一个实例的锁是不会过期的

    所以,在客户端访问资源期间,所有实例上的锁都不会自动过期,其他客户端也无法获取这个锁。

    2.故障恢复问题

    多节点的Redis系统没有单节点failover带来的问题,但当某个节点崩溃重启时,仍然会有问题。如下步骤:

    1. 客户端1锁住了A,B,C三个节点,由于种种原因未锁住D和E
    2. C崩溃,key并未持久化到硬盘,C重启
    3. 客户端2锁住C,D,E三个节点,获取锁成功。

    出现上述情况时就会导致访问资源冲突。当然,我们可以将redis的AOF持久化方式设置为每次修改数据都进行fsync,但这样会降低 系统性能,并且即使每次更新都执行fsync,操作系统仍不能完全保证数据持久化到硬盘上,因此antirez提出了延迟重启(delayed restarts)的概念:当一个节点崩溃后并不直接重启,而是过一段时间重启。这段时间应该大于锁的有效期。

    RedLock缺陷

    1.客户端长时间阻塞问题

    著名分布式大师Martion在2016-2-8日发布了“How to do distributed locking”博客,博客中给出了一个时序图:


    unsafe-lock.png

    Martin指出,即使锁服务能够正常工作,但仍会出现问题。例如上面所示时序图:

    1. client1获取锁成功

    2. client1触发了full gc(或者阻塞时间太长或者业务耗时太长)

    3. 锁超时释放

    4. client2获取锁成功,访问公共资源

    5. client1恢复,访问公共资源,造成冲突

      一般来说client在访问公共资源时首先check是否还持有锁,如果不持有锁再访问数据。但由于GC Pause可能在任何时间点发生,有可能在check之后发生,仍不能避免上述问题,即使我们使用非JAVA类语言即不存在Long GC Pause,但仍然有可能会因为某些原因导致长阻塞。

    基于fencing机制的分布式锁
    Martin提出了一种基于fencing tocken的解决方案。fencing token是一个单调递增的数字,客户端在加锁时获取token,在访问资源时待着token进行访问。这样就可以通过比较所带的token和公共资源上token大小来避免过期的token堆资源进行访问。如下所示时序图

    fencing-tokens.png
    1. client1获取锁成功,同时获取一个Token 33
    2. client1进入GC Pause,锁超时释放
    3. client2获取锁成功,同时获取Token 34
    4. client2访问公共资源,并将Token 34 写到资源上
    5. client1从GC Pause恢复,访问公共资源,发现所携带的Token小于正在访问公共资源的Token,则访问失败,直接返回,避免了访问冲突

    其实上述fencing机制并不能完全解决客户端阻塞问题,因为GC有可能发生在任何时刻。如果在check Token之后发生长时间的GC仍然有可能造成访问资源冲突

    2. 对系统计时(timing)太过于依赖

    Martin在博客中指出Redlock对系统的计时(timing)太过于依赖,例如文中给出的一个示例:

    1. client1从Redis节点A,B,C获取锁,D,E获取失败
    2. 节点C上的时间向前发生了跳跃,导致其维护的锁失效
    3. client2获取C,D,E节点锁成功
    4. client1和client2同时获取了锁

    上边这种情况的发生本质上就是Redlock对系统时钟有比较强的依赖。redis是通过gettimeofday函数来判断key是否过期的,而这种做法是不推荐的(https://blog.habets.se/2010/09/gettimeofday-should-never-be-used-to-measure-time.html)。当发生时间跳跃或者管理员修改了机器的本地时间,Redlock就无法保证其安全性。

    三、基于ZooKeeper的分布式锁

    很多人都认为如果要构建一个更加安全的分布式锁,那么需要使用Zookeeper,而不是Redis。另一个著名的分布式专家Flavio Junqueira在Martin发表blog后也写了一篇博客,介绍基于ZK的分布式锁,博客地址:https://fpj.me/2016/02/10/note-on-fencing-and-distributed-locks/

    文中指出,基于ZK创建分布式锁的一种方式:client1创建临时节点/lock,如果创建成功则说明其拿到锁,其他客户端无法创建。由于/lock节点是临时节点,所以创建它的客户端一旦崩溃就会自动删除/lock节点。ZK是如何检测到客户端崩溃的呢,实际上ZK和客户端维护者一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。

    设想如下的执行序列:

    1. 客户端1创建了znode节点/lock,获得了锁。
    2. 客户端1进入了长时间的GC pause。
    3. 客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
    4. 客户端2创建了znode节点/lock,从而获得了锁。
    5. 客户端1从GC pause中恢复过来,它仍然认为自己持有锁。

    最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。对于这种情况,Flavio提出了对资源进行访问时先进行Mark,其实类似于Martin提出的Fencing机制,每次访问共享资源时对资源进行mark,防止旧的(比当前mark小的)客户端访问资源。

    Zookeeper作为分布式锁的另一个优势是其具有watch机制,当客户端创建/lock节点失败时并不一定立即返回,其进入等待状态。当/lock节点被删除时ZK可以通过watch机制通知它,这样客户端就可以继续完成创建节点,直到获取锁。显然Redis是无法提供这样特性的。

    四、总结

    1. 按照Martin提到的两种用途,如果我们使用分布式锁仅仅是为了效率,那么我们可以选择任何一种分布式锁的实现。但如果是为了正确性,那么我们就需要谨慎点,认真选择。
    2. 对于客户端出现长时间GC Pause的情况,通过fencing机制可以解决,但如果客户端在访问公共资源时出现长时间的GC Pause,目前暂未有解决方案。
    3. 相比于Redis,Zookeeper提供了更加灵活地加锁方式,同时其也可以避免客户端崩溃而长期持有锁。

    相关文章

      网友评论

          本文标题:闲聊Redis分布式锁

          本文链接:https://www.haomeiwen.com/subject/ccjabctx.html