Kafka消費者(四)

Kafka中的offset

對於 Kafka 中的分區而言,它的每條消息都有唯一的 offset,用來表示消息在分區中對應的位置。對於消費者而言,它也有一個 offset 的概念,消費者使用 offset 來表示消費到分區中某個消息所在的位置。爲了區分,對於消息在分區中的位置,我們將 offset 稱爲“偏移量”;對於消費者消費到的位置,將 offset 稱爲“位移”或者“消費位移”。

 

消費者的位移提交

在每次調用 poll() 方法時,它返回的是還沒有被消費過的消息集(暫不考慮異常情況的發生),要做到這一點,就需要記錄上一次消費時的消費位移offset。並且這個消費位移必須做持久化保存,而不是單單保存在內存中,否則消費者重啓之後就無法知曉之前的消費位移。再考慮一種情況,當有新的消費者加入時,那麼必然會有再均衡的動作,對於同一分區而言,它可能在再均衡動作之後分配給新的消費者,如果不持久化保存消費位移,那麼這個新的消費者也無法知曉之前的消費位移。

在舊消費者客戶端中(scala版本),消費位移是存儲在 ZooKeeper 中的。而在新消費者客戶端中(java版本),消費位移存儲在 Kafka 內部的主題__consumer_offsets 中。這裏把將消費位移存儲起來(持久化)的動作稱爲“提交”,消費者在消費完消息之後需要執行消費位移的提交。

 

last Consumed offset和position

參考下圖中的消費位移,x表示某一次拉取操作中此分區消息的最大偏移量,假設當前消費者已經消費了x位置的消息,那麼我們就可以說消費者的消費位移爲x,圖中也用了 lastConsumedOffset 這個單詞來標識它。而當前當前消費者需要提交的消費位移並不是x,而是x+1,對應於上圖中的 position,它表示下一條需要拉取的消息的位置

KafkaConsumer 類提供了 position(TopicPartition) 和 committed(TopicPartition) 兩個方法來分別獲取上面所說的 position 和 committed offset 的值。這兩個方法的定義如下所示。

public long position(TopicPartition partition)
public OffsetAndMetadata committed(TopicPartition partition)

驗證一下 lastConsumedOffset、committed offset 和 position 的值。

//代碼清單11-1 消費位移的演示
TopicPartition tp = new TopicPartition(topic, 0);
consumer.assign(Arrays.asList(tp));
long lastConsumedOffset = -1;//當前消費到的位移
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    if (records.isEmpty()) {
        break;
    }
    List<ConsumerRecord<String, String>> partitionRecords
            = records.records(tp);
    lastConsumedOffset = partitionRecords
            .get(partitionRecords.size() - 1).offset();
    consumer.commitSync();//同步提交消費位移
}
System.out.println("comsumed offset is " + lastConsumedOffset);
OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
System.out.println("commited offset is " + offsetAndMetadata.offset());
long posititon = consumer.position(tp);
System.out.println("the offset of the next record is " + posititon);

結果:

comsumed offset is 377

commited offset is 378

the offset of the next record is 378

可以看出,消費者消費到此分區消息的最大偏移量爲377,對應的消費位移 lastConsumedOffset 也就是377。在消費完之後就執行同步提交,但是最終結果顯示所提交的位移 committed offset 爲378,並且下一次所要拉取的消息的起始偏移量 position 也爲378。在本示例中,position = committed offset = lastConsumedOffset + 1,不過 position 和 committed offset 並不一定相等。

 

重複消費和消息丟失

對於位移提交的具體時機的把握也很有講究,有可能會造成重複消費和消息丟失的現象。

參考下圖,當前一次 poll() 操作所拉取的消息集爲 [x+2, x+7],x+2 代表上一次提交的消費位移,說明已經完成了 x+1 之前(包括 x+1 在內)的所有消息的消費,x+5 表示當前正在處理的位置。如果拉取到消息之後就進行了位移提交,即提交了 x+8,那麼當前消費 x+5 的時候遇到了異常,在故障恢復之後,我們重新拉取的消息是從 x+8 開始的。也就是說,x+5 至 x+7 之間的消息並未能被消費,如此便發生了消息丟失的現象。

再考慮另外一種情形,位移提交的動作是在消費完所有拉取到的消息之後才執行的,那麼當消費 x+5 的時候遇到了異常,在故障恢復之後,我們重新拉取的消息是從 x+2 開始的。也就是說,x+2 至 x+4 之間的消息又重新消費了一遍,故而又發生了重複消費的現象。

 

Kafka的自動提交

在 Kafka 中默認的消費位移提交方式是自動提交,由消費者客戶端參數 enable.auto.commit 配置,默認值爲 true。當然這個默認的自動提交不是每消費一條消息就提交一次,而是定期提交,這個定期的週期時間由客戶端參數 auto.commit.interval.ms 配置,默認值爲5秒,此參數生效的前提是 enable.auto.commit 參數爲 true

在默認的方式下,消費者每隔5秒會將拉取到的每個分區中最大的消息位移進行提交。自動位移提交的動作是在 poll() 方法的邏輯裏完成的,在每次真正向服務端發起拉取請求之前會檢查是否可以進行位移提交,如果可以,那麼就會提交上一次輪詢的位移。

在 Kafka 消費的編程邏輯中位移提交是一大難點,自動提交消費位移的方式非常簡便,它免去了複雜的位移提交邏輯,讓編碼更簡潔。但是可能會造成重複消費和消息丟失的問題。

 

手動提交(同步提交和異步提交)

由於自動位移提交的方式在發生異常的情況下可能會發生消息丟失或重複消費的現象,而且自動位移提交也無法做到精確的位移管理。在 Kafka 中還提供了手動位移提交的方式,可以更加靈活的控制消費位移。在很多時候並不是說拉取到消息就算消費完成,而是需要將消息寫入數據庫、寫入本地緩存,或者是更加複雜的業務處理。在這些場景下,所有的業務處理完成才能認爲消息被成功消費。開啓手動提交功能的前提是消費者客戶端參數 enable.auto.commit 配置爲 false,示例如下:

props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

手動提交可以細分爲同步提交和異步提交,對應於 KafkaConsumer 中的 commitSync() 和 commitAsync() 兩種類型的方法。我們這裏先講述同步提交的方式,commitSync() 方法的定義如下:包括有參和無參2種類型的方法

public void commitSync()
public void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)

無參方法很簡單,下面使用它演示同步提交的簡單用法:

while (isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        //do some logical processing.
    }
    consumer.commitSync();
}

示例中先對拉取到的每一條消息做相應的邏輯處理,然後對整個消息集做同步提交。很明顯看出這種方式會有重複消費的風險。commitSync() 方法會根據 poll() 方法拉取的最新位移來進行提交(注意提交的值對應於第1張圖中 position 的位置),只要沒有發生不可恢復的錯誤(Unrecoverable Error),它就會阻塞消費者線程直至位移提交完成。對於不可恢復的錯誤,比如 CommitFailedException、WakeupException、InterruptException、AuthenticationException、AuthorizationException 等,我們可以將其捕獲並做針對性的處理。

對於採用 commitSync() 的無參方法而言,它提交消費位移的頻率和拉取批次消息、處理批次消息的頻率是一樣的,如果想尋求更細粒度的、更精準的提交,那麼就需要使用 commitSync() 的另一個含參方法,該方法提供了一個 offsets 參數,用來提交指定分區的位移。無參的 commitSync() 方法只能提交當前批次對應的 position 值。如果需要提交一箇中間值,比如業務每消費一條消息就提交一次位移(實際中很少這樣),那麼就可以使用這種方式,如下所示。

//代碼清單11-3 按分區粒度同步提交消費位移
try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(1000);
        for (TopicPartition partition : records.partitions()) {
            List<ConsumerRecord<String, String>> partitionRecords =
                    records.records(partition);
            for (ConsumerRecord<String, String> record : partitionRecords) {
                //do some logical processing.
            }
            long lastConsumedOffset = partitionRecords
                    .get(partitionRecords.size() - 1).offset();
            consumer.commitSync(Collections.singletonMap(partition,
                    new OffsetAndMetadata(lastConsumedOffset + 1)));
        }
    }
} finally {
    consumer.close();
}

由於commitSync() 方法本身是同步執行的,與 commitSync() 方法相反,異步提交的方式(commitAsync())在執行的時候消費者線程不會被阻塞,可能在提交消費位移的結果還未返回之前就開始了新一次的拉取操作。異步提交可以使消費者的性能得到一定的增強。commitAsync 方法有三個不同的重載方法,具體定義如下:

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

其中第二個方法的 callback 參數,提供了一個異步提交的回調方法,當位移提交完成後會回調 OffsetCommitCallback 中的 onComplete() 方法。如下:

while (isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        //do some logical processing.
    }
    consumer.commitAsync(new OffsetCommitCallback() {
        @Override
        public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
                               Exception exception) {
            if (exception == null) {
                System.out.println(offsets);
            }else {
                log.error("fail to commit offsets {}", offsets, exception);
            }
        }
    });
}

commitAsync() 提交的時候同樣會有失敗的情況發生,那麼我們應該怎麼處理呢?

首先想到的是重試,問題的關鍵也就在這裏了。如果某一次異步提交的消費位移爲x,但是提交失敗了,然後下一次又異步提交了消費位移爲x+y,這次成功了。如果這裏引入了重試機制,前一次的異步提交的消費位移在重試的時候提交成功了,那麼此時的消費位移又變爲了x。如果此時發生異常(或者再均衡),那麼恢復之後的消費者(或者新的消費者)就會從x處開始消費消息,這樣就發生了重複消費的問題。

爲此我們可以設置一個遞增的序號來維護異步提交的順序,每次位移提交之後就增加序號相對應的值。在遇到位移提交失敗需要重試的時候,可以檢查所提交的位移和序號的值的大小,如果前者小於後者,則說明有更大的位移已經提交了,不需要再進行本次重試;如果兩者相同,則說明可以進行重試提交。除非程序編碼錯誤,否則不會出現前者大於後者的情況。

如果位移提交失敗的情況經常發生,那麼說明系統肯定出現了故障,在一般情況下,位移提交失敗的情況很少發生,不重試也沒有關係,後面的提交也會有成功的。重試會增加代碼邏輯的複雜度,不重試會增加重複消費的概率。如果消費者異常退出,那麼這個重複消費的問題就很難避免,因爲這種情況下無法及時提交消費位移;如果消費者正常退出或發生再均衡的情況,那麼可以在退出或再均衡執行之前使用同步提交的方式做最後的把關。

try {
    while (isRunning.get()) {
        //poll records and do some logical processing.
        consumer.commitAsync();
    }
} finally {
    try {
        consumer.commitSync();
    }finally {
        consumer.close();
    }
}

 

控制或關閉消費

KafkaConsumer 提供了對消費速度進行控制的方法,在有些應用場景下我們可能需要暫停某些分區的消費而先消費其他分區,當達到一定條件時再恢復這些分區的消費。KafkaConsumer 中使用 pause() 來實現暫停某些分區在拉取操作時返回數據給客戶端 和 resume() 方法來恢復某些分區向客戶端返回數據的操作。具體定義如下:

public void pause(Collection<TopicPartition> partitions)
public void resume(Collection<TopicPartition> partitions)

KafkaConsumer 還提供了一個無參的 paused() 方法來返回被暫停的分區集合,此方法的具體定義如下:

public Set<TopicPartition> paused()

在while 循環來包裹住 poll() 方法及相應的消費邏輯,除了演示中使用的退出這個循環的方式外。還有一種方式是調用 KafkaConsumer .wakeup()方法,wakeup() 方法是 KafkaConsumer 中唯一可以從其他線程裏安全調用的方法(KafkaConsumer 是非線程安全的),調用 wakeup() 方法後可以退出 poll() 的邏輯,並拋出 WakeupException 的異常,我們也不需要處理 WakeupException 的異常,它只是一種跳出循環的方式。

跳出循環以後一定要顯式地執行關閉動作以釋放運行過程中佔用的各種系統資源,包括內存資源、Socket 連接等。KafkaConsumer 提供了 close() 方法來實現關閉,close() 方法有三種重載方法,分別如下:

/**沒有 timeout 參數,這並不意味着會無限制地等待,它內部設定了最長等待時間(30秒)*/
public void close()

/**通過 timeout 參數來設定關閉方法的最長執行時間,有些內部的關閉邏輯會耗費一定的時間,比如設置了自動提交消費位移,這裏還會做一次位移提交的動作*/
public void close(Duration timeout)

@Deprecated
public void close(long timeout, TimeUnit timeUnit)

 

 

指定位移消費

當一個新的消費組建立的時候,它根本沒有可以查找的消費位移。或者消費組內的一個新消費者訂閱了一個新的主題,它也沒有可以查找的消費位移。當 __consumer_offsets 主題中有關這個消費組的位移信息過期而被刪除後,它也沒有可以查找的消費位移。在 Kafka 中每當消費者查找不到所記錄的消費位移時(除了查找不到消費位移,位移越界也會),就會根據消費者客戶端參數 auto.offset.reset 的配置來決定從何處開始進行消費,這個參數的默認值爲“latest”,表示從分區末尾開始消費消息。

3-9

參考上圖,按照默認的配置,消費者會從9開始進行消費(9是下一條要寫入消息的位置),更加確切地說是從9開始拉取消息。如果將 auto.offset.reset 參數配置爲“earliest”,那麼消費者會從起始處,也就是0開始消費。auto.offset.reset 參數還有一個可配置的值:“none”,配置爲此值就意味着出現查到不到消費位移的時候,既不從最新的消息位置處開始消費,也不從最早的消息位置處開始消費,此時會報出 NoOffsetForPartitionException 異常,如果能夠找到消費位移,那麼配置爲“none”不會出現任何異常。如果配置的不是“latest”、“earliest”和“none”中的一個,則會報出 ConfigException 異常

 

我們知道消息的拉取是根據 poll() 方法中的邏輯來處理的,這個 poll() 方法中的邏輯對於普通的開發人員而言是一個黑盒,無法精確地掌控其消費的起始位置。提供的 auto.offset.reset 參數也只能在找不到消費位移或位移越界的情況下粗粒度地從開頭或末尾開始消費。有些時候,我們需要一種更細粒度的掌控,可以讓我們從特定的位移處開始拉取消息,而 KafkaConsumer 中的 seek() 方法正好提供了這個功能,讓我們得以追前消費或回溯消費。seek() 方法的具體定義如下:

public void seek(TopicPartition partition, long offset)

seek() 方法中的參數 partition 表示分區,而 offset 參數用來指定從分區的哪個位置開始消費。seek() 方法只能重置消費者分配到的分區的消費位置,而分區的分配是在 poll() 方法的調用過程中實現的。也就是說,在執行 seek() 方法之前需要先執行一次 poll() 方法,等到分配到分區之後纔可以重置消費位置。seek() 方法的使用如下:

//代碼清單12-1 seek方法的使用示例
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
consumer.poll(Duration.ofMillis(10000));                      	//①
Set<TopicPartition> assignment = consumer.assignment();    	//②
for (TopicPartition tp : assignment) {
    consumer.seek(tp, 10);   	                                //③
}
while (true) {
    ConsumerRecords<String, String> records = 
            consumer.poll(Duration.ofMillis(1000));
    //consume the record.
}

③設置了每個分區的消費位置爲10

②行中的 assignment() 方法是用來獲取消費者所分配到的分區信息的,這個方法的具體定義如下:

public Set<TopicPartition> assignment()

如果我們上面代碼的第①行 poll() 方法的參數設置爲0,即這一行替換爲:

consumer.poll(Duration.ofMillis(0));

在此之後,會發現 seek() 方法並未有任何作用。因爲當 poll() 方法中的參數爲0時,此方法立刻返回,那麼 poll() 方法內部進行分區分配的邏輯就會來不及實施。也就是說,消費者此時並未分配到任何分區,如此第②行中的 assignment 便是一個空列表,第③行代碼也不會執行。那麼這裏的 timeout 參數設置爲多少合適呢?太短會使分配分區的動作失敗,太長又有可能造成一些不必要的等待。我們可以通過 KafkaConsumer 的 assignment() 方法來判定是否分配到了相應的分區,參考下面的代碼:


KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {//如果不爲0,則說明已經成功分配到了分區
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}
for (TopicPartition tp : assignment) {
    consumer.seek(tp, 10);
}
while (true) {
    ConsumerRecords<String, String> records =
            consumer.poll(Duration.ofMillis(1000));
    //consume the record.
}

如果對未分配到的分區執行 seek() 方法,那麼會報出 IllegalStateException 的異常。類似在調用 subscribe() 方法之後直接調用 seek() 方法:

consumer.subscribe(Arrays.asList(topic));
consumer.seek(new TopicPartition(topic,0),10);

會報出如下的異常:

java.lang.IllegalStateException: No current assignment for partition topic-demo-0

如果消費組內的消費者在啓動的時候能夠找到消費位移,除非發生位移越界,否則 auto.offset.reset 參數並不會奏效,此時如果想指定從開頭或末尾開始消費,就需要 seek() 方法,如下指定從分區末尾開始消費。

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}
Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);    
for (TopicPartition tp : assignment) {
    consumer.seek(tp, offsets.get(tp));							        
}

其中:endOffsets() 方法用來獲取指定分區的末尾的消息位置,參考第一個圖中9的位置,是將要寫入最新消息的位置。endOffsets 的具體方法定義如下:

public Map<TopicPartition, Long> endOffsets(
            Collection<TopicPartition> partitions)
public Map<TopicPartition, Long> endOffsets(
            Collection<TopicPartition> partitions,
            Duration timeout)

其中 partitions 參數表示分區集合,而 timeout 參數用來設置等待獲取的超時時間。如果沒有指定 timeout 參數的值,那麼 endOffsets() 方法的等待時間由客戶端參數 request.timeout.ms 來設置,默認值爲30000。與 endOffsets 對應的是 beginningOffsets() 方法,一個分區的起始位置起初是0,但並不代表每時每刻都爲0,因爲日誌清理的動作會清理舊的數據,所以分區的起始位置會自然而然地增加。beginningOffsets() 方法的具體定義如下:

public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions)
public Map<TopicPartition,Long>beginningOffsets(Collection<TopicPartition>partitions,Duration timeout)

KafkaConsumer 中直接提供了 seekToBeginning() 方法和 seekToEnd() 方法來實現從分區的開頭或末尾開始消費,這兩個方法的具體定義如下:

public void seekToBeginning(Collection<TopicPartition> partitions)
public void seekToEnd(Collection<TopicPartition> partitions)

KafkaConsumer 中提供了一個 offsetsForTimes() 方法,通過 timestamp 來查詢與此對應的分區位置。比如我們並不知道特定的消費位置,卻知道一個相關的時間點,比如我們想要消費昨天8點之後的消息,這個需求更符合正常的思維邏輯。此時我們無法直接使用 seek() 方法來追溯到相應的位置,就可以使用 offsetsForTimes() 方法

public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)
public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout)

 timestampsToSearch 是一個 Map 類型,key 爲待查詢的分區,而 value 爲待查詢的時間戳,該方法會返回時間戳大於等於待查詢時間的第一條消息對應的位置和時間戳,對應於 OffsetAndTimestamp 中的 offset 和 timestamp 字段。

演示了 offsetsForTimes() 和 seek() 之間的使用方法,首先通過 offsetForTimes() 方法獲取一天之前的消息位置,然後使用 seek() 方法追溯到相應位置開始消費:

Map<TopicPartition, Long> timestampToSearch = new HashMap<>();
for (TopicPartition tp : assignment) {
    timestampToSearch.put(tp, System.currentTimeMillis()-1*24*3600*1000);
}
Map<TopicPartition, OffsetAndTimestamp> offsets =
        consumer.offsetsForTimes(timestampToSearch);
for (TopicPartition tp : assignment) {
    OffsetAndTimestamp offsetAndTimestamp = offsets.get(tp);
    if (offsetAndTimestamp != null) {
        consumer.seek(tp, offsetAndTimestamp.offset());
    }
}

 

前面說過位移越界也會觸發 auto.offset.reset 參數的執行,位移越界是指知道消費位置卻無法在實際的分區中查找到,比如想要從上圖中的位置10處拉取消息時就會發生位移越界。注意拉取上圖中位置9處的消息時並未越界,這個位置代表特定的含義(LEO)。我們通過 seek() 方法來演示發生位移越界時的情形,此時客戶端會報出如下的提示信息:

[2018-08-19 16:13:44,700] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-3, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-0, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-2, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-1, resetting offset 
[2018-08-19 16:13:44,708] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-3 to offset 100. 
[2018-08-19 16:13:44,708] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-0 to offset 100. 
[2018-08-19 16:13:44,709] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-2 to offset 100. 
[2018-08-19 16:13:44,713] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-1 to offset 100. 

通過上面加粗的提示信息可以瞭解到,原本拉取位置爲101(fetch offset 101),但已經越界了(out of range),所以此時會根據 auto.offset.reset 參數的默認值來將拉取位置重置(resetting offset)爲100,我們也能知道此時分區 topic-demo-3 中最大的消息 offset爲99。

seek() 方法可以突破 Kafka 中的消費位移是存儲在一個內部主題中的限制,使得消費位移可以保存在任意的存儲介質中,例如數據庫、文件系統等。以數據庫爲例,我們將消費位移保存在其中的一個表中,在下次消費的時候可以讀取存儲在數據表中的消費位移並通過 seek() 方法指向這個具體的位置,參考如下

//消費位移保存在DB中
consumer.subscribe(Arrays.asList(topic));
//省略poll()方法及assignment的邏輯
for(TopicPartition tp: assignment){
    long offset = getOffsetFromDB(tp);//從DB中讀取消費位移
    consumer.seek(tp, offset);
}
while(true){
    ConsumerRecords<String, String> records =
            consumer.poll(Duration.ofMillis(1000));
    for (TopicPartition partition : records.partitions()) {
        List<ConsumerRecord<String, String>> partitionRecords =
                records.records(partition);
        for (ConsumerRecord<String, String> record : partitionRecords) {
            //process the record.
        }
        long lastConsumedOffset = partitionRecords
                .get(partitionRecords.size() - 1).offset();
         //將消費位移存儲在DB中
        storeOffsetToDB(partition, lastConsumedOffset+1);
    }
}

seek() 方法爲我們提供了從特定位置讀取消息的能力,我們可以通過這個方法來向前跳過若干消息,也可以通過這個方法來向後回溯若干消息,這樣爲消息的消費提供了很大的靈活性。seek() 方法也爲我們提供了將消費位移保存在外部存儲介質中的能力,還可以配合再均衡監聽器來提供更加精準的消費能力。

 

再均衡

前面提到過,再均衡是指分區的所屬權從一個消費者轉移到另一消費者的行爲,它爲消費組具備高可用性和伸縮性提供保障,使我們可以既方便又安全地刪除消費組內的消費者或往消費組內添加消費者。不過在再均衡發生期間,消費組內的消費者是無法讀取消息的。也就是說,在再均衡發生期間的這一小段時間內,消費組會變得不可用。

另外,當一個分區被重新分配給另一個消費者時,消費者當前的狀態也會丟失。比如消費者消費完某個分區中的一部分消息時還沒有來得及提交消費位移就發生了再均衡操作,之後這個分區又被分配給了消費組內的另一個消費者,原來被消費完的那部分消息又被重新消費一遍,也就是發生了重複消費。一般情況下,應儘量避免不必要的再均衡的發生。

訂閱主題時候,我們使用subscribe() 方法會使用再均衡監聽器 ConsumerRebalanceListener

subscribe(Collection<String> topics, ConsumerRebalanceListener listener)

subscribe(Pattern pattern, ConsumerRebalanceListener listener)

其中再均衡監聽器用來設定發生再均衡動作前後的一些準備或收尾的動作。ConsumerRebalanceListener 是一個接口,包含2個方法如下:

void onPartitionsRevoked(Collection partitions) 
//這個方法會在再均衡開始之前和消費者停止讀取消息之後被調用。可以通過這個回調方法來處理消費位移的提交,以此來避免一些不必要的重複消費現象的發生。參數 partitions 表示再均衡前所分配到的分區。

void onPartitionsAssigned(Collection partitions) 
//這個方法會在重新分配分區之後和消費者開始讀取消費之前被調用。參數 partitions 表示再均衡後所分配到的分區。

如下ConsumerRebalanceListener,將消費位移暫存到一個局部變量 currentOffsets 中,這樣在正常消費的時候可以通過 commitAsync() 方法來異步提交消費位移,在發生再均衡動作之前可以通過再均衡監聽器的 onPartitionsRevoked() 回調執行 commitSync() 方法同步提交消費位移,以儘量避免一些不必要的重複消費。

Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        consumer.commitSync(currentOffsets);
	        currentOffsets.clear();
    }
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        //do nothing.
    }
});

try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records =
                consumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
            //process the record.
            currentOffsets.put(
                    new TopicPartition(record.topic(), record.partition()),
                    new OffsetAndMetadata(record.offset() + 1));
        }
        consumer.commitAsync(currentOffsets, null);
    }
} finally {
    consumer.close();
}

再均衡監聽器還可以配合外部存儲使用。我們將消費位移保存在數據庫中,這裏可以通過再均衡監聽器查找分配到的分區的消費位移,並且配合 seek() 方法來進一步優化代碼邏輯,如下:
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        //store offset in DB (storeOffsetToDB)
    }
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        for(TopicPartition tp: partitions){
            consumer.seek(tp, getOffsetFromDB(tp));//從DB中讀取消費位移
        }
    }
});

消費者攔截器

除了生產者攔截器,對應的消費者也有相應的攔截器的概念。消費者攔截器主要在消費到消息或在提交消費位移時進行一些定製化的操作。與生產者攔截器對應的,消費者攔截器需要自定義實現 org.apache.kafka.clients.consumer. ConsumerInterceptor 接口,ConsumerInterceptor 接口包含3個方法:

KafkaConsumer 會在 poll() 方法返回之前調用攔截器的 onConsume() 方法來對消息進行相應的定製化操作,
比如修改返回的消息內容、按照某種規則過濾消息(可能會減少 poll() 方法返回的消息的個數)。如果
onConsume() 方法中拋出異常,那麼會被捕獲並記錄到日誌中,但是異常不會再向上傳遞
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);


KafkaConsumer 會在提交完消費位移之後調用攔截器的 onCommit() 方法,可以使用這個方法來記錄跟蹤所提
交的位移信息,比如當消費者使用 commitSync 的無參方法時,我們不知道提交的消費位移的具體細節,而使用
攔截器的 onCommit() 方法卻可以做到這一點
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);

public void close()。

在某些業務場景中會對消息設置一個有效期的屬性,如果某條消息在既定的時間窗口內無法到達,那麼就會被視爲無效,它也就不需要再被繼續處理了(消息TTL的功能)。自定義的消費者攔截器 ConsumerInterceptorTTL 使用消息的 timestamp 字段來判定是否過期,如果消息的時間戳與當前的時間戳相差超過10秒則判定爲過期,那麼這條消息也就被過濾而不投遞給具體的消費者。

public class ConsumerInterceptorTTL implements 
        ConsumerInterceptor<String, String> {
    private static final long EXPIRE_INTERVAL = 10 * 1000;

    @Override
    public ConsumerRecords<String, String> onConsume(
            ConsumerRecords<String, String> records) {
        long now = System.currentTimeMillis();
        Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords 
                = new HashMap<>();
        for (TopicPartition tp : records.partitions()) {
            //拿到分區的消息集合
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
            for (ConsumerRecord<String, String> record : tpRecords) {
                if (now - record.timestamp() < EXPIRE_INTERVAL) {
                    newTpRecords.add(record);
                }
            }
            if (!newTpRecords.isEmpty()) {
                newRecords.put(tp, newTpRecords);
            }
        }
        return new ConsumerRecords<>(newRecords);
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp, offset) -> 
                System.out.println(tp + ":" + offset.offset()));
    }

    @Override
    public void close() {}

    @Override
    public void configure(Map<String, ?> configs) {}
}

配置使用消費者攔截器:

props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG,
        ConsumerInterceptorTTL.class.getName());

不過使用這種功能時需要注意的是:在使用帶參數的位移提交的方式時,有可能提交了錯誤的位移信息。因爲在一次消息拉取的批次中,可能含有最大偏移量的消息會被消費者攔截器過濾。

在消費者中也有攔截鏈的概念,和生產者的攔截鏈一樣,也是按照 interceptor.classes 參數配置的攔截器的順序來一一執行的(配置的時候,各個攔截器之間使用逗號隔開)。同樣也要提防“副作用”的發生。如果在攔截鏈中某個攔截器執行失敗,那麼下一個攔截器會接着從上一個執行成功的攔截器繼續執行。

 

Kakfa中多線程的實現

KafkaProducer 是線程安全的,然而 KafkaConsumer 卻是非線程安全的。KafkaConsumer 中定義了一個 acquire() 方法,用來檢測當前是否只有一個線程在操作,若有其他線程正在操作則會拋出 ConcurrentModifcationException 異常:

java.util.ConcurrentModificationException: KafkaConsumer is not safe for multi-threaded access.

KafkaConsumer 中的每個公用方法在執行所要執行的動作之前都會調用這個 acquire() 方法,(只有 wakeup() 方法是個例外)acquire() 方法和我們通常所說的鎖(synchronized、Lock 等)不同,它不會造成阻塞等待,可以將其看作一個輕量級鎖,它僅通過線程操作計數標記的方式來檢測線程是否發生了併發操作,以此保證只有一個線程在操作。acquire() 方法和 release() 方法成對出現,表示相應的加鎖和解鎖操作,具體定義如下:

//require()方法
private final AtomicLong currentThread
    = new AtomicLong(NO_CURRENT_THREAD); //KafkaConsumer中的成員變量

private void acquire() {
    long threadId = Thread.currentThread().getId();
    if (threadId != currentThread.get() &&
            !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
        throw new ConcurrentModificationException
                ("KafkaConsumer is not safe for multi-threaded access");
    refcount.incrementAndGet();
}

//release()方法
private void release() {
    if (refcount.decrementAndGet() == 0)
        currentThread.set(NO_CURRENT_THREAD);
}

acquire() 方法和 release() 方法都是私有方法,因此在實際應用中不需要我們顯式地調用,但瞭解其內部的機理之後可以促使我們正確、有效地編寫相應的程序邏輯。

多線程的方式來實現消息消費

KafkaConsumer 非線程安全並不意味着我們在消費消息的時候只能以單線程的方式執行。如果生產者發送消息的速度大於消費者處理消息的速度,那麼就會有越來越多的消息得不到及時的消費,造成了一定的延遲。除此之外,由於 Kafka 中消息保留機制的作用,有些消息有可能在被消費之前就被清理了,從而造成消息的丟失。所以可以通過多線程的方式來實現消息消費,多線程的目的就是爲了提高整體的消費能力。

多線程實現消息消費的方式

第一種也是最常見的方式:線程封閉,即爲每個線程實例化一個 KafkaConsumer 對象,

一個線程對應一個 KafkaConsumer 實例,我們可以稱之爲消費線程。一個消費線程可以消費一個或多個分區中的消息,所有的消費線程都隸屬於同一個消費組。這種實現方式的併發度受限於分區的實際個數,前面介紹的消費者與分區數的關係,當消費線程的個數大於分區數時,就有部分消費線程一直處於空閒的狀態。

å¾3-10

第二種方式是多個消費者線程同時消費同一個分區,這個通過 assign()、seek() 等方法實現,這樣可以打破原有的消費線程的個數不能超過分區數的限制,進一步提高了消費的能力。不過這種實現方式對於位移提交和順序控制的處理就會變得非常複雜,也並不推薦。一般而言,分區是消費線程的最小劃分單位

//演示多線程消費情況(方式一)
public class FirstMultiConsumerThreadDemo {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConfig(){
        Properties props = new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        //分區數,如果不知道分區數:通過KafkaConsumer的partitionFor()獲取 
        int consumerThreadNum = 4;
        for(int i=0;i<consumerThreadNum;i++) {
            new KafkaConsumerThread(props,topic).start();
        }
    }

    public static class KafkaConsumerThread extends Thread{
        private KafkaConsumer<String, String> kafkaConsumer;

        public KafkaConsumerThread(Properties props, String topic) {
            this.kafkaConsumer = new KafkaConsumer<>(props);
            this.kafkaConsumer.subscribe(Arrays.asList(topic));
        }

        @Override
        public void run(){
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    for (ConsumerRecord<String, String> record : records) {
                        //處理消息模塊	①
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}

上面的消費線程的數量由 consumerThreadNum 變量指定。可以將 consumerThreadNum 設置成不大於分區數的值,如果不知道主題的分區數,那麼也可以通過 KafkaConsumer 類的 partitionsFor() 方法來間接獲取,這種多線程的實現方式和開啓多個消費進程的方式沒有本質上的區別,它的優點是每個線程可以按順序消費各個分區中的消息。缺點也很明顯,每個消費線程都要維護一個獨立的TCP連接,如果分區數和 consumerThreadNum 的值都很大,那麼會造成不小的系統開銷

在處理消息模塊中,如果這裏對消息的處理非常迅速,那麼 poll() 拉取的頻次也會更高,進而整體消費的性能也會提升;相反,如果在這裏對消息的處理緩慢,比如進行一個事務性操作,或者等待一個RPC的同步響應,那麼 poll() 拉取的頻次也會隨之下降,進而造成整體消費性能的下降。可以通過異步和多線程的方式處理這裏面的邏輯,當然這樣的話,在處理位移提交的時候,在每一個處理消息的的類在處理完消息之後都將對應的消費位移保存到共享變量 offsets 中,KafkaConsumerThread 在每一次 poll() 方法之後都讀取 offsets 中的內容並對其進行位移提交。注意在實現的過程中對 offsets 讀寫需要加鎖處理,防止出現併發問題。並且在寫入 offsets 的時候需要注意位移覆蓋的問題,如下:

多線程消費邏輯
for (TopicPartition tp : records.partitions()) {
    List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
    //處理tpRecords.
    long lastConsumedOffset = tpRecords.get(tpRecords.size() - 1).offset();
    synchronized (offsets) {
        if (!offsets.containsKey(tp)) {
            offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
        }else {
            long position = offsets.get(tp).offset();
            if (position < lastConsumedOffset + 1) {
                offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
            }
        }
    }
}
消費完提交的邏輯
synchronized (offsets) {
    if (!offsets.isEmpty()) {
        kafkaConsumer.commitSync(offsets);
        offsets.clear();
    }
}

這種方式依然存在風險。對於同一個分區中的消息,假設一個處理線程 RecordHandler1 正在處理 offset 爲0~99的消息,而另一個處理線程 RecordHandler2 已經處理完了 offset 爲100~199的消息並進行了位移提交,此時如果 RecordHandler1 發生異常,則之後的消費只能從200開始而無法再次消費0~99的消息,從而造成了消息丟失的現象。這裏雖然針對位移覆蓋做了一定的處理,但還沒有解決異常情況下的位移覆蓋問題。

 

關於消費者的參數總結

除了默認必須設定的參數外,還有以下參數:

1. fetch.min.bytes

該參數用來配置 Consumer 在一次拉取請求(調用 poll() 方法)中能從 Kafka 中拉取的最小數據量,默認值爲1(B)。Kafka 在收到 Consumer 的拉取請求時,如果返回給 Consumer 的數據量小於這個參數所配置的值,那麼它就需要進行等待,直到數據量滿足這個參數的配置大小。可以適當調大這個參數的值以提高一定的吞吐量,不過也會造成額外的延遲(latency),對於延遲敏感的應用可能就不可取了。

2. fetch.max.bytes

該參數與 fetch.min.bytes 參數對應,它用來配置 Consumer 在一次拉取請求中從Kafka中拉取的最大數據量,默認值爲52428800(B),也就是50MB。

如果這個參數設置的值比任何一條寫入 Kafka 中的消息要小,那麼會不會造成無法消費呢?很多資料對此參數的解讀認爲是無法消費的,比如一條消息的大小爲10B,而這個參數的值是1(B),既然此參數設定的值是一次拉取請求中所能拉取的最大數據量,那麼顯然1B<10B,所以無法拉取。這個觀點是錯誤的,該參數設定的不是絕對的最大值,如果在第一個非空分區中拉取的第一條消息大於該值,那麼該消息將仍然返回,以確保消費者繼續工作。也就是說,上面問題的答案是可以正常消費。

與此相關的,Kafka 中所能接收的最大消息的大小通過服務端參數 message.max.bytes(對應於主題端參數 max.message.bytes)來設置。

3. fetch.max.wait.ms

這個參數也和 fetch.min.bytes 參數有關,如果 Kafka 僅僅參考 fetch.min.bytes 參數的要求,那麼有可能會一直阻塞等待而無法發送響應給 Consumer,顯然這是不合理的。fetch.max.wait.ms 參數用於指定 Kafka 的等待時間,默認值爲500(ms)。如果 Kafka 中沒有足夠多的消息而滿足不了 fetch.min.bytes 參數的要求,那麼最終會等待500ms。這個參數的設定和 Consumer 與 Kafka 之間的延遲也有關係,如果業務應用對延遲敏感,那麼可以適當調小這個參數。

4. max.partition.fetch.bytes

這個參數用來配置從每個分區裏返回給 Consumer 的最大數據量,默認值爲1048576(B),即1MB。這個參數與 fetch.max.bytes 參數相似,只不過前者用來限制一次拉取中每個分區的消息大小,而後者用來限制一次拉取中整體消息的大小。同樣,如果這個參數設定的值比消息的大小要小,那麼也不會造成無法消費,Kafka 爲了保持消費邏輯的正常運轉不會對此做強硬的限制。

5. max.poll.records

這個參數用來配置 Consumer 在一次拉取請求中拉取的最大消息數,默認值爲500(條)。如果消息的大小都比較小,則可以適當調大這個參數值來提升一定的消費速度。

6. connections.max.idle.ms

這個參數用來指定在多久之後關閉閒置的連接,默認值是540000(ms),即9分鐘。

7. exclude.internal.topics

Kafka 中有兩個內部的主題: __consumer_offsets 和 __transaction_state。exclude.internal.topics 用來指定 Kafka 中的內部主題是否可以向消費者公開,默認值爲 true。如果設置爲 true,那麼只能使用 subscribe(Collection)的方式而不能使用 subscribe(Pattern)的方式來訂閱內部主題,設置爲 false 則沒有這個限制。

8. receive.buffer.bytes

這個參數用來設置 Socket 接收消息緩衝區(SO_RECBUF)的大小,默認值爲65536(B),即64KB。如果設置爲-1,則使用操作系統的默認值。如果 Consumer 與 Kafka 處於不同的機房,則可以適當調大這個參數值。

9. send.buffer.bytes

這個參數用來設置Socket發送消息緩衝區(SO_SNDBUF)的大小,默認值爲131072(B),即128KB。與receive.buffer.bytes參數一樣,如果設置爲-1,則使用操作系統的默認值。

10. request.timeout.ms

這個參數用來配置 Consumer 等待請求響應的最長時間,默認值爲30000(ms)。

11. metadata.max.age.ms

這個參數用來配置元數據的過期時間,默認值爲300000(ms),即5分鐘。如果元數據在此參數所限定的時間範圍內沒有進行更新,則會被強制更新,即使沒有任何分區變化或有新的 broker 加入。

12. reconnect.backoff.ms

這個參數用來配置嘗試重新連接指定主機之前的等待時間(也稱爲退避時間),避免頻繁地連接主機,默認值爲50(ms)。這種機制適用於消費者向 broker 發送的所有請求。

13. retry.backoff.ms

這個參數用來配置嘗試重新發送失敗的請求到指定的主題分區之前的等待(退避)時間,避免在某些故障情況下頻繁地重複發送,默認值爲100(ms)。

14. isolation.level

這個參數用來配置消費者的事務隔離級別。字符串類型,有效值爲“read_uncommitted”和“read_committed”,表示消費者所消費到的位置,如果設置爲“read_committed”,那麼消費者就會忽略事務未提交的消息,即只能消費到LSO(LastStableOffset)的位置,默認情況下爲“read_uncommitted”,即可以消費到 HW(High Watermark)處的位置。有關事務和 LSO 的內容可以參考《圖解Kafka之核心原理》的相關章節。

還有一些消費者參數在本節沒有提及,這些參數同樣非常重要,它們需要用單獨的章節或場景中描述。部分參數在前面的章節內容中已經提及,比如 boostrap.servers;還有部分參數會在後面的《圖解Kafka之核心原理》中提及,比如 heartbeat.interval.ms。下表羅列了部分消費者客戶端的重要參數。

參 數 名 稱 默 認 值 參 數 釋 義
bootstrap.servers “” 指定連接 Kafka 集羣所需的 broker 地址清單
key.deserializer   消息中 key 所對應的反序列化類,需要實現 org.apache.kafka.common.serialization.Deserializer 接口
value.deserializer   消息中 key 所對應的反序列化類,需要實現 org.apache.kafka.common.serialization.Deserializer 接口
group.id “” 此消費者所隸屬的消費組的唯一標識,即消費組的名稱
client.id “” 消費者客戶端的id。
heartbeat.interval.ms 3000 當使用 Kafka 的分組管理功能時,心跳到消費者協調器之間的預計時間。心跳用於確保消費者的會話保持活動狀態,當有新消費者加入或離開組時方便重新平衡。該值必須比 session.timeout.ms 小,通常不高於1/3。它可以調整得更低,以控制正常重新平衡的預期時間
session.timeout.ms 10000 組管理協議中用來檢測消費者是否失效的超時時間
max.poll.interval.ms 300000 當通過消費組管理消費者時,該配置指定拉取消息線程最長空閒時間,若超過這個時間間隔還沒有發起 poll 操作,則消費組認爲該消費者已離開了消費組,將進行再均衡操作
auto.offset.reset latest 參數值爲字符串類型,有效值爲“earliest”“latest”“none”,配置爲其餘值會報出異常
enable.auto.commit true boolean 類型,配置是否開啓自動提交消費位移的功能,默認開啓
auto.commit.interval.ms 5000 當enbale.auto.commit參數設置爲 true 時才生效,表示開啓自動提交消費位移功能時自動提交消費位移的時間間隔
partition.assignment.strategy org.apache.kafka.clients.consumer.RangeAssignor 消費者的分區分配策略
interceptor.class “” 用來配置消費者客戶端的攔截器

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章