快速入門Kafka消費者

消費者與消費者組

消費者(Consumer)負責訂閱Kafka中的主題(Topic),並且從主題上拉取消息。Kafka中還存在消費者組(Consumer Group)的概念。每一個消費者都有一個對應的消費者組。當消息發佈到主題後,會被投遞給訂閱它的消費者組中的一個消費者。

Kafka支持兩種消息投遞模式:點對點(P2P,Point-to-Point)模式和發佈訂閱(Pub/Sub)模式.

  • 點對點模式基於隊列,消息生產者發送消息到隊列,消費者從隊列中接受消息。所有消費者都位於同一個消費者組中,每條消息只會被一個消費者所處理。
  • 發佈訂閱模式在消息一對多廣播時採用。所有消費者都屬於不同的消費者組,那麼所有消息都會廣播給所有消費者,每條消息會被所有消費者所處理。

要注意,主題下的某個分區的消息只能分配給一個消費者消費,一條消息也只能給消費者組中一個成員消費者進行消費。關於消費者組,踩過的坑詳見:關於Kafka消費者羣組的使用與理解–記一次故障引入的及時測試暴露與定位

客戶端開發一般流程

一般消費者的消費邏輯包含以下幾個步驟:

  1. 配置消費者客戶端參數及創建相應的消費者實例;
  2. 訂閱主題;
  3. 拉取消息並消費;
  4. 提交消費位移;
  5. 關閉消費者。

示例:

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消費者中,消費方式一般有以下兩種:

  1. 按分區消費消息

    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));
        } 	   
    }
    
    
  2. 按主題消費消息

    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是不會重試的。

同步+異步組合位移提交

那麼,在手動提交時,使用同步提交+異步提交的組合方式,才能達到最理想的效果。二者配合使用可以解決一下問題:

  1. commitSync的重試機制可以規避瞬時性的錯誤,如網絡抖動、Broker端GC等。針對這些瞬時性問題,自動重試通常都會成功;
  2. 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++;
        }
}

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