Zookeeper—基本使用以及應用場景(手寫實現分佈式鎖和rpc框架)

Zookeeper的基本使用

在之前的文章主要講述了Zookeeper的原理,本文則是實踐,包含Zookeeper單機環境到集羣環境的搭建,基本配置,JavaAPI的使用以及手寫實現分佈式鎖等內容。(PS:在往下進行之前,請務必準備至少3臺linux虛擬機搭建集羣。)

Zookeeper單機部署

  1. 首先到官網下載Zookeeper(筆者使用的是3.4.10版本)到linux虛擬機上,然後tar -zxvf解壓即可。

  2. Zookeeper常用的命令如下(在zookeeper/bin目錄下執行):

  • sh zkServer.sh start:啓動服務

  • sh zkServer.sh stop:停止服務

  • sh zkServer.sh status:查看服務狀態

  • sh zkServer.sh restart:重啓服務

  • sh zkCli.sh:連接本地zookeeper服務器

  • sh zkCli.sh -timeout 0 -r -server ip:port:遠程連接zookeeper服務器,並指定超時時間

  • 初次使用zookeeper需要將conf目錄下的zoo_sample.cfg複製一份並重命名爲zoo.cfg(因爲Zookeeper服務器啓動時默認會去找該文件名的配置文件),然後編輯該文件配置dataDir和dataLogDir參數。重要配置說明如下(詳細參數說明請參照官方文檔):

啓動服務器,使用客戶端連接。客戶端常用命令如下(中括號代表非必須參數):

  • create [-s] [-e] path data [acl]:創建節點,-s指定該節點爲有序節點,-e指定該節點爲臨時節點,path是節點key,data是節點對應的數據,acl是權限信息,默認情況不做任何權限控制

  • ls path [watch]:查看指定節點的子節點列表,watch監聽器

  • get path [watch]:獲取指定節點的數據內容和屬性信息,watch監聽器。節點屬性內容說明:

  • czxid:createdZXID,表示該數據節點被創建時的事務id

  • ctime:createdTime,表示該節點被創建時的時間

  • mzxid:modifiedZXID,表示該節點最後一次被修改時的事務id

  • mtime:modifiedTime,表示該節點最後一次的修改時間

  • version:節點的版本號

  • cversion:子節點的版本號

  • aversion:節點的ACL版本號

  • ephemeralOwner:創建該臨時節點時的sessionID,如果該節點是持久節點,該值爲0

  • dataLength:數據內容的長度

  • numChildren:子節點的個數

  • pzxid:表示該節點的子節點列表最後一次變更時的事務ID,只有子節點列表變化該值纔會變化,子節點的數據變化不會影響該值

  • set path data [version]:更新節點的數據內容,version指定版本號,若版本號不匹配則會更新失敗

  • delete path [version]:刪除節點,version指定版本號

Zookeeper集羣搭建

搭建集羣時首先需要在每臺機器下配置兩個文件:

  1. 第一個是在dataDir指定的目錄下創建一個myid,文件中指定一個服務器的id

  2. 第二個是zoo.cfg,對於該文件,集羣中每臺機器的配置應該都是一樣的:


server.1=192.168.0.106:2000:3000
server.2=192.168.0.108:2000:3000
server.3=192.168.0.109:2000:3000

# 1\. server.1中的“1”就是myid中指定的服務器id,服務器id要和後面的ip對應;
# 2\. 第一個端口是Follower服務器和Leader服務器通信同步數據時的端口
# 3\. 第二個端口是競選Leader時投票用的端口 

這樣,集羣環境就配置好了,只需要啓動三個Zookeeper服務就行了(PS:可以逐個啓動,並用sh zkServer.sh status和sh zkCli.sh命令看看會輸出什麼)。

JavaAPI的使用

Zookeeper爲多種語言提供了API方便調用,在Java中可以使用原生的API和開源的客戶端(zkClient、curator)進行開發,這裏就不再演示這些基礎的代碼了

Zookeeper的應用場景

Zookeeper有很多的應用場景:數據的發佈/訂閱、負載均衡、分佈式協調/通知、集羣管理、分佈式鎖等等。下面主要討論如何實現分佈式鎖以及基於Zookeeper實現一個簡易版本的RPC服務註冊中心。

分佈式鎖的實現

在單機架構中,實現線程同步只需要通過synchronized關鍵字和Lock類就能實現,但是在分佈式中要如何實現呢? 有三種方式可以實現,分別是數據庫、redis和zookeeper。而鎖的種類有很多,包含了獨享鎖/共享鎖、可/不可重入鎖、公平/非公平鎖等等。這裏僅通過Zookeeper分析獨享鎖和可重入鎖的實現方式。

獨享鎖

什麼是獨享鎖?簡單的說就是資源在同一時間只能被一個線程佔用。通過前面的學習,我們不難想到,利用多個線程在Zookeeper中創建同一個節點只會有一個線程能創建成功的特性很容易就能實現一個獨享鎖。 即獲取鎖時,當前線程判斷鎖是否已被其它機器獲取,若沒有,則在/locks節點下創建臨時節點/lock,創建成功則獲取到鎖,未創建成功或者鎖已經被其它機器佔用則監聽/locks下子節點的變化等待獲取鎖;鎖被釋放後,刪除掉/lock節點,其它機器接收到Watcher通知又開始重新競爭鎖。這樣就實現了一個簡單的獨享鎖,但是爲什麼要創建臨時節點呢?這樣可以避免獲取到鎖的機器還未釋放鎖就突然掛掉而產生死鎖。流程圖如下:

除了上述方式還有沒有其它方式可以實現呢? 我們還可以基於有序節點來實現:當多臺機器競爭鎖時,都去/locks下創建臨時有序節點,這樣所有機器都會創建成功,那我們如何確定哪一個機器獲取到鎖呢?我們可以獲取子節點列表,從中選出序號最小或最大的節點對應的機器獲取到鎖(一般是最小),而其它未獲取到鎖的機器則監聽/locks子節點的變化等待獲取鎖,一旦鎖釋放或異常中斷退出則刪除對應的節點,其它機器接收到Watcher通知後,重新獲取子節點列表,重複獲取鎖的過程即可。

上面兩種分佈式鎖的實現基本上能滿足一般的業務需求,但它們都存在一個問題:羊羣效應。當競爭鎖的服務器數量非常多時,一旦上一個鎖被釋放,所有等待鎖的服務器都會收到通知,但最終只會有一臺服務器獲取到鎖,其它服務器繼續等待,這就是羊羣效應,很耗費性能。 怎麼改進呢?我們注意到在上面的例子中鎖一旦釋放就會通知所有等待鎖的服務器,那我們是不是可以讓其只通知其中某一臺服務器呢?比如說在第二個例子中讓每一個服務器只監聽前一個節點的變化,因爲是有序節點,所以這是很容易做到的。這樣,當前鎖被釋放,就只會通知後一個節點所對應的服務器。

可重入鎖

可重入鎖是指當前獲取到鎖的線程可以再次獲取到該鎖。那基於上面的實現,我們只需要在當前服務器獲取到鎖時,綁定一個計數器,每當該服務器在持有鎖期間再次獲取鎖時,無需阻塞等待,直接將計數器遞增加1即可,釋放鎖則是對該計數器遞減減1;而其它線程要獲取到鎖,則要等待該計數器爲0纔行。具體實現可參照curator-recipes中locks包下的InterProcessMutex類。

實現RPC框架

基本概念及原理

Zookeeper最重要的一個功能就是服務註冊管理,Dubbo就依賴於此。那什麼是服務註冊管理呢?又爲什麼需要這個東西呢?如何實現呢?

在分佈式集羣中,會拆分出很多的服務模塊,部署在不同的機器上,當服務間彼此有依賴關係,就可以通過RPC調用其它服務的接口。若只是像上面這樣,只有兩臺服務器,沒有什麼問題。但分佈式應用通常是非常複雜的,拆分了非常多的服務,其中某些服務還會搭建集羣,它們相互依賴就會跟下面這張圖一樣:

相信你也發現其中的問題了,當系統比較龐大時,服務間調用不再是點對點的關係,靠我們自己已經很難人工維護服務器的地址和調用關係了,所以就需要一個服務註冊中心來統一管理服務。

像上面這樣,所有的服務首先都會去服務中心發佈自己的服務(服務名、服務地址等),客戶端調用服務時,只需要通過服務名稱去註冊中心找到對應的服務並調用。 在瞭解了服務註冊中心的基本概念後,不難發現Zookeeper就很容易實現一個註冊中心。因爲Zookeeper是以樹形結構存儲數據,那發佈服務時只需要根據服務名稱創建一個節點,並將服務地址作爲該節點的子節點存入到Zookeeper就行:

[圖片上傳失敗...(image-5a5ca6-1607174878102)]

客戶端通過服務名稱去獲取其子節點,即服務地址,若該服務搭建了集羣,則會有多個,那麼客戶端也可以據此實現負載均衡。說了這麼多,下面就來看看一個簡單的RPC框架實現。

代碼實現

我這裏是通過curator-4.0.0客戶端來連接Zookeeper的,所以需要引入curator-framework依賴,然後實現一個獲取連接的工具類:


public class ZkConnectUtils {

    private static CuratorFramework curator;

    public static CuratorFramework getConnector() {
        curator = CuratorFrameworkFactory.builder()
                .connectString(ZkConfig.CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .namespace(ZkConfig.NAMESPACE)
                .sessionTimeoutMs(5000).build();
        curator.start();
        return curator;
    }
}

服務端

我們先實現服務端,首先需要實現服務的註冊:


public interface IRpcRegistryCenter {

    void register(String serviceName, String address, Integer port) throws Exception;

}

public class RpcRegistryCenterImpl implements IRpcRegistryCenter {

    private static final String SEPARATOR = "/";

    /**
     * 服務註冊,將服務的地址以臨時節點的方式註冊到zookeeper中,
     * 這樣斷開連接即銷燬服務,而有新服務加入時,客戶端通過監聽器動態發現
     * 服務變化
     *
     * @param serviceName 服務名稱
     * @param address     服務地址
     * @param port        服務端口
     * @return void
     * @date 2019-07-18
     */
    @Override
    public void register(String serviceName, String address, Integer port) throws Exception {
        String serviceAddress = address + ":" + port;
        String serviceNode = SEPARATOR + serviceName;

        CuratorFramework connector = ZkConnectUtils.getConnector();
        // 判斷該服務節點是否已經創建,服務名稱最好不要以臨時節點的方式創建,否則一旦該連接斷開,整個集羣都將不可用。
        if (connector.checkExists().forPath(serviceNode) == null) {
            connector.create().creatingParentsIfNeeded()
                    .withMode(CreateMode.PERSISTENT)
                    .forPath(serviceNode, "0".getBytes());
        }

        // 創建臨時的地址節點
        String addressNode = serviceNode + SEPARATOR + serviceAddress;
        connector.create().withMode(CreateMode.EPHEMERAL).forPath(addressNode, "0".getBytes());
        System.out.println("服務註冊成功!");
    }
}

註冊服務很簡單,就是去Zookeeper創建相應的節點。接着實現發佈服務的接口:


public class RpcServer {

    private static final ExecutorService SERVICE_POOL = Executors.newCachedThreadPool();

    private IRpcRegistryCenter registryCenter; // 註冊中心
    private String address; // 服務地址
    private Integer port; // 服務端口

    public RpcServer(IRpcRegistryCenter registryCenter, String address, Integer port) {
        this.registryCenter = registryCenter;
        this.address = address;
        this.port = port;
    }

    /**
     * 發佈服務
     *
     * @date 2019-07-16
     */
    public void publish() throws Exception {
        // 將服務註冊到zookeeper中
        Set<String> serviceNames = ServiceRepository.getServiceNames();
        for (String serviceName : serviceNames) {
            registryCenter.register(serviceName, this.address, this.port);
            System.out.println("成功發佈服務:" + serviceName + " -> " + this.address + ":" + this.port);
        }

        ServerSocket serverSocket = new ServerSocket(port);
        while (true) {
            // 監聽服務並交由線程池處理
            Socket socket = serverSocket.accept();
            SERVICE_POOL.execute(new ProcessHandler(socket));
        }
    }

}

public class ServiceRepository {

    /**
     * 存儲服務名稱和服務對象的關係
     */
    private static final Map<String, Object> SERVICE_CACHE = new HashMap<>();

    /**
     * 綁定服務名稱和服務對象
     *
     * @param service 服務對象
     * @date 2019-07-18
     *
     */
    public static void bind(Object service) {
        String serviceName = service.getClass().getAnnotation(Service.class).value().getName();
        SERVICE_CACHE.put(serviceName, service);
    }

    /**
     * 獲取已發佈的服務名稱
     *
     * @return java.util.Set<java.lang.String>
     * @date 2019-07-18
     *
     */
    public static Set<String> getServiceNames() {
        return SERVICE_CACHE.keySet();
    }

    /**
     * 根據服務名稱獲取到服務對象
     *
     * @param serviceName
     * @return java.lang.Object
     * @date 2019-07-19
     *
     */
    public static Object getService(String serviceName) {
        return SERVICE_CACHE.get(serviceName);
    }
}

ServiceRepository類是服務端本地服務名稱和具體服務對象的映射存儲倉庫,在發佈服務之前,具體的服務對象需要通過bind方法綁定自己的服務名稱和服務對象的映射關係,然後調用publish方法將這裏的服務名稱都註冊到Zookeeper中去;當客戶端調用時,服務端就能通過服務名稱找到具體的服務對象(還可以通過動態代理等方法實現該功能)。 服務註冊完成後,就需要監聽等待客戶端的調用,這裏是通過Socket實現的,並通過線程池異步處理客戶端的消息。怎麼處理呢?也就是反序列化客戶端消息並調用相關服務的一個過程,因此,我們需要一個統一的傳輸對象。因爲是遠程調用服務,所以該對象應該包含服務的類的全名稱、方法名稱和方法參數這些字段,並實現Serializable接口:


public class RpcEntity implements Serializable {

    private static final long serialVersionUID = -8789719821222021770L;

    private String className; // 全類名
    private String methodName; // 調用方法名
    private Object[] args; // 方法的參數值

    public RpcEntity(String className, String methodName, Object[] args) {
        this.className = className;
        this.methodName = methodName;
        this.args = args;
    }

    public String getClassName() {
        return className;
    }

    public void setClassName(String className) {
        this.className = className;
    }

    public String getMethodName() {
        return methodName;
    }

    public void setMethodName(String methodName) {
        this.methodName = methodName;
    }

    public Object[] getArgs() {
        return args;
    }

    public void setArgs(Object[] args) {
        this.args = args;
    }
}

服務端接收到消息後則交由ProcessHandler類處理(確保類的單一職責),該類實現了Runnable接口,交由線程池異步處理:


public class ProcessHandler implements Runnable {

    private Socket socket;

    public ProcessHandler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
             ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream())) {
            // 反序列化客戶端傳過來的對象,並通過反射調用服務端對象的方法
            RpcEntity entity = (RpcEntity) ois.readObject();
            Object result = invokeService(entity);

            // 將結果返回給客戶端
            oos.writeObject(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private Object invokeService(RpcEntity entity) throws Exception {
        // 通過參數類型和參數名稱拿到Method,再反射調用方法
        Object[] args = entity.getArgs();
        Class[] params = new Class[args.length];
        for (int i = 0; i < params.length; i++) {
            params[i] = args[i].getClass();
        }

        // 根據客戶端傳入的服務名調用相應的服務對象的方法
        String serviceName = entity.getClassName();
        Object service = ServiceRepository.getService(serviceName);
        Method method = service.getClass().getMethod(entity.getMethodName(), params);
        return method.invoke(service, args);
    }
}

這樣,服務端功能就實現完了,客戶端該如何實現?可以自己先思考一下。

客戶端

服務端有一個註冊服務中心,將服務註冊到Zookeeper;所以,客戶端就需要有個發現服務中心從Zookeeper獲取到服務:


public interface IRpcDiscovery {

    String discover(String serviceName) throws Exception;

}

public class RpcDiscoveryImpl implements IRpcDiscovery {

    private static final String SEPARATOR = "/";

    private CuratorFramework curator;
    private ILB lb; // 負載均衡器
    private List<String> serviceAddresses; // 服務地址

    public RpcDiscoveryImpl() {
        this(null);
    }

    public RpcDiscoveryImpl(ILB lb) {
        this.lb = lb;
        this.curator = ZkConnectUtils.getConnector();
    }

    /**
     * 發現服務
     *
     * @param serviceName
     * @return java.lang.String
     * @date 2019-07-19
     *
     */
    @Override
    public String discover(String serviceName) throws Exception {
        String node = SEPARATOR + serviceName;
        serviceAddresses = curator.getChildren().forPath(node);
        if (serviceAddresses == null || serviceAddresses.size() == 0) {
            throw new RuntimeException("未發現服務,無法進行遠程調用!");
        }

        // 添加監聽器,動態發現節點的變化
        addWatcher(node);

        // 可由外部配置負載均衡器,若未配置,則默認使用隨機負載均衡器
        if (lb == null) {
            lb = new RandomLB();
        }
        return lb.selectHost(serviceAddresses);
    }

    private void addWatcher(String node) throws Exception {
        PathChildrenCache childrenCache = new PathChildrenCache(curator, node, true);
        PathChildrenCacheListener listener = new PathChildrenCacheListener() {
            @Override
            public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
                serviceAddresses = curator.getChildren().forPath(node);
            }
        };
        childrenCache.getListenable().addListener(listener);
        childrenCache.start();
    }
}

通過服務名稱獲取到所有的子節點,即服務地址並監聽子節點的變化,這樣集羣中上線服務或者下線服務,客戶端立刻就能感知到。拿到服務地址後,若是集羣,則可以考慮在此實現負載均衡:


public interface ILB {

    String selectHost(List<String> hosts);

}

public abstract class AbstractLB implements ILB {

    /**
     * 負載均衡,採用模板模式的思想提高擴展性,該方法只是抽離出公共的代碼,
     * 具體的算法由doSelect方法實現
     *
     * @param hosts
     * @return java.lang.String
     * @date 2019-07-19
     *
     */
    @Override
    public String selectHost(List<String> hosts) {
        if (hosts == null || hosts.size() == 0) {
            return null;
        }

        if (hosts.size() == 1) {
            return hosts.get(0);
        }

        // 節點中都包含分隔符,統一處理後返回
        String node = doSelect(hosts);
        return node.replace("/", "");
    }

    /**
     * 抽象的負載算法,根據需求擴展
     *
     * @param hosts
     * @return java.lang.String
     * @date 2019-07-19
     *
     */
    protected abstract String doSelect(List<String> hosts);

}

public class PollingLB extends AbstractLB {

    private static int count = 0; // 輪詢計數器

    @Override
    protected String doSelect(List<String> hosts) {
        if (count >= hosts.size()) {
            count = 0;
        }
        return hosts.get(count++);
    }

}

public class RandomLB extends AbstractLB {

    @Override
    protected String doSelect(List<String> hosts) {
        Random random = new Random();
        return hosts.get(random.nextInt(hosts.size()));
    }
}

這裏我實現了簡單的隨機和輪詢算法,可以通過模板方法模式讓用戶自定義負載算法(在Dubbo中就是這麼實現的)。 找到服務後,我們該如何去調用呢?需要考慮以下兩個方面:

  • 假設客戶端要調用服務端IHelloService.sayHello方法,那麼客戶端也需要IHelloService的接口

  • 服務端是通過Socket接收消息的,那麼客戶端肯定需要Sokect傳遞消息,即序列化傳遞RpcEntity消息實體類

所以客戶端調用IHelloService.sayHello方法時,實際應該是通過Socket發送消息,告訴服務端我要調用的服務。這裏通過動態代理來實現(當然客戶端也可以直接寫一個實現類,但就太不靈活了):


public class RpcClientProxy {

    /**
     * 通過動態代理創建客戶端代理對象
     *
     * @param interfaceClass 代理對象需實現的接口
     * @param discovery 發現服務
     * @return T
     * @date 2019-07-16
     *
     */
    public static <T> T newProxy(Class<T> interfaceClass, IRpcDiscovery discovery) {
        return (T) Proxy.newProxyInstance(interfaceClass.getClassLoader(),
                new Class[]{interfaceClass},
                new RemoteHandler(discovery));
    }
}

只需要通過Proxy類創建一個代理類就好了,具體的實現在RemoteHandler類中:


public class RemoteHandler implements InvocationHandler {

    private IRpcDiscovery discovery;

    public RemoteHandler(IRpcDiscovery discovery) {
        this.discovery = discovery;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String className = method.getDeclaringClass().getName();
        String address = discovery.discover(className);
        String[] arrs = address.split(":");

        RpcEntity rpcEntity = new RpcEntity(className, method.getName(), args);
        SocketTransport transport = new SocketTransport(arrs[0], Integer.parseInt(arrs[1]));
        return transport.sendInfo(rpcEntity);
    }

}

public class SocketTransport {

    private String host;
    private int port;

    public SocketTransport(String host, int port) {
        this.host = host;
        this.port = port;
    }

    /**
     * 發送消息給服務端
     *
     * @param rpcEntity
     * @return java.lang.Object
     * @date 2019-07-16
     */
    public Object sendInfo(RpcEntity rpcEntity) throws Exception {
        Socket socket = null;
        try {
            socket = newSocket();
            ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
            oos.writeObject(rpcEntity);
            oos.flush();

            ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
            Object result = ois.readObject();

            ois.close();
            oos.close();
            return result;
        } catch (Exception e) {
            throw new Exception("遠程調用出錯!");
        } finally {
            if (socket != null) {
                socket.close();
            }
        }
    }

    private Socket newSocket() throws Exception {
        System.out.println("創建連接......");
        Socket socket;
        try {
            socket = new Socket(this.host, this.port);
            return socket;
        } catch (Exception e) {
            throw new Exception("連接建立失敗!");
        }
    }
}

通過從Zookeeper拿到的服務地址創建Socket連接,然後將RpcEntity消息類序列化傳遞即可。 至此,一個簡單的RPC框架就實現完成了,最後我們來看看如何使用。首先服務端實現一個服務提供者併發布:


public interface IHelloService {

    String sayHello(String msg);

}

@Service(IHelloService.class)
public class HelloServiceImpl8080 implements IHelloService {

    @Override
    public String sayHello(String msg) {
        return "8080: Hello, " + msg;
    }
}

public class Server8080 {

    public static void main(String[] args) throws Exception {
        // 綁定服務
        IHelloService iHelloService = new HelloServiceImpl8080();
        ServiceRepository.bind(iHelloService);

        // 將服務發佈到註冊中心
        IRpcRegistryCenter registryCenter = new RpcRegistryCenterImpl();
        RpcServer server = new RpcServer(registryCenter, "127.0.0.1", 8080);
        server.publish();
    }
}

@Service註解只是用於定義發佈服務的名稱。下面看看客戶端如何調用


public class Client {

    public static void main(String[] args) {
        IRpcDiscovery discovery = new RpcDiscoveryImpl(new PollingLB());
        IHelloService iHelloService = RpcClientProxy.newProxy(IHelloService.class, discovery);
        for (int i = 0; i < 10; i++) {
            String result = iHelloService.sayHello("dark");
            System.out.println(result);
        }
    }
}

初始化服務發現中心並配置負載均衡算法,然後通過代理類遠程調用。若要看負載均衡的效果,可在服務端模擬集羣服務即可

總結

本篇文章主要講解了Zookeeper的基本使用、分佈式鎖和RPC框架的實現原理,但Zookeeper的應用場景非常的豐富,結合平時的實際項目多多思考,才能更加深刻的理解Zookeeper。另外,我們可以看到分佈式鎖和RPC框架實現原理都依賴於Watcher機制,那Zookeeper是如何實現監聽器的呢?

作者:夜勿語
原文鏈接:https://blog.csdn.net/l6108003/article/details/98518215

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