無鏡--kafka之消費者(四)

funnyok發表於2021-09-09

消費者網路客戶端輪詢:ConsumerNetworkClient。ConsumerNetworkClient是對NetworkClient的封裝。

客戶端傳送請求後,不知道服務端什麼時候返回響應。所以客戶端獲取結果有三種輪詢方式:

1,客戶端不阻塞,設定超時時間為0,表示請求傳送完成後馬上返回到呼叫者的主執行緒中。

2,客戶端設定超時時間,如果在指定時間內沒有結果,返回返回到呼叫者的主執行緒中。

3,客戶端設定超時時間為最大值(可以理解為一直阻塞),如果沒有結果,就會一直阻塞,不會返回到呼叫者的主執行緒中。

NetworkClient傳送請求過程

1,client.ready(node):客戶端連線上目標節點,並準備好傳送請求。

2,client.send(request):傳送請求,將請求設定到網路通道中。

3,client.poll(timeout):客戶端輪詢獲取結果。

ConsumerNetworkClient傳送請求過程

1,send():建立客戶端請求,並快取到未傳送的請求集合(unsent)中。

2,poll():處理客戶端請求。

3,trySend():呼叫NetworkClient.send(),暫時把請求放到網路通道中。

4,NetworkClient.poll():真正傳送請求。

trySend()

處理unsent:Map<節點,List<請求物件>>中儲存的請求,把各個節點的請求設定到各個節點對應的通道(KafkaChannel)中,並註冊該通道的寫事件。注:每個節點對應的通道一次只會處理一個請求,所以如果一個節點的上一個傳送請求還沒有傳送,那麼當前此節點的當前請求就不會設定到通道中。成功設定到節點對應的通道後,從集合中刪除,以防重複傳送。

NetworkClient.poll()

真正把請求傳送到網路中。trySend()方法中,為節點對應的通道註冊了寫事件。在輪詢方法中,寫事件準備就緒,處理通道的寫操作將資料寫到網路中。trySend()方法可能設定多個節點的請求,所以就會有多個通道的寫事件準備就緒,因此輪詢方法中就會傳送多個請求。kafka的處理方式不是每次呼叫NetworkClient.send()方法就呼叫一次NetworkClient.poll()。而是把所有準備好的客戶端請求都設定到對應的網路通道後執行一次輪詢:把所有寫事件準備就緒的通道找出來,執行寫操作。

 有比較詳細的介紹。

心跳任務

每個消費者都需要定時的向服務端的協調者傳送心跳,以表明自己是存活的。如果消費者在一段時間內沒有傳送心跳到服務端的協調者,那麼服務端的協調者就會認為消費者掛掉。就會將掛掉的消費者上的分割槽分給消費組中的其他消費者。

傳送心跳是在消費者的協調者上完成的,消費者在加入消費組時,啟動傳送心跳執行緒。ConsumerCoordinator.poll-->ensureActiveGroup()-->startHeartbeatThreadIfNeeded()-->HeartbeatThread().start()。HeartbeatThread是AbstractCoordinator的內部類。HeartbeatThread採用死迴圈來不斷的傳送心跳請求。

傳送心跳請求採用組合模式,每個消費者都只有一個心跳任務,心跳物件記錄了心跳任務的後設資料。

public final class Heartbeat {

private final long sessionTimeout;  // 會話超時的時間,超過表示會話失敗

private final long heartbeatInterval; // 心跳間隔,表示多久傳送一次心跳

private final long maxPollInterval;

private final long retryBackoffMs;

private volatile long lastHeartbeatSend; // 傳送心跳請求時,記錄傳送時間

private long lastHeartbeatReceive; // 接收心跳結果後,記錄接收時間

private long lastSessionReset; // 上一次的會話重置時間

private long lastPoll;

private boolean heartbeatFailed;

public boolean shouldHeartbeat(long now) {        return timeToNextHeartbeat(now) == 0;    }

public long timeToNextHeartbeat(long now) {

  // 從上次傳送心跳後到現在一共過去了多長時間

long timeSinceLastHeartbeat = now - Math.max(lastHeartbeatSend, lastSessionReset);

final long delayToNextHeartbeat;

if (heartbeatFailed)

delayToNextHeartbeat = retryBackoffMs;

else

delayToNextHeartbeat = heartbeatInterval;

// 從上次傳送心跳後到現在一共過去了多長時間大於了心跳間隔時間,表明要立即傳送心跳。返回0

if (timeSinceLastHeartbeat > delayToNextHeartbeat)

return 0;

else // 否則返回還有多久傳送下一次心跳請求

return delayToNextHeartbeat - timeSinceLastHeartbeat;

}

}

消費者和服務端的協調者進行互動,必須確保消費者連線上協調者所在的節點。但是在互動過程中兩邊都會出現問題。比如協調者可能會掛掉,那麼服務端應該給消費組重新選擇一個協調者,那麼後面消費組裡面的消費者就需要去連線新的協調者了。所以在消費者的傳送心跳執行緒裡面必須針對服務端返回的不同的錯誤碼處理不同的業務:

if (coordinatorUnknown()) { // 沒有連線上服務端協調者

if (findCoordinatorFuture == null)

lookupCoordinator(); // 傳送GroupCoordinator請求

else

AbstractCoordinator.this.wait(retryBackoffMs);

} else if (heartbeat.sessionTimeoutExpired(now)) { // 在會話超時時間內沒有收到心跳應答,客戶端認為協調者掛了.

coordinatorDead(); // 處理協調者掛掉的邏輯,比如:處理unsent變數中儲存的請求的處理器中的onFailure方法

} else if (heartbeat.pollTimeoutExpired(now)) { // 檢視消費者客戶端的輪詢時不是超過了心跳最大的輪詢等待時間

maybeLeaveGroup(); // 傳送離開組請求

} else if (!heartbeat.shouldHeartbeat(now)) { // 現在不需要傳送心跳,下一次迴圈再檢查                            AbstractCoordinator.this.wait(retryBackoffMs);

}

消費者提交偏移量

消費組發生再平衡時分割槽會被分配給新的消費者,為了保證新的消費者能夠從分割槽的上一次消費位置繼續拉取並處理訊息的話,那麼每個消費者都需要將所消費的分割槽的消費進度定時的同步給消費組對應的服務端協調者節點上。

在KafkaConsumer中提供了兩種偏移量的提交方式:同步和非同步。

非同步

如果使用者設定了自動提交偏移量(enable.auto.commit=true),客戶端在每一次輪詢的時候,都會自定提交偏移量。

KafkaConsumer.poll()-->KafkaConsumer.pollOnce()-->ConsumerCoordinator.poll()-->ConsumerCoordinator.maybeAutoCommitOffsetsAsync()

可以看出提交偏移量請求還是透過客戶端協調者傳送的。每次的輪詢在傳送拉取請求之前,在客戶端協調者的輪詢方法中,除了檢查心跳,就是要提交偏移量。也就是說只要消費者要去消費訊息,就會執行提交偏移量的動作(enable.auto.commit=true的前提下)。

疑問:如果一個消費者消費了一次訊息之後,就不消費了或者就掛機了。這時提交偏移量還沒有提交給服務端的,那麼在再平衡後把此分割槽分給了消費組中的其他消費者後就會出現重複消費了。當然還有一種情況就是一個主題下的一個分割槽只能被一個消費組中的一個消費者所消費,但是可以被其他消費組中的消費者進行消費,這樣情況是kafka所准許的,本身就會出現重複消費的情況,kafka只保證分割槽在同一個消費組中的有序性,不保證在不同消費組中的有序性,所以以上情況下也就算是一個消費組中的一個消費者掛了或者不消費訊息了,對其他消費組是沒有任何影響的。

那麼kafka是如何來處理這個問題的或者是有沒有處理這個問題喃?


ConsumerCoordinator.maybeAutoCommitOffsetsAsync()

private void maybeAutoCommitOffsetsAsync(long now) {

if (autoCommitEnabled) {

if (coordinatorUnknown()) {

this.nextAutoCommitDeadline = now + retryBackoffMs;

} else if (now >= nextAutoCommitDeadline) {

this.nextAutoCommitDeadline = now + autoCommitIntervalMs;                doAutoCommitOffsetsAsync();

}

}

}

private void doAutoCommitOffsetsAsync() {        commitOffsetsAsync(subscriptions.allConsumed(), new OffsetCommitCallback() {

public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {

if (exception != null) {

if (exception instanceof RetriableException)

nextAutoCommitDeadline = Math.min(time.milliseconds() + retryBackoffMs, nextAutoCommitDeadline);

} else {

}

}

});

}

public void commitOffsetsAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, final OffsetCommitCallback callback) {

invokeCompletedOffsetCommitCallbacks();

if (!coordinatorUnknown()) { // 確保連線上服務端的協調者,執行提交偏移量請求            doCommitOffsetsAsync(offsets, callback);

} else {

// 傳送連線服務端的協調者請求,並在監聽器中執行提交偏移量請求            lookupCoordinator().addListener(new RequestFutureListener<Void>() {

public void onSuccess(Void value) {

doCommitOffsetsAsync(offsets, callback);

}

public void onFailure(RuntimeException e) {

completedOffsetCommits.add(new OffsetCommitCompletion(callback, offsets, new RetriableCommitFailedException(e)));

}

});

}

client.pollNoWakeup();

}

private void doCommitOffsetsAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, final OffsetCommitCallback callback) {

this.subscriptions.needRefreshCommits(); // 通知訂閱狀態需要拉取提交偏移量        RequestFuture<Void> future = sendOffsetCommitRequest(offsets);

final OffsetCommitCallback cb = callback == null ? defaultOffsetCommitCallback : callback;        future.addListener(new RequestFutureListener<Void>() {

public void onSuccess(Void value) {

if (interceptors != null)

interceptors.onCommit(offsets);

completedOffsetCommits.add(new OffsetCommitCompletion(cb, offsets, null));

}

public void onFailure(RuntimeException e) {

completedOffsetCommits.add(new OffsetCommitCompletion(cb, offsets, commitException));            }

});

}

private RequestFuture<Void> sendOffsetCommitRequest(final Map<TopicPartition, OffsetAndMetadata> offsets) {

if (offsets.isEmpty())

return RequestFuture.voidSuccess();

Node coordinator = coordinator();

if (coordinator == null)

return RequestFuture.coordinatorNotAvailable();

Map<TopicPartition, OffsetCommitRequest.PartitionData> offsetData = new HashMap<>(offsets.size());

for (Map.Entry<TopicPartition, OffsetAndMetadata> entry : offsets.entrySet()) {            OffsetAndMetadata offsetAndMetadata = entry.getValue();

offsetData.put(entry.getKey(), new OffsetCommitRequest.PartitionData(                    offsetAndMetadata.offset(), offsetAndMetadata.metadata()));

}

final Generation generation;

if (subscriptions.partitionsAutoAssigned())

generation = generation();

else

generation = Generation.NO_GENERATION;

if (generation == null)

return RequestFuture.failure(new CommitFailedException());

OffsetCommitRequest req = new OffsetCommitRequest(                this.groupId,                generation.generationId,                generation.memberId,                OffsetCommitRequest.DEFAULT_RETENTION_TIME,                offsetData);

return client.send(coordinator, ApiKeys.OFFSET_COMMIT, req)                .compose(new OffsetCommitResponseHandler(offsets));

}

採用組合模式傳送提交偏移量請求(OFFSET_COMMIT)。提交偏移量請求其實就是把拉取偏移量的值提交到服務端去儲存。消費者收到提交偏移量請求的響應後由OffsetCommitResponseHandler(組合模式中的介面卡)處理,更新消費者的訂閱狀態中的提交偏移量。

在組合模式非同步請求的監聽器的回撥方法中,把完成的請求存放到completedOffsetCommits變數中。在ConsumerCoordinator.poll的方法中第一句會呼叫completedOffsetCommits變數中儲存完成的提交偏移量請求的callback方法。

以上程式碼中offsets引數來自於訂閱狀態(SubscriptionState)的allConsumed()方法。消費者所有消費的分割槽偏移量實際上是分割槽狀態物件(TopicPartitionState)的拉取偏移量(position變數),而不是提交偏移量(committed)

拉取偏移量和提交偏移量的關係

談到拉取偏移量就會想到拉取請求,傳送一次拉取請求,在客戶端輪詢方法返回拉取的記錄集之前,會計算出下一次傳送拉取請求時用到的拉取偏移量的值,並更新分割槽狀態的拉取偏移量。在這個時候並沒有更新提交偏移量,所以拉取偏移量也能代表分割槽的消費進度。

疑問:如果客戶端返回的記錄集後面就出現異常或者當機了。那麼最新計算的拉取偏移量還沒有賦值給提交偏移量和提交到服務端的協調者節點中儲存,那麼等消費者重新啟動的時候,獲取拉取偏移量就是老的,這樣拉取的訊息就是消費過的,出現重複消費。

消費者客戶端在輪詢方法中返回記錄集的時候就計算出下一次拉取請求的偏移量,並更新分割槽狀態的拉取偏移量。之後的處理記錄集的業務是使用者自己保證。如果在下一次輪詢前,客戶端掛掉,那麼沒有把已經處理過的偏移量提交到服務端協調者,那麼等客戶端下一次啟動的時候,從服務端協調者獲取的偏移量就是老的。這樣就會出現重複消費。這點kafka把處理方式留給了業務系統。

拉取記錄集(enable.auto.commit=true):

1,先提交拉取偏移量的值到服務端的協調者。提交請求成功在回撥方法中更新分割槽狀態的提交偏移量。注意這裡的拉取偏移量要麼是從服務端獲得的拉取偏移量,要麼就是上一次拉取到記錄後重新計算出的拉取偏移量。

2,獲取記錄集:

      2-1有記錄:計算出下一次拉取請求的拉取偏移量並更新分割槽狀態的拉取偏移量的值。

      2-2無記錄:使用從服務端獲得的拉取偏移量建立拉取請求。

傳送提交拉取偏移量的請求是在傳送拉取請求之前,也就是說使用的拉取偏移量在拉取記錄之前就保持到了伺服器的協調者中。試想把這兩個步驟反過來,先傳送拉取請求再傳送提交拉取偏移量的請求,會出現什麼情況喃?

這個引出了訊息處理語義:至多一次,至少一次,正好一次。

至多一次:訊息最多被處理一次,可能會丟失,但絕不會重複消費。

至少一次:訊息至少被處理一次,不可能丟失,但可能會重複消費。

正好一次:訊息正好被處理一次,不可能丟失,但絕不會重複消費。

至多一次

現象:先傳送提交拉取偏移量的請求儲存消費進度,再獲取記錄集處理訊息。這樣可能會出現:消費者傳送提交拉取偏移量請求在服務端儲存完消費進度後,再處理訊息之前掛掉。那麼在再平衡後新的消費者獲取的拉取偏移量在這個位置之前的訊息可能沒有被真正的處理。這樣就是出現訊息丟失了(沒有被處理,訊息還在伺服器裡)。

kafka實現至多一次的方式:設定消費者自動提交偏移量,並且設定較短的提交時間間隔。

至少一次

現象:先獲取記錄集處理訊息。再傳送提交拉取偏移量的請求儲存消費進度。這樣可能會出現:消費者處理完訊息,但是在傳送提交拉取偏移量的請求儲存消費進度的時候掛了。那麼在再平衡後新的消費者獲取的拉取偏移量在這個位置後面的訊息可能被處理過了,那麼新的消費者又會重新處理一次,這樣訊息就被重複處理了。

kafka實現至少一次的方式:設定消費者自動提交偏移量,但設定很長的提交時間間隔;或者關閉消費者自動提交偏移量,處理完訊息後手動同步提交偏移量。

正好一次

正好一次其實就是保證處理記錄集和儲存偏移量的請求必須是一個原子操作。要麼同時成功要麼同時失敗。

kafka實現至少一次的方式:關閉消費者自動提交偏移量,訂閱主題時設定自定義的消費者再平衡監聽器:傳送再平衡時,獲取偏移量就從關心資料庫或者是檔案中獲取。

同步

消費者同步提交偏移量的做法:在最外層用一個死迴圈來確保必須收到服務端的響應結果才能結束。

public void commitOffsetsSync(Map<TopicPartition, OffsetAndMetadata> offsets) {        invokeCompletedOffsetCommitCallbacks();

if (offsets.isEmpty())

return;

while (true) {

ensureCoordinatorReady();

RequestFuture<Void> future = sendOffsetCommitRequest(offsets);

client.poll(future);

if (future.succeeded()) {

if (interceptors != null)

interceptors.onCommit(offsets);

return;

}

if (!future.isRetriable())

throw future.exception();

time.sleep(retryBackoffMs);

}

}

同步方式提交偏移量通常是存在依賴條件,必須等待偏移量提交完成後才能繼續往下執行。在加入消費組或者是重新加入消費組的時候,如果enable.auto.commit=true,那麼就會用阻塞的方式完成一次提交偏移量請求。把自己本地儲存的最新的拉取偏移量提交到伺服器端的協調者儲存。這樣後面分到分割槽的消費者獲取拉取偏移量就可以從最新的消費點開始消費。



作者:吉之無鏡
連結:


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

相關文章