Zookeeper入門——利用Java客戶端API實現分佈式鎖

在簡單學習了客戶端API之後,基本可以嘗試利用這些API去開發一個分佈式鎖

初版實現

利用的兩個Zookeeper特性:

(1)臨時有序節點:保證了即使客戶端發生異常沒有刪除節點,該節點也會自動被刪除。而且有序節點可以將所有操作變成串行操作。

(2)事件監聽與回調機制:Zookeeper客戶端與服務端實現了事件監聽與回調,該機制非常重要,他可以保證服務端的節點發生改變時,客戶端可以及時的感知並作出相應的動作。

代碼實現如下

public class SecondKill {
    private int number = 10000;

    public void decrease() {
        if (number>0) {
            Thread.yield();
            number--;
            System.out.println(number);
        }
    }
}



package com.example.zookeeper_lock.lock;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
 * @ClassName ReentrantLockZk
 * @Deacription zookeeper實現分佈式可重入鎖
 * @Author
 * @Date 2020/3/12
 * @Version 1.0
 * @Modefied what?
 **/
public class ReentrantLockZk {

    private static String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
    private ZooKeeper zooKeeper = null;
    private String lockPath = null;
    private String parentPath = "/lock";
    private int version;
//初始化,創建客戶端連接對象,並且初始化創建父節點
    public ReentrantLockZk() throws IOException, InterruptedException, KeeperException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
            public void process(WatchedEvent event) {
                if (event.getState().equals(Event.KeeperState.SyncConnected)) {
                    countDownLatch.countDown();
                }
            }
        });
        countDownLatch.await();
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        Stat stat = zooKeeper.exists(parentPath, true);
        if (stat == null) {
            parentPath = zooKeeper.create(parentPath, "lock".getBytes(), acls, CreateMode.PERSISTENT);
        }

    }

//創建節點,也就是獲取鎖
    public void createNode() throws IOException, KeeperException, InterruptedException {
        final CountDownLatch countDownLatch=new CountDownLatch(1);
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        //創建節點
        lockPath = zooKeeper.create(parentPath + "/demo", "helloworld".getBytes(), acls, CreateMode.EPHEMERAL_SEQUENTIAL);
        Stat stat = new Stat();
        zooKeeper.getData(lockPath,true, stat);
        version = stat.getVersion();
        //獲取子節點列表,並設置回調方法實現所獲取的判斷邏輯
        zooKeeper.getChildren(parentPath, false, new LockCallBack(), countDownLatch);
        //主線程阻塞
        countDownLatch.await();
    }

    private class LockCallBack implements AsyncCallback.Children2Callback {
        @Override
        public void processResult(int i, String s, Object o, List<String> list, Stat stat) {
            CountDownLatch countDownLatch = (CountDownLatch)o;
            //遍歷查詢出的節點集合,這個經過實際測試發現並不是有序的,並不是按照序號大小有序返回的
            for (int j=0 ; j < list.size(); j++) {
                //遍歷到當前節點
                if (lockPath.equals(parentPath+"/"+list.get(j))) {
                    try {
                        //如果當前節點是第一個,那就直接獲取鎖,解除主線程阻塞
                        if (j == 0) {
                            countDownLatch.countDown();
                            return;
                        } else {
                            //否則監控前一個節點的事件狀態,這裏使用同步方法進行獲取,實際上會有一些問題,應該採用異步方法
                            stat = zooKeeper.exists(parentPath+"/"+list.get(j-1), new Watcher() {
                                @Override
                                public void process(WatchedEvent watchedEvent) {
                                    //如果事件類型爲節點刪除,那麼就解除主線程阻塞,獲取鎖
                                    if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
                                        countDownLatch.countDown();
                                    }
                                }
                            });
                            //該情況表示前一個節點已經被刪除了,直接解除主線程阻塞,表示獲取到鎖
                            if (stat == null) {
                                countDownLatch.countDown();
                            }

                        }

                    } catch (KeeperException e) {
                        e.printStackTrace();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                }
            }
        }
    }

//刪除節點。也就是釋放鎖
    public void deleteNode() throws IOException, KeeperException, InterruptedException {
        zooKeeper.delete(lockPath, version);
    }

    public static void main(String[] args) throws InterruptedException, IOException, KeeperException {
        //測試加鎖和釋放鎖
//        ReentrantLockZk lockZk = new ReentrantLockZk();
//        lockZk.createNode();
//        lockZk.deleteNode();

        //簡單模擬秒殺場景
        SecondKill secondKill = new SecondKill();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ReentrantLockZk lockZk = null;
                try {
                    //創建鎖對象
                    lockZk = new ReentrantLockZk();
                    //阻塞直到獲取鎖,
                    lockZk.createNode();
                    //數量減一
                    secondKill.decrease();
                    //這行代碼主要是查看加鎖效果,是否會造成其他線程的阻塞等待,一定要注意不要設置時間太長,最好註釋這行代碼
                    //否則一旦超出創建客戶端連接對象設置的50秒的過期時間,就會報錯異常。
                    Thread.sleep(1000);
                    //釋放鎖
                    lockZk.deleteNode();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        };
        //啓動101個線程進行測試,最後的輸出結果應該爲9899
        for (int i = 0; i <= 100; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }

}

代碼缺陷分析

初版代碼缺陷很多,只能用來進行測試,做一個分佈式鎖的實現思路分析與驗證,初步驗證運行結果是沒問題的,但是問題還是存在很多。

(1)異常處理,代碼中沒有做正確的異常處理,幾乎所有的異常都是無腦向上拋出或者未做任何處理,一旦發生異常就會導致代碼運行異常。這裏的異常情況說實話,確實比較多,具體應該怎麼處理就必須要詳細思考,每一個異常分支都要進行相應的處理,如果獲取鎖方法報了異常,是否要進行重試,重試幾次,如果確實無法獲取鎖的話主線程的業務代碼是否繼續執行,都是需要考慮的。

(2)LockCallBack的processResult方法中,通過exists方法監控前一個節點的刪除事件時,如果該節點已經被刪除了,那麼方法就會拋出異常,進而無釋放主線程的鎖,導致死鎖問題,造成內存溢出或者逃逸一系列問題,該問題只能通過鎖超時參數解決。

(3)一旦請求壓力過大,瞬間數萬請求(也就是數萬個子節點)甚至數十萬打入Zookeeper下:首先要面臨的問題就是節點隊列過長的問題,如果一個節點的從創建到刪除操作需要1秒的時間,那麼數萬個節點可以想象,對於處於後面的節點等待時間是非常恐怖的,根本等待不到獲取鎖。另一個問題就是,數萬個節點的節點名數據都是要緩存一份到本地的,可能會導致出現內存溢出。這個可以通過一些限流手段解決,首先可以想到加一個消息中間件比如RabbitMQ或者Kafka去做一個削峯限流。

或者,利用Zookeeper有序節點的特性,當前節點的序號-1就是上一個節點的序號,所以只需要監控上一個節點,就可以無需獲取所有的子節點序列集合。

(4)沒有實現可重入鎖,這個比較簡單,模仿一下JDK中ReentrantLock鎖的實現即可,非常簡單。

(5)可以提供幾個有參構造方法,可以手動指定父節點、子節點的路徑名字,數據內容可選。目前父節點和子節點的路徑名稱都是默認的,這樣會造成的問題比較大(不同業務代碼,卻被同一把鎖加鎖)。

(6)獲取鎖超時問題,沒有進行獲取鎖超時情況下的處理。加鎖的方法最好設置一個超時參數。這個實現比較簡單,可以直接通過countDownLatch.await()方法來實現,加超時參數即可,該方法有一個重載版本,專門用於進行超時處理

public boolean await(long timeout, TimeUnit unit)

 

改進第二版

在上一版的基礎上,稍微做了一點修改,主要修改在對上一版的缺陷分析中的第3點和第5點進行改造,節點監控的代碼性能提高,提供有參構造方法儘量貼合業務代碼。源碼如下

package com.example.zookeeper_lock.lock;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
 * @ClassName ReentrantLockZk2
 * @Deacription Zookeeper分佈式鎖
 * @Author
 * @Date 2020/3/17
 * @Version 2.0
 * @Modefied what?
 **/
public class ReentrantLockZk2 {
    private String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
    private ZooKeeper zooKeeper = null;
    private String lockPath = null;
    private String parentLockPath = null;
    private int version;

    /**
     * @Author dinggang
     * @Description //初始化鎖
     * @Date 2020/3/17
     * @Param [parentLockPath, zkNodes]
     * parentLockPath參數表示指定的鎖的父節點的路徑名稱,不帶 /
     * zkNodes表示Zookeeper集羣地址
     * @return
     * @throws
     **/
    public ReentrantLockZk2(String parentLockPath, String zkNodes) throws IOException, InterruptedException, KeeperException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        this.parentLockPath = "/" + parentLockPath;
        this.zkNodes = zkNodes;
        //初始化Zookeeper對象
        zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
            public void process(WatchedEvent event) {
                if (event.getState().equals(Event.KeeperState.SyncConnected)) {
                    countDownLatch.countDown();
                }
            }
        });
        countDownLatch.await();

        //創建父節點
        Stat stat = zooKeeper.exists(this.parentLockPath, false);
        if (stat == null) {
            ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
            List<ACL> acls = new ArrayList<ACL>();
            acls.add(acl);
            //臨時節點下無法創建子節點,所以只能採用永久節點
            this.parentLockPath = zooKeeper.create(this.parentLockPath, "parentLockPath".getBytes(), acls, CreateMode.PERSISTENT);
        }
    }

    /**
     * @Author dinggang
     * @Description //獲取加鎖
     * @Date 2020/3/17
     * @Param [lockPath, data]
     * lockPath子節點路徑名稱,不帶/
     * data表示子節點上存儲的數據
     * @return void
     * @throws
     **/
    public void lock(String lockPath, byte[] data) throws Exception {

        final CountDownLatch countDownLatch=new CountDownLatch(1);
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        //創建臨時有序節點
        String path = parentLockPath + "/" + lockPath;
        this.lockPath = zooKeeper.create(path, data, acls, CreateMode.EPHEMERAL_SEQUENTIAL);
        Stat stat = new Stat();
        //獲取版本號,用於刪除節點,釋放鎖實際上不刪也可以,直接調用zooKeeper的close方法關閉客戶端連接,效果相同,但是會慢一點
        zooKeeper.getData(this.lockPath,true, stat);
        version = stat.getVersion();
        //獲取上一個節點的路徑名,demo0000000809
        String str = this.lockPath.substring(path.length());
        Long number = Long.valueOf(str);
        number = number - 1;
        int count = String.valueOf(number).length();
        StringBuilder builder = new StringBuilder(path);
        for (int i = 1; i <= 10-count; i++) {
            builder.append(0);
        }
        builder.append(number);
        //異步執行,判斷是否存在,並添加回調方法邏輯
        zooKeeper.exists(builder.toString(), true, new lockCallBack(), countDownLatch);
        //主線程阻塞,直到異步方法中判斷前一個節點已經不存在
        countDownLatch.await();
    }

    private class lockCallBack implements AsyncCallback.StatCallback {

        @Override
        public void processResult(int i, String s, Object o, Stat stat) {
            CountDownLatch countDownLatch = (CountDownLatch) o;
            if (stat == null) {
                countDownLatch.countDown();
                return;
            }
            try {
                zooKeeper.exists(s, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        //節點被刪除
                        if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
                            countDownLatch.countDown();
                        }
                        //節點不存在
                        if (watchedEvent.getType().equals(Event.EventType.None)) {
                            countDownLatch.countDown();
                        }
                    }
                });
            } catch (KeeperException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

    /**
     * @Author dinggang
     * @Description //釋放鎖
     * @Date 2020/3/17
     * @Param []
     * @return void
     * @throws
     **/
    public void unlock() throws IOException, KeeperException, InterruptedException {
        zooKeeper.delete(lockPath, version);
        zooKeeper.close();
    }

    public static void main(String[] args) {
        //簡單模擬秒殺場景
        SecondKill secondKill = new SecondKill();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ReentrantLockZk2 lockZk = null;
                try {
                    //創建鎖對象
                    lockZk = new ReentrantLockZk2("parent", "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183");
                    //阻塞直到獲取鎖,
                    lockZk.lock("child", "data".getBytes());
                    //數量減一
                    secondKill.decrease();
                    //釋放鎖
                    lockZk.unlock();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }
        };
        //啓動101個線程進行測試,最後的輸出結果應該爲9899
        for (int i = 0; i <= 100; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }

}

經過測試,發現代碼結果正確。但是感覺,Zookeeper確實不是很適合用來作爲分佈式鎖的實現,首先問題是性能低,性能真滴低,100個併發線程,業務代碼僅僅是很簡單的數據-1,100個線程執行完畢耗時差不多有3-5秒左右,其中的創建和刪除節點的操作和與Zookeeper服務端通信來監聽某個節點的數據變化,這三個操作耗費的時間遠大於真正的業務代碼時間。

而且還發現另一個問題,當我把併發線程數調整至400時,就開始報錯了,java.io.IOException:Connection reset by peer。這個錯誤表示客戶端連接數超過Zookeeper上限,與Zookeeper之間創建的長連接connection在被不斷的關閉重置,原因在於Zookeeper客戶端連接數是有限制的,可以在啓動Zookeeper的配置文件中進行配置。

同時調整代碼把Zookeeper設置爲static變量,也就是類變量(考慮初始化的問題,採用單例模式,要保證多線程下只初始化一次),這樣可以使得一個虛擬機中對應一個Zookeeper對象,儘量保證連接複用。沒有必要每一個線程都創建一個新的客戶端連接對象。

maxClientCnxns

這個配置參數將限制連接到ZooKeeper的客戶端的數量,限制併發連接的數量,它通過IP來區分不同的客戶端。此配置選項可以用來阻止某些類別的Dos攻擊。將它設置爲0將會取消對併發連接的限制。

例如,將maxClientCnxns的值設置爲1

啓動ZooKeeper之後,首先用一個客戶端連接到ZooKeeper服務器之上。然後,當第二個客戶端嘗試對ZooKeeper進行連接,或者某些隱式的對客戶端的連接操作,將會觸發ZooKeeper的上述配置。

ZooKeeper關於maxClientCnxns參數的官方解釋:

單個客戶端與單臺服務器之間的連接數的限制,是ip級別的,默認是60,如果設置爲0,那麼表明不作任何限制。請注意這個限制的使用範圍,僅僅是單臺客戶端機器與單臺ZK服務器之間的連接數限制,不是針對指定客戶端IP,也不是ZK集羣的連接數限制,也不是單臺ZK對所有客戶端的連接數限制。

 

Zookeeper實現分佈式鎖差不多就這樣,自己的一點小思路,當然並沒有做完全的實現,可重入鎖、鎖獲取超時處理、讀寫鎖這些其實實現也並不難,思路比較重要,實現也比較簡單,難點在於對於可能發生的各種異常情況的處理,異常情況太多,很難思考的非常全面,而且難以對每一種異常情況都做出合理的處理。看網上說很少用Zookeeper實現分佈式鎖,搜索來搜索去也就是數據庫、Redis、Zookeeper三種實現方式。

 

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