手寫RPC框架(2) 引入zookeeper做服務治理

本人微信公眾號(jwfy)歡迎關注

上一期完成了手寫一個RPC框架,看看100個線程同時調用效果如何,但還是遺留了很多問題以及可以優化的點,這次就完全重寫之前的代碼,演進到v2版本,使得代碼邏輯更加規范的同時,引入ZooKeeper輔助完成服務治理。

在代碼展示之前還是先介紹一些基本的概念以及設計思路,ZooKeeper是什么,服務治理又是什么等,最后貼了部分關鍵代碼以說明和v1版本的區別,有哪些點的改進措施。

最后還提了個問題:線程池打滿了怎么辦?,你有什么好的解決方案呢?

ZooKeeper

ZooKeeper(直譯為動物管理員,簡稱zk)是一個分布式、開源的應用協調服務,利用和Paxos類似的ZAB選舉算法實現分布式一致性服務。有類似于Unix文件目錄的節點信息,同時可以針對節點的變更添加watcher監聽以能夠即使感知到節點信息變更。可提供的功能例如域名服務、配置維護、同步以及組服務等(此功能介紹來自官網描述:It exposes common services - such as naming, configuration management, synchronization, and group services - in a simple interface)。如下圖就是DUBBO存儲在ZooKeeper的節點數據情況。

image

在本地啟動服務后通過zk客戶端連接后也可通過命令查看節點信息,如下圖所示。

image

ZooKeeper包含了4種不同含義的功能節點,在每次創建節點之前都需要明確聲明節點類型。

類型 定義 描述
PERSISTENT 持久化目錄節點 客戶端與zookeeper斷開連接后,該節點依舊存在
PERSISTENT_SEQUENTIAL 持久化順序編號目錄節點 客戶端與zookeeper斷開連接后,該節點依舊存在,只是Zookeeper給該節點名稱進行順序編號
EPHEMERAL 臨時目錄節點 客戶端與zookeeper斷開連接后,該節點被刪除
EPHEMERAL_SEQUENTIAL 臨時順序編號目錄節點 客戶端與zookeeper斷開連接后,該節點被刪除,只是Zookeeper給該節點名稱進行順序編號

ZooKeeper使用之前需要先進行安裝,后開啟服務端的服務,我們的服務作為客戶端連接ZooKeeper以便于后續的操作。具體可參考官網文檔Zookeeper3.5.5 官方文檔,在實際的java項目開發中也是可以通過maven引入ZkClient或者Curator開源的客戶端,在本文學習筆記中是使用的Curator,因為其已經封裝了原始的節點注冊、數據獲取、添加watcher等功能。具體maven引入的版本如下,

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>4.2.0</version>
</dependency>

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.2.0</version>
</dependency>

服務治理

服務治理也就是針對服務進行管理的措施,例如服務發現服務暴露負載均衡快速上下線等都是服務治理的具體體現。

  • 服務發現:從服務管理中心獲取到需要的服務相關信息,例如我們可以從zk中獲取相關服務的機器信息,然后我們就可以和具體機器直連完成相關功能
  • 服務暴露:服務提供方可以提供什么樣子的功能,經過服務暴露暴露出去,其他使用方就可以通過服務發現發現具體的服務提供方信息
  • 負載均衡:一般針對的是服務提供方,避免大量請求同時打到一臺機器上,采用隨機、輪詢等措施讓請求均分到各個機器上,提供服務效率,限流灰度等也都是類似的操作,通過動態路由、軟負載的形式處理分發請求。
  • 快速上線下:以往需要上下線可能需要殺掉機器上的進程,現在只需要讓該服務停止暴露即可,實現服務的靈活上下線。

數據處理流程

服務端:服務的提供方,接受網絡傳輸的請求數據、通過網絡把應答數據發送給客戶端
客戶端:服務的調用方,使用本地代理,通過網絡把請求數據發送出去,接受服務端返回的應答數據

image

所有的數據傳輸都是按照上面圖片說的流程來的,如果需要添加自定義的序列化工具,則需要在把數據提交到socket的輸出流緩沖區之前按照序列化工具完成序列化操作,反序列化則進行反向操作即可。

RPC 實踐 V2版本

文件夾目錄如下圖所示,其中:

  • balance文件夾:負載均衡有關
  • config文件夾:網絡套接字傳輸的數據模型以及服務暴露、服務發現的數據模型
  • core文件夾:核心文件夾,包含了服務端和客戶端的請求處理、代理生成等
  • demo文件夾:測試試用
  • io.protocol文件夾:目前是只有具體的請求對象和網絡io的封裝
  • register:服務注冊使用,實現了使用zk進行服務注冊和服務發現的操作
image

由于代碼太長,只貼部分重要的代碼操作。

服務暴露 & 服務發現

public interface ServiceRegister {

    /**
     * 服務注冊
     * @param config
     */
    void register(BasicConfig config);

    /**
     * 服務發現,從注冊中心獲取可用的服務提供方信息
     * @param request
     * @return
     */
    InetSocketAddress discovery(RpcRequest request, ServiceType nodeType);
}

默認使用了CuratorFramework客戶端完成zk數據的操作

public class ZkServiceRegister implements ServiceRegister {

    private CuratorFramework client;

    private static final String ROOT_PATH = "jwfy/simple-rpc";

    private LoadBalance loadBalance = new DefaultLoadBalance();

    public ZkServiceRegister() {
        RetryPolicy policy = new ExponentialBackoffRetry(1000, 3);

        this.client = CuratorFrameworkFactory
                .builder()
                .connectString("127.0.0.1:2182")
                .sessionTimeoutMs(50000)
                .retryPolicy(policy)
                .namespace(ROOT_PATH)
                .build();
        // 業務的根路徑是 /jwfy/simple-rpc ,其他的都會默認掛載在這里

        this.client.start();
        System.out.println("zk啟動正常");
    }

    @Override
    public void register(BasicConfig config) {
        String interfacePath = "/" + config.getInterfaceName();
        try {
            if (this.client.checkExists().forPath(interfacePath) == null) {
                // 創建 服務的永久節點
                this.client.create()
                        .creatingParentsIfNeeded()
                        .withMode(CreateMode.PERSISTENT)
                        .forPath(interfacePath);
            }

            config.getMethods().forEach(method -> {
                try {
                    String methodPath = null;
                    ServiceType serviceType = config.getType();
                    if (serviceType == ServiceType.PROVIDER) {
                        // 服務提供方,需要暴露自身的ip、port信息,而消費端則不需要
                        String address = getServiceAddress(config);
                        methodPath = String.format("%s/%s/%s/%s", interfacePath, serviceType.getType(), method.getMethodName(), address);
                    } else {
                        methodPath = String.format("%s/%s/%s", interfacePath, serviceType.getType(), method.getMethodName());
                    }
                    System.out.println("zk path: [" + methodPath + "]");
                    this.client.create()
                            .creatingParentsIfNeeded()
                            .withMode(CreateMode.EPHEMERAL)
                            .forPath(methodPath, "0".getBytes());
                    // 創建臨時節點,節點包含了服務提供段的信息
                } catch (Exception e) {
                    e.getMessage();
                }
            });
        } catch (Exception e) {
            e.getMessage();
        }
    }

    @Override
    public InetSocketAddress discovery(RpcRequest request, ServiceType nodeType) {
        String path = String.format("/%s/%s/%s", request.getClassName(), nodeType.getType(), request.getMethodName());
        try {
            List<String> addressList = this.client.getChildren().forPath(path);
            // 采用負載均衡的方式獲取服務提供方信息,不過并沒有添加watcher監聽模式
            String address = loadBalance.balance(addressList);
            if (address == null) {
                return null;
            }
            return parseAddress(address);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    private String getServiceAddress(BasicConfig config) {
        String hostInfo = new StringBuilder()
                .append(config.getHost())
                .append(":")
                .append(config.getPort())
                .toString();
        return hostInfo;
    }

    private InetSocketAddress parseAddress(String address) {
        String[] result = address.split(":");
        return new InetSocketAddress(result[0], Integer.valueOf(result[1]));
    }

    public void setLoadBalance(LoadBalance loadBalance) {
        // 可以重新設置負載均衡的策略
        this.loadBalance = loadBalance;
    }
}
image

服務啟動后利用zkclient查詢到在zk中包含的節點信息,其中默認的命名空間是jwfy/simple-rpc

負載均衡

public interface LoadBalance {
    String balance(List<String> addressList);
}
public abstract class AbstractLoadBalance implements LoadBalance {

    @Override
    public String balance(List<String> addressList) {
        if (addressList == null || addressList.isEmpty()) {
            return null;
        }
        if (addressList.size() == 1) {
            return addressList.get(0);
        }
        return doLoad(addressList);
    }

    abstract String doLoad(List<String> addressList);
}
public class DefaultLoadBalance extends AbstractLoadBalance {

    @Override
    String doLoad(List<String> addressList) {
        Random random = new Random();
        // 利用隨機函數選擇一個,其中random.nextIn生成的數據是在[0, size) 之間
        return addressList.get(random.nextInt(addressList.size()));
    }
}

上面的負載均衡代碼其實很簡單,就是從一個機器列表addressList中選擇一個,如果列表為空或者不存在則直接返回null,如果機器只有1臺則直接獲取返回即可,當列表記錄超過1條后利用隨機函數生成一個列表偏移量以獲取對應數據。也可以按照類似完成更多負載均衡的策略,然后調用setLoadBalance方法就可以了。

IO 處理

public interface MessageProtocol {
    /**
     * 服務端解析從網絡傳輸的數據,轉變成request
     * @param inputStream
     * @return
     */
    void serviceToRequest(RpcRequest request, InputStream inputStream);

    /**
     * 服務端把計算機的結果包裝好,通過outputStream 返回給客戶端
     * @param response
     * @param outputStream
     * @param <T>
     */
     <T> void serviceGetResponse(RpcResponse<T> response, OutputStream outputStream);

    /**
     * 客戶端把請求拼接好,通過outputStream發送到服務端
     * @param request
     * @param outputStream
     */
     void clientToRequest(RpcRequest request, OutputStream outputStream);

    /**
     * 客戶端接收到服務端響應的結果,轉變成response
     * @param response
     * @param inputStream
     */
     void clientGetResponse(RpcResponse response, InputStream inputStream);
}

實現類DefaultMessageProtocol

public class DefaultMessageProtocol implements MessageProtocol {

    @Override
    public void serviceToRequest(RpcRequest request, InputStream inputStream) {
        try {
            ObjectInputStream input = new ObjectInputStream(inputStream);

            request.setClassName(input.readUTF());
            request.setMethodName(input.readUTF());
            request.setParameterTypes((Class<?>[])input.readObject());
            request.setArguments((Object[])input.readObject());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public <T> void serviceGetResponse(RpcResponse<T> response, OutputStream outputStream) {
        try {
            ObjectOutputStream output = new ObjectOutputStream(outputStream);

            output.writeBoolean(response.getError());
            output.writeObject(response.getResult());
            output.writeObject(response.getErrorMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public void clientToRequest(RpcRequest request, OutputStream outputStream) {
        try {
            ObjectOutputStream ouput = new ObjectOutputStream(outputStream);

            ouput.writeUTF(request.getClassName());
            ouput.writeUTF(request.getMethodName());
            ouput.writeObject(request.getParameterTypes());
            ouput.writeObject(request.getArguments());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void clientGetResponse(RpcResponse response, InputStream inputStream) {
        try {
            ObjectInputStream input = new ObjectInputStream(inputStream);

            response.setError(input.readBoolean());
            response.setResult(input.readObject());
            response.setErrorMessage((String) input.readObject());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

服務端請求處理

public class ServiceHandler {

    private ThreadPoolExecutor executor = null;
    private RpcService rpcService;
    private MessageProtocol messageProtocol;

    public ServiceHandler(RpcService rpcService) {
        this.rpcService = rpcService;

        ThreadFactory commonThreadName = new ThreadFactoryBuilder()
                .setNameFormat("Parse-Task-%d")
                .build();

        this.executor = new ThreadPoolExecutor(
                10,
                10,
                2,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(200),
                commonThreadName, new RejectedExecutionHandler() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                SocketTask socketTask = (SocketTask) r;
                Socket socket = socketTask.getSocket();
                if (socket != null) {
                    try {
                        socket.close();
                        System.out.println("reject socket:" + socketTask + ", and closed");
                        // 無法及時處理和響應的就快速拒絕掉
                    } catch (IOException e) {
                    }
                }
            }
        });
    }

    public RpcService getRpcService() {
        return rpcService;
    }

    public void setRpcService(RpcService rpcService) {
        this.rpcService = rpcService;
    }

    public MessageProtocol getMessageProtocol() {
        return messageProtocol;
    }

    public void setMessageProtocol(MessageProtocol messageProtocol) {
        this.messageProtocol = messageProtocol;
    }

    public void handler(Socket socket) {
        // 接收到新的套接字,包裝成為一個runnable提交給線程去執行
        this.executor.execute(new SocketTask(socket));
    }

    class SocketTask implements Runnable {

        private Socket socket;

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

        public Socket getSocket() {
            return socket;
        }

        @Override
        public void run() {
            try {
                InputStream inputStream = socket.getInputStream();
                OutputStream outputStream = socket.getOutputStream();

                RpcRequest request = new RpcRequest();
                messageProtocol.serviceToRequest(request, inputStream);
                // 獲取客戶端請求數據,統一包裝成RpcRequest
                RpcResponse response = rpcService.invoke(request);
                // 反射調用,得到具體的返回值
                System.out.println("request:[" + request + "], response:[" + response + "]");
                messageProtocol.serviceGetResponse(response, outputStream);
                // 再返回給客戶端
            } catch (Exception e) {
                // error
            } finally {
                if (socket != null) {
                    // SOCKET 關閉一定要加上,要不然會出各種事情
                    try {
                        socket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

客戶端 代理對象

public class ProxyInstance implements InvocationHandler {

    private RpcClient rpcClient;
    private Class clazz;

    public ProxyInstance(RpcClient client, Class clazz) {
        this.rpcClient = client;
        this.clazz = clazz;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RpcRequest request = new RpcRequest();
        request.setClassName(clazz.getName());
        request.setMethodName(method.getName());
        request.setParameterTypes(method.getParameterTypes());
        request.setArguments(args);

        // 獲取服務提供方信息,這里既是服務發現的入口,找到一個合適的可用的服務提供方信息
        InetSocketAddress address = rpcClient.discovery(request);
        System.out.println("[" + Thread.currentThread().getName() + "]discover service:" + address);

        // 發起網絡請求,得到請求數據
        RpcResponse response = rpcClient.invoke(request, address);
        return response.getResult();
    }
}

上面的InetSocketAddress address = rpcClient.discovery(request)是相比v1多了一個最重要的東西,每次獲取請求后都實時從zk中獲取對應的服務提供方信息,這就是服務發現

實踐

public class Client {

    public static void main(String[] args) {
        RpcClient rpcClient = new RpcClient();

        rpcClient.subscribe(Calculate.class);
        rpcClient.start();

        Calculate<Integer> calculateProxy = rpcClient.getInstance(Calculate.class);

        for(int i=0; i< 200; i++) {
            new Thread(() -> {
                long start = System.currentTimeMillis();
                int s1 = new Random().nextInt(100);
                int s2 = new Random().nextInt(100);
                int s3 = calculateProxy.add(s1, s2);
                System.out.println("[" + Thread.currentThread().getName() + "]a: " + s1 + ", b:" + s2 + ", c=" + s3 + ", 耗時:" + (System.currentTimeMillis() - start));
            }).start();
        }
    }
}

客戶端開啟200個線程后,執行結果是順利執行的,在服務端開啟的接受請求被添加到線程池中,而代碼中線程池的任務隊列長度是200,可以完全的存儲200個線程,但是如果我們把客戶端請求量從200個改成500個呢,又會出現什么情況?

服務端

image

客戶端

image

image

如上述的圖片顯示,當請求量打滿線程池之后,線程池的拒絕策略就開始生效了,在本代碼中是直接調用了close操作,而客戶端感知到關閉后也會出現io錯誤,而正常的請求則順利執行。其中還有輸出discover服務發現了服務提供方的機器信息,這也是符合起初的想法的。

這里一定要加上一些策略以及時關閉無法處理的socket,否則就會出現服務提供方無任何可執行,但是服務調用方卻還在等待中,因為socket并沒有關閉,從而出現資源被占用了,還不執行相關任務。

提個問題:線程池打滿了怎么辦?

在本demo中采取了非常粗暴的策略,直接丟棄了無法處理的任務,在實際的線上業務中,可以先加機器以能再最短的時間內恢復線上情況,后期結合業務特點提出針對性的解決方案。如果業務接受一定的延遲,可以考慮接入kafka類似的消息隊列(削峰是mq的一大特點);如果對時間要求很高,要么加機器,要么壓榨機器性能,可能之前設置的線程池的數量太小,那就需要調節線程池的各個核心數據,修改線程池的任務隊列類型也是可以考慮的;此外也有可能是業務耗時太多,無法及時處理完全造成請求堆積導致的,那么就需要考慮業務的同步改異步化。最后線上告警也需要及時完善。

沒有絕對的解決方案,只有最合適當下場景的方案,沒有銀彈,任何不具體結合業務的方案都是扯淡。

總結思考

v2版本相比v1版本修改了整個代碼結構,使得結構能夠更加明確,引入zookeeper作為服務治理功能,大致介紹了zookeeper的特點以及功能,給服務注冊、服務發現、序列化協議等均留下了口子,以便實現自定義的協議,v1的io模型是BIO,v2并沒有變化,只是由單線程改造成多線程。

整體而言符合一個簡單的rpc框架,依舊還是有很多點可以完善、優化的點,如:

  • io模型還是沒有替換,后面考慮直接整體接入netty;
  • 也不應該每次實時從zk獲取節點信息,應該先設置一個本地緩存,再利用zookeeper的watcher功能,開啟一個異步線程去監聽更新本地緩存,降低和zk交互帶來的性能損耗;
  • 也沒有快速失敗、重試的功能,客觀情況下存在網絡抖動的問題,重試就可以了
  • 整體的各種協議約定并沒有明確規范,比較混亂

本人微信公眾號(搜索jwfy)歡迎關注

微信公眾號
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容