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

1.背景

對於鎖你們確定不會陌生,在Java中synchronized關鍵字和ReentrantLock可重入鎖在咱們的代碼中是常常見的,通常咱們用其在多線程環境中控制對資源的併發訪問,可是隨着分佈式的快速發展,本地的加鎖每每不能知足咱們的須要,在咱們的分佈式環境中上面加鎖的方法就會失去做用。因而人們爲了在分佈式環境中也能實現本地鎖的效果,也是紛紛各出其招,今天讓咱們來聊一聊通常分佈式鎖實現的套路。java

2.分佈式鎖

2.1爲什麼須要分佈式鎖

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

  • 效率:使用分佈式鎖能夠避免不一樣節點重複相同的工做,這些工做會浪費資源。好比用戶付了錢以後有可能不一樣節點會發出多封短信。
  • 正確性:加分佈式鎖一樣能夠避免破壞正確性的發生,若是兩個節點在同一條數據上面操做,好比多個節點機器對同一個訂單操做不一樣的流程有可能會致使該筆訂單最後狀態出現錯誤,形成損失。

2.2分佈式鎖的一些特色

當咱們肯定了在不一樣節點上須要分佈式鎖,那麼咱們須要瞭解分佈式鎖到底應該有哪些特色:mysql

  • 互斥性:和咱們本地鎖同樣互斥性是最基本,可是分佈式鎖須要保證在不一樣節點的不一樣線程的互斥。
  • 可重入性:同一個節點上的同一個線程若是獲取了鎖以後那麼也能夠再次獲取這個鎖。
  • 鎖超時:和本地鎖同樣支持鎖超時,防止死鎖。
  • 高效,高可用:加鎖和解鎖須要高效,同時也須要保證高可用防止分佈式鎖失效,能夠增長降級。
  • 支持阻塞和非阻塞:和ReentrantLock同樣支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序得到鎖,非公平鎖就相反是無序的。這個通常來講實現的比較少。

2.3常見的分佈式鎖

咱們瞭解了一些特色以後,咱們通常實現分佈式鎖有如下幾個方式:git

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

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

3Mysql分佈式鎖

首先來講一下Mysql分佈式鎖的實現原理,相對來講這個比較容易理解,畢竟數據庫和咱們開發人員在平時的開發中息息相關。對於分佈式鎖咱們能夠建立一個鎖表:redis

前面咱們所說的lock(),trylock(long timeout),trylock()這幾個方法能夠用下面的僞代碼實現。

3.1 lock()

lock通常是阻塞式的獲取鎖,意思就是不獲取到鎖誓不罷休,那麼咱們能夠寫一個死循環來執行其操做: 算法

mysqlLock.lcok內部是一個sql,爲了達到可重入鎖的效果那麼咱們應該先進行查詢,若是有值,那麼須要比較node_info是否一致,這裏的node_info能夠用機器IP和線程名字來表示,若是一致那麼就加可重入鎖count的值,若是不一致那麼就返回false。若是沒有值那麼直接插入一條數據。僞代碼以下: sql

須要注意的是這一段代碼須要加事務,必需要保證這一系列操做的原子性。數據庫

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。

相關文章
相關標籤/搜索