分佈式鎖詳解

基於數據庫:

 

基於數據庫表作樂觀鎖,用於分佈式鎖。(version) redis

 

基於數據庫表作悲觀鎖(InnoDB,for update)數據庫

 

基於數據庫表數據記錄作惟一約束(表中記錄方法名稱)緩存

 

基於緩存:

 

使用redis的setnx()用於分佈式鎖。(setNx,直接設置值爲當前時間+超時時間,保持操做原子性)服務器

 

使用memcached的add()方法,用於分佈式鎖。網絡

 

使用Tair的put()方法,用於分佈式鎖。session

 

基於Zookeeper:

 

每一個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個惟一的瞬時有序節點。 併發

 

判斷是否獲取鎖只須要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除便可。分佈式

 

基於數據庫實現分佈式鎖

 

基於數據庫表數據記錄作惟一約束(表中記錄方法名稱)

 

要實現分佈式鎖,最簡單的方式可能就是直接建立一張鎖表,而後經過操做該表中的數據來實現了。

 

當咱們要鎖住某個方法或資源時,咱們就在該表中增長一條記錄,想要釋放鎖的時候就刪除這條記錄。 建立這樣一張數據庫表:memcached

 

 

 

 

當咱們想要鎖住某個方法時,執行如下SQL:高併發

 

 

 

 

由於咱們對method_name作了惟一性約束,這裏若是有多個請求同時提交到數據庫的話,數據庫會保證只有一個操做能夠成功(原子性),那麼咱們就能夠認爲操做成功的那個線程得到了該方法的鎖,能夠執行方法體內容。

當方法執行完畢以後,想要釋放鎖的話,須要執行如下Sql:

 

 

 

 

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

 

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

 

二、這把鎖沒有失效時間,一旦解鎖操做失敗,就會致使鎖記錄一直在數據庫中,其餘線程沒法再得到到鎖。

 

三、這把鎖只能是非阻塞的,由於數據的insert操做,一旦插入失敗就會直接報錯。沒有得到鎖的線程並不會進入排隊隊列,要想再次得到鎖就要再次觸發得到鎖操做。

 

四、這把鎖是非重入的,同一個線程在沒有釋放鎖以前沒法再次得到該鎖。由於數據中數據已經存在了。

 

固然,咱們也能夠有其餘方式解決上面的問題。

 

  • 數據庫是單點?搞兩個數據庫,數據以前雙向同步。一旦掛掉快速切換到備庫上。

     

  • 沒有失效時間?只要作一個定時任務,每隔必定時間把數據庫中的超時數據清理一遍。

     

  • 非阻塞的?搞一個while循環,直到insert成功再返回成功。

     

  • 非重入的?在數據庫表中加兩個字段,一個記錄當前得到鎖的機器的主機信息和線程信息,另外一個是count值,用於記錄重入的次數,那麼下次再獲取鎖的時候先查詢數據庫,若是當前機器的主機信息和線程信息在數據庫能夠查到的話,直接把鎖分配給他就能夠了,並把count加1。在釋放鎖的時候把count值減1,當count值爲0時候,刪除記錄便可。

     

基於數據庫表作悲觀鎖(InnoDB引擎,for update語句)

 

除了能夠經過增刪操做數據表中的記錄之外,其實還能夠藉助數據中自帶的鎖來實現分佈式的鎖。 咱們還用剛剛建立的那張數據庫表。能夠經過數據庫的排他鎖來實現分佈式鎖。 基於MySql的InnoDB引擎,可使用如下方法來實現加鎖操做:

 

在查詢語句後面增長for update,數據庫會在查詢過程當中給數據庫表增長排他鎖(這裏再多提一句,InnoDB引擎在加鎖的時候,只有經過索引進行檢索的時候纔會使用行級鎖,不然會使用表級鎖。這裏咱們但願使用行級鎖,就要給method_name添加索引,值得注意的是,這個索引必定要建立成惟一索引,不然會出現多個重載方法之間沒法同時被訪問的問題。重載方法的話建議把參數類型也加上)

 

當某條記錄被加上排他鎖以後,其餘線程沒法再在該行記錄上增長排他鎖。 咱們能夠認爲得到排它鎖的線程便可得到分佈式鎖,當獲取到鎖以後,能夠執行方法的業務邏輯,執行完方法以後,再經過如下方法解鎖:

 

public void lock(){

connection.setAutoCommit(false)

int count = 0;

while(count < 4){

try{

select * from lock where lock_name=xxx for update;

if(結果不爲空){

//表明獲取到鎖

return;

}

}catch(Exception e){

 

}

//爲空或者拋異常的話都表示沒有獲取到鎖

sleep(1000);

count++;

}

throw new LockException();

}

 

經過connection.commit()操做來釋放鎖。

 

這種方法能夠有效的解決上面提到的沒法釋放鎖和阻塞鎖的問題。

 

  • 阻塞鎖? for update語句會在執行成功後當即返回,在執行失敗時一直處於阻塞狀態,直到成功。

  • 鎖定以後服務宕機,沒法釋放?使用這種方式,服務宕機以後數據庫會本身把鎖釋放掉

     

可是仍是沒法直接解決數據庫單點和可重入問題。

 

這裏還可能存在另一個問題,雖然咱們對method_name 使用了惟一索引,而且顯示使用for update來使用行級鎖。可是,MySql會對查詢進行優化,即使在條件中使用了索引字段,可是否使用索引來檢索數據是由 MySQL 經過判斷不一樣執行計劃的代價來決定的,若是 MySQL 認爲全表掃效率更高,好比對一些很小的表,它就不會使用索引,這種狀況下 InnoDB 將使用表鎖,而不是行鎖。若是發生這種狀況就悲劇了。。。

 

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

 

基於數據庫資源表作樂觀鎖,用於分佈式鎖:

 

1.首先說明樂觀鎖的含義:

 

 大多數是基於數據版本(VERSION)的記錄機制實現的。何謂數據版本號?即爲數據增長一個版本標識,在基於數據庫表的版本解決方案中,通常是經過爲數據庫表添加一個「VERSION」字段來實現讀取出數據時,將此版本號一同讀出,以後更新時,對此版本號加1。

在更新過程當中,會對版本號進行比較,若是是一致的,沒有發生改變,則會成功執行本次操做; 若是版本號不一致,則會更新失敗。

 

2.對樂觀鎖的含義有了必定的瞭解後,結合具體的例子,咱們來推演下咱們應該怎麼處理:

 

  • 假設咱們有一張資源表,以下圖所示: T_RESOURCE , 其中有6個字段ID, RESOOURCE, STATE, ADD_TIME, UPDATE_TIME, VERSION,分別表示表主鍵、資源、分配狀態(1未分配 2已分配)、資源建立時間、資源更新時間、資源數據版本號。

     

  • 假設咱們如今咱們對ID=5780這條數據進行分配,那麼非分佈式場景的狀況下,咱們通常先查詢出來STATE=1(未分配)的數據,而後從其中選取一條數據能夠經過如下語句進行,若是能夠更新成功,那麼就說明已經佔用了這個資源 UPDATE T_RESOURCE SET STATE=2 WHERE STATE=1 AND ID=5780。

     

  • 若是在分佈式場景中,因爲數據庫的UPDATE操做是原子是原子的,其實上邊這條語句理論上也沒有問題,可是這條語句若是在典型的「ABA」狀況下,咱們是沒法感知的。有人可能會問什麼是「ABA」問題呢?你們能夠網上搜索一下,這裏我說簡單一點就是,若是在你第一次SELECT和第二次UPDATE過程當中,因爲兩次操做是非原子的,因此這過程當中,若是有一個線程,先是佔用了資源(STATE=2),而後又釋放了資源(STATE=1),實際上最後你執行UPDATE操做的時候,是沒法知道這個資源發生過變化的。也許你會說這個在你說的場景中應該也還好吧,可是在實際的使用過程當中,好比銀行帳戶存款或者扣款的過程當中,這種狀況是比較恐怖的。

     

  • 那麼若是使用樂觀鎖咱們如何解決上邊的問題呢?

     

  A. 先執行SELECT操做查詢當前數據的數據版本號,好比當前數據版本號是26:

  SELECT ID, RESOURCE, STATE,VERSION FROM T_RESOURCE WHERE STATE=1 AND ID=5780;

  B. 執行更新操做:

  UPDATE T_RESOURE SET STATE=2, VERSION=27, UPDATE_TIME=NOW() WHERE RESOURCE=XXXXXX AND 

  STATE=1 AND VERSION=26

  C. 若是上述UPDATE語句真正更新影響到了一行數據,那就說明佔位成功。若是沒有更新影響到一行數據,則說明這個資源已經被別人佔位了。

 

3.基於數據庫表作樂觀鎖的一些缺點:

 

(1). 這種操做方式,使本來一次的UPDATE操做,必須變爲2次操做: SELECT版本號一次;UPDATE一次。增長了數據庫操做的次數。

 

(2). 若是業務場景中的一次業務流程中,多個資源都須要用保證數據一致性,那麼若是所有使用基於數據庫資源表的樂觀鎖,就要讓每一個資源都有一張資源表,這個在實際使用場景中確定是沒法知足的。並且這些都基於數據庫操做,在高併發的要求下,對數據庫鏈接的開銷必定是沒法忍受的。

 

(3). 樂觀鎖機制每每基於系統中的數據存儲邏輯,所以可能會形成髒數據被更新到數據庫中。在系統設計階段,咱們應該充分考慮到這些狀況出現的可能性,並進行相應調整,如將樂觀鎖策略在數據庫存儲過程當中實現,對外只開放基於此存儲過程的數據更新途徑,而不是將數據庫表直接對外公開。

 

講了樂觀鎖的實現方式和缺點,是否是會以爲不敢使用樂觀鎖了呢???固然不是,在文章開頭我本身的業務場景中,場景1和場景2的一部分都使用了基於數據庫資源表的樂觀鎖,已經很好的解決了線上問題。因此你們要根據的具體業務場景選擇技術方案,並非隨便找一個足夠複雜、足夠新潮的技術方案來解決業務問題就是好方案?!好比,若是在個人場景一中,我使用zookeeper作鎖,能夠這麼作,可是真的有必要嗎???答案以爲是沒有必要的!!!

 

總結一下使用數據庫來實現分佈式鎖的方式,這兩種方式都是依賴數據庫的一張表,一種是經過表中的記錄的存在狀況肯定當前是否有鎖存在,另一種是經過數據庫的排他鎖來實現分佈式鎖。

 

數據庫實現分佈式鎖的優勢

    直接藉助數據庫,容易理解。

 

數據庫實現分佈式鎖的缺點

    會有各類各樣的問題,在解決問題的過程當中會使整個方案變得愈來愈複雜。

    操做數據庫須要必定的開銷,性能問題須要考慮。

    使用數據庫的行級鎖並不必定靠譜,尤爲是當咱們的鎖表並不大的時候。

 

基於緩存實現分佈式鎖 Redis

 

使用redis的setnx()用於分佈式鎖。(原子性)

 

SETNX是將 key 的值設爲 value,當且僅當 key 不存在。若給定的 key 已經存在,則 SETNX 不作任何動做。

 

• 返回1,說明該進程得到鎖,SETNX將鍵 lock.id 的值設置爲鎖的超時時間,當前時間 +加上鎖的有效時間。

• 返回0,說明其餘進程已經得到了鎖,進程不能進入臨界區。進程能夠在一個循環中不斷地嘗試 SETNX 操做,以得到鎖。

 

存在死鎖的問題

 

SETNX實現分佈式鎖,可能會存在死鎖的狀況。與單機模式下的鎖相比,分佈式環境下不只須要保證進程可見,還須要考慮進程與鎖之間的網絡問題。某個線程獲取了鎖以後,斷開了與Redis 的鏈接,鎖沒有及時釋放,競爭該鎖的其餘線程都會hung,產生死鎖的狀況。因此在這種狀況下須要對獲取的鎖進行超時時間設置,即setExpire,超時自動釋放鎖

 

基於Zookeeper實現分佈式鎖

 

基於zookeeper臨時有序節點能夠實現的分佈式鎖。

 

大體思想即爲:

 

每一個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個惟一的臨時有序節點。 判斷是否獲取鎖的方式很簡單,只須要判斷有序節點中序號最小的一個。 

 

當釋放鎖的時候,只需將這個臨時節點刪除便可。同時,排隊的節點須要監聽排在本身以前的節點,這樣能在節點釋放時候接收到回調通知,讓其得到鎖。zk的session由客戶端管理,其能夠避免服務宕機致使的鎖沒法釋放,而產生的死鎖問題,不須要關注鎖超時。

 

來看下Zookeeper能不能解決前面提到的問題。

 

  • 鎖沒法釋放?使用Zookeeper能夠有效的解決鎖沒法釋放的問題,由於在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖以後忽然掛掉(Session鏈接斷開),那麼這個臨時節點就會自動刪除掉。其餘客戶端就能夠再次得到鎖。

     

  • 非阻塞鎖?使用Zookeeper能夠實現阻塞的鎖,客戶端能夠經過在ZK中建立順序節點,而且在節點上綁定監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端能夠檢查本身建立的節點是否是當前全部節點中序號最小的,若是是,那麼本身就獲取到鎖,即可以執行業務邏輯了。

     

  • 不可重入?使用Zookeeper也能夠有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機信息和線程信息直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的數據比對一下就能夠了。若是和本身的信息同樣,那麼本身直接獲取到鎖,若是不同就再建立一個臨時的順序節點,參與排隊。

     

  • 單點問題?使用Zookeeper能夠有效的解決單點問題,ZK是集羣部署的,只要集羣中有半數以上的機器存活,就能夠對外提供服務。

     

能夠直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。

 

Curator提供的InterProcessMutex是分佈式鎖的實現。acquire方法用戶獲取鎖,release方法用於釋放鎖。

 

使用ZK實現的分佈式鎖好像徹底符合了本文開頭咱們對一個分佈式鎖的全部指望。可是,其實並非,Zookeeper實現的分佈式鎖其實存在一個缺點,那就是性能上可能並無緩存服務那麼高。由於每次在建立鎖和釋放鎖的過程當中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能經過Leader服務器來執行,而後將數據同步到全部的Follower機器上。

 

其實,使用Zookeeper也有可能帶來併發問題,只是並不常見而已。考慮這樣的狀況,因爲網絡抖動,客戶端到ZK集羣的session鏈接斷了,那麼zk覺得客戶端掛了,就會刪除臨時節點,這時候其餘客戶端就能夠獲取到分佈式鎖了。就可能產生併發問題。這個問題不常見是由於zk有重試機制,一旦zk集羣檢測不到客戶端的心跳,就會重試,Curator客戶端支持多種重試策略。屢次重試以後還不行的話纔會刪除臨時節點。(因此,選擇一個合適的重試策略也比較重要,要在鎖的粒度和併發之間找一個平衡。)

 

基於ZK的方案的總結

 

使用Zookeeper實現分佈式鎖的優勢

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

 

使用Zookeeper實現分佈式鎖的缺點

性能上不如使用緩存實現分佈式鎖。 須要對ZK的原理有所瞭解。

 

三種方案的比較

上面幾種方式,哪一種方式都沒法作到完美。就像CAP同樣,在複雜性、可靠性、性能等方面沒法同時知足,因此,根據不一樣的應用場景選擇最適合本身的纔是王道。

 

從理解的難易程度角度(從低到高)

數據庫 > 緩存 > Zookeeper

 

從實現的複雜性角度(從低到高)

Zookeeper >= 緩存 > 數據庫

 

從性能角度(從高到低)

緩存 > Zookeeper >= 數據庫

 

從可靠性角度(從高到低)

Zookeeper > 緩存 > 數據庫

相關文章
相關標籤/搜索