深入理解分佈式鎖:原理、應用與挑戰| 京東物流技術團隊

前言

在單機環境中,我們主要通過線程間的加鎖機制來確保同一時間只有一個線程能夠訪問某個共享資源或執行某個關鍵代碼塊,從而防止各種併發修改異常。例如,在Java中提供了synchronized/Lock。但是在分佈式環境中,這種線程間的鎖機制已經不起作用了,因爲系統會被部署在不同機器上,這些資源已經不是在線程間共享了,而是進程之間共享資源。爲了解決這個問題,分佈式鎖應運而生。本文將詳細解析分佈式鎖的原理、應用與挑戰,以幫助讀者更好地理解和應用分佈式鎖。

分佈式鎖的原理

首先,從最原始的鎖定義來看,鎖是一種同步機制,主要用於協調併發訪問共享資源的行爲。 分佈式鎖也符合這個定義,只不過運行環境從單機變爲分佈式環境。它們的核心操作都可以分爲以下三個步驟:

  1. 獲取:在訪問共享資源前,先獲取一個鎖

  2. 佔有:獲取成功的進程或線程可以訪問共享資源,其他進程或線程則需要等待鎖釋放後才能進行訪問

  3. 釋放:釋放鎖

同時,分佈式鎖也具備一般鎖的以下特性:

  1. 互斥性:這是鎖的核心特性,確保在任意時刻,同一個鎖只能被一個進程或線程所持有。這種特性對於確保資源的獨佔訪問和防止併發衝突至關重要。

  2. 一致性:加鎖和釋放鎖的過程應儘量由同一個線程或進程完成,以確保鎖狀態的一致性,防止因鎖狀態不一致而導致的錯誤或混亂。

  3. 可重入性:這意味着已經持有鎖的線程或進程可以再次獲得同一個鎖,這在某些情況下是有用的,例如遞歸函數中的鎖操作。

還有分佈式鎖的特性問題:

  1. 鎖租期問題:在分佈式鎖的場景中,爲避免死鎖或無法正常釋放,鎖通常設置有效時間。當有效時間過期但業務還在執行時,需要通過特定的機制(如watchdog)來續租,確保鎖的持有者能夠繼續完成其操作。

  2. 性能:避免鎖成爲分佈式系統的瓶頸。



分佈式鎖的主流實現方案

常見的分佈式鎖實現方案可以分爲以下三大類:基於數據庫(比如MySQL),基於緩存(比如 Redis)和基於分佈式一致性協調服務組件(比如 ZooKeeper、etcd)





基於數據庫的分佈式鎖(以MySQL爲例)

要實現一套基於數據庫的分佈式鎖,最簡單的方式可能就是直接創建一張鎖表,然後通過操作該表中的數據來實現分佈式鎖。

爲了更好的演示,我們先創建一張數據庫表,例如:

CREATE TABLE `database_lock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `resource` int(11) NOT NULL COMMENT '鎖定的資源',
  `desc` varchar(128) NOT NULL DEFAULT '' COMMENT '描述',
  `create_time` datetime COMMENT '創建時間', 
  `update_time` datetime COMMENT '更新時間'
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分佈式鎖表';

記錄鎖

  1. 獲取鎖:

當想要獲取鎖時,可以插入一條數據:

INSERT INTO `database_lock` (resource, desc, create_time, update_time) VALUES (1,'lock',now(), now());

由於表中對resource設置了唯一索引,也就存在唯一性約束,這樣如果有多個請求同時提交到數據庫的話,數據庫可以保證只有一個操作成功,那麼我們就可以認爲操作成功的請求獲得了鎖。

  1. 佔有鎖:

成功獲取鎖後,就可以繼續操作共享資源了。

  1. 釋放鎖:

當需要釋放鎖時,可以刪除這條數據:

DELETE FROM database_lock WHERE resource = 1;

以上實現方式非常簡單,但是以下幾點需要特別注意:

  1. 這種鎖沒有失效時間,一旦釋放鎖的操作失敗就會導致鎖記錄一直存在數據庫中,鎖無法釋放,其他線程無法獲得鎖。這個缺陷也很好解決,比如可以增加一個定時任務定時清理未正常釋放的鎖記錄。

  2. 這種鎖的可靠性依賴於數據庫。可以設置備庫,避免單點,進一步提升可靠性。

  3. 這種鎖時非阻塞的,因爲插入數據失敗後會立即報錯,想要獲得鎖就需要再次操作。如果需要阻塞式的,可以通過For循環、while循環模擬,直至成功再返回。

  4. 這種鎖時非可重入的,因爲同一個線程在沒有釋放鎖之前無法再次獲得鎖,因爲數據庫中已經存在同一份記錄了。想要實現可重入,可以在數據庫中添加一些鎖的唯一標識字段,比如 主機信息、線程信息等,那麼再次獲取鎖的時候可以先查詢數據,如果當前的主機信息和線程信息等能被查詢到的話,可以直接分配鎖。

樂觀鎖

如果數據的更新在大多數情況下是不會產生衝突的,那麼只在數據庫更新操作提交的時候對數據作衝突檢測,如果檢測的結果與預期一致,則獲得鎖,如果出現了與預期數據不一致的情況,則丟棄本次更新。

樂觀鎖大多數是基於版本控制實現的。即給數據增加一個版本標識,比如通過爲數據庫表添加一個"version"字段來實現。

爲了更好的理解數據庫樂觀鎖在實際項目中的使用,這裏就列舉一個典型的電商庫存更新的例子。電商平臺中,當用戶提單的時候就會對庫存進行操作(庫存減1代表已經賣出了一件)。我們將這個庫存模型用下面的一張表optimistic_lock來表述:

CREATE TABLE `optimistic_lock` (
 `id` BIGINT NOT NULL AUTO_INCREMENT,
 `resource` int NOT NULL COMMENT '鎖定的資源',
 `version` int NOT NULL COMMENT '鎖的版本信息',
 `create_time` datetime COMMENT '創建時間',
 `update_time` datetime COMMENT '更新時間',
 `delete_time` datetime COMMENT '刪除時間', 
 PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分佈式鎖表-樂觀鎖';

其中:resource表示具體操作的資源,在這裏也就是特指庫存;version表示版本號。

在使用樂觀鎖之前要確保表中有相應的數據,比如:

INSERT INTO optimistic_lock (resource, version, create_at, update_at) VALUES(20, 10, now(), now());

如果只有一個線程進行操作,數據庫本身就能保證操作的正確性。主要步驟如下:

  1. 獲取資源信息:SELECT resource FROM optimistic_lock WHERE id = 1

  2. 執行業務邏輯

  3. 提交數據:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1

但是當有兩個用戶同時購買一件商品時,庫存實際操作應該是庫存(resource)減2,但是由於有高併發的存在,第一個用戶請求執行之後(執行了1、2,但是還沒有完成3),第二個用戶在購買相同的商品(執行1),此時查詢出的庫存並沒有完成減1的動作,那麼最終會導致2個線程購買的商品卻出現庫存只減1的情況,最終導致庫存異常。

在引入了version版本控制之後,具體的操作就會演變成如下步驟:

  1. 獲取資源信息: SELECT resource, version as oldVersion FROM optimistic_lock WHERE id = 1

  2. 執行業務邏輯

  3. 更新資源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion

另外,藉助更新時間戳(update_at)也可以實現樂觀鎖,和採用version字段的方式相似:更新操作執行前先獲取並記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。

由於在檢測數據衝突時並不依賴唯一索引,不會影響請求的性能,在併發量較小的時候只有少部分請求會失敗,適用於競爭較少的場景。缺點是當應用併發量高的時候,version值在頻繁變化,則會導致大量請求失敗,影響系統的可用性。另外,我們通過上述sql語句還可以看到,數據庫鎖都是作用於同一行數據記錄上,這就會導致熱點數據,在一些特殊場景,如大促、秒殺等活動的時候,大量的請求同時請求同一條記錄的行鎖,會對數據庫產生很大的寫壓力。所以綜合數據庫樂觀鎖的優缺點,可以看出樂觀鎖比較適合併發量不高,寫操作不頻繁的場景。

悲觀鎖

我們還可以藉助數據庫中自帶的鎖來實現分佈式鎖。例如在查詢語句後面增加FOR UPDATE,數據庫會在查詢過程中給數據庫表增加悲觀鎖,也稱排他鎖。當某條記錄被加上悲觀鎖之後,其它線程也就無法再該行上增加悲觀鎖。

悲觀鎖,與樂觀鎖相反,總是假設最壞的情況,它認爲數據的更新在大多數情況下是會產生衝突的。

在使用悲觀鎖的同時,我們需要注意一下鎖的級別。MySQL InnoDB引擎在加鎖的時候,只有明確地指定主鍵(或唯一索引)的纔會執行行鎖 (只鎖住被選取的數據)。 在使用悲觀鎖時,我們必須關閉MySQL數據庫的自動提交屬性(參考下面的示例),因爲MySQL默認使用autocommit模式,也就是說,當你執行一個更新操作後,MySQL會立刻將結果進行提交。

mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)

這樣在使用FOR UPDATE獲得鎖之後可以執行相應的業務邏輯,執行完之後再使用COMMIT來釋放鎖。

下面通過前面的database_lock表來具體表述一下用法。假設有一線程A需要獲得鎖並執行相應的操作,那麼它的具體步驟如下:

  1. 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。

  2. 執行業務邏輯。

  3. 釋放鎖:COMMIT。

如果另一個線程B在線程A釋放鎖之前執行步驟1,那麼它會被阻塞,直至線程A釋放鎖之後才能繼續。注意,如果線程A長時間未釋放鎖,那麼線程B會報錯,參考如下(lock wait time可以通過innodb_lock_wait_timeout來進行配置):

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

注意事項:

  1. 上面的示例中演示了指定主鍵並且能查詢到數據的過程(觸發行鎖),如果查不到數據那麼也就無從“鎖”起了。 2. 如果未指定主鍵(或者唯一索引)且能查詢到數據,那麼就會觸發表鎖或間隙鎖,比如步驟1改爲執行:
SELECT * FROM database_lock WHERE desc='lock' FOR UPDATE;

或者主鍵不明確也會觸發表鎖,又比如步驟1改爲執行:

SELECT * FROM database_lock WHERE id>0 FOR UPDATE;

在悲觀鎖中,每一次行數據的訪問都是獨佔的,只有當正在訪問該行數據的請求事務提交以後,其他請求才能依次訪問該數據,否則將阻塞等待鎖的獲取。悲觀鎖可以嚴格保證數據訪問的安全。但是缺點也明顯,即每次請求都會額外產生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高併發環境下,容易造成大量請求阻塞,影響系統性能。另外,悲觀鎖使用不當還可能產生死鎖的情況。

小結

基於以上討論,藉助與數據庫自身的能力(唯一索引,數據庫排他鎖),基於數據庫實現分佈式鎖還是挺簡單的。下面對其實用性其進行簡單分析:

優點

▪實現簡單,容易理解,不需要額外的第三方中間件。

▪通過數據庫的事務特性可以確保鎖的原子性、互斥性。

不足

▪性能相對較低,特別是在高併發場景下,頻繁的數據庫操作可能導致性能瓶頸。

▪需要自己考慮鎖超時等問題,實現起來較爲繁瑣。

▪依賴本地事務,不支持集羣部署,不能保證高可用。

基於Redis實現的分佈式鎖

方案一:SETNX+EXPIRE

這種是最簡單的實現方式,先通過setNX或取到鎖,然後通過expire命令添加超時時間。這種方式存在一個很大的問題:這兩個命令不是原子操作,需要和redis交互兩次,客戶端可能會在第一個命令執行完之後掛掉,導致沒有設置超時時間,鎖無法正常失效。於是產生了以下優化方案。

方案二:SETNX+VALUE

這種方式的value值中保存的是客戶端計算出的過期時間,通過setnx命令一次性寫入redis中

public boolean getLock(String key,Long expireTime) {
    long now = System.currentTimeMills();
    //絕對超時時間
    long expireTime = now + expireTime; 
    String expiresStr = String.valueOf(expireTime); 
    // 加鎖成功 
    if ( jedis.setnx(key, expiresStr)==1) { 
        return true; 
    } 
    // 檢查鎖是否過期,獲取鎖的value 
    String currentValueStr = jedis.get(key); 
    // 如果記錄的過期時間小於系統時間,則表示已過期 
    if (currentValueStr != null && Long.parseLong(currentValueStr) < now) { 
        // 鎖已過期,獲取上一個鎖的過期時間,並設置現在鎖的過期時間 
        String oldValueStr = jedis.getSet(key, expiresStr); 
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { 
            // 考慮多線程併發的情況,只有一個線程的設置值和當前值相同,它纔可以加鎖 
            return true; 
        } 
    } 
    //其他情況,均返回加鎖失敗 
    return false;
}

這種方式通過value將超時時間賦值,解決了第一種方案的兩次操作不能保證原子性的問題。但是這種方式也有問題:

  1. 在鎖過期時,如果多個線程同時來加鎖,可能會導致多個線程都加鎖成功(不滿足互斥性);

  2. 在多個線程都加鎖成功後,因爲鎖中沒有加鎖線程的標識,會導致多個線程都可以解鎖(不滿足一致性);

  3. 超時時間是在客戶端計算的,不同的客戶端的時鐘可能會存在差異,導致在加鎖客戶端沒有超時的鎖,在另一個客戶端已經超時(基於客戶端時鐘,不滿足一致性)。

方案三:使用Lua腳本

同樣是爲了解決第一種方案中的原子性問題,我們可以採用Lua腳本,來保證SETNX+EXPIRE操作的原子性。

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then 
    redis.call('expire',KEYS[1],ARGV[2])
else
    return 0
end;

在Java代碼中,使用jedis.eval()執行加鎖。

public boolean getLock(String key, String value, long expireTime) {  
    String lua_scripts = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +  
                          "redis.call('expire', KEYS[1], ARGV[2]) " +  
                          "return 1 " +  
                          "else " +  
                          "return 0 " +  
                          "end";  
    List<String> keys = Collections.singletonList(key);  
    List<String> argv = Arrays.asList(value, String.valueOf(expireTime));  
    Long result = (Long) jedis.eval(lua_scripts, keys, argv);  
    return result != null && result == 1;  
}

這種方式可以完全避免在加鎖後中斷設置不上超時時間的問題。也不會存在有時鐘不一致的問題,和高併發情況下多個線程都加上鎖的問題。但是這種方式就一定沒有問題了嗎?答案是否定的。考慮以下場景:

當服務A加鎖成功後,正在執行業務的過程中,鎖過期啦,這時服務A是沒有感知的;

接着服務B這時來獲取鎖,成功獲取到了;

緊接着,服務A處理完業務了,來釋放鎖,成功釋放掉了,而服務B這時還以爲它的鎖還在,在執行代碼。

全亂套了有沒有?以爲自己加鎖了,其實你沒加;

以爲自己解鎖成功了,其實解的是別人的鎖;

這種方案的問題主要是因爲兩點:鎖過期釋放,業務沒處理完;鎖沒有唯一身份標識

備註:從Redis 2.6.12版本開始支持setNx同時設置超時時間

如果你想要在設置key的同時爲其設置過期時間,並希望這是一個原子操作,你可以考慮使用Redis的 SET 命令,如下所示:

SET mykey "myvalue" NX EX 10  # 設置mykey的值爲myvalue,僅當mykey不存在時,並設置過期時間爲10秒

方案四:SET NX PX EX + 唯一標識

對於誤刪鎖的問題,我們可以在加鎖時,由客戶端生成一個唯一ID作爲value設置在鎖中,在刪除鎖時先進行身份判斷,再刪除;加鎖邏輯如下:

public boolean getLock(String key,String uniId,Long expireTime) {    
    //加鎖    
    return jedis.set(key, uniId, "NX", "EX", expireTime) == 1;
}
// 解鎖
public boolean releaseLock(String key,String uniId) {    
    // 因爲get和del操作並不是原子的,所以使用lua腳本    
    String lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then  return redis.call('del',KEYS[1]) else return 0  end;"; 
    List<String> keys = Collections.singletonList(key); 
    List<String> argv = Arrays.asList(uuiId);  
    Object result = jedis.eval(lua_scripts, keys, argv);    
    return result !=null && result.equals(1L);
}

這種方式解決了鎖被誤刪的問題,但是同樣存在鎖超時失效,但是業務還未處理完的問題

方案五:Redission框架

那麼對於鎖過期失效,業務未處理完畢的問題,該如何處理呢?

我們可以在加鎖成功後,啓動一個守護線程,在守護線程中隔一段時間就對鎖的超時時間再續長一點,直到業務處理完成後再釋放鎖,防止鎖在業務處理完畢之前提前釋放。而Redission框架就是使用的這種機制來解決的這個問題。

  1. 當一個線程去獲取鎖,在加鎖成功的情況下,那麼它已經通過Lua腳本將數據保存在了redis中;

  2. 然後在加鎖成功的同時,啓動Watch Dog看門狗,每隔10秒檢查是否還持有鎖,如果是則將鎖超時時間延長。

  3. 如果一開始就獲取鎖失敗,則會一直循環獲取。

方案六:RedLock

以上的這些方案,都只是在Redis單機模式下討論的方案,如果Redis是採用集羣模式,還會存在一些問題,比如:

在集羣模式下,一般Master節點會將數據同步到Salve節點,如果我們先在Master節點上加鎖成功,在同步到Salve節點之前,這個Master節點掛了,然後另一臺Salve節點升級爲Master節點,這時這個節點上並沒有我們的加鎖數據;

此時另一個客戶端線程來獲取相同的鎖,它就會獲取成功,這時在我們的應用中將會有兩個線程同時獲取到這個鎖,這個鎖也就不安全了。

爲了解決這個問題,Redis的作者提出了一種高級的分佈式鎖算法,叫:RedLock, 即:Redis Distributed Lock, Redis分佈式鎖。

RedLock的核心原理:

•在Redis集羣中選出多個Master節點,保證這些Master節點不會同時宕機;

•並且各個Master節點之間相互獨立,數據不同步;

•使用與Redis單實例相同的方法來加鎖和解鎖。

那麼RedLock到底是如何來保證在有節點宕機的情況下,還能安全的呢?

1.假設集羣中有N臺Master節點,首先,獲取當前時間戳;

2.客戶端按照順序使用相同的key,value依次獲取鎖,並且獲取時間要比鎖超時時間足夠小;比如超時時間5s,那麼獲取鎖時間最多1s,超過1s則放棄,繼續獲取下一個;

3.客戶端通過獲取所有能獲取的鎖之後減去第一步的時間戳,這個時間差要小於鎖超時時間,並且要至少有N/2 + 1臺節點獲取成功,才表示鎖獲取成功,否則獲取失敗;

4.如果成功獲取鎖,則鎖的有效時間是原本超時時間減去第三步的時間差;

5.如果獲取鎖失敗,則要解鎖所有的節點,不管該節點加鎖時是否成功,防止有漏網之魚。

Redssion庫對RedLock方案已經做了實現,如果你的Redis是集羣部署,可以看看使用方法。

參考文檔:https://redis.io/topics/distlock

小結

優點

▪實現簡單,性能較高。

▪可以利用Redis的集羣特性實現高可用性和可擴展性。

▪有現成的第三方包和工具支持,實現起來相對簡單。

缺點

▪如果Redis節點故障,可能導致鎖失效或死鎖。

▪RedLock算法雖然提高了容錯性,但增加了實現的複雜性和開銷。

基於Zookeeper等實現的分佈式鎖

zookeeper 鎖相關基礎知識

zk 一般由多個節點構成(單數),採用 zab 一致性協議。因此可以將 zk 看成一個單點結構,對其修改數據其內部自動將所有節點數據進行修改而後才提供查詢服務。zk 的數據以目錄樹的形式,每個目錄稱爲 znode,znode 中可存儲數據(一般不超過 1M),還可以在其中增加子節點。

znode節點有三種類型。序列化節點,每在該節點下增加一個節點自動給該節點的名稱上添加序號並且自增1。臨時節點,一旦創建這個 znode 的客戶端與服務器失去聯繫,這個 znode 也將自動刪除。最後就是普通節點。

Watch 機制,client 可以監控每個節點的變化,當產生變化時 client 會接受到一個事件通知。

zk 基本鎖

原理:利用臨時節點與 watch 機制。每個鎖佔用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下創建一個臨時節點,創建成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作後再去爭鎖。臨時節點好處在於當進程掛掉後能自動上鎖的節點自動刪除即取消鎖。

缺點:所有取鎖失敗的進程都監聽父節點,很容易發生羊羣效應,即當釋放鎖後所有等待進程一起來創建節點,併發量很大,增加zk集羣壓力。

zk 鎖優化

原理:上鎖改爲創建臨時有序節點,每個上鎖的節點均能創建節點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則 watch 序號比本身小的前一個節點 (公平鎖)。

步驟:

•在 /lock 節點下創建一個有序臨時節點 (EPHEMERAL_SEQUENTIAL)。

•判斷創建的節點序號是否最小,如果是最小則獲取鎖成功。不是則獲取鎖失敗,然後 watch 序號比本身小的前一個節點。

•當取鎖失敗,設置 watch 後則等待 watch 事件到來後,再次判斷是否序號最小。

•取鎖成功則執行代碼,最後釋放鎖(刪除該節點)。

參考代碼:

@Slf4j
public class DistributedLock implements Lock, Watcher{
     /**
      * zk客戶端
      */
      private ZooKeeper zk;
     /**
     * 根目錄
     */
     private final String root = "/locks";
     /**
     * 鎖名稱
     */
     private final String lockName;

     /**
     * 等待前一個鎖
     */
     private String waitNode;

     /**
     * 當前鎖
     */
     private String myZnode;
     /**
     * 計數器
     */
     private CountDownLatch latch;
     /**
     * 會話超時時間
     */
     private final int sessionTimeout = 30000;
     /**
     * 異常列表
     */
     private final List<Exception> exception = new ArrayList<>();

     /**
     * 創建分佈式鎖
     * @param config 服務器配置
     * @param lockName 競爭資源標誌,lockName中不能包含單詞lock
     */
     public DistributedLock(String config, String lockName){
         this.lockName = lockName;
         // 創建與服務器的連接
         try {
             zk = new ZooKeeper(config, sessionTimeout, this);
             Stat stat = zk.exists(root, false);
             if(stat == null){
                 // 創建根節點
                 zk.create(root, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
             }
         } catch (IOException | KeeperException | InterruptedException e) {
              exception.add(e);
         }
     }

    /**
     * zookeeper節點的監視器
     */
     @Override
     public void process(WatchedEvent event) {
         if(this.latch != null) {
             this.latch.countDown();
         }
     }

     @Override
     public void lock() {
         if(!exception.isEmpty()){
              throw new LockException(exception.get(0));
         }
         try {
             if(this.tryLock()){
                 log.info("Thread " + Thread.currentThread().getId() + " " +myZnode + " get lock true");
             } else{
                 //等待鎖
                 waitForLock(waitNode, sessionTimeout);
             }
         } catch (KeeperException | InterruptedException e) {
             throw new LockException(e);
         }
     }

     @Override
     public boolean tryLock() {
         try {
             String splitStr = "_lock_";
             if(lockName.contains(splitStr)) {
                 throw new LockException("lockName can not contains \u000B");
         }
         //創建臨時有序子節點
         myZnode = zk.create(root + "/" + lockName + splitStr, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL_SEQUENTIAL);
         log.info(myZnode + " is created ");
         //取出所有子節點
         List<String> subNodes = zk.getChildren(root, false);
         //取出所有lockName的鎖
         List<String> lockObjNodes = new ArrayList<String>();
         for (String node : subNodes) {
             String _node = node.split(splitStr)[0];
             if(_node.equals(lockName)){
                 lockObjNodes.add(node);
             }
         }
         Collections.sort(lockObjNodes);
         log.info("myZnode={} minZnode={}", myZnode, lockObjNodes.get(0));
         if(myZnode.equals(root+"/"+lockObjNodes.get(0))){
             //如果是最小的節點,則表示取得鎖
             return true;
         }
         //如果不是最小的節點,找到比自己小1的節點
         String subMyZnode = myZnode.substring(myZnode.lastIndexOf("/") + 1);
         waitNode = lockObjNodes.get(Collections.binarySearch(lockObjNodes, subMyZnode) - 1);
         } catch (KeeperException | InterruptedException e) {
             throw new LockException(e);
         }
         return false;
     }

     @Override
     public boolean tryLock(long time,@NonNull TimeUnit unit) {
         try {
             if(this.tryLock()){
                 return true;
             }
             return waitForLock(waitNode,time);
         } catch (Exception e) {
             log.error("tryLock exception:", e);
         }
         return false;
     }

     /**
     * @param lower 監視節點
     * @param waitTime 等待超時時間
     * @return 是否獲得鎖
     * @throws InterruptedException
     * @throws KeeperException
     */
      private boolean waitForLock(String lower, long waitTime) throws InterruptedException, KeeperException {
          Stat stat = zk.exists(root + "/" + lower,true);
          //判斷比自己小一個數的節點是否存在,如果不存在則無需等待鎖,同時註冊監聽
          if(stat != null){
             log.info("Thread " + Thread.currentThread().getId() + " waiting for " + root + "/" + lower);
             this.latch = new CountDownLatch(1);
             this.latch.await(waitTime, TimeUnit.MILLISECONDS);
             this.latch = null;
          }
          return true;
     }


      /**
      * 解鎖方法
      * @throws InterruptedException 線程中斷異常
      * @throws KeeperException ZooKeeper異常
      */
      @Override
      public void unlock() {
          try {
              log.info("unlock " + myZnode);
              zk.delete(myZnode,-1);
              myZnode = null;
              zk.close();
         } catch (InterruptedException | KeeperException e) {
             log.error("unlock exception:", e);
         }
     }

     @Override
     public void lockInterruptibly() throws InterruptedException {
          this.lock();
     }

     @Override
     public Condition newCondition() {
         return null;
     }

     /**
     * 自定義鎖異常
     */
     public static class LockException extends RuntimeException {
         private static final long serialVersionUID = 1L;

         /**
         * @param e 異常
         */
         public LockException(String e){
             super(e);
         }

         /**
         * @param e 異常
         */
         public LockException(Exception e){
             super(e);
         }
     }
}

小結

優點

▪有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較爲簡單。

▪具有良好的順序性和公平性,可以有效的避免死鎖和競爭問題。

▪支持高可用,容錯性較好,通過zookeeper集羣可以確保鎖的可靠性和強一致性。

▪有現成的第三方包和工具支持,實現起來相對簡單。

不足

▪性能相對較低,ZK中創建和刪除節點只能通過 Leader 服務器來執行,然後將數據同步到所有的 Follower 機器上。

▪需要維護ZooKeeper集羣,增加了系統的複雜性和維護成本。

▪在高併發場景下,頻繁的鎖操作可能導致ZooKeeper集羣成爲性能瓶頸。

分佈式鎖的應用

分佈式鎖的應用場景

分佈式鎖在分佈式系統中有着廣泛的應用,主要體現在以下幾個方面:

1.共享資源競爭:當多個進程或線程嘗試同時訪問或修改共享資源時,爲了避免數據衝突和不一致,可以使用分佈式鎖來確保同一時刻只有一個節點可以訪問資源。這在多機器或多節點的分佈式系統中尤爲重要,因爲傳統的單機併發控制策略可能不再適用。

2.效率性:使用分佈式鎖可以避免不同節點或進程重複執行相同的任務或操作。例如,在任務調度系統中,如果多個節點都嘗試執行同一任務,通過使用分佈式鎖,可以確保只有一個節點執行該任務,從而提高系統的整體效率。

3.特殊業務場景:在電商業務中,分佈式鎖常用於處理高併發場景下的資源競爭問題。例如,在扣減庫存或防止流量過載時,通過分佈式鎖可以確保操作的原子性和一致性。此外,秒殺搶購、優惠券領取等場景也常利用分佈式鎖來確保數據的一致性。

4.微服務架構:在微服務架構的系統中,分佈式鎖發揮着至關重要的作用。特別是在金融支付系統等對一致性要求極高的場景中,分佈式鎖被廣泛應用於實現各種特殊需求,確保操作的原子性、數據的準確性和一致性。

總的來說,分佈式鎖的主要應用場景涉及需要確保數據一致性、防止數據衝突和提高系統效率的場景。通過使用分佈式鎖,可以在分佈式系統中實現更精細化的控制和協調,確保系統的穩定性和可靠性。

選型分析

根據以上實現原理的分析,選擇哪種分佈式鎖方案取決於具體的應用場景和需求。對於簡單的應用場景和對性能要求不高的系統,基於MySQL的分佈式鎖可能是一個不錯的選擇。對於高併發、高性能要求的系統,基於Redis的分佈式鎖可能更合適。而如果需要確保鎖的公平性和一致性,並且對性能要求不是特別高,那麼基於ZooKeeper的分佈式鎖可能是一個更好的選擇。在實際應用中,還需要根據系統的具體情況和需求進行權衡和選擇。

關於布式鎖互斥性的進一步討論

經過以探討,我們可以得出一個結論:基於單機模式的MySQL、Redis以及ZooKeeper集羣,均能夠嚴格實現分佈式鎖,從而確保鎖的互斥性。這裏之所以強調鎖的互斥性,是因爲它確保了同一時刻僅有一個進程或線程能夠訪問特定的共享資源,從而避免了數據衝突和不一致性的發生。

然而,當我們轉向MySQL主從模式或Redis主從模式時,情況便發生了變化。這些模式在保障鎖的互斥性方面存在明顯的不足。要深入探究這一現象的根源,我們不得不提及分佈式領域中的一個關鍵理論——CAP理論。

從鎖的定義和特性出發,我們知道,在獲取鎖的過程中,需要一個全局可見的標識。當一個進程或線程成功獲取鎖後,該標識會被設置並變得全局可見,這樣其他線程就無法突破鎖的互斥性限制,確保鎖的互斥性得到維護。而這一切的前提,便是數據必須保持一致性

然而,主從模式更傾向於保障可用性和分區容忍性,即AP模型,這在一定程度上犧牲了數據的一致性。相比之下,ZooKeeper集羣則採用了CP模型,即保證一致性和分區容忍性。因此,在分佈式環境下,ZooKeeper集羣能夠確保數據的一致性,從而確保鎖的互斥性得到嚴格保障。

綜上所述,在分佈式系統中,確保鎖的互斥性至關重要。我們在選擇和設計分佈式鎖時,必須充分考慮其互斥性保障能力,並結合實際場景和需求,選擇最合適的實現方案。當業務場景需要高可靠性的分佈式鎖時,ZooKeeper集羣因其出色的數據一致性保障能力,自然成爲了一個更加值得考慮的優秀選擇。

分佈式鎖的挑戰

雖然分佈式鎖爲分佈式系統帶來了諸多好處,但在實際應用中也面臨一些挑戰:

1.性能問題:分佈式鎖的獲取和釋放需要通過網絡通信,這可能會引入額外的性能開銷。在高併發場景下,如果大量進程或線程爭用同一個鎖,可能導致性能瓶頸。

2.可靠性問題:分佈式鎖的可靠性受到網絡、硬件、軟件等多方面因素的影響。如果鎖服務出現故障或網絡中斷,可能導致死鎖或數據不一致等問題。

3.可擴展性問題:隨着分佈式系統的規模不斷擴大,如何確保分佈式鎖的可擴展性成爲一個重要問題。需要設計合理的分佈式鎖策略,以適應不同規模和需求的系統。

本文主要討論了分佈式鎖的原理和不同的實現方案,有基於數據庫,Redis和ZooKeeper三種選擇,並且各有優缺點。項目開發過程中根據自己實際的業務場景,選擇適合自己項目的方案。



文章中難免會有不足之處,希望讀者能給予寶貴的意見和建議。謝謝!

參考文檔

https://cloud.tencent.com/developer/article/1909596

https://zhuanlan.zhihu.com/p/42056183

作者:京東物流 劉浩

來源:京東雲開發者社區

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章