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進行拉取,程式碼片段如下:
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大資料探勘從入門到進階實戰》,喜歡的朋友或同學, 可以在公告欄那裡點選購買連結購買博主的書進行學習,在此感謝大家的支援。關注下面公眾號,根據提示,可免費獲取書籍的教學視訊。