跟著小白學zookeeper: 分散式鎖的實現

Mr_小白發表於2018-09-03

前言

最近小白在做一個系統功能時,發現有個方法是需要做同步的,but,生產環境中專案的部署是多個tomcat做叢集的,而簡單的使用synchronized加鎖只是針對同一個JVM程式中的多執行緒實現同步,對於跨程式的同步無法達到統一加鎖的目的。於是,小白便想到了分散式鎖。前段時間剛好看到一幅有意思的漫畫,其中就提到Zookeeper被設計的初衷,就是利用臨時順序節點,可以輕鬆實現分散式鎖,便研究了下利用zk實現分散式鎖。本文只研究了zk的基本特性以及使用java實現一個簡單的分散式鎖,如有錯誤,歡迎拍磚,另外稍微白話,不喜勿噴。

假設背景

假設小白的系統生產環境上部署了2臺tomcat(t1 和 t2),而同一時間使用者A、B的請求剛好分別由t1和t2進行響應處理,使用者A、B的請求都需要呼叫方法m作相關處理(對共享資料的處理),為了保證資料的準確性,小白希望一個時間點只有一個執行緒可以執行方法m,也就是說t1中有執行緒執行m時,t1、t2的其他執行緒都不能執行m,直至那個執行緒對m呼叫結束。

思考方案

單機環境下如何實現同步的?可以使用synchronized或是ReentrantLock實現,究其原理也是存在一個鎖標誌變數,執行緒每次要執行同步程式碼時先去檢視該標誌是否已經被其他執行緒佔有,若是則阻塞等待其他執行緒釋放鎖,若不是則設定標誌後執行(此處只是簡單描述,具體原理博大精深)。

為何跨程式就不行了呢?因為同一個程式內,鎖是所有這個程式內所有執行緒都可以訪問的,但是其他程式中的執行緒時訪問不了的。OK,那隻要提供一個所有程式內執行緒都可見的鎖標誌,問題就解決咯。so,zookeeper就可以充當第三方程式,對需要管理的程式開放訪問許可權,所有需要跨程式同步的程式碼在被執行前,都需要先來我大zk這裡檢視是否可以執行。

一、動手前多問幾個問題

為什麼zookeeper可以實現分散式鎖?

多個程式內同一時間都有執行緒在執行方法m,鎖就一把,你獲得了鎖得以執行,我就得被阻塞,那你執行完了誰來喚醒我呢?你並不知道我被阻塞了,你也就不能通知我“嗨,小白,我用完了,你用吧”。你能做的只有用的時候設定鎖標誌,用完了再取消你設定的標誌。我就必須在阻塞的時候隔一段時間主動去看看,但這樣總歸是有點麻煩的,最好有人來通知我可以執行了。zookeeper對於自身節點的監聽者提供事件通知功能,是不是有點雪中送炭的感覺呢。

節點是什麼? 節點是zookeeper中資料儲存的基礎結構,zk中萬物皆節點,就好比java中萬物皆物件是一樣的。zk的資料模型就是基於好多個節點的樹結構,但zk規定每個節點的引用規則是路徑引用。每個節點中包含子節點引用、儲存資料、訪問許可權以及節點後設資料等四部分。

zk中節點有型別區分嗎? 有。zk中提供了四種型別的節點,各種型別節點及其區別如下:

  • 持久節點(PERSISTENT):節點建立後,就一直存在,直到有刪除操作來主動清除這個節點
  • 持久順序節點(PERSISTENT_SEQUENTIAL):保留持久節點的特性,額外的特性是,每個節點會為其第一層子節點維護一個順序,記錄每個子節點建立的先後順序,ZK會自動為給定節點名加上一個數字字尾(自增的),作為新的節點名。
  • 臨時節點(EPHEMERAL):和持久節點不同的是,臨時節點的生命週期和客戶端會話繫結,當然也可以主動刪除。
  • 臨時順序節點(EPHEMERAL_SEQUENTIAL):保留臨時節點的特性,額外的特性如持久順序節點的額外特性。

如何操作節點? 節點的增刪改查分別是creat\delete\setData\getData,exists判斷節點是否存在,getChildren獲取所有子節點的引用。

上面提到了節點的監聽者,我們可以在對zk的節點進行查詢操作時,設定當前執行緒是否監聽所查詢的節點。getData、getChildren、exists都屬於對節點的查詢操作,這些方法都有一個boolean型別的watch引數,用來設定是否監聽該節點。一旦某個執行緒監聽了某個節點,那麼這個節點發生的creat(在該節點下新建子節點)、setData、delete(刪除節點本身或是刪除其某個子節點)都會觸發zk去通知監聽該節點的執行緒。但需要注意的是,執行緒對節點設定的監聽是一次性的,也就是說zk通知監聽執行緒後需要改執行緒再次設定監聽節點,否則該節點再次的修改zk不會再次通知。

zookeeper具備了實現分散式鎖的基礎條件:多程式共享、可以儲存鎖資訊、有主動通知的機制。

怎麼使用zookeeper實現分散式鎖呢?

分散式鎖也是鎖,沒什麼牛的,它也需要一個名字來告訴別人自己管理的是哪塊同步資源,也同樣需要一個標識告訴別人自己現在是空閒還是被使用。zk中,需要建立一個專門的放鎖的節點,然後各種鎖節點都作為該節點的子節點方便管理,節點名稱用來表明自己管理的同步資源。那麼鎖標識呢?

方案一:使用節點中的儲存資料區域,zk中節點儲存資料的大小不能超過1M,但是隻是存放一個標識是足夠的。執行緒獲得鎖時,先檢查該標識是否是無鎖標識,若是可修改為佔用標識,使用完再恢復為無鎖標識。

方案二:使用子節點,每當有執行緒來請求鎖的時候,便在鎖的節點下建立一個子節點,子節點型別必須維護一個順序,對子節點的自增序號進行排序,預設總是最小的子節點對應的執行緒獲得鎖,釋放鎖時刪除對應子節點便可。

死鎖風險

兩種方案其實都是可行的,但是使用鎖的時候一定要去規避死鎖。方案一看上去是沒問題的,用的時候設定標識,用完清除標識,但是要是持有鎖的執行緒發生了意外,釋放鎖的程式碼無法執行,鎖就無法釋放,其他執行緒就會一直等待鎖,相關同步程式碼便無法執行。方案二也存在這個問題,但方案二可以利用zk的臨時順序節點來解決這個問題,只要執行緒發生了異常導致程式中斷,就會丟失與zk的連線,zk檢測到該連結斷開,就會自動刪除該連結建立的臨時節點,這樣就可以達到即使佔用鎖的執行緒程式發生意外,也能保證鎖正常釋放的目的。

那要是zk掛了怎麼辦?sad,zk要是掛了就沒轍了,因為執行緒都無法連結到zk,更何談獲取鎖執行同步程式碼呢。不過,一般部署的時候,為了保證zk的高可用,都會使用多個zk部署為叢集,叢集內部一主多從,主zk一旦掛掉,會立刻通過選舉機制有新的主zk補上。zk叢集掛了怎麼辦?不好意思,除非所有zk同時掛掉,zk叢集才會掛,概率超級小。

二、開始動手搞一搞

要什麼東西

  1. 需要一個鎖物件,每次建立這個鎖物件的時候需要連線zk(也可將連線操作放在加鎖的時候);
  2. 鎖物件需要提供一個加鎖的方法;
  3. 鎖物件需要提供一個釋放鎖的方法;
  4. 鎖物件需要監聽zk節點,提供接收zk通知的回撥方法。

實現分析

  1. 構造器中,建立zk連線,建立鎖的根節點,相關API如下:

    public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)
    建立zk連線。該構造器要求傳入三個引數分別是:ip:埠(String)、會話超時時間、本次連線的監聽器。
    public String create(String path, byte[] data, List<ACL> acl, CreateMode createMode) 建立節點。引數:節點路徑、節點資料、許可權策略、節點型別

  2. 加鎖時,首先需要在鎖的根節點下建立一個臨時順序節點(該節點名稱規則統一,由zk拼接自增序號),然後獲取根節點下所有子節點,將節點根據自增序號進行排序,判斷最小的節點是否為本次加鎖建立的節點,若是,加鎖成功,若否,阻塞當前執行緒,等待鎖釋放(阻塞執行緒可以使用)。相關API如下:

    public List<String> getChildren(String path, boolean watch)
    獲取某節點的所有子節點。引數:節點路徑、是否監控該節點

  3. 釋放鎖時,刪除執行緒建立的子節點,同時關閉zk連線。相關API如下:

    public void delete(String path, int version)
    刪除指定節點。引數:節點路徑、資料版本號
    public synchronized void close()
    斷開zk連結

  4. 監聽節點。首先需要明確監聽哪個節點,我們可以監聽鎖的根節點,這樣每當有執行緒釋放鎖刪除對應子節點時,zk就會通知監聽執行緒,有鎖被釋放了,這個時候只需要獲取根節點的所有子節點,根據自增序號判斷自己對應的節點是否為最小,便可知道自己能否獲取鎖。但是上述做法很明顯有一點不太好,只要有子節點被移除,zk就會重新通知所有等待鎖的執行緒。獲得不到鎖的執行緒接收到通知後發現自己還需等待,又得重新設定監聽再次等待。由於我們要採用臨時有序節點,該型別節點的特性就是有序,那麼就可以只監聽上一個節點,也就是等待被移除的節點,這樣可以保證接到通知時,就是對應子節點時最小,可以獲得鎖的時候。在實現分散式鎖的時候,執行緒加鎖時如果不能立馬獲得鎖,便會被通過特定方式阻塞,那麼既然接到通知時便是可以獲得鎖的時候,那麼對應的操作就應該是恢復執行緒的執行,取消阻塞

    zk提供了Watcher介面,鎖物件需要監聽zk中上一個節點,便需要實現該介面。Watcher介面內部包含封裝了事件型別和連線型別的Event介面,還提供了唯一一個需要實現的方法。
    void process(WatchedEvent var1)
    該方法便是用來接收zk通知的回撥方法。引數為監聽節點發生的事件。當監聽器監聽的節點發生變化時,zk會通知監聽者,同時該方法被執行,引數便是zk通知的資訊。

開寫程式碼

雖然是一個簡單的分散式鎖的實現,程式碼也有點略長。建議跟小白一樣從零開始瞭解分散式鎖實現的朋友,先從上面的大步驟分析簡單思考下每個方法內部的具體實現再看程式碼,印象更為深刻,理解也更容易。如有不同思路,歡迎留言討論。程式碼中判斷加鎖的方法中,使用分隔符字串是為了區分各個資源的鎖。專案中有臨界資源A和B,那麼管理A的鎖釋放與否,跟執行緒要持有管理B的鎖是沒有關係的。當然,也可以每一類鎖單獨建立獨立的根節點。

public class ZooKeeperLock implements Watcher {

    private ZooKeeper zk = null;
    private String rootLockNode;            // 鎖的根節點
    private String lockName;                // 競爭資源,用來生成子節點名稱
    private String currentLock;             // 當前鎖
    private String waitLock;                // 等待的鎖(前一個鎖)
    private CountDownLatch countDownLatch;  // 計數器(用來在加鎖失敗時阻塞加鎖執行緒)
    private int sessionTimeout = 30000;     // 超時時間
    
    // 1. 構造器中建立ZK連結,建立鎖的根節點
    public ZooKeeperLock(String zkAddress, String rootLockNode, String lockName) {
        this.rootLockNode = rootLockNode;
        this.lockName = lockName;
        try {
            // 建立連線,zkAddress格式為:IP:PORT
            zk = new ZooKeeper(zkAddress,this.sessionTimeout,this);
            // 檢測鎖的根節點是否存在,不存在則建立
            Stat stat = zk.exists(rootLockNode,false);
            if (null == stat) {
                zk.create(rootLockNode, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
    
    // 2. 加鎖方法,先嚐試加鎖,不能加鎖則等待上一個鎖的釋放
    public boolean lock() {
        if (this.tryLock()) {
            System.out.println("執行緒【" + Thread.currentThread().getName() + "】加鎖(" + this.currentLock + ")成功!");
            return true;
        } else {
            return waitOtherLock(this.waitLock, this.sessionTimeout);
        }
    }
    
    public boolean tryLock() {
        // 分隔符
        String split = "_lock_";
        if (this.lockName.contains("_lock_")) {
            throw new RuntimeException("lockName can't contains '_lock_' ");
        }
        try {
            // 建立鎖節點(臨時有序節點)
            this.currentLock = zk.create(this.rootLockNode + "/" + this.lockName + split, new byte[0],
                    ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println("執行緒【" + Thread.currentThread().getName() 
                        + "】建立鎖節點(" + this.currentLock + ")成功,開始競爭...");
            // 取所有子節點
            List<String> nodes = zk.getChildren(this.rootLockNode, false);
            // 取所有競爭lockName的鎖
            List<String> lockNodes = new ArrayList<String>();
            for (String nodeName : nodes) {
                if (nodeName.split(split)[0].equals(this.lockName)) {
                    lockNodes.add(nodeName);
                }
            }
            Collections.sort(lockNodes);
            // 取最小節點與當前鎖節點比對加鎖
            String currentLockPath = this.rootLockNode + "/" + lockNodes.get(0);
            if (this.currentLock.equals(currentLockPath)) {
                return true;
            }
            // 加鎖失敗,設定前一節點為等待鎖節點
            String currentLockNode = this.currentLock.substring(this.currentLock.lastIndexOf("/") + 1);
            int preNodeIndex = Collections.binarySearch(lockNodes, currentLockNode) - 1;
            this.waitLock = lockNodes.get(preNodeIndex);
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    private boolean waitOtherLock(String waitLock, int sessionTimeout) {
        boolean islock = false;
        try {
            // 監聽等待鎖節點
            String waitLockNode = this.rootLockNode + "/" + waitLock;
            Stat stat = zk.exists(waitLockNode,true);
            if (null != stat) {
                System.out.println("執行緒【" + Thread.currentThread().getName() 
                            + "】鎖(" + this.currentLock + ")加鎖失敗,等待鎖(" + waitLockNode + ")釋放...");
                // 設定計數器,使用計數器阻塞執行緒
                this.countDownLatch = new CountDownLatch(1);
                islock = this.countDownLatch.await(sessionTimeout,TimeUnit.MILLISECONDS);
                this.countDownLatch = null;
                if (islock) {
                    System.out.println("執行緒【" + Thread.currentThread().getName() + "】鎖(" 
                                + this.currentLock + ")加鎖成功,鎖(" + waitLockNode + ")已經釋放");
                } else {
                    System.out.println("執行緒【" + Thread.currentThread().getName() + "】鎖(" 
                                + this.currentLock + ")加鎖失敗...");
                }
            } else {
                islock = true;
            }
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return islock;
    }
    
    // 3. 釋放鎖
    public void unlock() throws InterruptedException {
        try {
            Stat stat = zk.exists(this.currentLock,false);
            if (null != stat) {
                System.out.println("執行緒【" + Thread.currentThread().getName() + "】釋放鎖 " + this.currentLock);
                zk.delete(this.currentLock, -1);
                this.currentLock = null;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        } finally {
            zk.close();
        }
    }
    
    // 4. 監聽器回撥
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (null != this.countDownLatch && watchedEvent.getType() == Event.EventType.NodeDeleted) {
            // 計數器減一,恢復執行緒操作
            this.countDownLatch.countDown();
        }
    }
}
複製程式碼

測試類如下:

public class Test {
    public static void doSomething() {
        System.out.println("執行緒【" + Thread.currentThread().getName() + "】正在執行...");
    }

    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            public void run() {
                ZooKeeperLock lock = null;
                lock = new ZooKeeperLock("10.150.27.51:2181","/locks", "test1");
                if (lock.lock()) {
                    doSomething();
                    try {
                        Thread.sleep(1000);
                        lock.unlock();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}
複製程式碼

這裡啟動了5個執行緒來進行驗證,輸出結果如下。需要注意的是,子節點的建立順序一定是從小到大的,但是下面輸出結果中顯示建立順序的隨機是由於建立節點和輸出語句不是原子操作導致的。重點是鎖的獲取和釋放,從輸出結果中可以看出,每個執行緒只有在上一個節點被刪除後才能執行。ok,一個基於zk的簡單的分散式鎖就實現了。

執行緒【Thread-3】建立鎖節點(/locks/test1_lock_0000000238)成功,開始競爭...
執行緒【Thread-2】建立鎖節點(/locks/test1_lock_0000000237)成功,開始競爭...
執行緒【Thread-1】建立鎖節點(/locks/test1_lock_0000000236)成功,開始競爭...
執行緒【Thread-0】建立鎖節點(/locks/test1_lock_0000000240)成功,開始競爭...
執行緒【Thread-4】建立鎖節點(/locks/test1_lock_0000000239)成功,開始競爭...
執行緒【Thread-1】加鎖(/locks/test1_lock_0000000236)成功!
執行緒【Thread-1】正在執行...
執行緒【Thread-3】鎖(/locks/test1_lock_0000000238)加鎖失敗,等待鎖(/locks/test1_lock_0000000237)釋放...
執行緒【Thread-2】鎖(/locks/test1_lock_0000000237)加鎖失敗,等待鎖(/locks/test1_lock_0000000236)釋放...
執行緒【Thread-0】鎖(/locks/test1_lock_0000000240)加鎖失敗,等待鎖(/locks/test1_lock_0000000239)釋放...
執行緒【Thread-4】鎖(/locks/test1_lock_0000000239)加鎖失敗,等待鎖(/locks/test1_lock_0000000238)釋放...
執行緒【Thread-1】釋放鎖 /locks/test1_lock_0000000236
執行緒【Thread-2】鎖(/locks/test1_lock_0000000237)加鎖成功,鎖(/locks/test1_lock_0000000236)已經釋放
執行緒【Thread-2】正在執行...
執行緒【Thread-2】釋放鎖 /locks/test1_lock_0000000237
執行緒【Thread-3】鎖(/locks/test1_lock_0000000238)加鎖成功,鎖(/locks/test1_lock_0000000237)已經釋放
執行緒【Thread-3】正在執行...
執行緒【Thread-3】釋放鎖 /locks/test1_lock_0000000238
執行緒【Thread-4】鎖(/locks/test1_lock_0000000239)加鎖成功,鎖(/locks/test1_lock_0000000238)已經釋放
執行緒【Thread-4】正在執行...
執行緒【Thread-4】釋放鎖 /locks/test1_lock_0000000239
執行緒【Thread-0】鎖(/locks/test1_lock_0000000240)加鎖成功,鎖(/locks/test1_lock_0000000239)已經釋放
執行緒【Thread-0】正在執行...
執行緒【Thread-0】釋放鎖 /locks/test1_lock_0000000240
複製程式碼

三、別人造好的輪子

話說zookeeper紅火了這麼久,就沒有幾個牛逼的人物去開源一些好用的工具,還需要自己這麼費勁去寫分散式鎖的實現?是的,有的,上面小白也只是為了加深自己對zk實現分散式鎖的理解去嘗試做一個簡單實現。有個叫Jordan Zimmerman的牛人提供了Curator來更好地操作zookeeper。

curator的分散式鎖

curator提供了四種分散式鎖,分別是:

curator的四種鎖方案

  • InterProcessMutex:分散式可重入排它鎖
  • InterProcessSemaphoreMutex:分散式排它鎖
  • InterProcessReadWriteLock:分散式讀寫鎖
  • InterProcessMultiLock:將多個鎖作為單個實體管理的容器

pom依賴:

    <dependency>
      <groupId>org.apache.curator</groupId>
      <artifactId>curator-framework</artifactId>
      <version>4.0.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.curator</groupId>
      <artifactId>curator-recipes</artifactId>
      <version>4.0.0</version>
    </dependency>
複製程式碼

這裡使用InterProcessMutex,即分散式可重入排他鎖,用法如下:

// 設定重試策略,建立zk客戶端
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
CuratorFramework client = CuratorFrameworkFactory.newClient("10.150.27.51:2181",retryPolicy);
// 啟動客戶端
client.start();
// 建立分散式可重入排他鎖,監聽客戶端為client,鎖的根節點為/locks
InterProcessMutex mutex = new InterProcessMutex(client,"/locks");
try {
    // 加鎖
    if (mutex.acquire(3,TimeUnit.SECONDS)) {
        // TODO-同步操作
        //釋放鎖
        mutex.release();
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    client.close();
}
複製程式碼

InterProcessMutex原始碼解讀

InterProcessMutex改造器較多,這裡就不展示改造器原始碼了,建議感興趣的朋友自己看看。InterProcessMutex內部有個ConcurrentMap型別的threadData屬性,該屬性會以執行緒物件為鍵,執行緒對應的LcokData物件為值,記錄每個鎖的相關資訊。在new一個InterProcessMutex例項時,其構造器主要是為threadData進行Map初始化,校驗鎖的根節點的合法性並使用basePath屬性記錄,此外還會例項化一個LockInternals物件由屬性internals引用,LockInternalsInterProcessMutex加鎖的核心。

加鎖

    // InterProcessMutex.class
    public void acquire() throws Exception {
        if (!this.internalLock(-1L, (TimeUnit)null)) {
            throw new IOException("Lost connection while trying to acquire lock: " + this.basePath);
        }
    }
    
    public boolean acquire(long time, TimeUnit unit) throws Exception {
        return this.internalLock(time, unit);
    }
    
    private boolean internalLock(long time, TimeUnit unit) throws Exception {
        Thread currentThread = Thread.currentThread();
        InterProcessMutex.LockData lockData = (InterProcessMutex.LockData)this.threadData.get(currentThread);
        if (lockData != null) {
            // 鎖的可重入性
            lockData.lockCount.incrementAndGet();
            return true;
        } else {
            // 加鎖並返回鎖節點
            String lockPath = this.internals.attemptLock(time, unit, this.getLockNodeBytes());
            if (lockPath != null) {
                InterProcessMutex.LockData newLockData = new InterProcessMutex.LockData(currentThread, lockPath);
                this.threadData.put(currentThread, newLockData);
                return true;
            } else {
                return false;
            }
        }
    }
複製程式碼

加鎖提供了兩個介面,分別為不設定超時和設定超時。不設定超時的話,執行緒等待鎖時會一直阻塞,直到獲取到鎖。不管哪個加鎖介面,都呼叫了internalLock()方法。這個方法裡的程式碼體現了鎖的可重入性。InterProcessMutex會直接從threadData中根據當前執行緒獲取其LockData,若LockData不為空,則意味著當前執行緒擁有此,在鎖的次數上加一就直接返回true。若為空,則通過internals屬性的attemptLock()方法去競爭鎖,該方法返回一個鎖對應節點的路徑。若該路徑不為空,代表當前執行緒獲得到了鎖,然後為當前執行緒建立對應的LcokData並記錄進threadData中。

競爭鎖

    // LockInternals.class
    String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
        long startMillis = System.currentTimeMillis();
        Long millisToWait = unit != null ? unit.toMillis(time) : null;
        byte[] localLockNodeBytes = this.revocable.get() != null ? new byte[0] : lockNodeBytes;
        int retryCount = 0;
        String ourPath = null;
        boolean hasTheLock = false;
        boolean isDone = false;

        while(!isDone) {
            isDone = true;
            try {
                // 建立鎖節點
                ourPath = this.driver.createsTheLock(this.client, this.path, localLockNodeBytes);
                // 競爭鎖
                hasTheLock = this.internalLockLoop(startMillis, millisToWait, ourPath);
            } catch (NoNodeException var14) {
                if (!this.client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++,  
                        System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper())) {
                    throw var14;
                }

                isDone = false;
            }
        }

        return hasTheLock ? ourPath : null;
    }
複製程式碼

一看這個方法,一大堆的變數定義,全部先忽略掉。最終的返回值由hasTheLock決定,為true時返回ourPathourPath初始化為null,後經this.driver.createsTheLock(this.client, this.path, localLockNodeBytes)賦值,這個方法點選去可看到預設的鎖驅動類的建立鎖節點方法,可知這裡只是建立了鎖節點。再看hasTheLock,為internalLockLoop()方法的返回值,只有該方法返回true時,attemptLock()才會返回鎖節點路徑,才會加鎖成功。那OK,鎖的競爭實現是由internalLockLoop進行。上面迴圈中的異常捕捉中是根據客戶端的重試策略進行重試。

     // LockInternals.class
    private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception {
        boolean haveTheLock = false;
        boolean doDelete = false;

        try {
            if (this.revocable.get() != null) {
                ((BackgroundPathable)this.client.getData().usingWatcher(this.revocableWatcher)).forPath(ourPath);
            }
            while(this.client.getState() == CuratorFrameworkState.STARTED && !haveTheLock) {
                // 獲取所有子節點
                List<String> children = this.getSortedChildren();
                // 獲取當前鎖節點
                String sequenceNodeName = ourPath.substring(this.basePath.length() + 1);
                // 使用鎖驅動加鎖
                PredicateResults predicateResults = this.driver.getsTheLock(this.client, children, 
                            sequenceNodeName, this.maxLeases);
                if (predicateResults.getsTheLock()) {
                    haveTheLock = true;
                } else {
                    // 阻塞等待上一個鎖釋放
                    String previousSequencePath = this.basePath + "/" + predicateResults.getPathToWatch();
                    synchronized(this) {
                        try {
                            ((BackgroundPathable)this.client.getData().usingWatcher(this.watcher)).forPath(previousSequencePath);
                            if (millisToWait == null) {
                                // 未設定超時一直阻塞
                                this.wait();
                            } else {
                                millisToWait = millisToWait - (System.currentTimeMillis() - startMillis);
                                startMillis = System.currentTimeMillis();
                                // 根據時間設定阻塞時間
                                if (millisToWait > 0L) {
                                    this.wait(millisToWait);
                                } else {
                                    // 已經超時,設定刪除節點標識
                                    doDelete = true;
                                    break;
                                }
                            }
                        } catch (NoNodeException var19) {
                            ;
                        }
                    }
                }
            }
        } catch (Exception var21) {
            ThreadUtils.checkInterrupted(var21);
            doDelete = true;
            throw var21;
        } finally {
            if (doDelete) {
                // 刪除已超時的鎖節點
                this.deleteOurPath(ourPath);
            }
        }
        return haveTheLock;
    }
複製程式碼

好吧,又是一大堆程式碼。還是先挑著看,返回值是haveTheLock,布林型,看名字就知道這個變數代表競爭鎖的成功與否。該變數的賦值發生在迴圈內,ok,看迴圈。先是獲取所有子節點以及當前節點名稱,再由驅動類進行鎖競爭,競爭結果封裝在PredicateResults類中,該類中包含一個布林型的結果標識getsTheLock和一個監聽節點路徑pathToWatch。最後根據所競爭結果決定是否阻塞執行緒等待監聽鎖節點的釋放。需要注意的是,這裡阻塞使用的是物件的wait()機制,同時根據是否設定超時時間,是否已經超時決定執行緒阻塞時間或是刪除超時節點。but,鎖競爭的具體實現還是不在這裡,這裡只是有詳細的鎖等待實現。Curator預設的鎖驅動類是StandardLockInternalsDriver

    // StandardLockInternalsDriver.class
    public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, 
            int maxLeases) throws Exception {
        int ourIndex = children.indexOf(sequenceNodeName);
        validateOurIndex(sequenceNodeName, ourIndex);
        boolean getsTheLock = ourIndex < maxLeases;
        String pathToWatch = getsTheLock ? null : (String)children.get(ourIndex - maxLeases);
        return new PredicateResults(pathToWatch, getsTheLock);
    }
複製程式碼

首先獲取所有子節點中當前節點所在的位置索引,然後校驗該索引,內部實現為判斷是否小於0,成立則丟擲一個NoNodeException。那肯定不是0啦。最終能否獲得鎖取決於該位置索引是否為0,也就是當前節點是否最小(maxLeases在InterProcessMutex構造器中初始化LockInternals設定的是1)。

總結

本文基於ZK實現分散式鎖的思路、實現以及Curator的分散式可重入排他鎖的原理剖析,算是小白研究ZK實現分散式鎖的所有收穫了。個人覺的關鍵點還是在於以下幾點:

  • 利用臨時節點避免客戶端程式異常導致的死鎖;
  • 利用有序節點設定鎖的獲取規則;
  • 利用程式內的執行緒同步機制實現跨程式的分散式鎖等待。

嗯,應該就這些了,要是小白有哪裡遺漏的,後續再補。

參考資料

漫畫:什麼是ZooKeeper?

Curator官方文件

相關文章