分布式鎖的幾種實現


前言

針對共享資源的互斥訪問歷來是很多業務系統需要解決的問題。用到分布式鎖說明遇到了多個進程共同訪問同一個資源的問題。

一般是在兩個場景下會防止對同一個資源的重復訪問:

  • 提高效率。比如多個節點計算同一批任務,如果某個任務已經有節點在計算了,那其他節點就不用重復計算了,以免浪費計算資源。不過重復計算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。
  • 保證正確性。這種情況對鎖的要求就很高了,如果重復計算,會對正確性造成影響。這種不允許失敗。

引入分布式鎖勢必要引入一個第三方的基礎設施,比如 MySQL,Redis,Zookeeper 等。本文聊聊各種實現的方案。

1. 單機鎖和分布式鎖

由此抽象一下分布式鎖的概念,首先分布式鎖需要是一個資源,這個資源能夠提供並發控制,並輸出一個排他性的狀態,也就是:

鎖 = 資源 + 並發控制 + 所有權展示

以常見的單機鎖為例:

  • Spinlock = BOOL +CAS(樂觀鎖)
  • Mutex = BOOL + CAS + 通知(悲觀鎖)

Spinlock 和 Mutex 都是一個 Bool 資源,通過原子的 CAS 指令:當現在為 0 設置為 1,成功的話持有鎖,失敗的話不持有鎖,如果不提供所有權的展示,例如 AtomicInteger,也是通過資源(Interger)+ CAS,但是不會明確的提示所有權,因此不會被視為一種鎖,當然,可以將“所有權展示”這個更多地視為某種服務提供形式的包裝。

單機環境下,內核具備“上帝視角”,能夠知道進程的存活,當進程掛掉的時候可以將該進程持有的鎖資源釋放,但發展到分布式環境,這就變成了一個挑戰,為了應對各種機器故障、宕機等,就需要給鎖提供了一個新的特性:可用性。

如下圖所示,任何提供三個特性的服務都可以提供分布式鎖的能力,資源可以是文件、KV 等,通過創建文件、KV 等原子操作,通過創建成功的結果來表明所有權的歸屬,同時通過 TTL 或者會話來保證鎖的可用性,通過超時釋放,可以避免死鎖。
分布式鎖的特性和實現.jpg

2. 分布式鎖的系統分類

根據鎖資源本身的安全性,我們將分布式鎖分為兩個陣營:

  • 基於異步復制的分布式系統,例如 mysql,tair,redis 等。

  • 基於 paxos 協議的分布式一致性系統,例如 zookeeper,etcd,consul 等。

基於異步復制的分布式系統,存在數據丟失(丟鎖)的風險,不夠安全,往往通過 TTL 的機制承擔細粒度的鎖服務,該系統接入簡單,適用於對時間很敏感,期望設置一個較短的有效期,執行短期任務,丟鎖對業務影響相對可控的服務。

基於 paxos 協議的分布式系統,通過一致性協議保證數據的多副本,數據安全性高,往往通過租約(會話)的機制承擔粗粒度的鎖服務,該系統需要一定的門檻,適用於對安全性很敏感,希望長期持有鎖,不期望發生丟鎖現象的服務。

2.1 Redis 實現

Redis 客戶端加鎖也要根據Redis 部署情況來使用不同的加鎖方式。

2.1.1 單機Redis的分布式鎖

單機方式可根據lua腳本實現

思路大概是這樣的:在redis中設置一個值表示加了鎖,然后釋放鎖的時候就把這個key刪除。具體代碼是這樣的:

// 獲取鎖  
// NX是指如果key不存在就成功,key存在返回false,PX可以指定過期時間 
      SET d_lock unique_value NX PX 30000
// 釋放鎖:通過執行一段lua腳本 
// 釋放鎖涉及到兩條指令,這兩條指令不是原子性的  
// 需要用到redis的lua腳本支持特性,redis執行lua腳本是原子性的   
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])    
else
    return 0
end

這種方式有兩大要點:

1)一定要用SET key value NX PXmilliseconds 命令

如果不用,先設置了值,再設置過期時間,這個不是原子性操作,有可能在設置過期時間之前宕機,會造成死鎖(key永久存在)。

2)value要具有唯一性

這個是為了在解鎖的時候,需要驗證value是和加鎖的一致才刪除key。

這是避免了一種情況:假設A獲取了鎖,過期時間30s,此時35s之后,鎖已經自動釋放了,A去釋放鎖,但是此時可能B獲取了鎖。A客戶端就不能刪除B的鎖了。

如果采用單機部署模式,會存在單點問題,只要redis故障了。加鎖就不行了。

2.1.2 集群分布式

redis 集群分布式集群方式有兩種

  • master-slave + sentinel選舉模式
  • redis cluster模式

集群使用redis鎖的問題:
采用master-slave模式,如果設置鎖之后,主機在傳輸到從機的時候掛掉了,從機還沒有加鎖信息,如何處理?即采用master-slave模式,加鎖的時候只對一個節點加鎖,即便通過sentinel做了高可用,但是如果master節點故障了,發生主從切換,此時就會有可能出現鎖丟失的問題。

redis cluster 模式下,edis的作者提出可依據RedLock算法:
這個算法的意思大概是這樣的:假設redis的部署模式是redis cluster,總共有6個master節點,通過以下步驟獲取一把鎖:

1.獲取當前時間戳,單位是毫秒;

2.輪流嘗試在每個master節點上創建鎖,過期時間設置較短,一般就幾十毫秒;

3.嘗試在大多數節點上建立一個鎖,比如5個節點就要求是3個節點(n / 2+1);

4.客戶端計算建立好鎖的時間,如果建立鎖的時間小於超時時間,就算建立成功了;

5.要是鎖建立失敗了,那么就依次刪除這個鎖;

6.只要別人建立了一把分布式鎖,你就得不斷輪詢去嘗試獲取鎖。

但是這樣的這種算法還是頗具爭議的,可能還會存在不少的問題,無法保證加鎖的過程一定正確。

基於Redission的實現:

Javaer都知道Jedis,Jedis是Redis的Java實現的客戶端,其API提供了比較全面的Redis命令的支持。Redission也是Redis的客戶端,相比於Jedis功能簡單。Jedis簡單使用阻塞的I/O和redis交互,Redission通過Netty支持非阻塞I/O。

Redission封裝了鎖的實現,其繼承了java.util.concurrent.locks.Lock的接口,讓我們像操作我們的本地Lock一樣去操作Redission的Lock,下面介紹一下其如何實現分布式鎖。

如果自己寫代碼來通過redis設置一個值,是通過下面這個命令設置的。

SET d_lock unique_value NX PX 30000

這里設置的超時時間是30s,假如我超過30s都還沒有完成業務邏輯的情況下,key會過期,其他線程有可能會獲取到鎖。這樣一來的話,第一個線程還沒執行完業務邏輯,第二個線程進來了也會出現線程安全問題。所以我們還需要額外的去維護這個過期時間,太麻煩了~我們來看看redisson是怎么實現的:

 Config config = new Config();
       config.useClusterServers()
        .addNodeAddress("redis://192.168.1.101:7001")
        .addNodeAddress("redis://192.168.1.101:7002")
        .addNodeAddress("redis://192.168.1.101:7003")
        .addNodeAddress("redis://192.168.1.102:7001")
        .addNodeAddress("redis://192.168.1.102:7002")
        .addNodeAddress("redis://192.168.1.102:7003");
        RedissonClient redisson = Redisson.create(config);
        RLock lock = redisson.getLock("d_lock");
        lock.lock();
        lock.unlock();

我們只需要通過它的api中的lock和unlock即可完成分布式鎖,他幫我們考慮了很多細節:

  • redisson所有指令都通過lua腳本執行,redis支持lua腳本原子性執行

  • redisson設置一個key的默認過期時間為30s,如果某個客戶端持有一個鎖超過了30s怎么辦?

redisson中有一個watchdog的概念,翻譯過來就是看門狗,它會在你獲取鎖之后,每隔10秒幫你把key的超時時間設為30s

這樣的話,就算一直持有鎖也不會出現key過期了,其他線程獲取到鎖的問題了。

  • redisson的“看門狗”邏輯保證了沒有死鎖發生

(如果機器宕機了,看門狗也就沒了。此時就不會延長key的過期時間,到了30s之后就會自動過期了,其他線程可以獲取到鎖)

看門狗.png

Redis小結
優點: 對於Redis實現簡單,性能對比ZK和Mysql較好。如果不需要特別復雜的要求,那么自己就可以利用setNx進行實現,如果自己需要復雜的需求的話那么可以利用或者借鑒Redission。對於一些要求比較嚴格的場景來說的話可以使用RedLock。

缺點: 需要維護Redis集群,如果要實現RedLock那么需要維護更多的集群。

2.2 Zookeeper 實現

zk實現分布式鎖的落地方案:

  1. 使用zk的臨時節點和有序節點,每個線程獲取鎖就是在zk創建一個臨時有序的節點,比如在/lock/目錄下。

  2. 創建節點成功后,獲取/lock目錄下的所有臨時節點,再判斷當前線程創建的節點是否是所有的節點的序號最小的節點

  3. 如果當前線程創建的節點是所有節點序號最小的節點,則認為獲取鎖成功

  4. 如果當前線程創建的節點不是所有節點序號最小的節點,則對節點序號的前一個節點添加一個事件監聽。

比如當前線程獲取到的節點序號為/lock/003,然后所有的節點列表為:

[/lock/001,/lock/002,/lock/003],則對/lock/002這個節點添加一個事件監聽器。

如果鎖釋放了,會喚醒下一個序號的節點,然后重新執行第3步,判斷是否自己的節點序號是最小。
比如/lock/001釋放了,/lock/002監聽到時間,此時節點集合為[/lock/002,/lock/003],則/lock/002為最小序號節點,獲取到鎖。
整個過程如下:
zk實現分布式鎖.png

Curator

Curator是一個zookeeper的開源客戶端,也提供了分布式鎖的實現。

他的使用方式也比較簡單:


InterProcessMutex ipm = new InterProcessMutex(client,"/d_lock");
                        ipm.acquire();
                        ipm.release();

其實現分布式鎖的核心源碼如下:

 while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) {
            // 獲取當前所有節點排序后的集合  
            List<String> children = getSortedChildren();
            // 獲取當前節點的名稱   
            String sequenceNodeName = ourPath.substring(basePath.length() + 1); 
            // 判斷當前節點是否是最小的節點  
            PredicateResults   predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
            if ( predicateResults.getsTheLock() ) {
                // 獲取到鎖    
                haveTheLock = true;
            } else {
                // 沒獲取到鎖,對當前節點的上一個節點注冊一個監聽器    
               ……
             }
        }

2.3 基於數據庫實現

2.3.1 基於數據庫表的排它鎖

鎖信息存儲表結構如下:

CREATE TABLE `t_ms_lock` (
    `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
    `name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
    `desc` varchar(1024) NOT NULL DEFAULT '描述',
    `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數據時間',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uidx_name` (`name `) USING BTREE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

當我們想要鎖住某個方法時,執行以下SQL:

insert into t_ms_lock(name,desc) values ('name','desc');

因為我們對name做了唯一性約束,這里如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之后,想要釋放鎖的話,需要執行以下Sql:

delete from t_ms_lock where name ='name';

上面這種簡單的實現有以下幾個問題:

  • 這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用;

    • 對於數據庫是單點可以搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上;但切換也是單點?
  • 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖;

    • 對於沒有失效時間,只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍;
  • 這把鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作;

    • 對於非阻塞:搞一個while循環,直到insert成功再返回成功;
  • 這把鎖是不可重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。

    • 對於非重入, 在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。

2.3.2 基於數據庫的排它鎖

除了可以通過增刪操作數據表中的記錄以外,其實還可以借助數據中自帶的鎖來實現分布式的鎖。

基於MySql的InnoDB引擎,可以使用以下方法來實現加鎖操作:

public boolean lock(){
      connection.setAutoCommit(false)
      while(true){
 result = select * from t_ms_lock where name=xxx for update;
          if(result==null){
              return true;
          }
      }
      return false;
  }

在查詢語句后面增加forupdate,數據庫會在查詢過程中給數據庫表增加排他鎖。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。(這里再多提一句,InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這里我們希望使用行級鎖,就要給method_name添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上)

我們可以認為獲得排它鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執行方法的業務邏輯,執行完方法之后,再通過以下方法解鎖:

public void unlock(){
        connection.commit();
    }

這里還可能存在另外一個問題,雖然我們對name 使用了唯一索引,並且顯示使用for update來使用行級鎖。但是,MySql會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由MySQL 通過判斷不同執行計划的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。

還有一個問題,就是我們要使用排他鎖來進行分布式鎖的lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。

2.3.3 基於數據庫的樂觀鎖

大多數是基於數據版本(version)的記錄機制實現的。何謂數據版本號?即為數據增加一個版本標識,在基於數據庫表的版本解決方案中,一般是通過為數據庫表添加一個“version”字段來實現讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加1。在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗。

注意ABA 問題等。

3. 比較總結

對於redis的分布式鎖而言,它有以下缺點:

它獲取鎖的方式簡單粗暴,獲取不到鎖直接不斷嘗試獲取鎖,比較消耗性能。另外來說的話,redis的設計定位決定了它的數據並不是強一致性的,在某些極端情況下,可能會出現問題。鎖的模型不夠健壯,即便使用redlock算法來實現,在某些復雜場景下,也無法保證其實現100%沒有問題。但是另一方面使用redis實現分布式鎖在很多企業中非常常見,而且大部分情況下都不會遇到所謂的“極端復雜場景”,所以使用redis作為分布式鎖也不失為一種好的方案,最重要的一點是redis的性能很高,可以支撐高並發的獲取、釋放鎖操作。

zookeeper天生設計定位就是分布式協調,強一致性。鎖的模型健壯、簡單易用、適合做分布式鎖。如果獲取不到鎖,只需要添加一個監聽器就可以了,不用一直輪詢,性能消耗較小。

但是如果有較多的客戶端頻繁的申請加鎖、釋放鎖,對於zk集群的壓力會比較大。

我們介紹了幾種分布式鎖的實現方式,並進行了一些優缺點比較,哪種方式都無法做到完美。就像CAP一樣,在復雜性、可靠性、性能等方面無法同時滿足,所以需要根據不同的應用場景選擇最適合的方式。

比較角度 結果
從理解的難易程度角度(從低到高) 數據庫 > 緩存 > Zookeeper
從實現的復雜性角度(從低到高) Zookeeper >= 緩存 > 數據庫
從性能角度(從高到低) 緩存 > Zookeeper >= 數據庫
從可靠性角度(從高到低) Zookeeper > 緩存 > 數據庫

References:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM