美文网首页
闲聊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