kafka中offset使用原理

前言

在使用kafka時,從消費端來說,基本上大家在使用的時候,一般是通過一個消息監聽器監聽具體的topic以及對應的partition,接收消息即可,但有必要深入瞭解一下關於kafka的offset原理

kafka在設計上和其他的消息中間其中有一個不同點是,kafka中存在一個offset的概念,即偏移量,而這個偏移量是需要消費端進行記錄的,即producer將消息發到broker上之後,當某個消費者訂閱了這個topic之後,consumer需要自己記錄每次的消費位置,以便下次知道從哪個位置開始消費消息,這個即offset的來源,簡單的原理圖如下
在這裏插入圖片描述
既然消費者要知道自己每次的消費位移,那麼對於消費者來說,就需要一種機制,提交每次的消費位移,以便各自的分區能夠準確知道各分區中消息的位置如何

對於Kafka中的分區而言,它的每條消息都有唯一的offset,用來表示消息在分區中的位置。

當我們調用poll()時,該方法會返回我們沒有消費的消息。當消息從broker返回消費者時,broker並不跟蹤這些消息是否被消費者接收到;Kafka讓消費者自身來管理消費的位移,並向消費者提供更新位移的接口,這種更新位移方式稱爲提交(commit)

演示代碼一

下面通過一個簡單的小案例說明一下offset的信息,我們在consumer端每次消費的時候打印出offset的信息,這裏爲了簡化代碼,將消息發送到指定的分區

producer端代碼

public class AssignProducer {

    private final KafkaProducer<String, String> producer;

    public final static String TOPIC = "zcy3";

    public AssignProducer() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "101.15.33.147:9092");//xxx服務器ip
        props.put("acks", "all");//所有follower都響應了才認爲消息提交成功,即"committed"
        props.put("retries", 0);//retries = MAX 一直重試直到認爲消息投遞成功)
        //batch.size當批量的數據大小達到設定值後,就會立即發送,不顧下面的linger.ms
        props.put("batch.size", 16384);//producer採用批處理消息發送機制,以減少請求次數.默認的批量處理消息字節數
        //延遲1ms發送,這項設置將通過增加小的延遲來完成--即,不是立即發送一條記錄,
        // producer將會等待給定的延遲時間以允許其他消息記錄發送,這些消息記錄可以批量處理
        props.put("linger.ms", 1);
        //producer可以用來緩存數據的內存大小。
        props.put("buffer.memory", 33554432);
        //發送的消息進行序列化,kafka提供了常用的一些默認類型的序列化類型,也可以自己實現序列化接口
        props.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
        //設置發送到指定的partition
        producer = new KafkaProducer<String, String>(props);
    }

    public void produce() {
        int messageNo = 1;
        final int COUNT = 10;
        while (messageNo < COUNT) {
            String key = String.valueOf(messageNo);
            String data = String.format("hello KafkaProducer message %s from special partition 0 ", key);
            try {
                producer.send(new ProducerRecord<String, String>(TOPIC, 0,key,data));
            } catch (Exception e) {
                e.printStackTrace();
            }
            messageNo++;
        }
        producer.close();
    }

    public static void main(String[] args) {
        new AssignProducer().produce();
    }

}

consumer端

public class AssignConsumer {

    public static void main(String[] args){
        Properties properties = new Properties();
        //指定kafka的broker地址,集羣中多個以,分割
        properties.put("bootstrap.servers", "101.15.33.147:9092");
        //指定消費者組,建議指定
        properties.put("group.id", "zcy-group");
        properties.put("enable.auto.commit", "true");
        properties.put("auto.commit.interval.ms", "1000");
        properties.put("auto.offset.reset", "latest");
        properties.put("session.timeout.ms", "30000");
        //消費端設置反序列化器
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties);
        //kafkaConsumer.subscribe(Arrays.asList("second"));
        //指定消費分區的時候,使用assign
        kafkaConsumer.assign(Arrays.asList(new TopicPartition("zcy3",0)));
        //消費
        while (true) {
            ConsumerRecords<String, String> records = kafkaConsumer.poll(100);
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("-----------------");
                int partition = record.partition();
                System.out.printf("partition = %d,offset = %d, value = %s", partition,record.offset(), record.value());
                System.out.println();
            }
        }
    }

}

分別啓動consumer和producer代碼,通過consumer端控制檯輸出信息
在這裏插入圖片描述
從控制檯的信息輸出,可以看出,在ConsumerRecord對象中,保存了每條消息記錄的元信息,大家有興趣的可以打印出更多的信息,包括,key,value,offset等信息,也就是說,每消費一條信息,這個ConsumerRecord中就保存了這條消息的最新進度

說明:

默認情況下,我們在properties的設置中,enable.auto.commit 這個屬性爲true,就算是不設置,會給一個默認值,即每次拉取消息完畢consumer端會自動提交offset,那麼下次再接收消息的時候就從最新的消息位置開始,上面的代碼中,我們顯式的設置爲true,可以通過控制檯看到最後一次的offset值是73,這時我們再發送一次消息,可以看到自動從74的位置開始消費了
在這裏插入圖片描述

但是在某些情況下,我們需要指定消費的位置,即每次消費的時候都從某一個offset的位置開始消費,要怎麼做呢?kafka中提供了一個seek()的方法,可以幫我們做到,改造消費端代碼如下:

public class AssignConsumer {

    public static void main(String[] args){
        Properties properties = new Properties();
        //指定kafka的broker地址,集羣中多個以,分割
        properties.put("bootstrap.servers", "101.15.33.147:9092");
        //指定消費者組,建議指定
        properties.put("group.id", "zcy-group");
        properties.put("enable.auto.commit", "true");
        properties.put("auto.commit.interval.ms", "1000");
        properties.put("auto.offset.reset", "latest");
        properties.put("session.timeout.ms", "30000");
        //消費端設置反序列化器
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties);
        //kafkaConsumer.subscribe(Arrays.asList("second"));
        //指定消費分區的時候,使用assign
        kafkaConsumer.assign(Arrays.asList(new TopicPartition("zcy3",0)));

        //獲取當前消費者獲取到的分區列表
        Set<TopicPartition> topicPartitions = kafkaConsumer.assignment();
        for(TopicPartition tp : topicPartitions){
            kafkaConsumer.seek(tp,50);
        }

        //消費
        while (true) {
            ConsumerRecords<String, String> records = kafkaConsumer.poll(100);
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("-----------------");
                int partition = record.partition();
                System.out.printf("partition = %d,offset = %d, value = %s", partition,record.offset(), record.value());
                System.out.println();
            }
        }
    }

}

主要添加如下的部分代碼
在這裏插入圖片描述

這時候再次啓動消費端,我們發現消息從第50條開始了消費
在這裏插入圖片描述

當然也可以指定從末尾消費,只需要在消費端做如下的調整即可,即添加如下的代碼

		//獲取當前消費者獲取到的分區列表
        Set<TopicPartition> assignment = new HashSet<>();
        while (assignment.size()<=0){
            kafkaConsumer.poll(100);
            //確保消費者能夠獲得一個分區
            assignment = kafkaConsumer.assignment();
        }

        //指定從分區末尾消費
        Map<TopicPartition, Long> offsets = kafkaConsumer.endOffsets(assignment);
        for(TopicPartition tp : assignment){
            kafkaConsumer.seek(tp,offsets.get(tp) + 1);
        }

演示代碼二

在kafka的消費端,可能會消息的重複消費問題,關於重複消費,這裏做一點簡單解釋,在上面我們設置的是,消費者每次消費完畢自動提交offset

這種方式讓消費者來管理位移,應用本身不需要顯式操作。當我們將enable.auto.commit設置爲true,那麼消費者會在poll方法調用後每隔5秒(由auto.commit.interval.ms指定)提交一次位移。

和很多其他操作一樣,自動提交也是由poll()方法來驅動的;在調用poll()時,消費者判斷是否到達提交時間,如果是則提交上一次poll返回的最大位移。
需要注意到,這種方式可能會導致消息重複消費。

假如,某個消費者poll消息後,應用正在處理消息,在3秒後Kafka進行了重平衡,那麼由於沒有更新位移導致重平衡後這部分消息重複消費

在這種情況下,我們可以嘗試使用手動提交位移的方式來處理,具體代碼如下:

public class AsyncConsumer {

    public static void main(String[] args){

        Properties properties = new Properties();
        //指定kafka的broker地址,集羣中多個以,分割
        properties.put("bootstrap.servers", "101.15.33.147:9092");
        //指定消費者組,建議指定
        properties.put("group.id", "zcy-group");
        //設置offset爲手動提交
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
        properties.put("auto.commit.interval.ms", "1000");
        properties.put("auto.offset.reset", "latest");
        properties.put("session.timeout.ms", "30000");
        //消費端設置反序列化器
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties);
        //指定消費分區的時候,使用assign
        TopicPartition topicPartition = new TopicPartition("zcy3", 0);
        kafkaConsumer.assign(Arrays.asList(topicPartition));

        //消費
        while (true) {
            ConsumerRecords<String, String> records = kafkaConsumer.poll(100);
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("-----------------");
                int partition = record.partition();
                System.out.printf("partition = %d,offset = %d, value = %s", partition,record.offset(), record.value());
                System.out.println();
                //每次消費完一條消息手動提交offset
                kafkaConsumer.commitAsync();
            }
        }
    }

}

效果上來說,看不出太大的差別,但是當批量的消息非常大的時候,這種方式可能不太適合,但在某些場景下,比如對消息的重複消費比較敏感以及消息的量不大時候可以採用這種方式
在這裏插入圖片描述

異步提交

手動提交有一個缺點,那就是當發起提交調用時應用會阻塞。當然我們可以減少手動提交的頻率,但這個會增加消息重複的概率(和自動提交一樣),另外一個解決辦法是,使用異步提交

但異步提交也有個缺點,那就是如果服務器返回提交失敗,異步提交不會進行重試。

相比較起來,同步提交會進行重試直到成功或者最後拋出異常給應用。異步提交沒有實現重試是因爲,如果同時存在多個異步提交,進行重試可能會導致位移覆蓋。

舉例來說,當我們發起了一個異步提交commitA,此時提交位移爲2000,隨後又發起了一個異步提交commitB且位移爲3000;commitA提交失敗但
commitB提交成功,此時commitA進行重試併成功的話,會將實際上將已經提交的位移從3000回滾到2000,導致消息重複消費。

下面看異步提交的consumer代碼

public class SyncConsumer {

    private static AtomicBoolean running = new AtomicBoolean(true);

    public static void main(String[] args) {

        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList("zcy3"));
        try {
            while (running.get()) {
                ConsumerRecords<String, String> records = consumer.poll(1000);
                for (ConsumerRecord<String, String> record : records) {
                    //do some logical processing.
                    System.out.println("-----------------");
                    int partition = record.partition();
                    System.out.printf("partition = %d,offset = %d, value = %s", partition,record.offset(), record.value());
                    System.out.println();
                }

                // 異步回調
                consumer.commitAsync(new OffsetCommitCallback() {
                    @Override
                    public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
                                           Exception exception) {
                        if (exception == null) {
                            //提交成功的時候處理邏輯 .....
                            //System.out.println(offsets);
                        } else {
                            System.out.printf("fail to commit offsets %d", offsets, exception);
                        }
                    }
                });
            }
        } finally {
            consumer.close();
        }

        try {
            while (running.get()) {
                consumer.commitAsync();
            }
        } finally {
            try {
                consumer.commitSync();
            } finally {
                consumer.close();
            }
        }
    }

    /**
     * 初始化相關配置
     * @return
     */
    private static Properties initConfig() {

        Properties properties = new Properties();
        //指定kafka的broker地址,集羣中多個以,分割
        properties.put("bootstrap.servers", "101.15.33.147:9092");
        //指定消費者組,建議指定
        properties.put("group.id", "zcy-group");
        //設置offset爲手動提交
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,false);
        properties.put("auto.commit.interval.ms", "1000");
        properties.put("auto.offset.reset", "latest");
        properties.put("session.timeout.ms", "30000");
        //消費端設置反序列化器
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        return properties;
    }

}

異步提交一般用於提交大批量消息的場景下使用,而且業務上能夠容忍少量的消息丟失時可以考慮使用,通過消息回調的方式進行處理

本篇主要講述了offset在kafka中的簡單使用,以及同步提交和異步提交的兩種方式,有興趣的同學可以繼續深入研究,最後感謝觀看!

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