Redis奇幻之旅(三)7.分布式鎖

7.分布式鎖

7.1 單機(jī)Redis的分布式鎖

? 分布式鎖的終極奧義就是在一個(gè)地方有一個(gè)唯一資源,當(dāng)多個(gè)客戶端過(guò)來(lái)時(shí),誰(shuí)先搶到這個(gè)資源,誰(shuí)就獲得了贏取其他公共資源的資格。常見(jiàn)的分布式鎖可以拿這些技術(shù)來(lái)實(shí)現(xiàn):redis、mysql、linux file、zookeeper。這些技術(shù)各有利弊,我就不在這分析了。

? 用redis做分布式鎖其實(shí)有三個(gè)階段,也是我自己在開(kāi)發(fā)過(guò)程中親身經(jīng)歷過(guò)的。

  • 最早的版本

    redis2.8之前一般是用setnx命令和expire命令來(lái)實(shí)現(xiàn)分布式鎖,但是這是兩個(gè)命令,無(wú)論我們用pipeline還是redis事務(wù),都解決不了它們是兩條命令的事實(shí),既然是兩條命令,那么就有可能第一條執(zhí)行成功,第二條執(zhí)行失敗。給個(gè)場(chǎng)景:客戶端A連續(xù)發(fā)送了setnx和expire給redis,redis執(zhí)行setnx成功并返回success,但是執(zhí)行expire失敗。客戶端拿到setnx的回執(zhí)繼續(xù)往下執(zhí)行代碼,但是到某處報(bào)錯(cuò)了,導(dǎo)致代碼中最后的釋放鎖未生效。這時(shí),若無(wú)人工干預(yù),這個(gè)分布式鎖將無(wú)法被其他客戶端獲得,進(jìn)入了死鎖狀態(tài)...

  • 中間的版本

    在redis2.8之后,set命令加入了nx和ex一起配置的功能,也就是說(shuō),上述的兩個(gè)命令變成了一個(gè)命令。也就很好的解決了上述提到的死鎖問(wèn)題。不過(guò)這時(shí)還有其他的問(wèn)題暴露出來(lái)。還是給個(gè)場(chǎng)景:當(dāng)客戶端A設(shè)置好了分布式鎖并且設(shè)置了3秒的有效時(shí)間,但3秒過(guò)后A并未執(zhí)行完代碼,分布式鎖被釋放。這時(shí)客戶端B獲得了分布式鎖,B還未執(zhí)行完代碼,但是A執(zhí)行完了業(yè)務(wù)代碼然后del了分布式鎖。這時(shí)我們看見(jiàn)A把B的鎖給刪了...這里就存在了兩個(gè)隱患,一是A未執(zhí)行完的時(shí)候B拿到了鎖,二是A把B的鎖給刪了。

  • 后來(lái)的版本

    上述的第一個(gè)問(wèn)題只能通過(guò)合理的設(shè)置ex時(shí)間來(lái)規(guī)避。所以焦點(diǎn)就到了上述的第二個(gè)問(wèn)題,如果我們?cè)谠O(shè)置lock的時(shí)候?qū)alue設(shè)置成一個(gè)唯一值,當(dāng)刪除命令觸發(fā)時(shí),先進(jìn)行value是否相等的判斷,如果相等則刪除,如果不相等則不執(zhí)行刪除,這樣就解決了A把B的鎖給刪了的情況。不過(guò)獲取value,比較value,然后執(zhí)行del這些步驟并不是原子執(zhí)行的,如果不是原子執(zhí)行的就有可能發(fā)生資源線程安全問(wèn)題。這時(shí)就想起了在redis中嵌套lua腳本,一個(gè)lua腳本中執(zhí)行的命令對(duì)redis來(lái)說(shuō)是原子的。

    這里給一個(gè)用Python2.7實(shí)現(xiàn)的分布式鎖:

    #!/usr/bin/env python
    # -*- coding:utf-8 -*-
    """
        使用lua腳本解決redis分布式鎖存在的錯(cuò)誤釋放問(wèn)題
    """
    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

貼一個(gè)官網(wǎng)地址:Distributed locks with Redis

? 其原理大致就是給定兩個(gè)時(shí)間,一個(gè)是設(shè)置鎖的超時(shí)時(shí)間,一個(gè)是分布式鎖的超時(shí)時(shí)間。前者用來(lái)在多臺(tái)機(jī)器上設(shè)置鎖,當(dāng)設(shè)置時(shí)間超出,那么這臺(tái)機(jī)器就算失敗,后者就是我們?cè)O(shè)置釋放分布式鎖的超時(shí)時(shí)間。當(dāng)過(guò)半的機(jī)器設(shè)置成功,那么這個(gè)分布式鎖就算成功設(shè)置上了,它的超時(shí)時(shí)間為用戶設(shè)置的超時(shí)時(shí)間減去在每臺(tái)機(jī)器上設(shè)置所花的時(shí)間。

詳細(xì)的說(shuō)明(tielei公眾號(hào)的文章):

  1. 獲取當(dāng)前時(shí)間(毫秒數(shù))。
  2. 按順序依次向N個(gè)Redis節(jié)點(diǎn)執(zhí)行獲取鎖的操作。這個(gè)獲取操作跟前面基于單Redis節(jié)點(diǎn)的獲取鎖的過(guò)程相同,包含隨機(jī)字符串my_random_value,也包含過(guò)期時(shí)間(比如PX 30000,即鎖的有效時(shí)間)。為了保證在某個(gè)Redis節(jié)點(diǎn)不可用的時(shí)候算法能夠繼續(xù)運(yùn)行,這個(gè)獲取鎖的操作還有一個(gè)超時(shí)時(shí)間(time out),它要遠(yuǎn)小于鎖的有效時(shí)間(幾十毫秒量級(jí))??蛻舳嗽谙蚰硞€(gè)Redis節(jié)點(diǎn)獲取鎖失敗以后,應(yīng)該立即嘗試下一個(gè)Redis節(jié)點(diǎn)。這里的失敗,應(yīng)該包含任何類(lèi)型的失敗,比如該Redis節(jié)點(diǎn)不可用,或者該Redis節(jié)點(diǎn)上的鎖已經(jīng)被其它客戶端持有(注:Redlock原文中這里只提到了Redis節(jié)點(diǎn)不可用的情況,但也應(yīng)該包含其它的失敗情況)。
  3. 計(jì)算整個(gè)獲取鎖的過(guò)程總共消耗了多長(zhǎng)時(shí)間,計(jì)算方法是用當(dāng)前時(shí)間減去第1步記錄的時(shí)間。如果客戶端從大多數(shù)Redis節(jié)點(diǎn)(>= N/2+1)成功獲取到了鎖,并且獲取鎖總共消耗的時(shí)間沒(méi)有超過(guò)鎖的有效時(shí)間(lock validity time),那么這時(shí)客戶端才認(rèn)為最終獲取鎖成功;否則,認(rèn)為最終獲取鎖失敗。
  4. 如果最終獲取鎖成功了,那么這個(gè)鎖的有效時(shí)間應(yīng)該重新計(jì)算,它等于最初的鎖的有效時(shí)間減去第3步計(jì)算出來(lái)的獲取鎖消耗的時(shí)間。
  5. 如果最終獲取鎖失敗了(可能由于獲取到鎖的Redis節(jié)點(diǎn)個(gè)數(shù)少于N/2+1,或者整個(gè)獲取鎖的過(guò)程消耗的時(shí)間超過(guò)了鎖的最初有效時(shí)間),那么客戶端應(yīng)該立即向所有Redis節(jié)點(diǎn)發(fā)起釋放鎖的操作(即前面介紹的Redis Lua腳本)。

當(dāng)然,上面描述的只是獲取鎖的過(guò)程,而釋放鎖的過(guò)程比較簡(jiǎn)單:客戶端向所有Redis節(jié)點(diǎn)發(fā)起釋放鎖的操作,不管這些節(jié)點(diǎn)當(dāng)時(shí)在獲取鎖的時(shí)候成功與否。

由于N個(gè)Redis節(jié)點(diǎn)中的大多數(shù)能正常工作就能保證Redlock正常工作,因此理論上它的可用性更高。我們前面討論的單Redis節(jié)點(diǎn)的分布式鎖在failover的時(shí)候鎖失效的問(wèn)題,在Redlock中不存在了,但如果有節(jié)點(diǎn)發(fā)生崩潰重啟,還是會(huì)對(duì)鎖的安全性有影響的。具體的影響程度跟Redis對(duì)數(shù)據(jù)的持久化程度有關(guān)。

假設(shè)一共有5個(gè)Redis節(jié)點(diǎn):A, B, C, D, E。設(shè)想發(fā)生了如下的事件序列:

  1. 客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒(méi)有鎖?。?。
  2. 節(jié)點(diǎn)C崩潰重啟了,但客戶端1在C上加的鎖沒(méi)有持久化下來(lái),丟失了。
  3. 節(jié)點(diǎn)C重啟后,客戶端2鎖住了C, D, E,獲取鎖成功。

這樣,客戶端1和客戶端2同時(shí)獲得了鎖(針對(duì)同一資源)。

? 在默認(rèn)情況下,Redis的AOF持久化方式是每秒寫(xiě)一次磁盤(pán)(即執(zhí)行fsync),因此最壞情況下可能丟失1秒的數(shù)據(jù)。為了盡可能不丟數(shù)據(jù),Redis允許設(shè)置成每次修改數(shù)據(jù)都進(jìn)行fsync,但這會(huì)降低性能。當(dāng)然,即使執(zhí)行了fsync也仍然有可能丟失數(shù)據(jù)(這取決于系統(tǒng)而不是Redis的實(shí)現(xiàn))。所以,上面分析的由于節(jié)點(diǎn)重啟引發(fā)的鎖失效問(wèn)題,總是有可能出現(xiàn)的。為了應(yīng)對(duì)這一問(wèn)題,antirez又提出了延遲重啟(delayed restarts)的概念。也就是說(shuō),一個(gè)節(jié)點(diǎn)崩潰后,先不立即重啟它,而是等待一段時(shí)間再重啟,這段時(shí)間應(yīng)該大于鎖的有效時(shí)間(lock validity time)。這樣的話,這個(gè)節(jié)點(diǎn)在重啟前所參與的鎖都會(huì)過(guò)期,它在重啟后就不會(huì)對(duì)現(xiàn)有的鎖造成影響。

? 關(guān)于Redlock還有一點(diǎn)細(xì)節(jié)值得拿出來(lái)分析一下:在最后釋放鎖的時(shí)候,antirez在算法描述中特別強(qiáng)調(diào),客戶端應(yīng)該向所有Redis節(jié)點(diǎn)發(fā)起釋放鎖的操作。也就是說(shuō),即使當(dāng)時(shí)向某個(gè)節(jié)點(diǎn)獲取鎖沒(méi)有成功,在釋放鎖的時(shí)候也不應(yīng)該漏掉這個(gè)節(jié)點(diǎn)。這是為什么呢?設(shè)想這樣一種情況,客戶端發(fā)給某個(gè)Redis節(jié)點(diǎn)的獲取鎖的請(qǐng)求成功到達(dá)了該Redis節(jié)點(diǎn),這個(gè)節(jié)點(diǎn)也成功執(zhí)行了SET操作,但是它返回給客戶端的響應(yīng)包卻丟失了。這在客戶端看來(lái),獲取鎖的請(qǐng)求由于超時(shí)而失敗了,但在Redis這邊看來(lái),加鎖已經(jīng)成功了。因此,釋放鎖的時(shí)候,客戶端也應(yīng)該對(duì)當(dāng)時(shí)獲取鎖失敗的那些Redis節(jié)點(diǎn)同樣發(fā)起請(qǐng)求。實(shí)際上,這種情況在異步通信模型中是有可能發(fā)生的:客戶端向服務(wù)器通信是正常的,但反方向卻是有問(wèn)題的。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容