如何實現(xiàn)一個合格的分布式鎖(典藏版)
共 10899字,需瀏覽 22分鐘
·
2024-08-05 07:40
回復架構師獲取資源
大家好,我是你們的朋友架構君,一個會寫代碼吟詩的架構師。
1、概述
在多線程的環(huán)境下,為了保證一個代碼塊在同一時間只能由一個線程訪問,Java中我們一般可以使用 synchronized 語法和 ReentrantLock 去保證,這實際上是本地鎖的方式。而在如今分布式架構的熱潮下,如何保證不同節(jié)點的線程同步執(zhí)行呢?
實際上,對于分布式場景,我們可以使用分布式鎖,分布式鎖是用于分布式環(huán)境下并發(fā)控制的一種機制,用于控制某個資源在同一時刻只能被一個應用所使用。
分布式鎖的特點
-
「互斥性:」 同一時刻只能有一個線程持有鎖。
-
「可重入性:」 同一節(jié)點上的同一個線程如果獲取了鎖之后能夠再次獲取鎖。
-
「鎖超時:」 類似于J.U.C中的鎖,支持鎖超時,以防止死鎖。
-
「高性能和高可用:」 加鎖和解鎖需要高效,并且需要保證高可用性,防止分布式鎖失效。
-
「具備阻塞和非阻塞性:」 能夠及時從阻塞狀態(tài)中被喚醒。
2、Redis粗糙實現(xiàn)
Redis本身可以被多個客戶端共享訪問,是一個共享存儲系統(tǒng),適合用來保存分布式鎖。由于Redis的讀寫性能高,可以應對高并發(fā)的鎖操作場景。
Redis的SET命令有一個NX參數(shù),可以實現(xiàn)「key不存在才插入」,因此可以用它來實現(xiàn)分布式鎖:
-
如果key不存在,則表示插入成功,可以用來表示加鎖成功;
-
如果key存在,則表示插入失敗,可以用來表示加鎖失?。?/p>
-
當需要解鎖時,只需刪除對應的key即可解鎖成功;
-
為了避免死鎖,需要設置合適的過期時間。
這樣描述,我們可以得到一個十分粗糙的分布式鎖實現(xiàn)。
// 嘗試獲得鎖
if (setnx(key, 1) == 1){
// 獲得鎖成功,設置過期時間
expire(key, 30)
try {
//TODO 業(yè)務邏輯
} finally {
// 解鎖
del(key)
}
}
然而,上述實現(xiàn)方式存在一些問題,使其不能被稱為合格的分布式鎖:
-
「非原子性操作:」 多條命令的操作不是原子性的,可能會導致死鎖的產(chǎn)生。
-
「鎖誤解除:」 存在鎖誤解除的可能性,即在持有鎖的線程在內(nèi)部出現(xiàn)阻塞時,鎖的TTL到期導致自動釋放,而其他線程誤解除鎖的情況。
-
「業(yè)務超時自動解鎖導致并發(fā)問題:」 由于業(yè)務超時自動解鎖,可能導致并發(fā)問題的發(fā)生。
-
「分布式鎖不可重入:」 實現(xiàn)的分布式鎖不支持重入。
3、解決遺留問題
3.1誤刪情況
在以下情況下可能會出現(xiàn)誤刪情況:
-
持有鎖的線程1在鎖的內(nèi)部出現(xiàn)了阻塞,導致其鎖的TTL到期從而鎖自動釋放;
-
此時線程2嘗試獲取鎖,由于線程1已經(jīng)釋放了鎖,線程2可以拿到;
-
但是隨后線程1解除阻塞,繼續(xù)執(zhí)行并開始釋放鎖;
-
此時可能會將屬于線程2的鎖釋放,導致誤刪別人鎖的情況。
為了解決這個問題,需要在釋放鎖的時候確保只有持有鎖的線程才能釋放對應的鎖,可以通過在鎖中添加標識來實現(xiàn)。
3.2解決方案
對應的解決方案也很簡單,既然是一個線程誤刪了別人的鎖,就相當于把別人的廁所門給誤開了,那么在開門之前校驗一下這扇門是不是自己關上的不就好了:
-
在存入鎖的時候,放入自己的線程標識; -
在刪除鎖的時候,判斷當前這把鎖是不是自己存入的: -
如果是,則進行刪除; -
如果不是,則不進行刪除。
這樣就可以確保只有持有鎖的線程才能釋放對應的鎖,有效地避免了誤刪別人鎖的情況。
// 嘗試獲得鎖
if (setnx(key, "當前線程號") == 1) {
// 獲得鎖成功,設置過期時間
expire(key, 30);
try {
// TODO 業(yè)務邏輯
} finally {
// 解鎖
if ("當前線程號".equals(get(key))) {
del(key);
}
}
}
同時,這種方式也能夠?qū)⒎植际芥i改造成可重入的分布式鎖,在獲取鎖的時候判斷一下是否是當前線程獲取的鎖,鎖標識自增便可。
3.2、原子性保證
前面說到,SETNX和EXPIRE操作是非原子性的。如果SETNX成功,還未設置鎖超時時間時,由于服務器掛掉、重啟或網(wǎng)絡問題等原因,導致EXPIRE命令沒有執(zhí)行,鎖沒有設置超時時間就有可能會導致死鎖產(chǎn)生。
同時,對于上面解決的誤刪問題,如果以下極端情況同樣會出現(xiàn)并發(fā)問題:
-
假設線程1已經(jīng)獲取了鎖,在判斷標識一致之后,準備釋放鎖的時候,又出現(xiàn)了阻塞(例如JVM垃圾回收機制);
-
于是鎖的TTL到期了,自動釋放了;
-
現(xiàn)在線程2趁虛而入,拿到了一把鎖;
-
但是線程1的邏輯還沒執(zhí)行完,那么線程1就會執(zhí)行刪除鎖的邏輯;
-
但是在阻塞前線程1已經(jīng)判斷了標識一致,所以現(xiàn)在線程1把線程2的鎖給誤刪了;
-
這就相當于判斷標識那行代碼沒有起到作用;
-
因為線程1的獲取鎖、判斷標識、刪除鎖,不是原子操作,所以我們要防止剛剛的情況。
對于Redis中并沒有對應的原子性API提供給我們進行調(diào)用,但是我們可以通過Lua腳本對Redis 功能進行拓展。
-- 過期時間設置
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1) then
return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
-- 刪除鎖
-- 比較鎖中的線程標識與線程標識是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致則釋放鎖
return redis.call('del', KEYS[1])
end;
return 0
以上就是原子性保證的lua腳本實現(xiàn),通過Java調(diào)用 call 方法執(zhí)行l(wèi)ua腳本即可通過lua腳本 實現(xiàn)原子性操作從而解決該問題。
3.3、超時自動解鎖
雖然上面解決了誤刪和原子性問題,但是如果獲取鎖的線程阻塞時間超過了設置的TTL,那么該自動解鎖還是得自動解鎖。
對于這種情況,一個簡單粗暴的方法就是把過期時間設置得很長,在設置的TTL內(nèi),能夠保證邏輯一定能夠執(zhí)行完。但是這種方式和不設置TTL一樣,如果發(fā)生意外宕機之類的情況,下一個線程將會阻塞很長時間,十分不優(yōu)雅。
因此,針對這個問題,我們可以給線程單獨開一個守護線程,去檢測當前線程運行情況。如果TTL即將到期,由守護線程對TTL進行續(xù)期,保證當前線程能夠正確地執(zhí)行完業(yè)務邏輯。
3.4、總結
綜上所述,基于 Redis 節(jié)點實現(xiàn)分布式鎖時,我們至少需要實現(xiàn)以下需求:
-
加鎖/解鎖包括了讀取鎖變量、檢查鎖變量值和設置鎖變量值三個操作,但需要以原子操作的方式完成;
-
鎖變量需要設置過期時間,以免客戶端拿到鎖后發(fā)生異常,導致鎖一直無法釋放出現(xiàn)死鎖,所以,我們在 SET 命令執(zhí)行時加上 EX/PX 選項,設置其過期時間;
-
鎖變量的值需要能區(qū)分來自不同客戶端的加鎖操作,以免在釋放鎖時,出現(xiàn)誤釋放操作,所以,我們使用 SET 命令設置鎖變量值時,每個客戶端設置的值是一個唯一值,用于標識客戶端。
4、Redis實現(xiàn)優(yōu)缺
「基于 Redis 實現(xiàn)分布式鎖的優(yōu)點:」
-
「性能高效:」 這是選擇緩存實現(xiàn)分布式鎖最核心的出發(fā)點。
-
「實現(xiàn)方便:」 很多研發(fā)工程師選擇使用 Redis 來實現(xiàn)分布式鎖,很大成分上是因為 Redis 提供了 setnx 方法,實現(xiàn)分布式鎖很方便。
-
「避免單點故障:」 因為 Redis 是跨集群部署的,自然就避免了單點故障。
「基于 Redis 實現(xiàn)分布式鎖的缺點:」
-
「超時時間不好設置:」 如果鎖的超時時間設置過長,會影響性能,如果設置的超時時間過短會保護不到共享資源。對于這種情況可以使用前面提及到的守護線程進行續(xù)期操作使得鎖得過期時間得到保障。
-
「Redis 主從復制模式中的數(shù)據(jù)是異步復制的,」 這樣導致分布式鎖的不可靠性。如果在 Redis 主節(jié)點獲取到鎖后,在沒有同步到其他節(jié)點時,Redis 主節(jié)點宕機了,此時新的 Redis 主節(jié)點依然可以獲取鎖,所以多個應用服務就可以同時獲取到鎖。
5、集群問題
5.1、主從集群
為了保證 Redis 的可用性,一般采用主從方式部署。主從數(shù)據(jù)同步有異步和同步兩種方式, Redis 將指令記錄在本地內(nèi)存 buffer 中,然后異步將 buffer 中的指令同步到從節(jié)點,從節(jié)點一邊執(zhí)行同步的指令流來達到和主節(jié)點一致的狀態(tài),一邊向主節(jié)點反饋同步情況。如果這個 master 節(jié)點由于某些原因發(fā)生了主從切換,那么就會出現(xiàn)鎖丟失的情況:
-
在 Redis 的 master 節(jié)點上拿到了鎖;
-
但是這個加鎖的 key 還沒有同步到 slave 節(jié)點;
-
master 故障,發(fā)生故障轉(zhuǎn)移,slave 節(jié)點升級為 master 節(jié)點;
-
導致鎖丟失。
5.2、集群腦裂
集群腦裂指因為網(wǎng)絡問題,導致 Redis master 節(jié)點跟 slave 節(jié)點和 sentinel 集群處于不同的網(wǎng)絡分區(qū),因為 sentinel 集群無法感知到 master 的存在,所以將 slave 節(jié)點提升為 master 節(jié)點,此時存在兩個不同的 master 節(jié)點。Redis Cluster 集群部署方式同理。
總結來說腦裂就是由于網(wǎng)絡問題,集群節(jié)點之間失去聯(lián)系。主從數(shù)據(jù)不同步;重新平衡選舉,產(chǎn)生兩個主服務。等網(wǎng)絡恢復,舊主節(jié)點會降級為從節(jié)點,再與新主節(jié)點進行同步復制的時候,由于從節(jié)點會清空自己的緩沖區(qū),所以導致之前客戶端寫入的數(shù)據(jù)丟失了
當不同的客戶端連接不同的 master 節(jié)點時,兩個客戶端可以同時擁有同一把鎖
6、RedLock
為了保證集群環(huán)境下分布式鎖的可靠性,Redis 官方已經(jīng)設計了一個分布式鎖算法 Redlock(紅鎖)。它是基于多個 Redis 節(jié)點的分布式鎖,即使有節(jié)點發(fā)生了故障,鎖變量仍然是存在的,客戶端還是可以完成鎖操作。官方推薦是至少部署 5 個 Redis 節(jié)點,而且都是主節(jié)點,它們之間沒有任何關系,都是一個個孤立的節(jié)點。
Redlock 算法的基本思路,是讓客戶端和多個獨立的 Redis 節(jié)點依次請求申請加鎖,如果客戶端能夠和半數(shù)以上的節(jié)點成功地完成加鎖操作,那么我們就認為,客戶端成功地獲得分布式鎖,否則加鎖失敗。
這樣一來,即使有某個 Redis 節(jié)點發(fā)生故障,因為鎖的數(shù)據(jù)在其他節(jié)點上也有保存,所以客戶端仍然可以正常地進行鎖操作,鎖的數(shù)據(jù)也不會丟失。
為了取到鎖,客戶端應該執(zhí)行以下操作:
-
獲取當前Unix時間,以毫秒為單位。
-
依次嘗試從5個實例,使用相同的key和具有唯一性的value(例如UUID)獲取鎖。當向 Redis請求獲取鎖時,客戶端應該設置一個網(wǎng)絡連接和響應超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在5-50毫秒之間。這樣可以避免服務器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應結果。如果服務器端沒有在規(guī)定時間內(nèi)響應,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
-
客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。
-
當且僅當從大多數(shù)( N/2+1 ,這里是3個節(jié)點)的Redis節(jié)點都取到鎖,并且使用的時間小于鎖失效時間時,鎖才算獲取成功。
-
如果取到了鎖,key的真正有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
-
如果因為某些原因,獲取鎖失?。]有在至少 N/2+1 個Redis實例取到鎖或者取鎖時間已經(jīng)超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖,這是因為即便某些Redis實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖??梢钥吹剑渔i成功要同時滿足兩個條件:
-
客戶端從超過半數(shù)(大于等于 N/2+1 )的 Redis 節(jié)點上成功獲取到了鎖;
-
客戶端從大多數(shù)節(jié)點獲取鎖的總耗時( t2-t1 )小于鎖設置的過期時間。
簡單來說就是:如果有超過半數(shù)的 Redis 節(jié)點成功的獲取到了鎖,并且總耗時沒有超過鎖 的有效時間,那么就是加鎖成功。
7、Redisson
7.1、簡單實現(xiàn)
Redisson 是 Redis 的 Java 客戶端之一,提供了豐富的功能和高級抽象,包括分布式鎖、分布式集合、分布式對象等。因此我們能夠很簡單的通過 Redisson 實現(xiàn)分布式鎖,而不用自己造輪子。
與此同時,Redisson 是支持原子性加/解鎖、鎖重試、可重入鎖、RedLock 等功能的,感興趣的話可以自行了解。
// 獲取分布式鎖
RLock lock = redissonClient.getLock("myLock");
try {
// 嘗試加鎖,最多等待 10 秒,加鎖后的鎖有效期為 30 秒
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
// 成功獲取鎖,執(zhí)行業(yè)務邏輯
System.out.println("獲取鎖成功,執(zhí)行業(yè)務邏輯...");
} else {
// 獲取鎖失敗,可能是超時等待或者其他原因
System.out.println("獲取鎖失敗...");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 釋放鎖
lock.unlock();
// 關閉 Redisson 客戶端
redissonClient.shutdown();
}
對了這里提一嘴,Redisson存儲分布式鎖是通過Hash結構進行存儲的,內(nèi)置的鍵值對是< 線程標識,重入次數(shù)>,其中重入次數(shù)便可用于實現(xiàn)可重入機制。
7.2、看門狗機制
在 Redisson 中,「看門狗機制(Watchdog)」 是用于維持 Redis 鍵的過期時間的一種機制。
通常情況下,當我們給 Redis 中的鍵設置過期時間后,Redis 會自動管理鍵的生命周期,并在鍵過期時通過過期刪除策略對其進行處理。然而,如果 Redis 進程崩潰或者網(wǎng)絡故障導致 Redis 服務器與客戶端連接中斷,那么鍵的過期時間可能無法得到及時刪除,從而導致鍵仍然存在于 Redis 中。
為了解決這個問題,Redisson 引入了看門狗機制。當 Redisson 客戶端為一個鍵設置過期時 間時,它會啟動一個看門狗線程,該線程會監(jiān)視鍵的過期時間,并在過期時間快到期時自動對鍵進行 續(xù)期操作。這樣,即使因為 Redis 進程崩潰或者網(wǎng)絡故障導致連接中斷,看門狗仍然可以繼續(xù)維護 鍵的過期時間。
看門狗機制的工作原理如下:
-
當客戶端獲取分布式鎖時,Redisson 會在 Redis 服務器中創(chuàng)建一個對應的鍵值對,并給這個鍵值對設置一個過期時間(通常是鎖的持有時間);
-
同時,Redisson 會啟動一個看門狗線程,在分布式鎖的有效期內(nèi)定時續(xù)期鎖的過期時間;
-
看門狗線程會周期性地檢查客戶端是否還持有鎖,如果持有鎖,則會為鎖的鍵值對設置新的過期時間,從而延長鎖的有效期;
-
如果客戶端在鎖的有效期內(nèi)未能續(xù)期,即看門狗線程無法找到對應的鎖鍵值對,那么鎖會自動過期,其他客戶端就可以獲取這個鎖。
在Redisson中,默認續(xù)約時間是30s(可配置),即每隔30s續(xù)約一次,延長30s。
設置較短的續(xù)約時間可以更快地釋放鎖,但可能會增加續(xù)約的頻率;較長的續(xù)約時間可以減 少續(xù)約的次數(shù),但會使得鎖的有效期更長。
看門狗機制的好處是保證了在獲取分布式鎖后,業(yè)務邏輯可以在鎖的有效期內(nèi)運行,不會因為鎖 的過期而導致鎖失效。當業(yè)務邏輯執(zhí)行時間超過鎖的過期時間時,看門狗線程會自動延長鎖的過期時 間,從而避免了鎖的自動釋放。
需要注意的是,看門狗線程是后臺線程(守護線程),不會影響到客戶端的正常業(yè)務邏輯。同時, 為了避免看門狗線程過多占用 Redis 的 CPU 資源,Redisson 會動態(tài)調(diào)整看門狗的檢查周期,使 得看門狗線程在不影響性能的情況下維持鎖的有效性
來源:juejin.cn/post/7346938279979925555
這些年小編給你分享過的干貨
2.優(yōu)質(zhì)ERP系統(tǒng)帶進銷存財務生產(chǎn)功能(附源碼)
3.優(yōu)質(zhì)SpringBoot帶工作流管理項目(附源碼)
5.SBoot+Vue外賣系統(tǒng)前后端都有(附源碼)
轉(zhuǎn)發(fā)在看就是最大的支持??
