圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

張哥說技術發表於2023-03-15

閱讀本文大約需要 40 分鐘。

大家好,我是 華仔, 又跟大家見面了。

上篇主要帶大家深度剖析了「傳送網路 I/O 的 Sender 執行緒的架構設計」,訊息先被暫存然後呼叫網路I/O元件進行傳送,今天主要聊聊「真正進行網路 I/O 的 NetworkClient 的架構設計」深度剖析下訊息是如何被髮送出去的。

圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

原文完整版在星球裡面,如果感興趣可以掃文末二維碼加入

認真讀完這篇文章,我相信你會對 Kafka NetworkClient 的原始碼有更加深刻的理解。

這篇文章乾貨很多,希望你可以耐心讀完。

01 總的概述

繼續透過「場景驅動」的方式,來看看訊息是如何在客戶端被累加和待傳送的。

在上篇中,我們知道了訊息被 Sender 子執行緒先暫存到 KafkaChannel 的 Send 欄位中,然後呼叫 NetworkClient#client.poll() 進行真正傳送出去,如下圖所示「6-11步」。

圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

NetworkClient 為「生產者」、「消費者」、「服務端」等上層業務提供了網路I/O的能力。在 NetworkClient 內部使用了前面介紹的 Kafka 對 NIO 的封裝元件,同時做了一定的封裝,最終實現了網路I/O能力。NetworkClient  不僅僅用於客戶端與服務端的通訊,也用於服務端之間的通訊

接下來我們就來看看,「NetworkClient 網路I/O元件的架構實現以及傳送處理流程」,為了方便大家理解,所有的原始碼只保留骨幹。

02 NetworkClient 架構設計

NetworkClient 類是 KafkaClient 介面的實現類,它內部的重要欄位有「Selectable」、「InflightRequest」以及內部類 「MetadataUpdate」。

github 原始碼地址如下:

02.1 關鍵欄位

 public class NetworkClient implements KafkaClient {
    // 狀態列舉值
    private enum State {
        ACTIVE,
        CLOSING,
        CLOSED
    }
    /* the selector used to perform network i/o */
    // 用於執行網路 I/O 的選擇器
    private final Selectable selector;
    // Metadata元資訊的更新器, 它可以嘗試更新元資訊
    private final MetadataUpdater metadataUpdater;
    /* the state of each node's connection */
    // 管理叢集所有節點連線的狀態
    private final ClusterConnectionStates connectionStates;
    /* the set of requests currently being sent or awaiting a response */
    // 當前正在傳送或等待響應的請求集合
    private final InFlightRequests inFlightRequests;
    /* the socket send buffer size in bytes */
    // 套接字傳送資料的緩衝區的大小(以位元組為單位)
    private final int socketSendBuffer;
    /* the socket receive size buffer in bytes */
    // 套接字接收資料的緩衝區的大小(以位元組為單位)
    private final int socketReceiveBuffer;
    /* the client id used to identify this client in requests to the server */
    // 表示客戶端id,標識客戶端身份
    private final String clientId;
    /* the current correlation id to use when sending requests to servers */
    // 向伺服器傳送請求時使用的當前關聯 ID
    private int correlation;
    /* default timeout for individual requests to await acknowledgement from servers */
    // 單個請求等待伺服器確認的預設超時
    private final int defaultRequestTimeoutMs;
    /* time in ms to wait before retrying to create connection to a server */
    // 重連的退避時間
    private final long reconnectBackoffMs;
    /**
     * True if we should send an ApiVersionRequest when first connecting to a broker.
     * 是否需要與 Broker 端的版本協調,預設為 true
     * 如果為 true 當第一次連線到一個 broker 時,應當傳送一個 version 的請求,用來得知 broker 的版本, 如果為 false 則不需要傳送 version 的請求。
     */

    private final boolean discoverBrokerVersions;
    // broker 端版本
    private final ApiVersions apiVersions;
    // 儲存著要傳送的版本請求,key 為 nodeId,value 為構建請求的 Builder
    private final Map<String, ApiVersionsRequest.Builder> nodesNeedingApiVersionsFetch = new HashMap<>();
    // 取消的請求集合
    private final List<ClientResponse> abortedSends = new LinkedList<>();

從該類屬性欄位來看比較多,這裡說幾個關鍵欄位:

  1. selector:Kafka 自己封裝的 Selector,該選擇器負責監聽「網路I/O事件」、「網路連線」、「讀寫操作」。
  2. metadataUpdater:NetworkClient 的內部類,主要用來實現Metadata元資訊的更新器, 它可以嘗試更新元資訊。
  3. connectionStates:管理叢集所有節點連線的狀態,底層使用 Map<nodeid, NodeConnectionState>實現,NodeConnectionState 列舉值表示連線狀態,並且記錄了最後一次連線的時間戳。
  4. inFlightRequests:用來儲存當前正在傳送或等待響應的請求集合。
  5. socketSenderBuffer:表示套接字傳送資料的緩衝區的大小。
  6. socketReceiveBuffer:表示套接字接收資料的緩衝區的大小。
  7. clientId:表示客戶端id,標識客戶端身份。
  8. reconnectBackoffMs:表示重連的退避事件,為了防止短時間內大量重連造成的網路壓力,設計了這麼一個時間段,在此時間段內不得重連。

02.2 關鍵方法

NetworkClient 類的方法也不少,這裡針對關鍵方法逐一講解下,原文完整版在星球裡,感興趣的可以掃文末二維碼加入

02.2.1 ready()

/**
 * Begin connecting to the given node, return true if we are already connected and ready to send to that node.
 *
 * @param node The node to check
 * @param now The current timestamp
 * @return True if we are ready to send to the given node
 */

 @Override
 public boolean ready(Node node, long now) {
     // 空節點
     if (node.isEmpty())
        throw new IllegalArgumentException("Cannot connect to empty node " + node);
     // 1、判斷節點是否準備好傳送請求
     if (isReady(node, now))
        return true;
     // 2、判斷節點連線狀態
     if (connectionStates.canConnect(node.idString(), now))
        // if we are interested in sending to a node and we don't have a connection to it, initiate one
        // 3、初始化連線,但此時不一定連線成功了
        initiateConnect(node, now);

     return false;
}

/**
 * Check if the node with the given id is ready to send more requests.
 * @param node The node
 * @param now The current time in ms
 * @return true if the node is ready
*/

@Override
public boolean isReady(Node node, long now) {
    // if we need to update our metadata now declare all requests unready to make metadata requests first priority
    // 當發現正在更新後設資料時,會禁止傳送請求 && 當連線沒有建立完畢或者當前傳送的請求過多時,也會禁止傳送請求
    return !metadataUpdater.isUpdateDue(now) && canSendRequest(node.idString(), now);
}

/**
 * Are we connected and ready and able to send more requests to the given connection?
 * 檢測連線狀態、傳送請求是否過多
 * @param node The node
 * @param now the current timestamp
 */

 private boolean canSendRequest(String node, long now) {
    // 三個條件必須都滿足
    return connectionStates.isReady(node, now) && selector.isChannelReady(node) &&
            inFlightRequests.canSendMore(node);
}

該方法表示某個節點是否準備好並可以傳送請求,主要做了三件事:

  1. 先判斷節點是否已經準備好連線並接收請求了,需要滿足以下四個條件:
  • !metadataUpdater.isUpdateDue(now):不能是正在更新後設資料的狀態,且後設資料不能過期。
  • canSendRequest(node.idString(), now):此處有3個條件。(1)、客戶端和 node 連線是否處於 ready 狀態;(2)、客戶端和 node 的 channel 是否建立好;(3)、inFlightRequests 中對應的節點是否可以接收更多的請求。
  • 如果連線好返回 true 表示準備好,如果沒有準備好接收請求,則會嘗試與對應的 Node 連線,此處也需要滿足兩個條件:
    • 首先連線必須是 isDisconnected,不能是 connecteding 狀態,即客戶端與服務端的連線狀態是沒有連線上
    • 兩次重試之間時間差要大於重試退避時間,目的就是為了避免網路擁塞,防止重連過於頻繁造成網路壓力過大
  • 最後初始化連線。
  • 02.2.2 initiateConnect()

      /**
      * 建立連線 
      * Initiate a connection to the given node
      * @param node the node to connect to
      * @param now current time in epoch milliseconds
      */

     private void initiateConnect(Node node, long now) {
        String nodeConnectionId = node.idString();
        try {
            // 1、更新連線狀態為正在連線
            connectionStates.connecting(nodeConnectionId, now, node.host(), clientDnsLookup);
            // 獲取連線地址
            InetAddress address = connectionStates.currentAddress(nodeConnectionId);
            log.debug("Initiating connection to node {} using address {}", node, address);
            // 2、呼叫 selector 嘗試非同步進行連線,後續透過selector.poll進行監聽事件就緒 
            selector.connect(nodeConnectionId,
                        new InetSocketAddress(address, node.port()),
                        this.socketSendBuffer,
                        this.socketReceiveBuffer);
        } catch (IOException e) {
            log.warn("Error connecting to node {}", node, e);
            // Attempt failed, we'll try again after the backoff
            connectionStates.disconnected(nodeConnectionId, now);
            // Notify metadata updater of the connection failure
            metadataUpdater.handleServerDisconnect(now, nodeConnectionId, Optional.empty());
        }
    }

    該方法主要是進行初始化連線,做了兩件事:

    1. 呼叫 connectionStates.connecting() 更新連線狀態為正在連線。
    2. 呼叫 selector.connect() 非同步發起連線,此時不一定連線上了,後續 Selector.poll() 會監聽連線是否準備好並完成連線,如果連線成功,則會將  ConnectionState 設定為 CONNECTED。

    當連線準備好後,接下來我們來看下傳送相關的方法。

    02.2.3 send()、doSend()

     /**
     * ClientRequest 是客戶端的請求,封裝了 requestBuilder 
     */

    public final class ClientRequest {
        // 節點地址
        private final String destination;
        // ClientRequest 中透過 requestBuilder 給不同型別的請求設定不同的請求內容
        private final AbstractRequest.Builder<?> requestBuilder;
        // 請求頭的 correlationId
        private final int correlationId;
        // 請求頭的 clientid
        private final String clientId;
        // 建立時間
        private final long createdTimeMs;
        // 是否需要進行響應
        private final boolean expectResponse;
        // 請求的超時時間
        private final int requestTimeoutMs;
        // 回撥函式 用來處理響應
        private final RequestCompletionHandler callback;
        ......
    }

    /**
     * Queue up the given request for sending. Requests can only be sent out to ready nodes.
     * @param request The request
     * @param now The current timestamp
     * 傳送請求,這個方法 生產者和消費者都會呼叫,其中 ClientRequest 表示客戶端的請求。
     */

     @Override
     public void send(ClientRequest request, long now) {
         doSend(request, false, now);
     }
     
     // 檢測請求版本是否支援,如果支援則傳送請求
     private void doSend(ClientRequest clientRequest, boolean isInternalRequest, long now) {
            // 確認是否活躍
            ensureActive();
            // 目標節點id
            String nodeId = clientRequest.destination();
            // 是否是 NetworkClient 內部請求 這裡為 false
            if (!isInternalRequest) {
                 // 檢測是否可以向指定 Node 傳送請求,如果還不能傳送請求則拋異常
                 if (!canSendRequest(nodeId, now))
                    throw new IllegalStateException("Attempt to send a request to node " + nodeId + " which is not ready.");
            }
            AbstractRequest.Builder<?> builder = clientRequest.requestBuilder();
            try {
                // 檢測版本
                NodeApiVersions versionInfo = apiVersions.get(nodeId);
                // ... 忽略
                // builder.build()是 ProduceRequest.Builder,結果是ProduceRequest
                // 呼叫 doSend 方法
                doSend(clientRequest, isInternalRequest, now, builder.build(version));
            } catch (UnsupportedVersionException unsupportedVersionException) {            log.debug("Version mismatch when attempting to send {} with correlation id {} to {}", builder, clientRequest.correlationId(), clientRequest.destination(), unsupportedVersionException);
               // 請求的版本不協調,那麼生成 clientResponse
               ClientResponse clientResponse = new ClientResponse(clientRequest.makeHeader(builder.latestAllowedVersion()),
                        clientRequest.callback(), clientRequest.destination(), now, now,
                        false, unsupportedVersionException, nullnull);
                // 新增到 abortedSends 集合裡
                abortedSends.add(clientResponse);
            }
      }

      /**
       * isInternalRequest 表示傳送前是否需要驗證連線狀態,如果為 true 則表示客戶端已經確定連線是好的
       * request表示請求體
       */

      private void doSend(ClientRequest clientRequest, boolean isInternalRequest, long now, AbstractRequest request) {
            // 目標節點地址
            String destination = clientRequest.destination();
            // 生成請求頭
            RequestHeader header = clientRequest.makeHeader(request.version());
            if (log.isDebugEnabled()) {
                log.debug("Sending {} request with header {} and timeout {} to node {}: {}",
                    clientRequest.apiKey(), header, clientRequest.requestTimeoutMs(), destination, request);
            }
            // 1、構建 NetworkSend 物件 結合請求頭和請求體,序列化資料,儲存到 NetworkSend 
            Send send = request.toSend(destination, header);
            // 2、構建 inFlightRequest 物件 儲存了傳送前的所有資訊
            InFlightRequest inFlightRequest = new InFlightRequest(
                    clientRequest,
                    header,
                    isInternalRequest,
                    request,
                    send,
                    now);
            // 3、把 inFlightRequest 加入 inFlightRequests 集合裡
            this.inFlightRequests.add(inFlightRequest);
            // 4、呼叫 Selector 非同步傳送資料,並將 send 和對應 kafkaChannel 繫結起來,並開啟該 kafkaChannel 底層 socket 的寫事件,等待下一步真正的網路傳送
            selector.send(send);
    }

    @Override
    public boolean active() {
        // 判斷狀態是否是活躍的
        return state.get() == State.ACTIVE;
    }

    // 確認是否活躍
    private void ensureActive() {
       if (!active())
          throw new DisconnectException("NetworkClient is no longer active, state is " + state);
    }

    從上面原始碼可以看出此處傳送並不是真正的網路傳送,而是先將資料傳送到快取中

    1. 首先最外層是 send() ,裡面呼叫 doSend() 。
    2. 這裡的 doSend() 主要的作用是判斷 inFlightRequests 集合上對應的節點是不是能傳送請求,需要滿足三個條件:
    • 客戶端和 node 連線是否處於 ready 狀態。
    • 客戶端和 node 的 channel 是否建立好。
    • inFlightRequests 集合中對應的節點是否可以接收更多的請求。
  • 最後再次呼叫另一個 doSend(),用來最終的請求傳送到快取中。步驟如下:
    • 構建 NetworkSend 物件 結合請求頭和請求體,序列化資料,儲存到 NetworkSend。
    • 構建 inFlightRequest 物件。
    • 把 inFlightRequest 加入 inFlightRequests 集合裡等待響應。
    • 呼叫Selector非同步傳送資料,並將 send 和對應 kafkaChannel 繫結起來,並開啟該 kafkaChannel 底層 socket 的寫事件,等待下一步真正的網路傳送。

    綜上可以得出這裡的傳送過程其實是把要傳送的請求先封裝成 inFlightRequest,然後放到 inFlightRequests 集合裡,然後放到對應 channel 的欄位 NetworkSend 裡快取起來。總之,這裡的傳送過程就是為了下一步真正的網路I/O傳送而服務的

    接下來看下真正網路傳送的方法。

    02.2.4 poll()

    該方法執行網路傳送並把響應結果「pollSelectionKeys 的各種讀寫」做各種狀態處理,此處是透過呼叫 handleXXX() 方法進行處理的,程式碼如下:

    /**
     * Do actual reads and writes to sockets.
     * @param timeout The maximum amount of time to wait (in ms) for responses if there are none immediately,
     * must be non-negative. The actual timeout will be the minimum of timeout, request timeout and
     * metadata timeout
     * @param now The current time in milliseconds
     * @return The list of responses received
    */

    @Override
    public List<ClientResponse> poll(long timeout, long now) {
       // 確認是否活躍
       ensureActive();
       // 取消傳送是否為空
       if (!abortedSends.isEmpty()) {
          // If there are aborted sends because of unsupported version exceptions or disconnects,
          // handle them immediately without waiting for Selector#poll.
          List<ClientResponse> responses = new ArrayList<>();
          handleAbortedSends(responses);
          completeResponses(responses);
          return responses;
       }
       // 1、嘗試更新後設資料
       long metadataTimeout = metadataUpdater.maybeUpdate(now);
       try {
          // 2、執行網路 I/O 操作,真正讀寫傳送的地方,如果客戶端的請求被完整的處理過了,會加入到completeSends 或 complteReceives 集合中
          this.selector.poll(Utils.min(timeout, metadataTimeout, defaultRequestTimeoutMs));
       } catch (IOException e) {
          log.error("Unexpected error during I/O", e);
       }

       // process completed actions
       long updatedNow = this.time.milliseconds();
       // 響應結果集合:真正的讀寫操作, 會生成responses
       List<ClientResponse> responses = new ArrayList<>();
       // 3、完成傳送的handler,處理 completedSends 集合
       handleCompletedSends(responses, updatedNow);
       // 4、完成接收的handler,處理 completedReceives 佇列
       handleCompletedReceives(responses, updatedNow);
       // 5、斷開連線的handler,處理 disconnected 列表
       handleDisconnections(responses, updatedNow);
       // 6、處理連線的handler,處理 connected 列表
       handleConnections();
       // 7、處理版本協調請求(獲取api版本號) handler
       handleInitiateApiVersionRequests(updatedNow);
       // 8、超時連線的handler,處理超時連線集合
       handleTimedOutConnections(responses, updatedNow);
       // 9、超時請求的handler,處理超時請求集合
       handleTimedOutRequests(responses, updatedNow);
       // 10、完成響應回撥
       completeResponses(responses);

       return responses;
    }

    這裡的步驟比較多,我們按照先後順序講解下。

    1. 嘗試更新後設資料。
    2. 呼叫 Selector.poll() 執行真正網路 I/O 操作,可以點選檢視 圖解 Kafka 原始碼網路層實現機制之 Selector 多路複用器 主要操作以下3個集合。
    • connected集合:已經完成連線的 Node 節點集合。
    • completedReceives集合:接收完成的集合,即 KafkaChannel 上的 NetworkReceive 寫滿後會放入這個集合裡。
    • completedSends集合:傳送完成的集合,即 channel 上的 NetworkSend 讀完後會放入這個集合裡。
  • 呼叫 handleCompletedSends() 處理 completedSends 集合。
  • 呼叫 handleCompletedReceives() 處理 completedReceives 佇列。
  • 呼叫 handleDisconnections() 處理與 Node 斷開連線的請求。
  • 呼叫 handleConnections() 處理 connected 列表。
  • 呼叫 handleInitiateApiVersionRequests() 處理版本號請求。
  • 呼叫 handleTimedOutConnections() 處理連線超時的 Node 集合。
  • 呼叫 handleTimedOutRequests() 處理 inFlightRequests 集合中的超時請求,並修改其狀態。
  • 呼叫 completeResponses() 完成每個訊息自定義的響應回撥。
  • 接下來看下第 3~9 步驟的方法實現。

    02.2.5 handleCompletedSends()

    當 NetworkClient 傳送完請求後,就會呼叫 handleCompletedSends 方法,表示請求已經傳送到 Broker 端了。

    /**
     * Handle any completed request send. In particular if no response is expected consider the request complete.
     * @param responses The list of responses to update
     * @param now The current time
    */

    private void handleCompletedSends(List<ClientResponse> responses, long now) {
       // if no response is expected then when the send is completed, return it
       // 1、遍歷 completedSends 傳送完成的請求集合,透過呼叫 Selector 獲取從上一次 poll 開始的請求
       for (Send send : this.selector.completedSends()) {
           // 2、從 inFlightRequests 集合獲取該 Send 關聯對應 Node 的佇列取出最新的請求,但並沒有從佇列中刪除,取出後判斷這個請求是否期望得到響應
           InFlightRequest request = this.inFlightRequests.lastSent(send.destination());
           // 3、是否需要響應, 如果不需要響應,當Send請求完成時,就直接返回.還是有request.completed生成的ClientResponse物件
           if (!request.expectResponse) {
               // 4、如果不需要響應就取出 inFlightRequests 中該 Sender 關聯對應 Node 的 inFlightRequest,即提取最新的請求
               this.inFlightRequests.completeLastSent(send.destination());
               // 5、呼叫 completed() 生成 ClientResponse,第一個引數為null,表示沒有響應內容,把請求新增到 Responses 集合
               responses.add(request.completed(null, now));
           }
       }
    }

    該方法主要用來在客戶端傳送請求後,對響應結果進行處理,做了五件事:

    1. 遍歷 seletor 中的 completedSends 集合,逐個處理完成的 Send 物件。
    2. 從 inFlightRequests 集合獲取該 Send 關聯對應 Node 的佇列中第一個元素,但並沒有從佇列中刪除,取出後判斷這個請求是否期望得到響應。
    3. 判斷是否需要響應。
    4. 如果不需要響應就刪除 inFlightRequests 中該 Sender 關聯對應 Node 的 inFlightRequest,對於 Kafka 來說,有些請求是不需要響應的,對於傳送完不用考慮是否傳送成功的話,就構建 callback 為 null 的 Response 物件。
    5. 透過 InFlightRequest.completed(),生成 ClientResponse,第一個引數為 null 表示沒有響應內容,最後把 ClientResponse 新增到 Responses 集合。

    從上面原始碼可以看出,「completedSends」集合與「InflightRequests」集合協作的關係。

    但是這裡有個問題:如何保證從 Selector 返回的請求,就是對應到 InflightRequests 集合佇列的最新的請求呢

    completedSends 集合儲存的是最近一次呼叫 poll() 方法中傳送成功的請求「傳送成功但還沒有收到響應的請求集合」。而 InflightRequests 集合儲存的是已經傳送但還沒收到響應的請求。每個請求傳送都需要等待前面的請求傳送完成,這樣就能保證同一時間只有一個請求正在傳送,因為 Selector 返回的請求是從上一次 poll 開始的,這樣就對上了。

    completedSends」的元素對應著「InflightRequests」集合裡對應佇列的最後一個元素, 如下圖所示:

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    02.2.6 handleCompletedReceives()

    當 NetworkClient 收到響應時,就會呼叫 handleCompletedReceives 方法。

    /**
     * Handle any completed receives and update the response list with the responses received.
     * @param responses The list of responses to update
     * @param now The current time
     * 處理 CompletedReceives 佇列,根據返回的響應資訊例項化 ClientResponse ,並加到響應集合裡
    */

    private void handleCompletedReceives(List<ClientResponse> responses, long now) {
       // 1、遍歷 CompletedReceives 響應集合,透過 Selector 返回未處理的響應
       for (NetworkReceive receive : this.selector.completedReceives()) {
           // 2、獲取傳送請求的 Node id
           String source = receive.source();
           // 3、從 inFlightRequests 集合佇列獲取已傳送請求「最老的請求」並刪除(從 inFlightRequests 刪除,因為inFlightRequests 儲存的是未收到請求響應的 ClientRequest,現在請求已經有響應了,就不需要儲存了)
           InFlightRequest req = inFlightRequests.completeNext(source);
           // 4、解析響應,並且驗證響應頭,生成 responseStruct 例項
           Struct responseStruct = parseStructMaybeUpdateThrottleTimeMetrics(receive.payload(), req.header,throttleTimeSensor, now);
           // 生成響應體
           AbstractResponse response = AbstractResponse.parseResponse(req.header.apiKey(), responseStruct, req.header.apiVersion());       
          ....
          // If the received response includes a throttle delay, throttle the connection.
          // 流控處理
          maybeThrottle(response, req.header.apiVersion(), req.destination, now);
          // 5、判斷返回型別
          if (req.isInternalRequest && response instanceof MetadataResponse)
              // 處理後設資料請求響應
              metadataUpdater.handleSuccessfulResponse(req.header, now, (MetadataResponse) response);
          else if (req.isInternalRequest && response instanceof ApiVersionsResponse)
              // 處理版本協調響應
              handleApiVersionsResponse(responses, req, now, (ApiVersionsResponse) response);
          else
              // 普通傳送訊息的響應,透過 InFlightRequest.completed(),生成 ClientResponse,將響應新增到 responses 集合中
              responses.add(req.completed(response, now));
        }
    }

    // 解析響應,並且驗證響應頭,生成 responseStruct 例項
    private static Struct parseStructMaybeUpdateThrottleTimeMetrics(ByteBuffer responseBuffer, RequestHeader requestHeader, Sensor throttleTimeSensor, long now) {
        // 解析響應頭
        ResponseHeader responseHeader = ResponseHeader.parse(responseBuffer,
                requestHeader.apiKey().responseHeaderVersion(requestHeader.apiVersion()));
        // 解析響應體
        Struct responseBody = requestHeader.apiKey().parseResponse(requestHeader.apiVersion(), responseBuffer);
        // 驗證請求頭與響應頭的 correlation id 必須相等
        correlate(requestHeader, responseHeader);
        if (throttleTimeSensor != null && responseBody.hasField(CommonFields.THROTTLE_TIME_MS))
                throttleTimeSensor.record(responseBody.get(CommonFields.THROTTLE_TIME_MS), now);
        return responseBody;
    }

    該方法主要用來處理接收完畢的網路請求集合,做了五件事:

    1. 遍歷 selector 中的 completedReceives 集合,逐個處理完成的 Receive 物件。
    2. 獲取傳送請求的 Node id。
    3. 從 inFlightRequests 集合佇列獲取已傳送請求「最老的請求」並刪除(從 inFlightRequests 刪除,因為inFlightRequests 儲存的是未收到請求響應的 ClientRequest,現在請求已經有響應了,就不需要儲存了)。
    4. 解析響應,並且驗證響應頭,生成 responseStruct 例項,生成響應體。
    5. 處理響應結果,此處分為三種情況:
    • 處理後設資料請求響應,則呼叫 metadataUpdater.handleSuccessfulResponse()。
    • 處理版本協調響應,則呼叫 handleApiVersionsResponse()。
    • 普通傳送訊息的響應,透過 InFlightRequest.completed(),生成 ClientResponse,將響應新增到 responses 集合中。

    從上面原始碼可以看出,「completedReceives」集合與「InflightRequests」集合也有協作的關係, completedReceives 集合指的是接收到的響應集合,如果請求已經收到響應了,就可以從 InflightRequests 刪除了,這樣 InflightRequests 就起到了可以防止請求堆積的作用。

    與 「completedSends」正好相反,「completedReceives」集合對應 「InflightRequests」集合裡對應佇列的第一個元素,如下圖所示:

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    02.2.7 leastLoadedNode()

    /**
     * Choose the node with the fewest outstanding requests which is at least eligible for connection. This method will
     * prefer a node with an existing connection, but will potentially choose a node for which we don't yet have a
     * connection if all existing connections are in use. If no connection exists, this method will prefer a node
     * with least recent connection attempts. This method will never choose a node for which there is no
     * existing connection and from which we have disconnected within the reconnect backoff period, or an active
     * connection which is being throttled.
     *
     * @return The node with the fewest in-flight requests.
     */

     @Override
     public Node leastLoadedNode(long now) {
            // 從後設資料中獲取所有的節點
            List<Node> nodes = this.metadataUpdater.fetchNodes();
            if (nodes.isEmpty())
                throw new IllegalStateException("There are no nodes in the Kafka cluster");
            int inflight = Integer.MAX_VALUE;

            Node foundConnecting = null;
            Node foundCanConnect = null;
            Node foundReady = null;

            int offset = this.randOffset.nextInt(nodes.size());
            for (int i = 0; i < nodes.size(); i++) {
                int idx = (offset + i) % nodes.size();
                Node node = nodes.get(idx);
                // 節點是否可以傳送請求
                if (canSendRequest(node.idString(), now)) {
                    // 獲取節點的佇列大小
                    int currInflight = this.inFlightRequests.count(node.idString());
                    // 如果為 0 則返回該節點,負載最小 
                    if (currInflight == 0) {
                        // if we find an established connection with no in-flight requests we can stop right away
                        log.trace("Found least loaded node {} connected with no in-flight requests", node);
                        return node;
                    } else if (currInflight < inflight) { // 如果佇列大小小於最大值
                        // otherwise if this is the best we have found so far, record that
                        inflight = currInflight;
                        foundReady = node;
                    }
                } else if (connectionStates.isPreparingConnection(node.idString())) {
                    foundConnecting = node;
                } else if (canConnect(node, now)) {
                    if (foundCanConnect == null ||
                            this.connectionStates.lastConnectAttemptMs(foundCanConnect.idString()) >
                                    this.connectionStates.lastConnectAttemptMs(node.idString())) {
                        foundCanConnect = node;
                    }
                } else {
                    log.trace("Removing node {} from least loaded node selection since it is neither ready " +
                            "for sending or connecting", node);
                }
            }

            // We prefer established connections if possible. Otherwise, we will wait for connections
            // which are being established before connecting to new nodes.
            if (foundReady != null) {
                log.trace("Found least loaded node {} with {} inflight requests", foundReady, inflight);
                return foundReady;
            } else if (foundConnecting != null) {
                log.trace("Found least loaded connecting node {}", foundConnecting);
                return foundConnecting;
            } else if (foundCanConnect != null) {
                log.trace("Found least loaded node {} with no active connection", foundCanConnect);
                return foundCanConnect;
            } else {
                log.trace("Least loaded node selection failed to find an available node");
                return null;
            }
     }

    該方法主要是選出一個負載最小的節點,如下圖所示:

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    中間的部分程式碼請移步到星球檢視

    03 InflightRequests 集合設計

    透過上面的程式碼分析,我們知道「InflightRequests」集合的作用就是快取已經傳送出去但還沒有收到響應的  ClientRequest 請求集合。底層是透過 ReqMap<string, Deque<NetworkClient.InFlightRequest>> 實現,其中 key 是 NodeId,value 是傳送到對應 Node 的 ClientRequest 請求佇列,預設為5個,引數:max.in.flight.requests.per.connection 配置請求佇列大小。它為每個連線生成一個雙端佇列,因此它能控制請求傳送的速度

    其作用有以下2個:

    1. 節點是否正常:收集從「開始傳送」到「接收響應」這段時間的請求,來判斷要傳送的 Broker 節點是否正常,請求和連線是否超時等等,也就是說用來監控傳送到哥哥節點請求是否正常
    2. 節點的負載情況:Deque 佇列到一定長度後就認為某個 Broker 節點負載過高了。
    /**
     * The set of requests which have been sent or are being sent but haven't yet received a response
     * 用來快取已經傳送出去或者正在傳送但均還沒有收到響應的  ClientRequest 請求集合
     */

    final class InFlightRequests {
        // 每個連線最大執行中的請求數
        private final int maxInFlightRequestsPerConnection;
        // 節點 Node 至客戶端請求雙端佇列 Deque<NetworkClient.InFlightRequest> 的對映集合,key為 NodeId, value 是請求佇列
        private final Map<String, Deque<NetworkClient.InFlightRequest>> requests = new HashMap<>();
        /** Thread safe total number of in flight requests. */
        // 執行緒安全的 inFlightRequestCount 
        private final AtomicInteger inFlightRequestCount = new AtomicInteger(0);
        // 設定每個連線最大執行中的請求數
        public InFlightRequests(int maxInFlightRequestsPerConnection) {
            this.maxInFlightRequestsPerConnection = maxInFlightRequestsPerConnection;
    }

    這裡透過「場景驅動」的方式來講解關鍵方法,當有新請求需要傳送處理時,會在隊首入隊。而實際被處理的請求,則是從隊尾出隊,保證入隊早的請求先得到處理。

    03.1 canSendMore()

    先來看下傳送條件限制, NetworkClient 呼叫這個方法用來判斷是否還可以向指定 Node 傳送請求。

    /**
     * Can we send more requests to this node?
     * @param node Node in question
     * @return true iff we have no requests still being sent to the given node
     * 判斷該連線是否還能傳送請求
    */

    public boolean canSendMore(String node) {
            // 獲取節點對應的雙端佇列
            Deque<NetworkClient.InFlightRequest> queue = requests.get(node);
            // 判斷條件 佇列為空 || (隊首已經傳送完成 && 佇列中沒有堆積更多的請求)
            return queue == null || queue.isEmpty() ||
                   (queue.peekFirst().send.completed() && queue.size() < this.maxInFlightRequestsPerConnection);
    }

    從上面程式碼可以看出限制條件,佇列雖然可以儲存多個請求,但是新的請求要是加進來條件是上一個請求必須傳送成功。

    條件判斷如下:

    1. queue == null || queue.isEmpty(),佇列為空就能傳送。
    2. 判斷 queue.peekFirst().send.completed() 隊首是否傳送完成。
    • 如果隊首的請求遲遲傳送不出去,可能就是網路的原因,因此不能繼續向此 Node 傳送請求。
    • 隊首的請求與對應的 KafkaChannel.send 欄位指向的是同一個請求,為了避免未傳送的訊息被覆蓋掉,也不能讓 KafkaChannel.send 欄位指向新請求
  • queue.size() < this.maxInFlightRequestsPerConnection,該條件就是為了判斷佇列中是否堆積過多請求,如果 Node 已經堆積了很多未響應的請求,說明這個節點出現了網路擁塞,繼續再傳送請求,則可能會超時。
  • 03.2 add() 入隊

    /**
     * Add the given request to the queue for the connection it was directed to
     * 將請求新增到佇列首部
    */

    public void add(NetworkClient.InFlightRequest request) {
            // 這個請求要傳送到哪個 Broker 節點上
            String destination = request.destination;
            // 從 requests 集合中根據給定請求的目標 Node 節點獲取對應 Deque<ClientRequest> 雙端佇列 reqs
            Deque<NetworkClient.InFlightRequest> reqs = this.requests.get(destination);
            // 如果雙端佇列reqs為null
            if (reqs == null) {
                // 構造一個雙端佇列 ArrayDeque 型別的 reqs
                reqs = new ArrayDeque<>();
                // 將請求目標 Node 節點至 reqs 的對映關係新增到 requests 集合
                this.requests.put(destination, reqs);
            }
            // 將請求 request 新增到 reqs 隊首
            reqs.addFirst(request);
            // 增加計數
            inFlightRequestCount.incrementAndGet();
    }

    03.3 completeNext() 出隊最老請求

    /**
     * Get the oldest request (the one that will be completed next) for the given node
     * 取出該連線對應的佇列中最老的請求
     */

     public NetworkClient.InFlightRequest completeNext(String node) {
         // 根據給定 Node 節點獲取客戶端請求雙端佇列 reqs,並從隊尾出隊
         NetworkClient.InFlightRequest inFlightRequest = requestQueue(node).pollLast();
         // 遞減計數器
         inFlightRequestCount.decrementAndGet();
         return inFlightRequest;
     }

    對比下入隊和出隊這2個方法,「入隊 add()時是透過 addFirst() 方法新增到隊首的,所以隊尾的請求是時間最久的,也是應該先處理的,所以出隊 completeNext()」是透過 pollLast(),將佇列中時間最久的請求袁術移出進行處理。

    03.4 lastSent() 獲取最新請求

    /**
     * Get the last request we sent to the given node (but don't remove it from the queue)
     * @param node The node id
     */

     public NetworkClient.InFlightRequest lastSent(String node) {
         return requestQueue(node).peekFirst();
     }

    03.5 completeLastSent() 出隊最新請求

    /**
      * Complete the last request that was sent to a particular node.
      * @param node The node the request was sent to
      * @return The request
      * 取出該連線對應的佇列中最新的請求
      */

     public NetworkClient.InFlightRequest completeLastSent(String node) {
            // 根據給定 Node 節點獲取客戶端請求雙端佇列 reqs,並從隊首出隊
            NetworkClient.InFlightRequest inFlightRequest = requestQueue(node).pollFirst();
            // 遞減計數器
            inFlightRequestCount.decrementAndGet();
            return inFlightRequest;
     }

    最後我們來看看「InflightRequests」,表示正在傳送的請求,儲存著請求傳送前的所有資訊。

    另外它支援生成響應 ClientResponse,當正常收到響應時,completed()會根據響應內容生成對應的 ClientResponse,當連線突然斷開後,disconnected() 會生成 ClientResponse 物件,程式碼如下:

    static class InFlightRequest {
         //  請求頭
         final RequestHeader header;
         // 這個請求要傳送到哪個 Broker 節點上
         final String destination;
         // 回撥函式
         final RequestCompletionHandler callback;
         // 是否需要進行響應
         final boolean expectResponse;
         // 請求體
         final AbstractRequest request;
         // 傳送前是否需要驗證連線狀態
         final boolean isInternalRequest; // used to flag requests which are initiated internally by NetworkClient
         // 請求的序列化資料
         final Send send;
         // 傳送時間
         final long sendTimeMs;
         // 請求的建立時間,即 ClientRequest 的建立時間
         final long createdTimeMs;
         // 請求超時時間
         final long requestTimeoutMs;
         .....
        /**
         * 收到響應,回撥的時候據響應內容生成 ClientResponse
         */

        public ClientResponse completed(AbstractResponse response, long timeMs) {
            return new ClientResponse(header, callback, destination, createdTimeMs, timeMs,
                        falsenullnull, response);
        }
        
        /**
         * 當連線突然斷開,也會生成 ClientResponse。
         */

        public ClientResponse disconnected(long timeMs, AuthenticationException authenticationException) {
           return new ClientResponse(header, callback, destination, createdTimeMs, timeMs,
                        truenull, authenticationException, null);
        }
    }

    中間的部分程式碼請移步到星球檢視

    05 完整請求流程串聯

    一條完整的請求主要分為以下幾個階段:

    1. 呼叫 NetworkClient 的 ready(),連線服務端。
    2. 呼叫 NetworkClient 的 poll(),處理連線。
    3. 呼叫 NetworkClient 的 newClientRequest(),建立請求 ClientRequest。
    4. 然後呼叫 NetworkClient 的 send(),傳送請求。
    5. 最後呼叫 NetworkClient 的 poll(),處理響應。
    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.1 建立連線過程

    NetworkClient 傳送請求之前,都需要先和 Broker 端建立連線。NetworkClient 負責管理與叢集的所有連線。

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.2 生成請求過程

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.3 傳送請求過程

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.4 處理響應過程

    05.4.1 請求傳送完成

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.4.2 請求收到響應

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    05.4.3 執行處理響應

    圖解 Kafka 原始碼之 NetworkClient 網路通訊元件架構設計

    06 總結

    這裡,我們一起來總結一下這篇文章的重點。

    1、開篇總述訊息訊息被 Sender 子執行緒先將訊息暫存到 KafkaChannel 的 send 中,等呼叫「poll方法」執行真正的網路I/O 操作,從而引出了為客戶端提供網路 I/O 能力的 「NetworkClient 元件」。

    2、帶你深度剖析了「NetworkClient 元件」 、「InflightRequests」、「ClusterConnectionState」的實現細節。

    3、最後帶你串聯了整個訊息傳送請求和處理響應的流程,讓你有個更好的整體認知。

    來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2939701/,如需轉載,請註明出處,否則將追究法律責任。

    相關文章