java 分佈式鎖 -圖解- 秒懂

瘋狂創客圈 Java 分佈式聊天室【 億級流量】實戰系列之 -26【 博客園 總入口node


寫在前面

​ 你們好,我是做者尼恩。目前和幾個小夥伴一塊兒,組織了一個高併發的實戰社羣【瘋狂創客圈】。正在開始高併發、億級流程的 IM 聊天程序 學習和實戰面試

​ 前面,已經完成一個高性能的 Java 聊天程序的四件大事:redis

接下來,須要進入到分佈式開發的環節了。 分佈式的中間件,瘋狂創客圈的小夥伴們,一致的選擇了zookeeper,不只僅是因爲其在大數據領域,太有名了。更重要的是,不少的著名框架,都使用了zk。算法

本篇介紹 ZK Curator 的分佈式鎖實現安全

1.1. 分佈式鎖 簡介

在咱們進行單機應用開發,涉及併發同步的時候,咱們每每採用synchronized或者Lock的方式來解決多線程間的代碼同步問題。但當咱們的應用是分佈式集羣工做的狀況下,那麼就須要一種更加高級的鎖機制,來處理種跨機器的進程之間的數據同步問題。服務器

這就是分佈式鎖。網絡

1.1.1. 圖解:公平鎖和可重入鎖 模型

分佈式鎖的概念和原理,比較抽象難懂。若是用一個簡單的故事來類比,估計就簡單多了。多線程

好久之前,在一個村子有一口井,水質很是的好,村民們都搶着取井裏的水。井就那麼一口,村裏的人不少,村民爲爭搶取水打架鬥毆,甚至頭破血流。併發

問題老是要解決,因而村長絞盡腦汁,最終想出了一個憑號取水的方案。井邊安排一個看井人,維護取水的秩序。

提及來,秩序很簡單,取水以前,先取號。號排在前面的,就能夠先取水。先到的排在前面,那些後到的,沒有排在最前面的人,一個一個挨着,在井邊排成一隊。取水示意圖以下 :

在這裏插入圖片描述

這種排隊取水模型,就是一種鎖的模型。排在最前面的號,擁有取水權,就是一種典型的獨佔鎖。另外,先到先得,號排在前面的人先取到水,取水以後就輪到下一個號取水,至少,看起來挺公平的,說明它是一種公平鎖。

在公平獨佔鎖的基礎上,再進一步,看看可重入鎖的模型。

假定,取水時以家庭爲單位,哪一個家庭任何人拿到號,就能夠排號取水,並且若是一個家庭有一我的拿到號,其它家人這時候過來打水不用再取號。新的排號取水示意圖以下 :

在這裏插入圖片描述

如上圖的1號,老公有號,他的老婆來了,直接排第一個,妻憑夫貴。再看上圖的2號,父親正在打水,他的兒子和女兒也到井邊了,直接排第二個,這個叫作子憑父貴。 等等,若是是同一個家庭,能夠直接複用排號,不用從新取號從後面排起。

以上這個故事模型,就是能夠重入鎖的模型。只要知足條件,同一個排號,能夠用來屢次取水。在鎖的模型中,至關於一把鎖,能夠被屢次鎖定,這就叫作可重入鎖。

1.1.2. 圖解: zookeeper分佈式鎖的原理

理解了鎖的原理後,就會發現,Zookeeper 天生就是一副分佈式鎖的胚子。

首先,Zookeeper的每個節點,都是一個自然的順序發號器。

在每個節點下面建立子節點時,只要選擇的建立類型是有序(EPHEMERAL_SEQUENTIAL 臨時有序或者PERSISTENT_SEQUENTIAL 永久有序)類型,那麼,新的子節點後面,會加上一個次序編號。這個次序編號,是上一個生成的次序編號加一

好比,建立一個用於發號的節點「/test/lock」,而後以他爲父親節點,能夠在這個父節點下面建立相同前綴的子節點,假定相同的前綴爲「/test/lock/seq-」,在建立子節點時,同時指明是有序類型。若是是第一個建立的子節點,那麼生成的子節點爲/test/lock/seq-0000000000,下一個節點則爲/test/lock/seq-0000000001,依次類推,等等。

在這裏插入圖片描述

其次,Zookeeper節點的遞增性,能夠規定節點編號最小的那個得到鎖。

一個zookeeper分佈式鎖,首先須要建立一個父節點,儘可能是持久節點(PERSISTENT類型),而後每一個要得到鎖的線程都會在這個節點下建立個臨時順序節點,因爲序號的遞增性,能夠規定排號最小的那個得到鎖。因此,每一個線程在嘗試佔用鎖以前,首先判斷本身是排號是否是當前最小,若是是,則獲取鎖。

第三,Zookeeper的節點監聽機制,能夠保障佔有鎖的方式有序並且高效。

每一個線程搶佔鎖以前,先搶號建立本身的ZNode。一樣,釋放鎖的時候,就須要刪除搶號的Znode。搶號成功後,若是不是排號最小的節點,就處於等待通知的狀態。等誰的通知呢?不須要其餘人,只須要等前一個Znode 的通知就能夠了。當前一個Znode 刪除的時候,就是輪到了本身佔有鎖的時候。第一個通知第二個、第二個通知第三個,擊鼓傳花似的依次向後。

Zookeeper的節點監聽機制,能夠說可以很是完美的,實現這種擊鼓傳花似的信息傳遞。具體的方法是,每個等通知的Znode節點,只須要監聽linsten或者 watch 監視排號在本身前面那個,並且緊挨在本身前面的那個節點。 只要上一個節點被刪除了,就進行再一次判斷,看看本身是否是序號最小的那個節點,若是是,則得到鎖。

爲何說Zookeeper的節點監聽機制,能夠說是很是完美呢?

一條龍式的首尾相接,後面監視前面,就不怕中間截斷嗎?好比,在分佈式環境下,因爲網絡的緣由,或者服務器掛了或則其餘的緣由,若是前面的那個節點沒能被程序刪除成功,後面的節點不就永遠等待麼?

其實,Zookeeper的內部機制,能保證後面的節點可以正常的監聽到刪除和得到鎖。在建立取號節點的時候,儘可能建立臨時znode 節點而不是永久znode 節點,一旦這個 znode 的客戶端與Zookeeper集羣服務器失去聯繫,這個臨時 znode 也將自動刪除。排在它後面的那個節點,也能收到刪除事件,從而得到鎖。

說Zookeeper的節點監聽機制,是很是完美的。還有一個緣由。

Zookeeper這種首尾相接,後面監聽前面的方式,能夠避免羊羣效應。所謂羊羣效應就是每一個節點掛掉,全部節點都去監聽,而後作出反映,這樣會給服務器帶來巨大壓力,因此有了臨時順序節點,當一個節點掛掉,只有它後面的那一個節點才作出反映。

1.1.3. 分佈式鎖的基本流程

接下來就是基於zookeeper,實現一下分佈式鎖。

首先定義了一個鎖的接口,很簡單,一個加鎖方法,一個解鎖方法。

/**
 * create by 尼恩 @ 瘋狂創客圈
 **/
public interface Lock {

    boolean lock() throws Exception;

    boolean unlock();
}

使用zookeeper實現分佈式鎖的算法流程,大體以下:

(1)若是鎖空間的根節點不存在,首先建立Znode根節點。這裏假設爲「/test/lock」。這個根節點,表明了一把分佈式鎖。

(2)客戶端若是須要佔用鎖,則在「/test/lock」下建立臨時的且有序的子節點。

這裏,儘可能使一個有意義的子節點前綴,好比「/test/lock/seq-」。則第一個客戶端對應的子節點爲「/test/lock/seq-000000000」,第二個爲 「/test/lock/seq-000000001」,以此類推。

若是前綴爲「/test/lock/」,則第一個客戶端對應的子節點爲「/test/lock/000000000」,第二個爲 「/test/lock/000000001」 ,以此類推,也很是直觀。

(3)客戶端若是須要佔用鎖,還須要判斷,判斷本身建立的子節點是否爲當前子節點列表中序號最小的子節點。若是是則認爲得到鎖,不然監聽前一個Znode子節點變動消息,得到子節點變動通知後重復此步驟直至得到鎖;

(4)獲取鎖後,開始處理業務流程。完成業務流程後,刪除對應的子節點,完成釋放鎖的工做。以便後面的節點得到分佈式鎖。

1.1.4. 加鎖的實現

lock方法的具體算法是,首先嚐試着去加鎖,若是加鎖失敗就去等待,而後再重複。

代碼以下:

@Override
    public boolean lock() {

       try {
            boolean locked = false;

            locked = tryLock();

            if (locked) {
                return true;
            }
            while (!locked) {

                await();


                if (checkLocked()) {
                    locked=true;
                }
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            unlock();
        }

        return false;
    }

嘗試加鎖的tryLock方法是關鍵。作了兩件重要的事情:

(1)建立臨時順序節點,而且保存本身的節點路徑

(2)判斷是不是第一個,若是是第一個,則加鎖成功。若是不是,就找到前一個Znode節點,而且保存其路徑到prior_path。

tryLock方法代碼節選以下:

private boolean tryLock() throws Exception {
        //建立臨時Znode
        List<String> waiters = getWaiters();
        locked_path = ZKclient.instance
                .createEphemeralSeqNode(LOCK_PREFIX);
        if (null == locked_path) {
            throw new Exception("zk error");
        }
        locked_short_path = getShorPath(locked_path);

        //獲取等待的子節點列表,判斷本身是否第一個
        if (checkLocked()) {
            return true;
        }

        // 判斷本身排第幾個
        int index = Collections.binarySearch(waiters, locked_short_path);
        if (index < 0) { // 網絡抖動,獲取到的子節點列表裏可能已經沒有本身了
            throw new Exception("節點沒有找到: " + locked_short_path);
        }

        //若是本身沒有得到鎖,則要監聽前一個節點
        prior_path = ZK_PATH + "/" + waiters.get(index - 1);

        return false;
    }

建立臨時順序節點後,其完整路徑存放在 locked_path 成員中。另外還截取了一個後綴路徑,放在 locked_short_path 成員中。 這個後綴路徑,是一個短路徑,只有完整路徑的最後一層。在和取到的遠程子節點列表中的其餘路徑進行比較時,須要用到短路徑。由於子節點列表的路徑,都是短路徑,只有最後一層。

而後,調用checkLocked方法,判斷是不是鎖定成功。若是是則返回。若是本身沒有得到鎖,則要監聽前一個節點。找出前一個節點的路徑,保存在 prior_path 成員中,供後面的await 等待方法,去監聽使用。

在進入await等待方法的介紹前,先說下checkLocked 鎖定判斷方法。

在checkLocked方法中,判斷是否能夠持有鎖。判斷規則很簡單:當前建立的節點,是否在上一步獲取到的子節點列表的第一個位置:

若是是,說明能夠持有鎖,返回true,表示加鎖成功;

若是不是,說明有其餘線程早已先持有了鎖,返回false。

checkLocked方法的代碼以下:

private boolean checkLocked() {
        //獲取等待的子節點列表

        List<String> waiters = getWaiters();
        //節點按照編號,升序排列
        Collections.sort(waiters);

        // 若是是第一個,表明本身已經得到了鎖
        if (locked_short_path.equals(waiters.get(0))) {
            log.info("成功的獲取分佈式鎖,節點爲{}", locked_short_path);
            return true;
        }
        return false;
    }

checkLocked方法比較簡單,就是獲取到全部子節點列表,而且從小到大根據節點名稱進行排序,主要依靠後10位數字,由於前綴都是同樣的。

排序的結果,若是本身的locked_short_path位置在第一個,表明本身已經得到了鎖。

如今正式進入等待方法await的介紹。

等待方法await,表示在爭奪鎖失敗之後的等待邏輯。那麼此處該線程應該作什麼呢?

private void await() throws Exception {

        if (null == prior_path) {
            throw new Exception("prior_path error");
        }

        final CountDownLatch latch = new CountDownLatch(1);


        //訂閱比本身次小順序節點的刪除事件
        Watcher w = new Watcher() {
            @Override
            public void process(WatchedEvent watchedEvent) {
                System.out.println("監聽到的變化 watchedEvent = " + watchedEvent);
                log.info("[WatchedEvent]節點刪除");

                latch.countDown();
            }
        };

        client.getData().usingWatcher(w).forPath(prior_path);
      
        latch.await(WAIT_TIME, TimeUnit.SECONDS);
    }

首先添加一個watcher監聽,而監聽的地址正是上面一步返回的prior_path 成員。這裏,僅僅會監聽本身前一個節點的變更,而不是父節點下全部節點的變更。而後,調用latch.await,進入等待狀態,等到latch.countDown()被喚醒。

一旦prior_path節點發生了變更,那麼就將線程從等待狀態喚醒,從新一輪的鎖的爭奪。

至此,關於加鎖的算法基本完成。可是,上面尚未實現鎖的可重入。

什麼是可重入呢?

​ 只須要保障同一個線程進入加鎖的代碼,能夠重複加鎖成功便可。

修改前面的lock方法,在前面加上可重入的判斷邏輯。代碼以下:

  public boolean lock() {
     synchronized (this) {
        if (lockCount.get() == 0) {
            thread = Thread.currentThread();
            lockCount.incrementAndGet();
        } else {
            if (!thread.equals(Thread.currentThread())) {
                return false;
            }
            lockCount.incrementAndGet();
            return true;
        }
    }
    
   //...
   }

爲了變成可重入,在代碼中增長了一個加鎖的計數器lockCount ,計算重複加鎖的次數。若是是同一個線程加鎖,只須要增長次數,直接返回,表示加鎖成功。

1.1.5. 釋放鎖的實現

釋放鎖主要有兩個工做:

(1)減小重入鎖的計數,若是不是0,直接返回,表示成功的釋放了一次;

(2)若是計數器爲0,移除Watchers監聽器,而且刪除建立的Znode臨時節點;

代碼以下:

@Override
    public boolean unlock() {

        if (!thread.equals(Thread.currentThread())) {
            return false;
        }

        int newLockCount = lockCount.decrementAndGet();

        if (newLockCount < 0) {
            throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + locked_path);
        }

        if (newLockCount != 0) {
            return true;
        }
        try {
            if (ZKclient.instance.isNodeExist(locked_path)) {
                client.delete().forPath(locked_path);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }

        return true;
    }

這裏,爲了儘可能保證線程安全,可重入計數器的類型,不是int類型,而是Java併發包中的原子類型——AtomicInteger。

1.1.1. 分佈式鎖的應用場景

前面的實現,主要的價值是展現一下分佈式鎖的基礎開發和原理。實際的開發中,若是須要使用到分佈式鎖,並不須要本身造輪子,能夠直接使用curator客戶端中的各類官方實現的分佈式鎖,好比其中的InterProcessMutex 可重入鎖。

InterProcessMutex 可重入鎖的使用實例以下:

@Test
public void testzkMutex() throws InterruptedException {

    CuratorFramework client=ZKclient.instance.getClient();
    final InterProcessMutex zkMutex =
            new InterProcessMutex(client,"/mutex");  ;
    for (int i = 0; i < 10; i++) {
        FutureTaskScheduler.add(() -> {

            try {
                zkMutex.acquire();

                for (int j = 0; j < 10; j++) {

                    count++;
                }
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info("count = " + count);
                zkMutex.release();

            } catch (Exception e) {
                e.printStackTrace();
            }

        });
    }

    Thread.sleep(Integer.MAX_VALUE);
}

最後,總結一下Zookeeper分佈式鎖。

Zookeeper分佈式鎖,能有效的解決分佈式問題,不可重入問題,實現起來較爲簡單。

可是,Zookeeper實現的分佈式鎖其實存在一個缺點,那就是性能並不過高。由於每次在建立鎖和釋放鎖的過程當中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能經過Leader服務器來執行,而後Leader服務器還須要將數據同不到全部的Follower機器上。

因此,在高性能,高併發的場景下,不建議使用Zk的分佈式鎖。

目前分佈式鎖,比較成熟、主流的方案是基於redis及基於zookeeper的二種方案。這兩種鎖,應用場景不一樣。而 zookeeper只是其中的一種。Zk的分佈式鎖的應用場景,主要高可靠,而不是過高併發的場景下。

在併發量很高,性能要求很高的場景下,推薦使用基於redis的分佈式鎖。

寫在最後

下一篇: zookeeper + netty 實現高併發IM 聊天


瘋狂創客圈 億級流量 高併發IM 實戰 系列

  • Java (Netty) 聊天程序【 億級流量】實戰 開源項目實戰

  • Netty 源碼、原理、JAVA NIO 原理
  • Java 面試題 一網打盡
  • 瘋狂創客圈 【 博客園 總入口 】

相關文章
相關標籤/搜索