閒聊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”博客,博客中給出了一個時序圖:

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堆資源進行訪問。如下所示時序圖

  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提供了更加靈活地加鎖方式,同時其也可以避免客戶端崩潰而長期持有鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章