從零開始的高併發(二)--- Zookeeper實現分散式鎖

說出你的願望吧發表於2019-07-07

前言

上一篇 從0開始的高併發(一)--- zookeeper的基礎概念 我們在結尾留下了一個分散式鎖的坑,它保證了我們在多節點應用的一次排程還有解決分散式環境下的資料一致性的問題

從零開始的高併發(二)--- Zookeeper實現分散式鎖

比如我們現在擁有這麼一個叢集,叢集裡面有個快取服務,叢集中每個程式都會用到這個快取,如果此時快取中有一項快取過期了,在大併發環境下,同一時刻中許許多多的服務都過來訪問快取,獲取快取中的資料,發現快取過期,就要再去資料庫取,然後更新到快取服務中去。但是其實我們僅僅只需要一個請求過來資料庫去更新快取即可,然後這個場景,我們該怎麼去做

我們參考多執行緒的場景下會使用到鎖的這個方法,放到現在的併發場景下,我們也是需要通過一種鎖來實現。


使用Zookeeper來進行開發

1.鎖的特點與原生zookeeper

① 普通鎖具備什麼特點?

排他(互斥)性:只有一個執行緒能獲取到
    檔案系統(同一個檔案不支援多個人去修改)
    資料庫:主鍵唯一約束 for update
    快取:redis setnx命令
    zookeeper:類似檔案系統
阻塞性:其他未搶到的執行緒阻塞,直到鎖被釋放再進行搶這個行為
可重入性:執行緒獲取鎖後,後續是否可重複獲得該鎖
複製程式碼

② 為什麼zookeeper可以用來實現鎖

同一個父目錄下面不能有相同的子節點,這就是zookeeper的排他性
通過JDK的柵欄來實現阻塞性
可重入性我們可以通過計數器來實現
複製程式碼

③ 原生的zookeeper存在著什麼問題

1.介面難以使用
2.連線zookeeper超時不支援自動重連
3.watch註冊一次會失效,需要反覆註冊
4.不支援遞迴建立節點(遞迴建立的話,比方說我要建立一個檔案,假如我在idea建立,那我可以連帶著包一起建立,但是在window我就做不到,這種整一個路徑一併建立下來的就可以視為遞迴建立)
5.需要手動設定序列化的問題
複製程式碼

④ 建立客戶端的核心類:Zookeeper

org.apache.zookeeper
org.apache.zookeeper.data

connect---連線到zookeeper集合
create---建立znode
exist---檢查znode是否存在及其資訊
getData---從特定的znode獲取資料
setData---從特定的znode設定資料
getChildren---獲取特定znode中的所有子節點
delete===刪除特定znode及其所有子項
close---關閉連線
複製程式碼

2.使用第三方客戶端zkClient來簡化操作

從零開始的高併發(二)--- Zookeeper實現分散式鎖

① 實現序列化介面 ZkSerializer

MyZkSerializer.java

public class MyZkSerializer implements ZkSerializer {

//正常來說我們還需要進行一個非空判斷,這裡為了省事沒做,不過嚴格來說是需要做的
//就是簡單的轉換
    @Override
    public byte[] serialize(Object data) throws ZkMarshallingError {
        String d = (String) data;
        try {
            return d.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public Object deserialize(byte[] bytes) throws ZkMarshallingError {
        try {
            return new String(bytes, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return null;
    }

}
複製程式碼

② zkclient的簡單使用

ZkClientDemo.java

public class ZkClientDemo {
    public static void main(String[] args) {
        // 建立一個zk客戶端
        ZkClient client = new ZkClient("localhost:2181");
        
        //實現序列化介面
        client.setZkSerializer(new MyZkSerializer());
        
        //建立一個節點zk,在zk節點下再建立一個子節點app6,賦值123
        //在之前也已經提到了,zookeeper中的節點既是資料夾也是檔案
        
        //原始碼中CreateMode是一個列舉,CreateMode.PERSISTENT---當客戶端斷開連線時,znode不會自動刪除
        client.create("/zk/app6", "123", CreateMode.PERSISTENT);

        client.subscribeChildChanges("/zk/app6", new IZkChildListener() {
            @Override
            public void handleChildChange(String parentPath, List<String> currentChilds) throws Exception {
                System.out.println(parentPath+"子節點發生變化:"+currentChilds);

            }
        });

        //這裡開始是建立一個watch,但是為什麼這個方法會命名為subscribeDataChanges()呢,原因是:
        //原本watch的設定然後獲取是僅一次性的,現在我們使用subscribe這個英文,代表訂閱,代表這個watch一直存在
        //使用這個方法我們可以輕易實現持續監聽的效果,比原生zookeeper方便
        
        client.subscribeDataChanges("/zk/app6", new IZkDataListener() {
            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                System.out.println(dataPath+"節點被刪除");
            }

            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {
                System.out.println(dataPath+"發生變化:"+data);
            }
        });

        try {
            Thread.currentThread().join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

執行結果

呼叫ls /zk---可以發現app6已經被建立,

通過get /zk/app6---可獲取到我們設定的123這個值

說明我們的程式沒有問題,可以成功執行

從零開始的高併發(二)--- Zookeeper實現分散式鎖

這裡測試監聽事件

create /zk/app6/tellYourDream時---控制檯列印/zk/app6子節點發生變化:[tellYourDream]

delete /zk/app6/tellYourDream---控制檯列印/zk/app6子節點發生變化:[],此時已經不存在任何節點,所以為空

set /zk/app6 123456---/zk/app6發生變化:123456

delete /zk/app6---同時觸發了兩個監聽事件,/zk/app6子節點發生變化:null 和 /zk/app6節點被刪除

從零開始的高併發(二)--- Zookeeper實現分散式鎖

③ CreateMode 的補充

1.持久化節點:不刪除節點永遠存在。且可以建立子節點

/**
 * The znode will not be automatically deleted upon client's disconnect.
 * 持久無序
 */
PERSISTENT (0, false, false),
/**
* The znode will not be automatically deleted upon client's disconnect,
* and its name will be appended with a monotonically increasing number.
* 持久有序
*/
PERSISTENT_SEQUENTIAL (2, false, true),
複製程式碼

2.非持久節點,換言之就是臨時節點,臨時節點就是客戶端連線的時候建立,客戶端掛起的時候,臨時節點自動刪除。不能建立子節點

/**
 * The znode will be deleted upon the client's disconnect.
 * 臨時無序
 */
EPHEMERAL (1, true, false),
/**
 * The znode will be deleted upon the client's disconnect, and its name
 * will be appended with a monotonically increasing number.
 * 臨時有序
 */
EPHEMERAL_SEQUENTIAL (3, true, true);
複製程式碼

還有更多的一些監聽方法,我們可以自己去嘗試一下。

3.Zookeeper實現分散式鎖

① zookeeper實現分散式鎖方式一

我們之前有提到,zookeeper中同一個子節點下面的節點名稱是不能相同的,我們可以利用這個互斥性,就可以實現分散式鎖的工具

臨時節點就是建立的時候存在,消失的時候,節點自動刪除,當客戶端失聯,網路不穩定或者崩潰的時候,這個通過臨時節點所建立的鎖就會自行消除。這樣就可以完美避免死鎖的問題。所以我們利用這個特性,實現我們的需求。

原理其實就是節點不可重名+watch機制。

比如說我們的程式有多個服務例項,哪個服務例項都去建立一個lock節點,誰建立了,誰就獲得了鎖,剩下我們沒有建立的應用,就去監聽這個lock節點,如果這個lock節點被刪除掉,這時可能出現兩種情況,一就是客戶端連不上了,另一種就是客戶端釋放鎖,將lock節點給刪除掉了。

ZkDistributeLock.java(注意,不需要重寫的方法已經刪除)
public class ZkDistributeLock implements Lock {

    //我們需要一個鎖的目錄
    private String lockPath;

    //我們需要一個客戶端
    private ZkClient client;


    //剛剛我們的客戶端和鎖的目錄,這兩個引數怎麼傳進來?
    //那就需要我們的建構函式來進行傳值

    public ZkDistributeLock(String lockPath) {
        if(lockPath ==null || lockPath.trim().equals("")) {
            throw new IllegalArgumentException("patch不能為空字串");
        }
        this.lockPath = lockPath;

        client = new ZkClient("localhost:2181");
        client.setZkSerializer(new MyZkSerializer());
    }
複製程式碼

實現Lock介面要重寫的方法(包括嘗試建立臨時節點tryLock(),解鎖unlock(),上鎖lock(),waitForLock()實現阻塞和喚醒的功能方法)

    // trylock方法我們是會嘗試建立一個臨時節點
    @Override
    public boolean tryLock() { // 不會阻塞
        // 建立節點
        try {
            client.createEphemeral(lockPath);
        } catch (ZkNodeExistsException e) {
            return false;
        }
        return true;
    }

    @Override
    public void unlock() {
        client.delete(lockPath);
    }


    @Override
    public void lock() {

        // 如果獲取不到鎖,阻塞等待
        if (!tryLock()) {

            // 沒獲得鎖,阻塞自己
            waitForLock();

            // 從等待中喚醒,再次嘗試獲得鎖
            lock();
        }

    }

    private void waitForLock() {
        final CountDownLatch cdl = new CountDownLatch(1);

        IZkDataListener listener = new IZkDataListener() {

            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                System.out.println("----收到節點被刪除了-------------");
                //喚醒阻塞執行緒
                cdl.countDown();
            }

            @Override
            public void handleDataChange(String dataPath, Object data)
                    throws Exception {
            }
        };

        client.subscribeDataChanges(lockPath, listener);

        // 阻塞自己
        if (this.client.exists(lockPath)) {
            try {
                cdl.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 取消註冊
        client.unsubscribeDataChanges(lockPath, listener);
    }
}
複製程式碼

ZkDistributeLock 現在我們再總結一下流程

獲取鎖,建立節點後

    1.成功獲取到的---執行業務---然後釋放鎖
                                    |
                                    |
                                    |               
    2.獲取失敗,註冊節點的watch---阻塞等待---取消watch---再回到獲取鎖,建立節點的判斷
複製程式碼

這個設計會有一個缺點,比如我的例項現在有無數個,此時我們的lock每次被建立,有人獲取了鎖之後,其他的人都要被通知阻塞,此時我們就浪費了很多的網路資源,也就是驚群效應。

此時我們必須進行優化

② zookeeper實現分散式鎖方式二

我們的Lock作為一個znode,也可以建立屬於它的子節點,我們使用lock建立臨時順序節點,我們在從0開始的高併發(一)--- zookeeper的基礎概念中已經提到了,zookeeper是有序的,臨時順序節點會自動進行由小到大的自動排序,此時我們把例項分配至這些順序子節點上,然後編號最小的獲取鎖即可。這非常類似於我們的公平鎖的概念,也是遵循FIFO原則的

原理:取號 + 最小號取lock + watch

從零開始的高併發(二)--- Zookeeper實現分散式鎖

同樣是基於Lock介面的實現

ZkDistributeImproveLock.java(注意,不需要重寫的方法已經刪除)
public class ZkDistributeImproveLock implements Lock {

    /*
     * 利用臨時順序節點來實現分散式鎖
     * 獲取鎖:取排隊號(建立自己的臨時順序節點),然後判斷自己是否是最小號,如是,則獲得鎖;不是,則註冊前一節點的watcher,阻塞等待
     * 釋放鎖:刪除自己建立的臨時順序節點
     */
     
     //同樣的鎖目錄
    private String lockPath;

    //同樣的客戶端
    private ZkClient client;

    private ThreadLocal<String> currentPath = new ThreadLocal<String>();

    private ThreadLocal<String> beforePath = new ThreadLocal<String>();
    // 鎖重入計數器
    private ThreadLocal<Integer> reenterCount = ThreadLocal.withInitial(()->0);

    public ZkDistributeImproveLock(String lockPath) {
        if(lockPath == null || lockPath.trim().equals("")) {
            throw new IllegalArgumentException("patch不能為空字串");
        }
        this.lockPath = lockPath;
        client = new ZkClient("localhost:2181");
        client.setZkSerializer(new MyZkSerializer());
        if (!this.client.exists(lockPath)) {
            try {
                this.client.createPersistent(lockPath, true);
            } catch (ZkNodeExistsException e) {

            }
        }
    }

    @Override
    public boolean tryLock() {
        System.out.println(Thread.currentThread().getName() + "-----嘗試獲取分散式鎖");
        if (this.currentPath.get() == null || !client.exists(this.currentPath.get())) {
        
            //這裡就是先去建立了一個臨時順序節點,在lockpath那裡建立
            //用銀行取號來表示這個行為吧,相當於每個例項程式先去取號,然後排隊等著叫號的場景
            String node = this.client.createEphemeralSequential(lockPath + "/", "locked");
            //記錄第一個節點編號
            currentPath.set(node);
            reenterCount.set(0);
        }

        // 獲得所有的號
        List<String> children = this.client.getChildren(lockPath);

        // 把這些號進行排序
        Collections.sort(children);

        // 判斷當前節點是否是最小的,和第一個節點編號做對比
        if (currentPath.get().equals(lockPath + "/" + children.get(0))) {
            // 鎖重入計數
            reenterCount.set(reenterCount.get() + 1);
            System.out.println(Thread.currentThread().getName() + "-----獲得分散式鎖");
            return true;
        } else {
            // 取到前一個
            // 得到位元組的索引號
            int curIndex = children.indexOf(currentPath.get().substring(lockPath.length() + 1));
            String node = lockPath + "/" + children.get(curIndex - 1);
            beforePath.set(node);
        }
        return false;
    }

    @Override
    public void lock() {
        if (!tryLock()) {
            // 阻塞等待
            waitForLock();
            // 再次嘗試加鎖
            lock();
        }
    }

    private void waitForLock() {

        final CountDownLatch cdl = new CountDownLatch(1);

        // 註冊watcher
        IZkDataListener listener = new IZkDataListener() {

            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                System.out.println(Thread.currentThread().getName() + "-----監聽到節點被刪除,分散式鎖被釋放");
                cdl.countDown();
            }

            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {

            }
        };

        client.subscribeDataChanges(this.beforePath.get(), listener);

        // 怎麼讓自己阻塞
        if (this.client.exists(this.beforePath.get())) {
            try {
                System.out.println(Thread.currentThread().getName() + "-----分散式鎖沒搶到,進入阻塞狀態");
                cdl.await();
                System.out.println(Thread.currentThread().getName() + "-----釋放分散式鎖,被喚醒");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 醒來後,取消watcher
        client.unsubscribeDataChanges(this.beforePath.get(), listener);
    }

    @Override
    public void unlock() {
        System.out.println(Thread.currentThread().getName() + "-----釋放分散式鎖");
        if(reenterCount.get() > 1) {
            // 重入次數減1,釋放鎖
            reenterCount.set(reenterCount.get() - 1);
            return;
        }
        // 刪除節點
        if(this.currentPath.get() != null) {
            this.client.delete(this.currentPath.get());
            this.currentPath.set(null);
            this.reenterCount.set(0);
        }
    }
複製程式碼

ps:不用擔心記憶體佔滿的問題,JVM會進行垃圾回收

4.更為簡單的第三方客戶端---Curator

從零開始的高併發(二)--- Zookeeper實現分散式鎖

這裡對於curator就不做展開了,有興趣可以自己去玩下

地址:curator.apache.org/curator-exa…

對於選舉leader,鎖locking,增刪改查的framework等都有實現

從零開始的高併發(二)--- Zookeeper實現分散式鎖

finally

距離上一篇的更新似乎隔了好一段1時間,也是因為上週比較忙抽不出空子來,之後還是會進行周更(盡力)

下一篇:從零開始的高併發(三)--- Zookeeper叢集的leader選舉

相關文章