分散式鎖總結

x7lovelin發表於2018-11-28

分散式鎖總結

基於資料庫:

基於資料庫表做樂觀鎖,用於分散式鎖。(version) 

基於資料庫表做悲觀鎖(InnoDB,for update)

基於資料庫表資料記錄做唯一約束(表中記錄方法名稱)
複製程式碼

基於快取:

使用redis的setnx()用於分散式鎖。(setNx,直接設定值為當前時間+超時時間,保持操作原子性)

使用memcached的add()方法,用於分散式鎖。

使用Tair的put()方法,用於分散式鎖。
複製程式碼

基於Zookeeper:

每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 
判斷是否獲取鎖只需要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除即可。
複製程式碼

基於資料庫實現分散式鎖

基於資料庫表資料記錄做唯一約束(表中記錄方法名稱)

要實現分散式鎖,最簡單的方式可能就是直接建立一張鎖表,然後通過操作該表中的資料來實現了。

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

分散式鎖總結

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

分散式鎖總結

因為我們對method_name做了唯一性約束,這裡如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功(原子性),那麼我們就可以認為操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。

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

分散式鎖總結

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

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

2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。

3、這把鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒並不會進入排隊佇列,要想再次獲得鎖就要再次觸發獲得鎖操作。

4、這把鎖是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。

當然,我們也可以有其他方式解決上面的問題。

  • 資料庫是單點?搞兩個資料庫,資料之前雙向同步。一旦掛掉快速切換到備庫上。
  • 沒有失效時間?只要做一個定時任務,每隔一定時間把資料庫中的超時資料清理一遍。
  • 非阻塞的?搞一個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語句真正更新影響到了一行資料,那就說明佔位成功。如果沒有更新影響到一行資料
      ,則說明這個資源已經被別人佔位了。
    複製程式碼
  1. 基於資料庫表做樂觀鎖的一些缺點:

(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 > 快取 > 資料庫

相關文章