靠譜的基於zookeeper分佈式鎖實現

幾年前介紹過一種基於zk的分佈式鎖1的實現,那種是沒有通過實踐證實的,聽過一場分享,而後以爲原來分佈式鎖能夠這麼搞,而後在實驗環境寫了一些代碼,簡單驗證一下,就認爲成了。其實那裏面有幾個比較嚴重的問題,第一個是鎖操做若是在併發的狀況下不是block的,而是經過循環+sleep的方式來反覆判斷,性能上是比較差的,不夠實時。第二個是設計太複雜,其實能夠不須要ip地址的介入的。java

基於zookeeper,其實能夠設計出優雅的分佈式鎖。這個鎖能夠有這幾個API:lock()unlock()isLock(),lock用於加鎖,unlock用於解鎖,isLock用於判斷是否已經鎖住了。zookeeper提供了這麼一套機制,你能夠監控watch節點的變化(內容更新,子節點添加,刪除),而後節點變化的時候經過回調咱們的監控器(watcher)來通知咱們節點的實時變化。在這種機制下,咱們能夠很簡單的作一個鎖。node

在單機模式,沒有引入zookeeper時,咱們能夠經過建立一個臨時文件來加鎖,而後在事務處理完畢的時候,把這個臨時文件刪除就能表明解鎖了。這種簡單的加鎖和解鎖模式能夠移植到zookeeper上,經過建立一個路徑,來證實該鎖已經存在,而後刪除路徑來釋放該鎖。而同時zookeeper又能支持對節點的監控,這樣一來,咱們在多機的狀況下就能同時且實時知道鎖是存在仍是已經解鎖了。網絡

firstimage

如圖所示,咱們在/lock下建立了/app1  /app2 … /appN n個子目錄,用於適用不一樣的應用, 每一個/app* 下面均可以根據業務需求建立鎖 /lock. , 而每一個機器在獲取鎖的時候,會在/lock. 下面建立 _0000000* 的自增加臨時節點,這個節點上的數字用於表示獲取鎖的前後順序。前面說的仍是有點抽象,下面舉個例子:session

一個後臺應用(app爲back)總共有3臺機器在處理事務,其中有一個業務(lock爲 biz)同一時間只能有一臺機器能處理,其餘若是也同時收處處理消息的時候,須要對這個事務加個鎖,以保證全局的事務性。三臺機器分別表示爲 server1, server2和 server3。併發

secondimage

對應的,鎖的路徑就是 /lock/back/biz 。 首先 server1先收到消息,要處理事務biz,那麼獲取鎖,並在/lock/back/biz下建立一個臨時節點/lock/back/biz/_00000001 ,這時候判斷/lock/back/biz 下的子節點,看最小的那個是否是和本身建立的相等,若是是,說明本身已經獲取了鎖,能夠繼續下一步的操做,直到事務完成。app

thirdimage

與此同時,server2和server3也收到的消息(稍微慢於server1收到),這時候server2和server3也分別會在/lock/back/biz下面建立臨時節點/lock/back/biz/_00000002 和 /lock/back/biz/_00000003,他們這時候也會判斷/lock/back/biz下的子節點,發現最小的節點是_00000001,不是本身建立的那個,因而乎就會進入一個wait的操做(經過mutex很容易實現)。框架

fouthimage

以後,等server1的事務處理完畢,釋放server1獲取的鎖,也就是把 /_00000001刪掉。因爲zookeeper能夠監控節點的變化,刪掉/_00000001的時候,zookeeper能夠經過節點刪除的事件,通知到server1 server2 server3,這時候server2和server3對上面經過mutex block住的區塊發送信號量(notify),server2和server3繼續進入判斷/lock/back/biz下面的子節點以及最小的節點和本身作對比,這時候server2因爲建立了節點_00000002,因此輪到他來獲取鎖:dom

fifthimage

以後server2開始進入事務處理,而後釋放鎖,server3開始獲得鎖,處理事務,釋放鎖。以此類推。分佈式

這樣一來,整個事務(biz)的處理就能保證同時只有一臺機能處理到了。性能

總體僞代碼以下:

public class LockImpl{

   //獲取zk實例,最好是單例的或者是共享鏈接池,否則併發高的時候,容易掛

   private Zookeeper zk = getZookeeper();

   //用於本地作wait和notify

   private byte[] mutex = new byte[0];

   //節點監控器

   private Watcher watcher;

   //這個鎖生成的序列號

   private String serial;

   public LockImpl(){

       watcher = new DefaultWatcher(mutex);

       createIfNotExist();

   }



   private createIfNotExist(){

      String path = buildPath();

      //建立路徑,若是不存在的話

      createIfNotExsitPath(path);

      //註冊監控器,才能監控到。

      registWatcher(watcher);

   }



   public  void lock(){

       //獲取序列號,其實就是建立一個當前path下的臨時節點,zk會返回該節點的值

       String serial = genSerial();

       while(true){

          //獲取子節點

          List<string> children = getChildren();

         //按從小到大排序

          sort(children);

          //若是當前節點是第一個,說明被當前進程的線程獲取。

          if(children.index(serial) == 0){

               //you get the lock

               break;

          }else{

               //不然等待別人刪除節點後通知,而後進入下一次循環。

               synchronized(mutex){

                   mutex.wait();

               }

          }

       }

      return;

   }



   public void unlock(){

          //刪除子節點就能解鎖

          deletePath(serial);

   }



   public void isLock(){

        //判斷路徑下面是否有子節點便可

        return ifHasChildren();

   }



}



//監控器類

public class DefaultWatcher implements Watcher{

      private byte[] mutex;

      public     DefaultWatcher(byte[] mutex){

          this.mutex = mutex;

      }



     public void process(WatchedEvent event){

          synchronized (mutex) {

               mutex.notifyAll();

          }

     }

}


至此,一個看起來優雅一點的分佈式鎖就實現了。這是一個理想狀態下的實現,咋看起來沒問題,其實裏面隱藏了比較嚴重的問題。就是這裏把zookeeper理想化了,認爲他是完美的,不會出現問題。這裏說的問題倒不是必定是zk的bug,好比網絡問題,在分佈式系統中,網絡問題是一個很常見的問題,很容易就會有異常的狀況。若是出現網絡問題,會出現什麼狀況呢?答案是「死鎖」

下面按多種狀況來分析這個問題:

當咱們向zk發起請求,要求建立一個臨時節點的時候,發生了異常(包括網絡異常,zk的session失效等)

  1. 假設zk的服務端還沒收到請求,這時候很簡單,咱們客戶端作一下重連和從新建立就能夠了,問題不大。
    sixth
  2. 假設zk服務端收到請求了,但服務端發生異常,沒有建立成功,那麼咱們客戶端再次重試一下就能夠了。
    seventh

  3. 假設zk服務端收到了請求,子節點建立成功了,可是返回給客戶端的時候網絡發生了異常。這時候咱們要是再作重試,建立的話,就會進入「死鎖」。這裏的「死鎖」跟咱們平時理解的死鎖不是一個概念,這裏的意思不是迴路循環等待,而是至關於這個鎖就是死掉了,由於前面的異常請求中其實建立了一個節點,而後這個節點沒有客戶端與他關聯,咱們能夠稱爲幽靈節點。這個幽靈節點因爲前後順序,他是優先級最高的,然而沒有客戶端跟他關聯,也就是沒有客戶端能夠釋放這個幽靈節點,那麼全部的客戶端都會進入無限的等待,形成了「死鎖」的現象。
    eighth
    ninth

  4. 假設zk服務端收到請求了,子節點建立成功了,返回給客戶端成功了,可是客戶端在處理的時發生了異常(這種通常是zk的bug纔會出現),這時候咱們再作一次重試,也會進入上面的「死鎖」現象。
    tenth
    爲何會出現3,4的現象呢?由於咱們只是作了簡單的重試,並無對服務端和客戶端作驗證。就是客戶端建立了一個幽靈節點,可是建立者自己甚至都不知道是本身建立的幽靈節點,仍是別人建立的。要如何規避這個問題呢?廢話,引入驗證的流程。就是驗證+無限重試。怎麼作驗證,不要把驗證想的太複雜,驗證就是你建立的節點裏面有你建立的私有的信息,你客戶端自己也擁有這個信息,而後二者一對比,就能知道哪一個節點是哪一個客戶端建立的。固然,這個信息必須保證我有你無的,也就是惟一性。簡單了,引入UUID就能夠搞定這個問題。

1. 建立臨時節點以前,客戶端先生成一個UUID,(直接用JDK的UUID.randomUUID().toString()就能夠了)。

2. 建立臨時節點時,把這個uuid做爲節點的一部分,建立出一個臨時節點。

3. 重試建立的流程中加入對已存在的UUID的判斷,對因而當前進程建立的子節點再也不重複建立。

4. 對children排序的時候,把uuid去除後再排序,就能達到先進先出的效果。

好比客戶端1,生成了UUID: fd456c28-cc85-4e2f-8d52-bcf7538a2acf, 而後建立了一個臨時節點: /lock/back/biz/_fd456c28-cc85-4e2f-8d52-bcf7538a2acf_00000001
這時候服務端返回異常,拿客戶端第一件事是先把children撈出來,而後判斷這些children裏面有沒有本身建立的 uuid,若是有的話,說明本身實際上是建立成功了,而後就看是否是輪到本身了,解決3的問題。若是返回正常,可是客戶端有bug拋異常了,這時客戶端仍要進行重試,重試以前也會走前面的流程,能夠解決4的問題。

對children排序不能簡單的把node的路徑進行排序,由於randomUUID是徹底隨機的,按這個排序可能會致使某些鎖請求一直沒有被響應,也會有問題。這裏由於UUID的長度是固定的,並且也有規律可循,因此很容易從node中分解出 uuid和序列號,而後對序列號進行排序,找出最小的值,再賦予鎖,就能夠了。

分佈式系統中,異常出現很正常,若是你的業務須要覓等操做(N^m = N)的話,就須要引入驗證和重試的機制。分佈式鎖就是須要一個覓等的操做,因此一個靠譜的分佈式鎖的實現,驗證和重試的機制是少不了的,這就是我想說的。具體要參考實現的話,能夠看netflix開源的zk客戶端框架curator2, 比直接用zk簡單得多也健壯得多。框架裏面也實現了一套分佈式鎖(還有其餘各類有用的東西),生產線其實能夠直接拿來使用的,很是方便。

相關文章
相關標籤/搜索