再有人問你分散式鎖,這篇文章扔給他

咖啡拿鐵發表於2018-10-09

1.背景

對於鎖大家肯定不會陌生,在Java中synchronized關鍵字和ReentrantLock可重入鎖在我們的程式碼中是經常見的,一般我們用其在多執行緒環境中控制對資源的併發訪問,但是隨著分散式的快速發展,本地的加鎖往往不能滿足我們的需要,在我們的分散式環境中上面加鎖的方法就會失去作用。於是人們為了在分散式環境中也能實現本地鎖的效果,也是紛紛各出其招,今天讓我們來聊一聊一般分散式鎖實現的套路。

2.分散式鎖

2.1為何需要分散式鎖

Martin Kleppmann是英國劍橋大學的分散式系統的研究員,之前和Redis之父Antirez進行過關於RedLock(紅鎖,後續有講到)是否安全的激烈討論。Martin認為一般我們使用分散式鎖有兩個場景:

  • 效率:使用分散式鎖可以避免不同節點重複相同的工作,這些工作會浪費資源。比如使用者付了錢之後有可能不同節點會發出多封簡訊。
  • 正確性:加分散式鎖同樣可以避免破壞正確性的發生,如果兩個節點在同一條資料上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最後狀態出現錯誤,造成損失。

2.2分散式鎖的一些特點

當我們確定了在不同節點上需要分散式鎖,那麼我們需要了解分散式鎖到底應該有哪些特點:

  • 互斥性:和我們本地鎖一樣互斥性是最基本,但是分散式鎖需要保證在不同節點的不同執行緒的互斥。
  • 可重入性:同一個節點上的同一個執行緒如果獲取了鎖之後那麼也可以再次獲取這個鎖。
  • 鎖超時:和本地鎖一樣支援鎖超時,防止死鎖。
  • 高效,高可用:加鎖和解鎖需要高效,同時也需要保證高可用防止分散式鎖失效,可以增加降級。
  • 支援阻塞和非阻塞:和ReentrantLock一樣支援lock和trylock以及tryLock(long timeOut)。
  • 支援公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。這個一般來說實現的比較少。

2.3常見的分散式鎖

我們瞭解了一些特點之後,我們一般實現分散式鎖有以下幾個方式:

  • MySql
  • Zk
  • Redis
  • 自研分散式鎖:如谷歌的Chubby。

下面分開介紹一下這些分散式鎖的實現原理。

3Mysql分散式鎖

首先來說一下Mysql分散式鎖的實現原理,相對來說這個比較容易理解,畢竟資料庫和我們開發人員在平時的開發中息息相關。對於分散式鎖我們可以建立一個鎖表:

再有人問你分散式鎖,這篇文章扔給他
前面我們所說的lock(),trylock(long timeout),trylock()這幾個方法可以用下面的虛擬碼實現。

3.1 lock()

lock一般是阻塞式的獲取鎖,意思就是不獲取到鎖誓不罷休,那麼我們可以寫一個死迴圈來執行其操作:

再有人問你分散式鎖,這篇文章扔給他

mysqlLock.lcok內部是一個sql,為了達到可重入鎖的效果那麼我們應該先進行查詢,如果有值,那麼需要比較node_info是否一致,這裡的node_info可以用機器IP和執行緒名字來表示,如果一致那麼就加可重入鎖count的值,如果不一致那麼就返回false。如果沒有值那麼直接插入一條資料。虛擬碼如下:

再有人問你分散式鎖,這篇文章扔給他

需要注意的是這一段程式碼需要加事務,必須要保證這一系列操作的原子性。

3.2tryLock()和tryLock(long timeout)

tryLock()是非阻塞獲取鎖,如果獲取不到那麼就會馬上返回,程式碼可以如下:

再有人問你分散式鎖,這篇文章扔給他
tryLock(long timeout)實現如下:
再有人問你分散式鎖,這篇文章扔給他
mysqlLock.lock和上面一樣,但是要注意的是select ... for update這個是阻塞的獲取行鎖,如果同一個資源併發量較大還是有可能會退化成阻塞的獲取鎖。

3.3 unlock()

unlock的話如果這裡的count為1那麼可以刪除,如果大於1那麼需要減去1。

再有人問你分散式鎖,這篇文章扔給他

3.4 鎖超時

我們有可能會遇到我們的機器節點掛了,那麼這個鎖就不會得到釋放,我們可以啟動一個定時任務,通過計算一般我們處理任務的一般的時間,比如是5ms,那麼我們可以稍微擴大一點,當這個鎖超過20ms沒有被釋放我們就可以認定是節點掛了然後將其直接釋放。

3.5 Mysql小結

  • 適用場景: Mysql分散式鎖一般適用於資源不存在資料庫,如果資料庫存在比如訂單,那麼可以直接對這條資料加行鎖,不需要我們上面多的繁瑣的步驟,比如一個訂單,那麼我們可以用select * from order_table where id = 'xxx' for update進行加行鎖,那麼其他的事務就不能對其進行修改。
  • 優點:理解起來簡單,不需要維護額外的第三方中介軟體(比如Redis,Zk)。
  • 缺點:雖然容易理解但是實現起來較為繁瑣,需要自己考慮鎖超時,加事務等等。效能侷限於資料庫,一般對比快取來說效能較低。對於高併發的場景並不是很適合。

3.6 樂觀鎖

前面我們介紹的都是悲觀鎖,這裡想額外提一下樂觀鎖,在我們實際專案中也是經常實現樂觀鎖,因為我們加行鎖的效能消耗比較大,通常我們會對於一些競爭不是那麼激烈,但是其又需要保證我們併發的順序執行使用樂觀鎖進行處理,我們可以對我們的表加一個版本號欄位,那麼我們查詢出來一個版本號之後,update或者delete的時候需要依賴我們查詢出來的版本號,判斷當前資料庫和查詢出來的版本號是否相等,如果相等那麼就可以執行,如果不等那麼就不能執行。這樣的一個策略很像我們的CAS(Compare And Swap),比較並交換是一個原子操作。這樣我們就能避免加select * for update行鎖的開銷。

4. ZooKeeper

ZooKeeper也是我們常見的實現分散式鎖方法,相比於資料庫如果沒了解過ZooKeeper可能上手比較難一些。ZooKeeper是以Paxos演算法為基礎分散式應用程式協調服務。Zk的資料節點和檔案目錄類似,所以我們可以用此特性實現分散式鎖。我們以某個資源為目錄,然後這個目錄下面的節點就是我們需要獲取鎖的客戶端,未獲取到鎖的客戶端註冊需要註冊Watcher到上一個客戶端,可以用下圖表示。

再有人問你分散式鎖,這篇文章扔給他
/lock是我們用於加鎖的目錄,/resource_name是我們鎖定的資源,其下面的節點按照我們加鎖的順序排列。

4.1Curator

Curator封裝了Zookeeper底層的Api,使我們更加容易方便的對Zookeeper進行操作,並且它封裝了分散式鎖的功能,這樣我們就不需要再自己實現了。

Curator實現了可重入鎖(InterProcessMutex),也實現了不可重入鎖(InterProcessSemaphoreMutex)。在可重入鎖中還實現了讀寫鎖。

4.2InterProcessMutex

InterProcessMutex是Curator實現的可重入鎖,我們可以通過下面的一段程式碼實現我們的可重入鎖:

再有人問你分散式鎖,這篇文章扔給他

我們利用acuire進行加鎖,release進行解鎖。

加鎖的流程具體如下:

  1. 首先進行可重入的判定:這裡的可重入鎖記錄在ConcurrentMap<Thread, LockData> threadData這個Map裡面,如果threadData.get(currentThread)是有值的那麼就證明是可重入鎖,然後記錄就會加1。我們之前的Mysql其實也可以通過這種方法去優化,可以不需要count欄位的值,將這個維護在本地可以提高效能。
  2. 然後在我們的資源目錄下建立一個節點:比如這裡建立一個/0000000002這個節點,這個節點需要設定為EPHEMERAL_SEQUENTIAL也就是臨時節點並且有序。
  3. 獲取當前目錄下所有子節點,判斷自己的節點是否位於子節點第一個。
  4. 如果是第一個,則獲取到鎖,那麼可以返回。
  5. 如果不是第一個,則證明前面已經有人獲取到鎖了,那麼需要獲取自己節點的前一個節點。/0000000002的前一個節點是/0000000001,我們獲取到這個節點之後,再上面註冊Watcher(這裡的watcher其實呼叫的是object.notifyAll(),用來解除阻塞)。
  6. object.wait(timeout)或object.wait():進行阻塞等待這裡和我們第5步的watcher相對應。

解鎖的具體流程:

  1. 首先進行可重入鎖的判定:如果有可重入鎖只需要次數減1即可,減1之後加鎖次數為0的話繼續下面步驟,不為0直接返回。
  2. 刪除當前節點。
  3. 刪除threadDataMap裡面的可重入鎖的資料。

4.3讀寫鎖

Curator提供了讀寫鎖,其實現類是InterProcessReadWriteLock,這裡的每個節點都會加上字首:

private static final String READ_LOCK_NAME  = "__READ__";
private static final String WRITE_LOCK_NAME = "__WRIT__";
複製程式碼

根據不同的字首區分是讀鎖還是寫鎖,對於讀鎖,如果發現前面有寫鎖,那麼需要將watcher註冊到和自己最近的寫鎖。寫鎖的邏輯和我們之前4.2分析的依然保持不變。

4.4鎖超時

Zookeeper不需要配置鎖超時,由於我們設定節點是臨時節點,我們的每個機器維護著一個ZK的session,通過這個session,ZK可以判斷機器是否當機。如果我們的機器掛掉的話,那麼這個臨時節點對應的就會被刪除,所以我們不需要關心鎖超時。

4.5 ZK小結

  • 優點:ZK可以不需要關心鎖超時時間,實現起來有現成的第三方包,比較方便,並且支援讀寫鎖,ZK獲取鎖會按照加鎖的順序,所以其是公平鎖。對於高可用利用ZK叢集進行保證。
  • 缺點:ZK需要額外維護,增加維護成本,效能和Mysql相差不大,依然比較差。並且需要開發人員瞭解ZK是什麼。

5.Redis

大家在網上搜尋分散式鎖,恐怕最多的實現就是Redis了,Redis因為其效能好,實現起來簡單所以讓很多人都對其十分青睞。

5.1Redis分散式鎖簡單實現

熟悉Redis的同學那麼肯定對setNx(set if not exist)方法不陌生,如果不存在則更新,其可以很好的用來實現我們的分散式鎖。對於某個資源加鎖我們只需要

setNx resourceName value
複製程式碼

這裡有個問題,加鎖了之後如果機器當機那麼這個鎖就不會得到釋放所以會加入過期時間,加入過期時間需要和setNx同一個原子操作,在Redis2.8之前我們需要使用Lua指令碼達到我們的目的,但是redis2.8之後redis支援nx和ex操作是同一原子操作。

set resourceName value ex 5 nx
複製程式碼

5.2Redission

Javaer都知道Jedis,Jedis是Redis的Java實現的客戶端,其API提供了比較全面的Redis命令的支援。Redission也是Redis的客戶端,相比於Jedis功能簡單。Jedis簡單使用阻塞的I/O和redis互動,Redission通過Netty支援非阻塞I/O。Jedis最新版本2.9.0是2016年的快3年了沒有更新,而Redission最新版本是2018.10月更新。

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

再有人問你分散式鎖,這篇文章扔給他

Redission不僅提供了Java自帶的一些方法(lock,tryLock),還提供了非同步加鎖,對於非同步程式設計更加方便。 由於內部原始碼較多,就不貼原始碼了,這裡用文字敘述來分析他是如何加鎖的,這裡分析一下tryLock方法:

  1. 嘗試加鎖:首先會嘗試進行加鎖,由於保證操作是原子性,那麼就只能使用lua指令碼,相關的lua指令碼如下:
    再有人問你分散式鎖,這篇文章扔給他
    可以看見他並沒有使用我們的sexNx來進行操作,而是使用的hash結構,我們的每一個需要鎖定的資源都可以看做是一個HashMap,鎖定資源的節點資訊是Key,鎖定次數是value。通過這種方式可以很好的實現可重入的效果,只需要對value進行加1操作,就能進行可重入鎖。當然這裡也可以用之前我們說的本地計數進行優化。
  2. 如果嘗試加鎖失敗,判斷是否超時,如果超時則返回false。
  3. 如果加鎖失敗之後,沒有超時,那麼需要在名字為redisson_lock__channel+lockName的channel上進行訂閱,用於訂閱解鎖訊息,然後一直阻塞直到超時,或者有解鎖訊息。
  4. 重試步驟1,2,3,直到最後獲取到鎖,或者某一步獲取鎖超時。

對於我們的unlock方法比較簡單也是通過lua指令碼進行解鎖,如果是可重入鎖,只是減1。如果是非加鎖執行緒解鎖,那麼解鎖失敗。

再有人問你分散式鎖,這篇文章扔給他

Redission還有公平鎖的實現,對於公平鎖其利用了list結構和hashset結構分別用來儲存我們排隊的節點,和我們節點的過期時間,用這兩個資料結構幫助我們實現公平鎖,這裡就不展開介紹了,有興趣可以參考原始碼。

5.3RedLock

我們想象一個這樣的場景當機器A申請到一把鎖之後,如果Redis主當機了,這個時候從機並沒有同步到這一把鎖,那麼機器B再次申請的時候就會再次申請到這把鎖,為了解決這個問題Redis作者提出了RedLock紅鎖的演算法,在Redission中也對RedLock進行了實現。

再有人問你分散式鎖,這篇文章扔給他

通過上面的程式碼,我們需要實現多個Redis叢集,然後進行紅鎖的加鎖,解鎖。具體的步驟如下:

  1. 首先生成多個Redis叢集的Rlock,並將其構造成RedLock。
  2. 依次迴圈對三個叢集進行加鎖,加鎖的過程和5.2裡面一致。
  3. 如果迴圈加鎖的過程中加鎖失敗,那麼需要判斷加鎖失敗的次數是否超出了最大值,這裡的最大值是根據叢集的個數,比如三個那麼只允許失敗一個,五個的話只允許失敗兩個,要保證多數成功。
  4. 加鎖的過程中需要判斷是否加鎖超時,有可能我們設定加鎖只能用3ms,第一個叢集加鎖已經消耗了3ms了。那麼也算加鎖失敗。
  5. 3,4步裡面加鎖失敗的話,那麼就會進行解鎖操作,解鎖會對所有的叢集在請求一次解鎖。

可以看見RedLock基本原理是利用多個Redis叢集,用多數的叢集加鎖成功,減少Redis某個叢集出故障,造成分散式鎖出現問題的概率。

5.4 Redis小結

  • 優點:對於Redis實現簡單,效能對比ZK和Mysql較好。如果不需要特別複雜的要求,那麼自己就可以利用setNx進行實現,如果自己需要複雜的需求的話那麼可以利用或者借鑑Redission。對於一些要求比較嚴格的場景來說的話可以使用RedLock。
  • 缺點:需要維護Redis叢集,如果要實現RedLock那麼需要維護更多的叢集。

6.分散式鎖的安全問題

上面我們介紹過紅鎖,但是Martin Kleppmann認為其依然不安全。有關於Martin反駁的幾點,我認為其實不僅僅侷限於RedLock,前面說的演算法基本都有這個問題,下面我們來討論一下這些問題:

  • 長時間的GC pause:熟悉Java的同學肯定對GC不陌生,在GC的時候會發生STW(stop-the-world),例如CMS垃圾回收器,他會有兩個階段進行STW防止引用繼續進行變化。那麼有可能會出現下面圖(引用至Martin反駁Redlock的文章)中這個情況:
    再有人問你分散式鎖,這篇文章扔給他
    client1獲取了鎖並且設定了鎖的超時時間,但是client1之後出現了STW,這個STW時間比較長,導致分散式鎖進行了釋放,client2獲取到了鎖,這個時候client1恢復了鎖,那麼就會出現client1,2同時獲取到鎖,這個時候分散式鎖不安全問題就出現了。這個其實不僅僅侷限於RedLock,對於我們的ZK,Mysql一樣的有同樣的問題。
  • 時鐘發生跳躍:對於Redis伺服器如果其時間發生了向跳躍,那麼肯定會影響我們鎖的過期時間,那麼我們的鎖過期時間就不是我們預期的了,也會出現client1和client2獲取到同一把鎖,那麼也會出現不安全,這個對於Mysql也會出現。但是ZK由於沒有設定過期時間,那麼發生跳躍也不會受影響。
  • 長時間的網路I/O:這個問題和我們的GC的STW很像,也就是我們這個獲取了鎖之後我們進行網路呼叫,其呼叫時間由可能比我們鎖的過期時間都還長,那麼也會出現不安全的問題,這個Mysql也會有,ZK也不會出現這個問題。

對於這三個問題,在網上包括Redis作者在內發起了很多討論。

6.1 GC的STW

對於這個問題可以看見基本所有的都會出現問題,Martin給出了一個解法,對於ZK這種他會生成一個自增的序列,那麼我們真正進行對資源操作的時候,需要判斷當前序列是否是最新,有點類似於我們樂觀鎖。當然這個解法Redis作者進行了反駁,你既然都能生成一個自增的序列了那麼你完全不需要加鎖了,也就是可以按照類似於Mysql樂觀鎖的解法去做。

我自己認為這種解法增加了複雜性,當我們對資源操作的時候需要增加判斷序列號是否是最新,無論用什麼判斷方法都會增加複雜度,後面會介紹谷歌的Chubby提出了一個更好的方案。

6.2 時鐘發生跳躍

Martin覺得RedLock不安全很大的原因也是因為時鐘的跳躍,因為鎖過期強依賴於時間,但是ZK不需要依賴時間,依賴每個節點的Session。Redis作者也給出瞭解答:對於時間跳躍分為人為調整和NTP自動調整。

  • 人為調整:人為調整影響的那麼完全可以人為不調整,這個是處於可控的。
  • NTP自動調整:這個可以通過一定的優化,把跳躍時間控制的可控範圍內,雖然會跳躍,但是是完全可以接受的。

6.3長時間的網路I/O

這一塊不是他們討論的重點,我自己覺得,對於這個問題的優化可以控制網路呼叫的超時時間,把所有網路呼叫的超時時間相加,那麼我們鎖過期時間其實應該大於這個時間,當然也可以通過優化網路呼叫比如序列改成並行,非同步化等。可以參考我的兩個文章: 並行化-你的高併發大殺器非同步化-你的高併發大殺器

7.Chubby的一些優化

大家搜尋ZK的時候,會發現他們都寫了ZK是Chubby的開源實現,Chubby內部工作原理和ZK類似。但是Chubby的定位是分散式鎖和ZK有點不同。Chubby也是使用上面自增序列的方案用來解決分散式不安全的問題,但是他提供了多種校驗方法:

  • CheckSequencer():呼叫Chubby的API檢查此時這個序列號是否有效。
  • 訪問資源伺服器檢查,判斷當前資源伺服器最新的序列號和我們的序列號的大小。
  • lock-delay:為了防止我們校驗的邏輯入侵我們的資源伺服器,其提供了一種方法當客戶端失聯的時候,並不會立即釋放鎖,而是在一定的時間內(預設1min)阻止其他客戶端拿去這個鎖,那麼也就是給予了一定的buffer等待STW恢復,而我們的GC的STW時間如果比1min還長那麼你應該檢查你的程式,而不是懷疑你的分散式鎖了。

8.小結

本文主要講了多種分散式鎖的實現方法,以及他們的一些優缺點。最後也說了一下有關於分散式鎖的安全的問題,對於不同的業務需要的安全程度完全不同,我們需要根據自己的業務場景,通過不同的維度分析,選取最適合自己的方案。

最後這篇文章被我收錄於JGrowing,一個全面,優秀,由社群一起共建的Java學習路線,如果您想參與開源專案的維護,可以一起共建,github地址為:github.com/javagrowing… 麻煩給個小星星喲。

最後打個廣告,如果你覺得這篇文章對你有文章,可以關注我的技術公眾號,也可以加入我的技術交流群進行更多的技術交流。你的關注和轉發是對我最大的支援,O(∩_∩)O。

再有人問你分散式鎖,這篇文章扔給他

相關文章