闲聊Redis分布式锁

引言

目前很多系统都是使用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提供了更加灵活地加锁方式,同时其也可以避免客户端崩溃而长期持有锁。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容