分布式鎖在很多場(chǎng)景中是非常有用的原語(yǔ), 不同的進(jìn)程必須以獨(dú)占資源的方式實(shí)現(xiàn)資源共享就是一個(gè)典型的例子。例如目前所在公司的司機(jī)搶單業(yè)務(wù)中的多個(gè)司機(jī)去搶同一個(gè)訂單的情況。實(shí)現(xiàn)一個(gè)分布式鎖要保證其最低性的保障,應(yīng)該至少滿足以下幾點(diǎn):
1. 安全屬性(Safety property): 獨(dú)享(相互排斥)。在任意一個(gè)時(shí)刻,只有一個(gè)客戶端持有鎖。
2.活性A(Liveness property A): 無(wú)死鎖。即便持有鎖的客戶端崩潰(crashed)或者網(wǎng)絡(luò)被分裂(gets partitioned),鎖仍然可以被獲取。
3.活性B(Liveness property B): 容錯(cuò)。 只要大部分Redis節(jié)點(diǎn)都活著,客戶端就可以獲取和釋放鎖.
4.每個(gè)客戶端釋解鎖的時(shí)候,只能解自己的鎖,而不能解其他客戶端的鎖。
當(dāng)前大多數(shù)基于Redis的分布式鎖現(xiàn)狀和實(shí)現(xiàn)方法.實(shí)現(xiàn)Redis分布式鎖的最簡(jiǎn)單的方法就是在Redis中創(chuàng)建一個(gè)key,這個(gè)key有一個(gè)失效時(shí)間(TTL),以保證鎖最終會(huì)被自動(dòng)釋放掉(這個(gè)對(duì)應(yīng)特性2)。當(dāng)客戶端釋放資源(解鎖)的時(shí)候,會(huì)刪除掉這個(gè)key。從表面上看,似乎效果還不錯(cuò),但是這里有一個(gè)問(wèn)題:這個(gè)架構(gòu)中存在一個(gè)嚴(yán)重的單點(diǎn)失敗問(wèn)題。如果Redis掛了怎么辦?你可能會(huì)說(shuō),可以通過(guò)增加一個(gè)slave節(jié)點(diǎn)解決這個(gè)問(wèn)題。但這通常是行不通的。這樣做,我們不能實(shí)現(xiàn)資源的 獨(dú)享 ,因?yàn)镽edis的主從同步通常是異步的。在此場(chǎng)景下存在明顯的競(jìng)態(tài)條件
- 客戶端A從master獲取到鎖(生成了一個(gè)key)
- 在master將鎖同步到slave之前,master宕掉了。
- slave節(jié)點(diǎn)被選舉為master節(jié)點(diǎn)
- 客戶端B從新的master節(jié)點(diǎn)獲得了一個(gè)客戶端A已經(jīng)獲得的鎖,安全失效!
雖然這是幾率比較小的事件,但是一旦出現(xiàn),可能造成非常不好的影響,如果可以容忍這種情況的發(fā)生,可以采取以上的做法,要想一個(gè)更為完善的,可以使用以下方法解決。在解決之前,先看一下單機(jī)限制下的,討論一下在這種簡(jiǎn)單情況下實(shí)現(xiàn)分布式鎖的正確做法,實(shí)際上這是一種可行的方案,盡管存在競(jìng)態(tài),結(jié)果仍然是可接受的,另外,這里討論的單實(shí)例加鎖方法也是分布式加鎖算法的基礎(chǔ)。
獲取鎖使用命令: SET resource_name my_random_value NX PX 30000
這個(gè)命令僅在不存在key的時(shí)候才能被執(zhí)行成功(NX選項(xiàng)),并且這個(gè)key有一個(gè)30秒的自動(dòng)失效時(shí)間(PX屬性)。這個(gè)key的值是“my_random_value”(一個(gè)隨機(jī)值),這個(gè)值在所有的客戶端必須是唯一的,所有同一key的獲取者(競(jìng)爭(zhēng)者)這個(gè)值都不能一樣。value的值必須是隨機(jī)數(shù)主要是為了更安全的釋放鎖,釋放鎖的時(shí)候使用腳本告訴Redis:只有key存在并且存儲(chǔ)的值和我指定的值一樣才能告訴我刪除成功??梢酝ㄟ^(guò)以下Lua腳本實(shí)現(xiàn):
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
java中使用
public class RedisLockUtil {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 通過(guò)lua腳本釋放分布式鎖
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請(qǐng)求標(biāo)識(shí)
* @return 是否釋放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(luaScript , Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
這段Lua代碼的功能是什么呢?其實(shí)很簡(jiǎn)單,首先獲取鎖對(duì)應(yīng)的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語(yǔ)言來(lái)實(shí)現(xiàn)呢?因?yàn)橐_保上述操作是原子性的。那么為什么執(zhí)行eval()方法可以確保原子性,源于Redis的特性,簡(jiǎn)單來(lái)說(shuō),就是在eval命令執(zhí)行Lua代碼的時(shí)候,Lua代碼將被當(dāng)成一個(gè)命令去執(zhí)行,并且直到eval命令執(zhí)行完成,Redis才會(huì)執(zhí)行其他命令。
使用這種方式釋放鎖可以避免刪除別的客戶端獲取成功的鎖。舉個(gè)栗子:客戶端A取得資源鎖,但是緊接著被一個(gè)其他操作阻塞了,當(dāng)客戶端A運(yùn)行完畢其他操作后要釋放鎖時(shí),原來(lái)的鎖早已超時(shí)并且被Redis自動(dòng)釋放,并且在這期間資源鎖又被客戶端B再次獲取到。如果僅使用DEL命令將key刪除,那么這種情況就會(huì)把客戶端B的鎖給刪除掉。使用Lua腳本就不會(huì)存在這種情況,因?yàn)槟_本僅會(huì)刪除value等于客戶端A的value的key(value相當(dāng)于客戶端的一個(gè)簽名),隨機(jī)字符串的設(shè)置只要是在你的任務(wù)中事務(wù)事務(wù)唯一的就可以。
key的失效時(shí)間,被稱作“鎖定有效期”。它不僅是key自動(dòng)失效時(shí)間,而且還是一個(gè)客戶端持有鎖多長(zhǎng)時(shí)間后可以被另外一個(gè)客戶端重新獲得。上述的實(shí)現(xiàn)是在Redis單例的情況下,只要這個(gè)單例一直存活,就可以一直正常的進(jìn)行下去。
Redlock算法
在Redis的分布式環(huán)境中,我們假設(shè)有N個(gè)Redis master。這些節(jié)點(diǎn)完全互相獨(dú)立,不存在主從復(fù)制或者其他集群協(xié)調(diào)機(jī)制。之前我們已經(jīng)描述了在Redis單實(shí)例下怎么安全地獲取和釋放鎖。我們確保將在每(N)個(gè)實(shí)例上使用此方法獲取和釋放鎖。在這個(gè)樣例中,我們假設(shè)有5個(gè)Redis master節(jié)點(diǎn),這是一個(gè)比較合理的設(shè)置,所以我們需要在5臺(tái)機(jī)器上面或者5臺(tái)虛擬機(jī)上面運(yùn)行這些實(shí)例,這樣保證他們不會(huì)同時(shí)都宕掉。
為了取到鎖,客戶端應(yīng)該執(zhí)行以下操作:
- 獲取當(dāng)前Unix時(shí)間,以毫秒為單位。
- 依次嘗試從N個(gè)實(shí)例,使用相同的key和隨機(jī)值獲取鎖。在步驟2,當(dāng)向Redis設(shè)置鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。例如你的鎖自動(dòng)失效時(shí)間為10秒,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間。這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應(yīng)結(jié)果。如果服務(wù)器端沒(méi)有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶端應(yīng)該盡快嘗試另外一個(gè)Redis實(shí)例。
- 客戶端使用當(dāng)前時(shí)間減去開(kāi)始獲取鎖時(shí)的時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間。當(dāng)且僅當(dāng)從大多數(shù)(這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖,并且獲取鎖使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功。
- 如果取到了鎖,key的 真正有效時(shí)間 等于 有效時(shí)間 減去 獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)。
- 如果因?yàn)槟承┰?,獲取鎖失敗(沒(méi)有在至少N/2+1個(gè)Redis實(shí)例 取到鎖或者取鎖時(shí)間已經(jīng)超過(guò)了有效時(shí)間),客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒(méi)有加鎖成功)。
失敗時(shí)重試
當(dāng)客戶端無(wú)法取到鎖時(shí),應(yīng)該在一個(gè)隨機(jī)延遲后重試,防止多個(gè)客戶端在同時(shí)搶奪同一資源的鎖(這樣會(huì)導(dǎo)致腦裂,沒(méi)有人會(huì)取到鎖)。同樣,客戶端取得大部分Redis實(shí)例鎖所花費(fèi)的時(shí)間越短,腦裂出現(xiàn)的概率就會(huì)越低(必要的重試),所以,理想情況一下,客戶端應(yīng)該同時(shí)(并發(fā)地)向所有Redis發(fā)送SET命令。
需要強(qiáng)調(diào),當(dāng)客戶端從大多數(shù)Redis實(shí)例獲取鎖失敗時(shí),應(yīng)該盡快地釋放(部分)已經(jīng)成功取到的鎖,這樣其他的客戶端就不必非得等到鎖過(guò)完“有效時(shí)間”才能取到(然而,如果已經(jīng)存在網(wǎng)絡(luò)分裂,客戶端已經(jīng)無(wú)法和Redis實(shí)例通信,此時(shí)就只能等待key的自動(dòng)釋放了,等于被懲罰了)。
釋放鎖
釋放鎖比較簡(jiǎn)單,向所有的Redis實(shí)例發(fā)送釋放鎖命令即可,不用關(guān)心之前有沒(méi)有從Redis實(shí)例成功獲取到鎖.
腦裂:集群的腦裂通常是發(fā)生在集群中部分節(jié)點(diǎn)之間不可達(dá)而引起的(或者因?yàn)楣?jié)點(diǎn)請(qǐng)求壓力較大,導(dǎo)致其他節(jié)點(diǎn)與該節(jié)點(diǎn)的心跳檢測(cè)不可用)。當(dāng)上述情況發(fā)生時(shí),不同分裂的小集群會(huì)自主的選擇出master節(jié)點(diǎn),造成原本的集群會(huì)同時(shí)存在多個(gè)master節(jié)點(diǎn)。
參考文章:
Redis分布式鎖的正確實(shí)現(xiàn)方式
Redis分布式鎖
集群腦裂問(wèn)題分析
方案之腦裂問(wèn)題探討
讓我們聊聊腦裂這事情