從零開始的高併發--- Zookeeper實現分佈式鎖

01 前言

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

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

image.png



02 使用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來簡化操作

image.png


① 實現序列化接口 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這個值

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

image.png


這裏測試監聽事件

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節點被刪除

image.png


③ 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創建臨時順序節點,zookeeper是有序的,臨時順序節點會自動進行由小到大的自動排序,此時我們把實例分配至這些順序子節點上,然後編號最小的獲取鎖即可。這非常類似於我們的公平鎖的概念,也是遵循FIFO原則的

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

image.png


同樣是基於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

image.png

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

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


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章