Kafka Fetch Session剖析

哥不是小蘿莉發表於2021-01-31

1.概述

最近有同學留言在使用Kafka的過程中遇到一些問題,比如在拉取的Topic中的資料時會丟擲一些異常,今天筆者就為大家來分享一下Kafka的Fetch流程。

2.內容

2.1 背景

首先,我們來了解一下,Fetch Session的目標。Kafka在1.1.0以後的版本中優化了Fetch問題,引入了Fetch Session,Kafka由Broker來提供服務(通訊、資料互動等)。每個分割槽會有一個Leader Broker,Broker會定期向Leader Broker傳送Fetch請求,來獲取資料,而對於分割槽數較大的Topic來說,需要發出的Fetch請求就會很大。這樣會有一個問題:

  • Follower感興趣的分割槽集很少改變,然而每個FetchRequest必須列舉Follower感興趣的所有分割槽集合;
  • 當上一個FetchRequest只會分割槽中沒有任何改變,仍然必須發回關於該分割槽的所有後設資料,其中包括分割槽ID、分割槽的起始Offset、以及能夠請求的最大位元組數等。

並且,這些問題與系統中現存分割槽的數量成線性比例,例如,假設Kafka叢集中有100000個分割槽,其中大多數分割槽很少接收新訊息。該系統中的Broker仍然會來回傳送非常大的FetchRequest和FetchResponse,即使每秒新增的實際訊息資料很少。隨著分割槽數量的增長,Kafka使用越來越多的網路頻寬來回傳遞這些訊息。

當Kafka被調整為較低延遲時,這些低效會變得更嚴重。如果我們將每秒傳送的FetchRequest數量增加一倍,我們應該期望在縮短的輪詢間隔內有更多的分割槽沒有改變。而且,我們無法在每個FetchRequest和FetchResponse中分攤每個分割槽傳送後設資料的所需要的頻寬資源,這將會導致Kafka需要使用更多的網路頻寬。

2.2 優化

為了優化上述問題,Kafka增加了增量拉取分割槽的概念,從而減少客戶端每次拉取都需要拉取全部分割槽的問題。Fetch Session與網路程式設計中的Session類似,可以認為它是有狀態的,這裡的狀態值的是知道它需要拉取哪些分割槽的資料,比如第一次拉取的分割槽0中的資料,後續分割槽0中沒有了資料,就不需要拉取分割槽0了,FetchSession資料結構如下

class FetchSession(val id: Int, // sessionid是隨機32位數字,用於鑑權,防止客戶端偽造
                   val privileged: Boolean, // 是否授權
                   val partitionMap: FetchSession.CACHE_MAP,// 快取資料CachedPartitionMap
                   val creationMs: Long, // 建立Session的時間
                   var lastUsedMs: Long, // 上次使用會話的時間,由FetchSessionCache更新
                   var epoch: Int) // 獲取會話序列號

需要注意的是,Fetch Epoch是一個單調遞增的32位計數器,它在處理請求N之後,Broker會接收請求N+1,序列號總是大於0,在達到最大值後,它會回到1。

如果Fetch Session支援增量Fetch,那麼它將維護增量Fetch中每個分割槽的資訊,關於每個分割槽,它需要維護:

  • Topic名稱
  • 分割槽ID
  • 該分割槽的最大位元組數
  • Fetch偏移量
  • HighWaterMark
  • FetcherLogStartOffset
  • LeaderLogStartOffset

其中,Topic名稱、分割槽ID來自於TopicPartition,最大位元組數、Fetch偏移量、FetcherLogStartOffset來自於最近的FetcherRequest,HighWaterMark、LocalLogStartOffset來自於本地的Leader。因為Follower活著Consumer發出的請求都會與分割槽Leader進行互動,所以FetchSession也是記錄在Leader節點上的。

對於客戶端來說,什麼時候一個分割槽會被包含到增量的拉取請求中:

  • Client通知Broker,分割槽的maxBytes,fetchOffset,LogStartOffset改變了;
  • 分割槽在之前的增量拉取會話中不存在,Client想要增加這個分割槽,從而來拉取新的分割槽;
  • 分割槽在增量拉取會話中,Client要刪除。

對於服務端來說,增量分割槽包含到增量的拉取響應中:

  • Broker通知Client分割槽的HighWaterMark或者brokerLogStartOffset改變了;
  • 分割槽有新的資料

Fetch.java類中,方法sendFetches(): prepareFetchRequests建立FetchSessionHandler.FetchRequestData。 構建拉取請求通過FetchSessionHandler.Builder,builder.add(partition, PartitionData)會新增next: 即要拉取的分割槽。構建時呼叫Builder.build(),針對Full進行拉取,程式碼片段如下:

FetchSessionHandler.java

if (nextMetadata.isFull()) { // epoch為0或者-1
    if (log.isDebugEnabled()) {
        log.debug("Built full fetch {} for node {} with {}.",
                  nextMetadata, node, partitionsToLogString(next.keySet()));
    }
    sessionPartitions = next; // next為之前動態增加的分割槽
    next = null; // 本地全量拉取,下次next為null
    Map<TopicPartition, PartitionData> toSend =
        Collections.unmodifiableMap(new LinkedHashMap<>(sessionPartitions));
    return new FetchRequestData(toSend, Collections.emptyList(), toSend, nextMetadata);
}

收到響應結果後,呼叫FetchSessionHandler.handleResponse()方法。 假如第一次是全量拉取,響應結果沒有出錯時,nextMetadata.isFull()仍然為true。 服務端建立了一個新的session(隨機的唯一ID),客戶端的Fetch SessionId會設定為服務端返回的sessionId, 並且epoch會增加1。這樣下次客戶端的拉取就不再是全量,而是增量了(toSend, toForget兩個集合容器,分別表示要拉取的和不需要拉取的)。 當服務端正常處理(這次不會生成新的session),客戶端也正常處理響應,則sessionId不會增加,但是epoch會增加1。

public boolean handleResponse(FetchResponse<?> response) {
        if (response.error() != Errors.NONE) {
            log.info("Node {} was unable to process the fetch request with {}: {}.",
                node, nextMetadata, response.error()); // 當叢集session超過最大閥值,會出現這個異常資訊
            if (response.error() == Errors.FETCH_SESSION_ID_NOT_FOUND) {
                nextMetadata = FetchMetadata.INITIAL;
            } else {
                nextMetadata = nextMetadata.nextCloseExisting();
            }
            return false;
        }
        if (nextMetadata.isFull()) {
            if (response.responseData().isEmpty() && response.throttleTimeMs() > 0) {
                // Normally, an empty full fetch response would be invalid.  However, KIP-219
                // specifies that if the broker wants to throttle the client, it will respond
                // to a full fetch request with an empty response and a throttleTimeMs
                // value set.  We don't want to log this with a warning, since it's not an error.
                // However, the empty full fetch response can't be processed, so it's still appropriate
                // to return false here.
                if (log.isDebugEnabled()) {
                    log.debug("Node {} sent a empty full fetch response to indicate that this " +
                        "client should be throttled for {} ms.", node, response.throttleTimeMs());
                }
                nextMetadata = FetchMetadata.INITIAL;
                return false;
            }
            String problem = verifyFullFetchResponsePartitions(response);
            if (problem != null) {
                log.info("Node {} sent an invalid full fetch response with {}", node, problem);
                nextMetadata = FetchMetadata.INITIAL;
                return false;
            } else if (response.sessionId() == INVALID_SESSION_ID) {
                if (log.isDebugEnabled())
                    log.debug("Node {} sent a full fetch response{}", node, responseDataToLogString(response));
                nextMetadata = FetchMetadata.INITIAL;
                return true;
            } else {
                // The server created a new incremental fetch session.
                if (log.isDebugEnabled())
                    log.debug("Node {} sent a full fetch response that created a new incremental " +
                            "fetch session {}{}", node, response.sessionId(), responseDataToLogString(response));
                nextMetadata = FetchMetadata.newIncremental(response.sessionId());
                return true;
            }
        } else {
            String problem = verifyIncrementalFetchResponsePartitions(response);
            if (problem != null) {
                log.info("Node {} sent an invalid incremental fetch response with {}", node, problem);
                nextMetadata = nextMetadata.nextCloseExisting();
                return false;
            } else if (response.sessionId() == INVALID_SESSION_ID) {
                // The incremental fetch session was closed by the server.
                if (log.isDebugEnabled())
                    log.debug("Node {} sent an incremental fetch response closing session {}{}",
                            node, nextMetadata.sessionId(), responseDataToLogString(response));
                nextMetadata = FetchMetadata.INITIAL;
                return true;
            } else {
                // The incremental fetch session was continued by the server.
                // We don't have to do anything special here to support KIP-219, since an empty incremental
                // fetch request is perfectly valid.
                if (log.isDebugEnabled())
                    log.debug("Node {} sent an incremental fetch response with throttleTimeMs = {} " +
                        "for session {}{}", node, response.throttleTimeMs(), response.sessionId(),
                        responseDataToLogString(response));
                nextMetadata = nextMetadata.nextIncremental();
                return true;
            }
        }
    }

Broker處理拉取請求是,會建立不同型別的FetchContext,型別如下:

  • SessionErrorContext:拉取會話錯誤(例如,epoch不相等)
  • SessionlessFetchContext:不需要拉取會話
  • IncrementalFetchContext:增量拉取
  • FullFetchContext:全量拉取

在KafkaApis#handleFetchRequest()中,程式碼片段如下:

val fetchContext = fetchManager.newContext(
      fetchRequest.metadata,
      fetchRequest.fetchData,
      fetchRequest.toForget,
      fetchRequest.isFromFollower)

// ......

if (fetchRequest.isFromFollower) {
        // We've already evaluated against the quota and are good to go. Just need to record it now.
        unconvertedFetchResponse = fetchContext.updateAndGenerateResponseData(partitions)
        val responseSize = KafkaApis.sizeOfThrottledPartitions(versionId, unconvertedFetchResponse, quotas.leader)
        quotas.leader.record(responseSize)
        trace(s"Sending Fetch response with partitions.size=${unconvertedFetchResponse.responseData.size}, " +
          s"metadata=${unconvertedFetchResponse.sessionId}")
        requestHelper.sendResponseExemptThrottle(request, createResponse(0), Some(updateConversionStats))
}

2.3 Fetch Session快取

因為Fetch Session使用的是Leader上的記憶體,所以我們需要限制在任何給定時間內的記憶體量,因此,每個Broker將只建立有限數量的增量Fetch Session。以下,有兩個公共引數,用來配置Fetch Session的快取:

  • max.incremental.fetch.session.cache.slots:用來限制每臺Broker上最大Fetch Session數量,預設1000
  • min.incremental.fetch.session.eviction.ms:從快取中逐步增量獲取會話之前等待的最短時間,預設120000

這裡需要注意的時候,該屬性屬於read-only。Kafka Broker配置中有三種型別,它們分別是:

型別 說明
read-only 修改引數值後,需要重啟Broker才能生效
per-broker 修改引數值後,只會在對應的Broker上生效,不需要重啟,屬於動態引數
cluster-wide 修改引數值後,整個叢集範圍內會生效,不需要重啟,屬於動態引數

 

當伺服器收到建立增量Fetch Session請求時,它會將新的Session與先有的Session進行比較,只有在下列情況下,新Session才會有效:

  • 新Session在Follower裡面;
  • 現有Session已停止,且超過最小等待時間;
  • 現有Session已停止,且超過最小等待時間,並且新Session有更多的分割槽。

這樣可以實現如下目標:

  • Follower優先順序高於消費者;
  • 隨著時間的推移,非活躍的Session將被替換;
  • 大請求(從增量中收益更多)被優先處理;
  • 快取抖動是有限的,避免了昂貴的Session重建時。

2.4 公共介面

新增瞭如下錯誤型別:

  • FetchSessionIdNotFound:當客戶端請求引用伺服器不知道的Fetch Session時,伺服器將使用此錯誤程式碼進行響應。如果存在客戶端錯誤,或者伺服器退出了Fetch Session,也會出現這種錯誤;
  • InvalidFetchSessionEpochException:當請求的Fetch Session Epoch與預期不相同時,伺服器將使用此錯誤程式碼來進行響應。

2.5 FetchRequest後設資料含義

請求SessionID 請求SessionEpoch 含義
0 -1 全量拉取(沒有使用或者建立Session時)
0 0 全量拉取(如果是新的Session,Epoch從1開始)
$ID 0 關閉表示為$ID的增量Fetch Session,並建立一個新的全量Fetch(如果是新的Session,Epoch從1開始)
$ID $EPOCH 如果ID和EPOCH是正確的,建立一個增量Fetch

2.6 FetchResponse後設資料含義

Request SessionID 含義
0 沒有Fetch Session是建立新的
$ID 下一個請求會使增量Fetch請求,並且SessionID是$ID

3.總結

Client和Broker的Fetch過程可以總結如下圖所示:

 

 4.結束語

這篇部落格就和大家分享到這裡,如果大家在研究學習的過程當中有什麼問題,可以加群進行討論或傳送郵件給我,我會盡我所能為您解答,與君共勉!

另外,博主出書了《Kafka並不難學》和《Hadoop大資料探勘從入門到進階實戰》,喜歡的朋友或同學, 可以在公告欄那裡點選購買連結購買博主的書進行學習,在此感謝大家的支援。關注下面公眾號,根據提示,可免費獲取書籍的教學視訊。

相關文章