如何實現一個合格的分布式鎖(典藏版)

數據智能相依偎 2024-06-15 07:22:04

1、概述

在多線程的環境下,爲了保證一個代碼塊在同一時間只能由一個線程訪問,Java中我們一般可以使用 synchronized 語法和 ReentrantLock 去保證,這實際上是本地鎖的方式。而在如今分布式架構的熱潮下,如何保證不同節點的線程同步執行呢?

實際上,對于分布式場景,我們可以使用分布式鎖,分布式鎖是用于分布式環境下並發控制的一種機制,用于控制某個資源在同一時刻只能被一個應用所使用。

分布式鎖的特點

「互斥性:」 同一時刻只能有一個線程持有鎖。「可重入性:」 同一節點上的同一個線程如果獲取了鎖之後能夠再次獲取鎖。「鎖超時:」 類似于J.U.C中的鎖,支持鎖超時,以防止死鎖。「高性能和高可用:」 加鎖和解鎖需要高效,並且需要保證高可用性,防止分布式鎖失效。「具備阻塞和非阻塞性:」 能夠及時從阻塞狀態中被喚醒。

2、Redis粗糙實現

Redis本身可以被多個客戶端共享訪問,是一個共享存儲系統,適合用來保存分布式鎖。由于Redis的讀寫性能高,可以應對高並發的鎖操作場景。

Redis的SET命令有一個NX參數,可以實現「key不存在才插入」,因此可以用它來實現分布式鎖:

如果key不存在,則表示插入成功,可以用來表示加鎖成功;如果key存在,則表示插入失敗,可以用來表示加鎖失敗;當需要解鎖時,只需刪除對應的key即可解鎖成功;爲了避免死鎖,需要設置合適的過期時間。

這樣描述,我們可以得到一個十分粗糙的分布式鎖實現。

// 嘗試獲得鎖 if (setnx(key, 1) == 1){  // 獲得鎖成功,設置過期時間   expire(key, 30) try {            //TODO 業務邏輯 } finally {            // 解鎖           del(key)    }}

然而,上述實現方式存在一些問題,使其不能被稱爲合格的分布式鎖:

「非原子性操作:」 多條命令的操作不是原子性的,可能會導致死鎖的産生。「鎖誤解除:」 存在鎖誤解除的可能性,即在持有鎖的線程在內部出現阻塞時,鎖的TTL到期導致自動釋放,而其他線程誤解除鎖的情況。「業務超時自動解鎖導致並發問題:」 由于業務超時自動解鎖,可能導致並發問題的發生。「分布式鎖不可重入:」 實現的分布式鎖不支持重入。

3、解決遺留問題

3.1誤刪情況

在以下情況下可能會出現誤刪情況:

持有鎖的線程1在鎖的內部出現了阻塞,導致其鎖的TTL到期從而鎖自動釋放;此時線程2嘗試獲取鎖,由于線程1已經釋放了鎖,線程2可以拿到;但是隨後線程1解除阻塞,繼續執行並開始釋放鎖;此時可能會將屬于線程2的鎖釋放,導致誤刪別人鎖的情況。

爲了解決這個問題,需要在釋放鎖的時候確保只有持有鎖的線程才能釋放對應的鎖,可以通過在鎖中添加標識來實現。

3.2解決方案

對應的解決方案也很簡單,既然是一個線程誤刪了別人的鎖,就相當于把別人的廁所門給誤開了,那麽在開門之前校驗一下這扇門是不是自己關上的不就好了:

在存入鎖的時候,放入自己的線程標識;在刪除鎖的時候,判斷當前這把鎖是不是自己存入的:如果是,則進行刪除;如果不是,則不進行刪除。

這樣就可以確保只有持有鎖的線程才能釋放對應的鎖,有效地避免了誤刪別人鎖的情況。

 // 嘗試獲得鎖if (setnx(key, "當前線程號") == 1) {        // 獲得鎖成功,設置過期時間        expire(key, 30);        try {                // TODO 業務邏輯        } finally {                // 解鎖                if ("當前線程號".equals(get(key))) {                    del(key);                }        }}

同時,這種方式也能夠將分布式鎖改造成可重入的分布式鎖,在獲取鎖的時候判斷一下是否是當前線程獲取的鎖,鎖標識自增便可。

3.2、原子性保證

前面說到,SETNX和EXPIRE操作是非原子性的。如果SETNX成功,還未設置鎖超時時間時,由于服務器挂掉、重啓或網絡問題等原因,導致EXPIRE命令沒有執行,鎖沒有設置超時時間就有可能會導致死鎖産生。

同時,對于上面解決的誤刪問題,如果以下極端情況同樣會出現並發問題:

假設線程1已經獲取了鎖,在判斷標識一致之後,准備釋放鎖的時候,又出現了阻塞(例如JVM垃圾回收機制);于是鎖的TTL到期了,自動釋放了;現在線程2趁虛而入,拿到了一把鎖;但是線程1的邏輯還沒執行完,那麽線程1就會執行刪除鎖的邏輯;但是在阻塞前線程1已經判斷了標識一致,所以現在線程1把線程2的鎖給誤刪了;這就相當于判斷標識那行代碼沒有起到作用;因爲線程1的獲取鎖、判斷標識、刪除鎖,不是原子操作,所以我們要防止剛剛的情況。

對于Redis中並沒有對應的原子性API提供給我們進行調用,但是我們可以通過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腳本實現,通過Java調用 call 方法執行lua腳本即可通過lua腳本 實現原子性操作從而解決該問題。

3.3、超時自動解鎖

雖然上面解決了誤刪和原子性問題,但是如果獲取鎖的線程阻塞時間超過了設置的TTL,那麽該自動解鎖還是得自動解鎖。

對于這種情況,一個簡單粗暴的方法就是把過期時間設置得很長,在設置的TTL內,能夠保證邏輯一定能夠執行完。但是這種方式和不設置TTL一樣,如果發生意外宕機之類的情況,下一個線程將會阻塞很長時間,十分不優雅。

因此,針對這個問題,我們可以給線程單獨開一個守護線程,去檢測當前線程運行情況。如果TTL即將到期,由守護線程對TTL進行續期,保證當前線程能夠正確地執行完業務邏輯。

3.4、總結

綜上所述,基于 Redis 節點實現分布式鎖時,我們至少需要實現以下需求:

加鎖/解鎖包括了讀取鎖變量、檢查鎖變量值和設置鎖變量值三個操作,但需要以原子操作的方式完成;鎖變量需要設置過期時間,以免客戶端拿到鎖後發生異常,導致鎖一直無法釋放出現死鎖,所以,我們在 SET 命令執行時加上 EX/PX 選項,設置其過期時間;鎖變量的值需要能區分來自不同客戶端的加鎖操作,以免在釋放鎖時,出現誤釋放操作,所以,我們使用 SET 命令設置鎖變量值時,每個客戶端設置的值是一個唯一值,用于標識客戶端。

4、Redis實現優缺

「基于 Redis 實現分布式鎖的優點:」

「性能高效:」 這是選擇緩存實現分布式鎖最核心的出發點。「實現方便:」 很多研發工程師選擇使用 Redis 來實現分布式鎖,很大成分上是因爲 Redis 提供了 setnx 方法,實現分布式鎖很方便。「避免單點故障:」 因爲 Redis 是跨集群部署的,自然就避免了單點故障。

「基于 Redis 實現分布式鎖的缺點:」

「超時時間不好設置:」 如果鎖的超時時間設置過長,會影響性能,如果設置的超時時間過短會保護不到共享資源。對于這種情況可以使用前面提及到的守護線程進行續期操作使得鎖得過期時間得到保障。「Redis 主從複制模式中的數據是異步複制的,」 這樣導致分布式鎖的不可靠性。如果在 Redis 主節點獲取到鎖後,在沒有同步到其他節點時,Redis 主節點宕機了,此時新的 Redis 主節點依然可以獲取鎖,所以多個應用服務就可以同時獲取到鎖。

5、集群問題

5.1、主從集群

爲了保證 Redis 的可用性,一般采用主從方式部署。主從數據同步有異步和同步兩種方式, Redis 將指令記錄在本地內存 buffer 中,然後異步將 buffer 中的指令同步到從節點,從節點一邊執行同步的指令流來達到和主節點一致的狀態,一邊向主節點反饋同步情況。如果這個 master 節點由于某些原因發生了主從切換,那麽就會出現鎖丟失的情況:

在 Redis 的 master 節點上拿到了鎖;但是這個加鎖的 key 還沒有同步到 slave 節點;master 故障,發生故障轉移,slave 節點升級爲 master 節點;導致鎖丟失。

5.2、集群腦裂

集群腦裂指因爲網絡問題,導致 Redis master 節點跟 slave 節點和 sentinel 集群處于不同的網絡分區,因爲 sentinel 集群無法感知到 master 的存在,所以將 slave 節點提升爲 master 節點,此時存在兩個不同的 master 節點。Redis Cluster 集群部署方式同理。

總結來說腦裂就是由于網絡問題,集群節點之間失去聯系。主從數據不同步;重新平衡選舉,産生兩個主服務。等網絡恢複,舊主節點會降級爲從節點,再與新主節點進行同步複制的時候,由于從節點會清空自己的緩沖區,所以導致之前客戶端寫入的數據丟失了

當不同的客戶端連接不同的 master 節點時,兩個客戶端可以同時擁有同一把鎖

6、RedLock

爲了保證集群環境下分布式鎖的可靠性,Redis 官方已經設計了一個分布式鎖算法 Redlock(紅鎖)。它是基于多個 Redis 節點的分布式鎖,即使有節點發生了故障,鎖變量仍然是存在的,客戶端還是可以完成鎖操作。官方推薦是至少部署 5 個 Redis 節點,而且都是主節點,它們之間沒有任何關系,都是一個個孤立的節點。

Redlock 算法的基本思路,是讓客戶端和多個獨立的 Redis 節點依次請求申請加鎖,如果客戶端能夠和半數以上的節點成功地完成加鎖操作,那麽我們就認爲,客戶端成功地獲得分布式鎖,否則加鎖失敗。

這樣一來,即使有某個 Redis 節點發生故障,因爲鎖的數據在其他節點上也有保存,所以客戶端仍然可以正常地進行鎖操作,鎖的數據也不會丟失。

爲了取到鎖,客戶端應該執行以下操作:

獲取當前Unix時間,以毫秒爲單位。依次嘗試從5個實例,使用相同的key和具有唯一性的value(例如UUID)獲取鎖。當向 Redis請求獲取鎖時,客戶端應該設置一個網絡連接和響應超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間爲10秒,則超時時間應該在5-50毫秒之間。這樣可以避免服務器端Redis已經挂掉的情況下,客戶端還在死死地等待響應結果。如果服務器端沒有在規定時間內響應,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數( N/2+1 ,這裏是3個節點)的Redis節點都取到鎖,並且使用的時間小于鎖失效時間時,鎖才算獲取成功。如果取到了鎖,key的真正有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。如果因爲某些原因,獲取鎖失敗(沒有在至少 N/2+1 個Redis實例取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖,這是因爲即便某些Redis實例根本就沒有加鎖成功,防止某些節點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖。可以看到,加鎖成功要同時滿足兩個條件:客戶端從超過半數(大于等于 N/2+1 )的 Redis 節點上成功獲取到了鎖;客戶端從大多數節點獲取鎖的總耗時( t2-t1 )小于鎖設置的過期時間。

簡單來說就是:如果有超過半數的 Redis 節點成功的獲取到了鎖,並且總耗時沒有超過鎖 的有效時間,那麽就是加鎖成功。

7、Redisson

7.1、簡單實現

Redisson 是 Redis 的 Java 客戶端之一,提供了豐富的功能和高級抽象,包括分布式鎖、分布式集合、分布式對象等。因此我們能夠很簡單的通過 Redisson 實現分布式鎖,而不用自己造輪子。

與此同時,Redisson 是支持原子性加/解鎖、鎖重試、可重入鎖、RedLock 等功能的,感興趣的話可以自行了解。

// 獲取分布式鎖RLock lock = redissonClient.getLock("myLock");try {    // 嘗試加鎖,最多等待 10 秒,加鎖後的鎖有效期爲 30 秒    boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);    if (locked) {        // 成功獲取鎖,執行業務邏輯        System.out.println("獲取鎖成功,執行業務邏輯...");   } else {        // 獲取鎖失敗,可能是超時等待或者其他原因        System.out.println("獲取鎖失敗...");   }} catch (InterruptedException e) {    e.printStackTrace();} finally {    // 釋放鎖    lock.unlock();    // 關閉 Redisson 客戶端    redissonClient.shutdown();}

對了這裏提一嘴,Redisson存儲分布式鎖是通過Hash結構進行存儲的,內置的鍵值對是< 線程標識,重入次數>,其中重入次數便可用于實現可重入機制。

7.2、看門狗機制

在 Redisson 中,「看門狗機制(Watchdog)」 是用于維持 Redis 鍵的過期時間的一種機制。

通常情況下,當我們給 Redis 中的鍵設置過期時間後,Redis 會自動管理鍵的生命周期,並在鍵過期時通過過期刪除策略對其進行處理。然而,如果 Redis 進程崩潰或者網絡故障導致 Redis 服務器與客戶端連接中斷,那麽鍵的過期時間可能無法得到及時刪除,從而導致鍵仍然存在于 Redis 中。

爲了解決這個問題,Redisson 引入了看門狗機制。當 Redisson 客戶端爲一個鍵設置過期時 間時,它會啓動一個看門狗線程,該線程會監視鍵的過期時間,並在過期時間快到期時自動對鍵進行 續期操作。這樣,即使因爲 Redis 進程崩潰或者網絡故障導致連接中斷,看門狗仍然可以繼續維護 鍵的過期時間。

看門狗機制的工作原理如下:

當客戶端獲取分布式鎖時,Redisson 會在 Redis 服務器中創建一個對應的鍵值對,並給這個鍵值對設置一個過期時間(通常是鎖的持有時間);同時,Redisson 會啓動一個看門狗線程,在分布式鎖的有效期內定時續期鎖的過期時間;看門狗線程會周期性地檢查客戶端是否還持有鎖,如果持有鎖,則會爲鎖的鍵值對設置新的過期時間,從而延長鎖的有效期;如果客戶端在鎖的有效期內未能續期,即看門狗線程無法找到對應的鎖鍵值對,那麽鎖會自動過期,其他客戶端就可以獲取這個鎖。

在Redisson中,默認續約時間是30s(可配置),即每隔30s續約一次,延長30s。

設置較短的續約時間可以更快地釋放鎖,但可能會增加續約的頻率;較長的續約時間可以減 少續約的次數,但會使得鎖的有效期更長。

看門狗機制的好處是保證了在獲取分布式鎖後,業務邏輯可以在鎖的有效期內運行,不會因爲鎖 的過期而導致鎖失效。當業務邏輯執行時間超過鎖的過期時間時,看門狗線程會自動延長鎖的過期時 間,從而避免了鎖的自動釋放。

需要注意的是,看門狗線程是後台線程(守護線程),不會影響到客戶端的正常業務邏輯。同時, 爲了避免看門狗線程過多占用 Redis 的 CPU 資源,Redisson 會動態調整看門狗的檢查周期,使 得看門狗線程在不影響性能的情況下維持鎖的有效性

來源:juejin.cn/post/7346938279979925555

0 阅读:12

數據智能相依偎

簡介:感謝大家的關注