分散式鎖簡單入門以及三種實現方式介紹

俺就不起網名發表於2018-09-17

目錄

 

一、為什麼使用分散式鎖

二、分散式鎖應該具備哪些條件

三、基於資料庫的實現方式

四、基於Redis的實現方式

五、基於zookeeper的實現方式

六、總結


一、為什麼使用分散式鎖

為了保證一個方法在高併發情況下的同一時間只能被同一個執行緒執行,在傳統單體應用單機部署的情況下,可以使用Java併發處理相關的API(如ReentrantLcok或synchronized)進行互斥控制。但是,隨著業務發展的需要,原單體單機部署的系統被演化成分散式系統後,由於分散式系統多執行緒、多程式並且分佈在不同機器上,這將使原單機部署情況下的併發控制鎖策略失效,為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分散式鎖要解決的問題。

二、分散式鎖應該具備哪些條件

在分析分散式鎖的三種實現方式之前,先了解一下分散式鎖應該具備哪些條件:

1、在分散式系統環境下,一個方法在同一時間只能被一個機器的一個執行緒執行; 
2、高可用的獲取鎖與釋放鎖; 
3、高效能的獲取鎖與釋放鎖; 
4、具備可重入特性; 
5、具備鎖失效機制,防止死鎖; 
6、具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。

分散式鎖通常有三種實現方式:基於資料庫實現、基於redis實現、基於zookeeper實現。

三、基於資料庫的實現方式

基於資料庫的實現方式的核心思想是:在資料庫中建立一個表,表中包含方法名等欄位,並在方法名欄位上建立唯一索引,想要執行某個方法,就使用這個方法名向表中插入資料,成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖。

1、想要執行某個方法,就使用這個方法名向表中插入資料:(method_name欄位有唯一性約束,且建了索引)

 

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');

2、成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖:

delete from method_lock where method_name ='methodName';

使用基於資料庫的這種實現方式很簡單,但是對於分散式鎖應該具備的條件來說,它有一些問題需要解決及優化:

1、因為是基於資料庫實現的,資料庫的可用性和效能將直接影響分散式鎖的可用性及效能,所以,資料庫需要雙機部署、資料同步、主備切換;
2、不具備可重入的特性,因為同一個執行緒在釋放鎖之前,行資料一直存在,無法再次成功插入資料,所以,需要在表中新增一列,用於記錄當前獲取到鎖的機器和執行緒資訊,在再次獲取鎖的時候,先查詢表中機器和執行緒資訊是否和當前機器和執行緒相同,若相同則直接獲取鎖;
3、沒有鎖失效機制,因為有可能出現成功插入資料後,伺服器當機了,對應的資料沒有被刪除,當服務恢復後一直獲取不到鎖,所以,需要在表中新增一列,用於記錄失效時間,並且需要有定時任務清除這些失效的資料;
4、不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優化獲取邏輯,迴圈多次去獲取;
5、在實施的過程中會遇到各種不同的問題,為了解決這些問題,實現方式將會越來越複雜;依賴資料庫需要一定的資源開銷,效能問題需要考慮。

四、基於Redis的實現方式

 

1、選用Redis實現分散式鎖原因

(1)Redis有很高的效能; 
(2)Redis命令對此支援較好,實現起來比較方便

2、使用命令介紹

(1)SETNX

SETNX key val:當且僅當key不存在時,set一個key為val的字串,返回1;若key存在,則什麼都不做,返回0。

(2)expire

expire key timeout:為key設定一個過期時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。

(3)delete

delete key:根據key刪除快取

在使用Redis實現分散式鎖的時候,主要就會使用到這三個命令。

3、實現思想

(1)獲取鎖的時候,使用setnx加鎖,並使用expire命令為鎖新增一個過期時間,超過該時間則自動釋放鎖;
(2)獲取鎖的時候還設定一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
(3)釋放鎖的時候,根據key刪除鎖。

綜合起來,我們分散式鎖實現的第一版虛擬碼如下:

if(setnx(key,1) == 1){
    expire(key,30)
    try {
        do something ......
    } finally {
        del(key)
    }
}

但是,這樣存在以下三個致命問題:

(1)setnx和expire的非原子性

設想一個極端場景,當節點1中的某個執行緒執行setnx,成功得到了鎖,setnx剛執行成功,還未來得及執行expire指令,節點1剛好掛掉了。這樣一來,這把鎖就沒有設定過期時間,變得“長生不老”,別的執行緒再也無法獲得鎖了。

解決方法:setnx指令本身是不支援傳入超時時間的,幸好Redis 2.6.12以上版本為set指令增加了可選引數,程式碼如下:

  /**
   * @param nxxx NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist.
   * @param expx EX|PX, expire time units: EX = seconds; PX = milliseconds
   * @param time expire time in the units of <code>expx</code>
   * @return Status code reply
   */
  public String set(final String key, final String value, final String nxxx, final String expx, final long time) {
  ……
  }

(2)del 導致誤刪

 

假如執行緒A成功得到了鎖,並且設定的超時時間是30秒,如果某些原因導致執行緒A執行的很慢很慢,過了30秒都沒執行完,這時候鎖過期自動釋放,執行緒B得到了鎖。隨後,執行緒A執行完了任務,執行緒A接著執行del指令來釋放鎖。但這時候執行緒B還沒執行完,執行緒A實際上刪除的是執行緒B加的鎖。

解決方法:可以在del釋放鎖之前做一個判斷,驗證當前的鎖是不是自己加的鎖。至於具體的實現,可以在加鎖的時候把當前的執行緒ID當做value,並在刪除之前驗證key對應的value是不是自己執行緒的ID。

//加鎖:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
//解鎖:
if(threadId .equals(redisClient.get(key))){
    del(key)
}

(3)出現併發的可能性

還是剛才第二點所描述的場景,雖然我們避免了執行緒A誤刪掉key的情況,但是同一時間有A,B兩個執行緒在訪問程式碼塊,仍然是不完美的。

解決方法:我們可以讓獲得鎖的執行緒開啟一個守護執行緒,用來給快要過期的鎖“續航”。當過去了29秒,執行緒A還沒執行完,這時候守護執行緒會執行expire指令,為這把鎖“續命”20秒。守護執行緒從第29秒開始執行,每20秒執行一次。當執行緒A執行完任務,會顯式關掉守護執行緒。另一種情況,如果節點1忽然斷電,由於執行緒A和守護執行緒在同一個程式,守護執行緒也會停下。這把鎖到了超時的時候,沒人給它續命,也就自動釋放了。

五、基於zookeeper的實現方式

基於zookeeper臨時有序節點可以實現的分散式鎖。

1、實現思想

(1)在Zookeeper當中建立一個持久節點ParentLock;
(2)當第一個客戶端想要獲得鎖時,需要在ParentLock這個節點下面建立一個臨時順序節點Lock1。之後,執行緒1查詢ParentLock下面所有的臨時順序節點並排序,判斷自己所建立的節點Lock1是不是順序最靠前的一個。如果是第一個節點,則成功獲得鎖;
(3)如果再有一個執行緒2前來獲取鎖,則在ParentLock下載再建立一個臨時順序節點Lock2。執行緒2查詢ParentLock下面所有的臨時順序節點並排序,判斷自己所建立的節點Lock2是不是順序最靠前的一個,結果發現節點Lock2並不是最小的。於是,執行緒2向排序僅比它靠前的節點Lock1註冊Watcher,用於監聽Lock1節點是否存在。這意味著Client2搶鎖失敗,進入了等待狀態;
(4)這時候,如果執行緒3前來獲取鎖,則在ParentLock下載再建立一個臨時順序節點Lock3。執行緒3查詢ParentLock下面所有的臨時順序節點並排序,判斷自己所建立的節點Lock3是不是順序最靠前的一個,結果同樣發現節點Lock3並不是最小的。於是,執行緒3向排序僅比它靠前的節點Lock2註冊Watcher,用於監聽Lock2節點是否存在。這意味著執行緒3同樣搶鎖失敗,進入了等待狀態;
(5)這這樣一來,執行緒1得到了鎖,執行緒2監聽了Lock1,執行緒3監聽了Lock2。這恰恰形成了一個等待佇列,很像是Java當中ReentrantLock所依賴的AQS(AbstractQueuedSynchronizer)。

2、釋放鎖分為兩種情況:

(1)任務完成,客戶端顯示釋放

 

當任務完成時,執行緒1會顯示呼叫刪除節點Lock1的指令。

(2)任務執行過程中,客戶端崩潰

獲得鎖的執行緒1在任務執行過程中,如果系統崩潰,則會斷開與Zookeeper服務端的連結。根據臨時節點的特性,相關聯的節點Lock1會隨之自動刪除。 由於執行緒2一直監聽著Lock1的存在狀態,當Lock1節點被刪除,執行緒2會立刻收到通知。這時候執行緒2會再次查詢ParentLock下面的所有節點,確認自己建立的節點Lock2是不是目前最小的節點。如果是最小,則執行緒2順理成章獲得了鎖。

3、Zookeeper能不能解決前面提到的問題

 

(1)鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖之後突然掛掉(Session連線斷開),那麼這個臨時節點就會自動刪除掉。其他客戶端就可以再次獲得鎖。
(2)非阻塞鎖?使用Zookeeper可以實現阻塞的鎖,客戶端可以通過在ZK中建立順序節點,並且在節點上繫結監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是,那麼自己就獲取到鎖,便可以執行業務邏輯了。
(3)不可重入?使用Zookeeper也可以有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
(4)單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,只要叢集中有半數以上的機器存活,就可以對外提供服務。

4、程式碼實現

zookeeper提供了開源庫Curator,它是一個ZooKeeper客戶端,Curator提供的InterProcessMutex是分散式鎖的實現,acquire方法用於獲取鎖,release方法用於釋放鎖,非常方便粗暴。

public class ZookeeperHelper {

    private CuratorFramework cf;
    
    @PostConstruct
    public void init() {
        //建立zookeeper客戶端
        CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
        client.start();
    }

    public InterProcessSemaphoreMutex getLock(String subPath) {
        String path = "zookeeper_lock" + "/" + subPath;
        return new InterProcessSemaphoreMutex(cf, path);
    }

}

獲取和關閉鎖如下:

InterProcessSemaphoreMutex lock = zookeeperHelper.getLock(firstCacheKey);
if(lock.acquire(15, TimeUnit.SECONDS)){
    try {
        do something ......
    } finally {
        if (lock.isAcquiredInThisProcess()) {
            lock.release();
        }
    }
}

5、與redis分散式鎖的對比優缺點

分散式鎖 優點 缺點
Zookeeper 1、有封裝好的框架,容易實現
2、有等待鎖的佇列,大大提升搶鎖效率
新增和刪除節點效能較低
Redis Set和Del指令效能較高     1、實現複雜,需要考慮超時,原子性,誤刪等情形
2、沒有等待鎖的佇列,只能在客戶端自旋來等待,效率低下

六、總結

1、分散式鎖的一些條件:高可用、高效能、可重入、鎖失效機制、非阻塞鎖特性;

2、分散式鎖實現的三種方式:資料庫、redis、zookeeper;

3、redis 中 SETNX/expire 存在非原子性,可以使用set方式一把設定;

4、redis可以檢查key/value一致性,防止誤刪;

5、zookeeper利用臨時有序節點來設定分散式鎖;

6、zookeeper利用Curator框架獲取客戶端,以及InterProcessMutex類獲取分散式鎖和釋放鎖;

7、zookeeper建立分散式鎖時,建議粒度不要太小,粒度太小容易建立太多節點,容易滿盤;

8、zookeeper實現簡單,但是效能低點;redis效能高,但是實現複雜,考慮點多。

參考:
https://mp.weixin.qq.com/s/8fdBKAyHZrfHmSajXT_dnA
https://mp.weixin.qq.com/s/u8QDlrDj3Rl1YjY4TyKMCA

相關文章