消費者與消費者組
消費者(Consumer)負責訂閱Kafka中的主題(Topic),並且從主題上拉取消息。Kafka中還存在消費者組(Consumer Group)的概念。每一個消費者都有一個對應的消費者組。當消息發佈到主題後,會被投遞給訂閱它的消費者組中的一個消費者。
Kafka支持兩種消息投遞模式:點對點(P2P,Point-to-Point)模式和發佈訂閱(Pub/Sub)模式.
- 點對點模式基於隊列,消息生產者發送消息到隊列,消費者從隊列中接受消息。所有消費者都位於同一個消費者組中,每條消息只會被一個消費者所處理。
- 發佈訂閱模式在消息一對多廣播時採用。所有消費者都屬於不同的消費者組,那麼所有消息都會廣播給所有消費者,每條消息會被所有消費者所處理。
要注意,主題下的某個分區的消息只能分配給一個消費者消費,一條消息也只能給消費者組中一個成員消費者進行消費。關於消費者組,踩過的坑詳見:關於Kafka消費者羣組的使用與理解–記一次故障引入的及時測試暴露與定位。
客戶端開發一般流程
一般消費者的消費邏輯包含以下幾個步驟:
- 配置消費者客戶端參數及創建相應的消費者實例;
- 訂閱主題;
- 拉取消息並消費;
- 提交消費位移;
- 關閉消費者。
示例:
public class KafkaConsumerAnalysis {
public static final String brokerList = "localhost:9092";
public static final String topic = "topic-demo";
public static final String groupId = "group.demo";
public static AtomicBoolean isRunning = new AtomicBoolean(true);
//初始化消費者配置
public static Properties initConfig() {
Properties pros = new Properties();
props.put(“bootstrap.servers", brokerList);
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("client.id", "consumer.client.id.demo");
props.put("group.id", groupId);
return props;
}
public static void main(String[] args) {
Properties props = initConfig();
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
try {
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMills(1000));
for (ConsumerRecords<String, String> record : records) {
//處理消息
}
}
} catch (Exception e) {
log.error("exception happends" , e);
} finally {
consumer.close();
}
}
}
訂閱主題與分區
-
訂閱主題
consumer的subscribe()方法可以用來爲消費者訂閱主題,可以以集合形式訂閱主題,也可以以正則表達式的形式訂閱特定模式的主題。subscribe()方法有以下幾種重載方法:
public void subscribe(Collection<String> topics); public void subscribe(Pattern pattern); public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener); public void subscribe(Pattern pattern, ConsumerRebalanceListener listener);
注意:一個消費者若先後兩次調用subscribe()方法訂閱主題,那麼最終訂閱的主題以最後調用的爲準,即主題的多次訂閱會以覆蓋的形式呈現。
-
訂閱特定分區
Kafka消費者除了可以訂閱主題,也可以通過assign()方法來直接訂閱主題下的特定分區,assign()方法簽名如下:
public void assign(Collection<TopicPartition> partition);
泛型TopicPartiton類中只有兩個屬性:topic和partition,分別代表分區所屬的主題和自身分區編號。下列代碼實現了消費者只訂閱分區中編號爲0的分區:
consumer.assign(Arrays.asList(new TopicPartition("topic-demo", 0)));
-
獲取主題元數據信息
KafkaConsumer還提供了一個獲得某一主題下所有分區的方法:partitionsFor()方法,該方法可以查詢指定主題的元數據信息,定義如下:
public List<PartitionInfo> partitionsFor(String topic);
-
取消訂閱
unsubscribe()方法用於取消消費者所訂閱的主題或分區,如果將subscribe(Collection)和assign(Collection)方法中的集合參數設置爲空,也可以取消訂閱,如下三個方法的效果相同:
consumer.unsubscribe(); consumer.subscribe(new ArrayList<>()); consumer.assign(new ArrayList<TopicPartition>());
消費消息
Kafka中的消息消費是基於拉模式的,由消費者主動向服務端發起請求來拉取消息。
Kafka中消息消費是一個不斷輪詢的過程,消費者不斷調用poll()方法,返回所訂閱的主題(分區)上的一組消息。
poll()方法定義如下:
public ConsumerRecords<K, V> poll(final Duration timeout);
超時參數timeout用來控制poll()方法的阻塞時間,在消費者的緩衝區裏沒有可用數據時會發生阻塞。
在Kafka消費者中,消費方式一般有以下兩種:
-
按分區消費消息
ConsumerRecords類提供了records(TopicPartition)方法,用來獲取去消息集中指定分區的消息:
public List<ConsumerRecord<K, V>> records(TopicPartition partition);
以下代碼展示瞭如何獲取消息集中的所有分區信息:
ConsumserRecord<String, String> records = consumer.poll(Duration.ofMills(1000)); for (TopicPartition tp : records.partition()) { for (ConsumerRecord<String, String> record : records.records(tp)) { System.out.println(record.partition() + ":" + record.value)); } }
-
按主題消費消息
public Iterable<ConsumerRecord<K, V>> records(String topic);
ConsumerRecords類並沒有提供與partition()類似的topics()方法來獲取消息集中所有包含的主題,所以按主題消費消息只能遍歷消費者訂閱的主題列表來獲取消息,代碼片段如下:
List<String> topicList = Arrays.asList(topic1, topic2); consumer.subscribe(topicList); try { while(isRunning.get()) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMills(1000)); for (String topic : topicList) { for (ConsumerRecord<String, String> record : records.records(topic)) { System.out.println(record.topic + ":" + record.value()); } } } } finally { consumer.close(); }
位移提交
每條消息在Kafka的分區中都有自己的offset,表徵自身在分區中的位置。消費者也有offset的概念,表示消費到分區中某個消息所在的位置。在分區中,offset可以翻譯爲“偏移量”,在消費者層面,offset可以翻譯爲“位移”。
關於lastConsumerOffset、committed offset和position之間的關係如下圖所示:
position = committed offset = lastConsumedOffset + 1
從用戶角度來看,位移提交有兩種方式,分別爲自動提交和手動提交。
自動提交
開啓Consumer端的參數enable.auto.commit,將其設置爲true,則開啓了自動提交。
同時派上用場的另一個參數:auto.commit.interval.ms,其默認值爲5秒,表明Kafka每5秒自動提交一次位移。
設置上述兩個參數的部分代碼爲:
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "2000");
開啓了自動提交,Kafka會保證在開始調用poll方法時,提交上次poll返回的所有消息。先提交上一批消息的位移,在處理下一批消息,因此自動提交可以保證不出現消費丟失的情況。
自動提交消費位移的方式較爲簡便,避免了複雜的位移提交邏輯。但是自動提交也有較大的缺點,那就是容易造成重複消費的問題。
重複消費的具體場景如下:剛剛提交完一次消費位移,然後拉取一批消息進行消費,在下一次自動提交消費位移之前,消費者發生了崩潰或者Kafka發生了重平衡,那麼就會從上一次位移提交的地方重新開始消費。減少自動提交的時間間隔可以減小重複消息的窗口大小,但無法完全避免消息的重複消費,並且會導致位移提交更加頻繁。
手動提交
與自動提交相比,手動提交實現更加靈活,能夠完全把控位移提交的時機和頻率。
使用手動提交,需要將enable.auto.commit參數設置爲false,然後在應用程序中手動調用相應的API手動進行消費位移的提交。
從Consumer端角度來看,手動提交根據方法返回方式又分爲兩種,分別爲同步提交和異步提交。
同步提交
同步提交對應的API 是KafkaConsumer#commitSync(),方法聲明如下:
public void commitSync();
這是一個同步操作,方法被調用後會一直等待,直到位移被成功提交纔會返回。同步提交的一般使用方法如下:
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(record);//處理消息
}
try {
consumer.commitSync();
} catch (CommitFailedException e) {
handle(e);//處理提交失敗異常
}
}
手動同步提交在調用commitSync()時,Consumer會處於阻塞狀態,直到Broker返回提交結果,阻塞狀態纔會結束,位移提交時的阻塞情況會影響整個應用程序的TPS。延長提交間隔可以減少阻塞對性能帶來的負面影響,但是後果是Consumer的提交頻率降低,下次Consumer重啓回來後,會有更多消息被重複消費。
異步提交
異步提交的方法聲明:
public void commitAsync();
這是一個異步操作,調用該方法後會立即返回,不會阻塞,不會影響Consumer應用的TPS 。由於它是異步的,Kafka提供了回調函數(callback),供實現提交之後的邏輯,如記錄日誌或處理異常等等。commitAsync的一般使用方法如下:
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.pool(Duration,ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(record);//處理消息
}
consumer.commitAsync(offsets, exception) -> {
if (exception != null) {
handle(exception);
}
});
}
異步提交能否完全替代同步提交呢?不能,異步提交的缺陷在於,出現問題時不會自動重試。因爲是異步操作,如果提交失敗後自動重試,那麼重試時提交的位移可能已經過期。因此重試對於異步提交來說沒有意義,故調用commitAsync是不會重試的。
同步+異步組合位移提交
那麼,在手動提交時,使用同步提交+異步提交的組合方式,才能達到最理想的效果。二者配合使用可以解決一下問題:
- commitSync的重試機制可以規避瞬時性的錯誤,如網絡抖動、Broker端GC等。針對這些瞬時性問題,自動重試通常都會成功;
- commitAsync的機制可以避免程序總是處於阻塞狀態,從而減少對TPS的影響。
同步+異步提交的具體實現如下:
try {
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(records);// 處理消息
consumer.commitAsync();// 使用異步提交規避阻塞
}
}
} catch (Exception e) {
handle(e);//處理異常
} finally {
try {
consumer.commitSync(); // 最後異常提交使用同步阻塞式提交
} finally {
consumer.close();
}
}
上述代碼段同時使用了commitSync()和commitAsync()。對於常規階段性提交調用commitAsync()避免程序阻塞,而在Consumer關閉前,調用commitSync()方法執行同步阻塞式的位移提交,以確保Consumer關閉前能夠保存正確的消費位移數據。
指定分區、消費位移提交
Consumer提供了帶參數的同步和異步提交方法,實現按具體消費位移進行提交,方法聲明如下:
public void commitSync(Map<TopicPartition, OffsetAndMetadata>);
public void commitAsync(Map<TopicPartition, OffsetAndMetadata>);
方法的參數是一個Map對象,key爲TopicPartition,即消息的分區,value爲OffsetAndMetadata對象,保存的主要是位移數據。
例如,我們需要每處理100條消息就提交一次位移,以commitAsync爲例(commitSync實現方式相同)。相應代碼如下:
private Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
int count = 0;
......
while(isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> record : records) {
process(records);// 處理消息
offsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)); //待提交的位移爲下一條消息的位移
if (count % 100 == 0) {
consumer.commitAsync(offsets, null); //回調處理邏輯爲null
}
count++;
}
}