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)的文章):
- 獲取當(dāng)前時(shí)間(毫秒數(shù))。
- 按順序依次向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)該包含其它的失敗情況)。 - 計(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)為最終獲取鎖失敗。
- 如果最終獲取鎖成功了,那么這個(gè)鎖的有效時(shí)間應(yīng)該重新計(jì)算,它等于最初的鎖的有效時(shí)間減去第3步計(jì)算出來(lái)的獲取鎖消耗的時(shí)間。
- 如果最終獲取鎖失敗了(可能由于獲取到鎖的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成功鎖住了A, B, C,獲取鎖成功(但D和E沒(méi)有鎖?。?。
- 節(jié)點(diǎn)C崩潰重啟了,但客戶端1在C上加的鎖沒(méi)有持久化下來(lái),丟失了。
- 節(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)題的。