Kafka的assign和subscribe訂閱模式和手動提交偏移量

一、前言:

使用Apache Kafka消費者組時,有一個爲消費者分配對應分區partition的過程,我們可以使用“自動”subscribe和“手動”assign的方式。

  • KafkaConsumer.subscribe():爲consumer自動分配partition,有內部算法保證topic-partition以最優的方式均勻分配給同group下的不同consumer。
  • KafkaConsumer.assign():爲consumer手動、顯示的指定需要消費的topic-partitions,不受group.id限制,相當與指定的group無效(this method does not use the consumer’s group management)。
     

二、如果兩種模式都用的話會報錯

報錯信息:

java.lang.IllegalStateException: Subscription to topics, partitions and pattern are mutually exclusive
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.setSubscriptionType(SubscriptionState.java:111) ~[kafka-clients-0.11.0.2.jar!/:na]
	at org.apache.kafka.clients.consumer.internals.SubscriptionState.subscribe(SubscriptionState.java:118) ~[kafka-clients-0.11.0.2.jar!/:na]
	at org.apache.kafka.clients.consumer.KafkaConsumer.subscribe(KafkaConsumer.java:873) ~[kafka-clients-0.11.0.2.jar!/:na]
	at org.apache.kafka.clients.consumer.KafkaConsumer.subscribe(KafkaConsumer.java:901) ~[kafka-clients-0.11.0.2.jar!/:na]
	at com.guoxin.sydjtxry.SydjtxryConsumer.doWork(SydjtxryConsumer.java:77) ~[classes!/:1.0-SNAPSHOT]
	at kafka.utils.ShutdownableThread.run(ShutdownableThread.scala:64) ~[kafka_2.11-0.11.0.2.jar!/:na]

2020-04-12 09:46:38.705  INFO 43884 --- [ConsumerExample] com.guoxin.sydjtxry.SydjtxryConsumer     : [KafkaConsumerExample]: Stopped

錯誤代碼:

consumer.subscribe(Collections.singletonList(this.topic));

TopicPartition partition = new TopicPartition(this.topic, 0);
consumer.assign(Arrays.asList(partition));
consumer.seek(partition, seekOffset);

 

三、從指定的offset進行消費

場景:kafka_2.11-0.11.0.2版本中創建的topic只有一個分區。如果是多分區的話可以參考下這篇文章https://www.cnblogs.com/dongxishaonian/p/12038500.html
代碼:

package com.guoxin.sydjtxry;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

/**
 * Created by HuiQ on 2019-10-30.
 */
@Component
public class KafkaConsumerTask implements CommandLineRunner {

    private static final Logger LOG = LoggerFactory.getLogger(KafkaConsumerTask.class);

    @Override
    public void run(String... args) {
        // 全量消費
        SydjtxryConsumer.consumer();
    }
}
package com.guoxin.sydjtxry;

import kafka.utils.ShutdownableThread;
import net.sf.json.JSONObject;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.TopicPartition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Arrays;
import java.util.Collections;
import java.util.Properties;
import java.util.UUID;

public class SydjtxryConsumer extends ShutdownableThread
{
    private static final Logger LOG = LoggerFactory.getLogger(SydjtxryConsumer.class);

    private final KafkaConsumer<String, String> consumer;

    private final String topic;

    private Long rowcount = 0L;
    // 一次請求的最大等待時間
    private final int waitTime = 10000;
    // consumer從指定的offset處理
    private Long seekOffset = 1936170L;

    // Broker連接地址
    private final String bootstrapServers = "bootstrap.servers";

    /**
     * NewConsumer構造函數
     * @param topic 訂閱的Topic名稱
     */
    public SydjtxryConsumer(String topic)
    {
		super("KafkaConsumerExample", false);
        Properties props = new Properties();

        KafkaProperties kafkaProc = KafkaProperties.getInstance();
        // Broker連接地址
        props.put(bootstrapServers,
            kafkaProc.getValues(bootstrapServers, "192.110.110.33:9092"));
        props.put("enable.auto.commit", "true"); // 自動提交
        props.put("auto.commit.interval.ms", "1000");
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        consumer = new KafkaConsumer<String, String>(props);
        this.topic = topic;
    }

    /**
     * 訂閱Topic的消息處理函數
     */
    public void doWork()
    {
        // 訂閱
        TopicPartition partition = new TopicPartition(this.topic, 0);
        consumer.assign(Arrays.asList(partition));
        consumer.seek(partition, seekOffset);
        compareOffset = seekOffset;

        // 消息消費請求
        ConsumerRecords<String, String> records = consumer.poll(waitTime);
        if (records.isEmpty()) {
            System.out.println("消費者沒有消費到數據------->");
        } else {
            // 消息處理
            for (ConsumerRecord<String, String> record : records) {
                try {
                    JSONObject jsondata = JSONObject.fromObject(record.value().toString());
                    String table = jsondata.getString("table"); // 庫名.表名
                    if (table.equals("BJSX_OGG.GR_XX")) {
                        rowcount++;
                        // 業務邏輯
                    }
                    LOG.info("數據偏移量爲-->"  + record.offset());
                } catch (Exception e) {
                    e.printStackTrace();
                    LOG.warn("偏移量爲" + record.offset() + "的數據處理有問題,請排查-->" + record.value().toString());
                }
                seekOffset = record.offset();
                if (seekOffset % 10000 == 0) {
                    LOG.info("offset-->" + seekOffset);
                }
            }
        }
    }

    public static void consumer()
    {
        SydjtxryConsumer consumerThread = new SydjtxryConsumer("heheda");
        consumerThread.start();
    }
}

遇到的問題:當消費到該topic最後一條數據後,以後的消費會循環消費該數據。改進:當消費完最後一條數據,以後的訂閱模式都由assign改爲subscribe。

package com.guoxin.sydjtxry;

import kafka.utils.ShutdownableThread;
import net.sf.json.JSONObject;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.TopicPartition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Arrays;
import java.util.Collections;
import java.util.Properties;

public class SydjtxryConsumer extends ShutdownableThread
{
    private static final Logger LOG = LoggerFactory.getLogger(SydjtxryConsumer.class);

    private final KafkaConsumer<String, String> consumer;

    private final String topic;

    private Long rowcount = 0L;
    // 一次請求的最大等待時間
    private final int waitTime = 10000;
    // consumer從指定的offset處理
    private Long seekOffset = 1936170L;
    private Long compareOffset = 0L;
    private boolean flag = false;

    // Broker連接地址
    private final String bootstrapServers = "bootstrap.servers";
    // Group id
    private final String groupId = "group.id";

    private StringBuilder grxxdata = new StringBuilder(); // 要批量插入gauss表中的數據
    private StringBuilder grylzfdata = new StringBuilder(); // 要批量插入gauss表中的數據

    /**
     * NewConsumer構造函數
     * @param topic 訂閱的Topic名稱
     */
    public SydjtxryConsumer(String topic)
    {
		super("KafkaConsumerExample", false);
        Properties props = new Properties();

        KafkaProperties kafkaProc = KafkaProperties.getInstance();
        // Broker連接地址
        props.put(bootstrapServers,
            kafkaProc.getValues(bootstrapServers, "192.110.110.33:9092"));
        props.put("enable.auto.commit", "true"); // 自動提交
        props.put("auto.commit.interval.ms", "1000");
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("auto.offset.reset", "latest"); // from-beginning功能
        // Group id
        props.put(groupId, UUID.randomUUID().toString());
        consumer = new KafkaConsumer<String, String>(props);
        this.topic = topic;
    }

    /**
     * 訂閱Topic的消息處理函數
     */
    public void doWork()
    {
        // 訂閱
        if (compareOffset.equals(seekOffset) && flag == false) {
            // 暫停kafka的消費 暫停分區的分配
            consumer.unsubscribe(); // 此處不取消訂閱暫停太久會出現訂閱超時的錯誤
            consumer.pause(consumer.assignment());

            consumer.subscribe(Collections.singletonList(this.topic));
            flag = true;
        } else if (flag == true) {
            consumer.subscribe(Collections.singletonList(this.topic));
        } else {
            TopicPartition partition = new TopicPartition(this.topic, 0);
            consumer.assign(Arrays.asList(partition));
            consumer.seek(partition, seekOffset);
            compareOffset = seekOffset;
        }

        // 消息消費請求
        ConsumerRecords<String, String> records = consumer.poll(waitTime);
        if (compareOffset.equals(seekOffset + records.count() - 1) && flag == false) {
            System.out.println("指定offset消費已結束,此條爲末尾的重複消費數據,跳過業務處理,此後由assign改爲subscribe訂閱模式-->");
        } else {
            if (records.isEmpty()) {
                System.out.println("消費者沒有消費到數據------->");
            } else {
                // 消息處理
                for (ConsumerRecord<String, String> record : records) {
                    try {
                        JSONObject jsondata = JSONObject.fromObject(record.value().toString());
                        String table = jsondata.getString("table"); // 庫名.表名
                        if (table.equals("BJSX_OGG.GR_XX")) {
                            // 業務邏輯
                        }
                        LOG.info("數據偏移量爲-->"  + record.offset());
                    } catch (Exception e) {
                        e.printStackTrace();
                        LOG.warn("偏移量爲" + record.offset() + "的數據處理有問題,請排查-->" + record.value().toString());
                    }
                    seekOffset = record.offset();
                    if (seekOffset % 10000 == 0) {
                        LOG.info("offset-->" + seekOffset);
                    }
                }
            }
        }
    }

    public static void consumer()
    {
        SydjtxryConsumer consumerThread = new SydjtxryConsumer("heheda");
        consumerThread.start();
    }
}

消費速度控制:
提供pause(Collection<TopicPartition> partitions)resume(Collection<TopicPartition> partitions)方法,分別用來暫停某些分區在拉取操作時返回數據給客戶端和恢復某些分區向客戶端返回數據操作。通過這兩個方法可以對消費速度加以控制,結合業務使用。
 

四、以時間戳查詢消息

Kafka 在0.10.1.1 版本增加了時間戳索引文件,因此我們除了直接根據偏移量索引文件查詢消息之外,還可以根據時間戳來訪問消息。consumer-API 提供了一個offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)方法,該方法入參爲一個Map 對象,Key 爲待查詢的分區,Value 爲待查詢的時間戳,該方法會返回時間戳大於等於待查詢時間的第一條消息對應的偏移量和時間戳。需要注意的是,若待查詢的分區不存在,則該方法會被一直阻塞。

假設我們希望從某個時間段開始消費,那們就可以用offsetsForTimes()方法定位到離這個時間最近的第一條消息的偏移量,在查到偏移量之後調用seek(TopicPartition partition, long offset)方法將消費偏移量重置到所查詢的偏移量位置,然後調用poll()方法長輪詢拉取消息。例如,我們希望從主題“stock-quotation”第0 分區距離當前時間相差12 小時之前的位置開始拉取消息

Properties props = new Properties();  
props.put("bootstrap.servers", "localhost:9092");  
props.put("group.id", "test");  
props.put("client.id", "test");  
props.put("enable.auto.commit", true);// 顯示設置偏移量自動提交  
props.put("auto.commit.interval.ms", 1000);// 設置偏移量提交時間間隔  
props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer");  
props.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer");  
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);  
// 訂閱主題  
consumer.assign(Arrays.asList(new TopicPartition("test", 0)));  
try {  
    Map<TopicPartition, Long> timestampsToSearch = new HashMap<TopicPartition,Long>();  
    // 構造待查詢的分區  
    TopicPartition partition = new TopicPartition("stock-quotation", 0);  
    // 設置查詢12 小時之前消息的偏移量  
    timestampsToSearch.put(partition, (System.currentTimeMillis() - 12 * 3600 * 1000));  
    // 會返回時間大於等於查找時間的第一個偏移量  
    Map<TopicPartition, OffsetAndTimestamp> offsetMap = consumer.offsetsForTimes (timestampsToSearch);  
    OffsetAndTimestamp offsetTimestamp = null;  
    // 這裏依然用for 輪詢,當然由於本例是查詢的一個分區,因此也可以用if 處理  
    for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsetMap.entrySet()) {  
        // 若查詢時間大於時間戳索引文件中最大記錄索引時間,  
        // 此時value 爲空,即待查詢時間點之後沒有新消息生成  
        offsetTimestamp = entry.getValue();  
        if (null != offsetTimestamp) {  
        // 重置消費起始偏移量  
        consumer.seek(partition, entry.getValue().offset());  
        }  
    }  
    while (true) {  
        // 等待拉取消息  
        ConsumerRecords<String, String> records = consumer.poll(1000);  
        for (ConsumerRecord<String, String> record : records){  
            // 簡單打印出消息內容  
            System.out.printf("partition = %d, offset = %d,key= %s value = %s%n", record.partition(), record.offset(), record.key(),record.value());  
        }  
    }  
} catch (Exception e) {  
    e.printStackTrace();  
} finally {  
    consumer.close();  
}  

 

五、消費者手動提交

場景:offset下標自動提交其實在很多場景都不適用,因爲自動提交是在kafka拉取到數據之後就直接提交,這樣很容易丟失數據,尤其是在需要事物控制的時候。
很多情況下我們需要從kafka成功拉取數據之後,對數據進行相應的處理之後再進行提交。如拉取數據之後進行寫入mysql這種 , 所以這時我們就需要進行手動提交kafka的offset下標。

實施測試:
將 enable.auto.commit 改成 false 進行手動提交,並且設置每次拉取最大10條

props.put("enable.auto.commit", "false");
props.put("max.poll.records", 10);

將提交方式改成false之後,需要手動提交只需加上這段代碼

  • 同步提交:consumer.commitSync();
  • 異步提交:consumer.commitAsync()

注:在成功提交或碰到無法恢復的錯誤之前,commitSync() 會一直重試,但是 commitAsync() 不會,這也是 commitAsync() 不好的一個地方。
它之所以不進行重試,是因爲在它收到服務器響應的時候,可能有一個更大的偏移量已經提交成功。假設我們發出一個請求用於提交偏移量 2000,這個時候發生了短暫的通信問題,服務器收不到請求,自然也不會作出任何響應。與此同時,我們處理了另外一批消息,併成功提交了偏移量 3000。如果 commitAsync() 重新嘗試提交偏移量 2000,它有可能在偏移量 3000 之後提交成功。這個時候如果發生再均衡,就會出現重複消息。

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> record : records) {
        System.out.printf("topic = %s, partition = %s,
        offset = %d, customer = %s, country = %s\n",
        record.topic(), record.partition(), record.offset(),
        record.key(), record.value());
    }
    consumer.commitAsync(new OffsetCommitCallback() {
        public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
            if (e != null)
                log.error("Commit failed for offsets {}", map, e);
        }
    }); 
}

說明:
手動提交的offset不能再次消費,未提交的可以再次進行消費。
這種做法一般也可以滿足大部分需求。例如從kafka獲取數據入庫,如果一批數據入庫成功,就提交offset,否則不提交,然後再次拉取。但是這種做法並不能最大的保證數據的完整性。
比如在運行的時候,程序掛了之類的。所以還有一種方法是手動的指定offset下標進行獲取數據,直到kafka的數據處理成功之後,將offset記錄下來,比如寫在數據庫中。
 

六、同步和異步混合提交:

一般情況下,針對偶爾出現的提交失敗,不進行重試不會有太大問題,因爲如果提交失敗是因爲臨時問題導致的,那麼後續的提交總會有成功的。
  
但如果這是發生在關閉消費者或再均衡前的最後一次提交,就要確保能夠提交成功。因此在這種情況下,我們應該考慮使用混合提交的方法:

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
            System.out.println("topic = %s, partition = %s, offset = %d,
            customer = %s, country = %s\n",
            record.topic(), record.partition(),
            record.offset(), record.key(), record.value());
        }
        consumer.commitAsync(); 
    }
} catch (Exception e) {
    log.error("Unexpected error", e);
} finally {
    try {
        consumer.commitSync(); 
    } finally {
        consumer.close();
    }
}
  1. 在程序正常運行過程中,我們使用 commitAsync 方法來進行提交,這樣的運行速度更快,而且就算當前提交失敗,下次提交成功也可以。
  2. 如果直接關閉消費者,就沒有所謂的“下一次提交”了,因爲不會再調用poll()方法。使用 commitSync() 方法會一直重試,直到提交成功或發生無法恢復的錯誤。
     

七、提交特定的偏移量:

如果 poll() 方法返回一大批數據,爲了避免因再均衡引起的重複處理整批消息,想要在批次中間提交偏移量該怎麼辦?這種情況無法通過調用 commitSync() 或 commitAsync() 來實現,因爲它們只會提交最後一個偏移量,而此時該批次裏的消息還沒有處理完。

這時候需要使用一下的兩個方法:

/**
 * Commit the specified offsets for the specified list of topics and partitions.
 */
@Override
public void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)

/**
 * Commit the specified offsets for the specified list of topics and partitions to Kafka.
 */
@Override
public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)

消費者 API 允許在調用 commitSync() 和 commitAsync() 方法時傳進去希望提交的分區和偏移量的 map。
假設處理了半個批次的消息,最後一個來自主題“customers”分區 3 的消息的偏移量是 5000,你可以調用 commitSync() 方法來提交它。不過,因爲消費者可能不只讀取一個分區,你需要跟蹤所有分區的偏移量,所以在這個層面上控制偏移量的提交會讓代碼變複雜。

代碼如下:

private Map<TopicPartition, OffsetAndMetadata> currentOffsets =new HashMap<>(); 
int count = 0;
。。。

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> record : records)
    {
        System.out.printf("topic = %s, partition = %s, offset = %d,
        customer = %s, country = %s\n",
        record.topic(), record.partition(), record.offset(),
        record.key(), record.value()); 
        currentOffsets.put(new TopicPartition(record.topic(),
        record.partition()), new
        OffsetAndMetadata(record.offset()+1, "no metadata")); 
        if (count % 1000 == 0) 
            consumer.commitAsync(currentOffsets,null); 
        count++;
    }
}

這裏調用的是 commitAsync(),不過調用commitSync()也是完全可以的。在提交特定偏移量時,仍然要處理可能發生的錯誤。
 

八、監聽再均衡:

如果 Kafka 觸發了再均衡,我們需要在消費者失去對一個分區的所有權之前提交最後一個已處理記錄的偏移量。如果消費者準備了一個緩衝區用於處理偶發的事件,那麼在失去分區所有權之前,需要處理在緩衝區累積下來的記錄。可能還需要關閉文件句柄、數據庫連接等。

在爲消費者分配新分區或移除舊分區時,可以通過消費者 API 執行一些應用程序代碼,在調用 subscribe() 方法時傳進去一個 ConsumerRebalanceListener 實例就可以了。 ConsumerRebalanceListener 有兩個需要實現的方法。

  • public void onPartitionsRevoked(Collection partitions) 方法會在再均衡開始之前和消費者停止讀取消息之後被調用。如果在這裏提交偏移量,下一個接管分區的消費者就知道該從哪裏開始讀取了。
  • public void onPartitionsAssigned(Collection partitions) 方法會在重新分配分區之後和消費者開始讀取消息之前被調用。

下面的例子將演示如何在失去分區所有權之前通過 onPartitionsRevoked() 方法來提交偏移量。

private Map<TopicPartition, OffsetAndMetadata> currentOffsets=
  new HashMap<>();

private class HandleRebalance implements ConsumerRebalanceListener { 
    public void onPartitionsAssigned(Collection<TopicPartition>
      partitions) { 
    }

    public void onPartitionsRevoked(Collection<TopicPartition>
      partitions) {
        System.out.println("Lost partitions in rebalance.
          Committing current
        offsets:" + currentOffsets);
        consumer.commitSync(currentOffsets); 
    }
}

try {
    consumer.subscribe(topics, new HandleRebalance()); 

    while (true) {
        ConsumerRecords<String, String> records =
          consumer.poll(100);
        for (ConsumerRecord<String, String> record : records)
        {
            System.out.println("topic = %s, partition = %s, offset = %d,
             customer = %s, country = %s\n",
             record.topic(), record.partition(), record.offset(),
             record.key(), record.value());
             currentOffsets.put(new TopicPartition(record.topic(),
             record.partition()), new
             OffsetAndMetadata(record.offset()+1, "no metadata"));
        }
        consumer.commitAsync(currentOffsets, null);
    }
} catch (WakeupException e) {
    // 忽略異常,正在關閉消費者
} catch (Exception e) {
    log.error("Unexpected error", e);
} finally {
    try {
        consumer.commitSync(currentOffsets);
    } finally {
        consumer.close();
        System.out.println("Closed consumer and we are done");
    }
}

如果發生再均衡,我們要在即將失去分區所有權時提交偏移量。要注意,提交的是最近處理過的偏移量,而不是批次中還在處理的最後一個偏移量。因爲分區有可能在我們還在處理消息的時候被撤回。我們要提交所有分區的偏移量,而不只是那些即將失去所有權的分區的偏移量——因爲提交的偏移量是已經處理過的,所以不會有什麼問題。調用 commitSync() 方法,確保在再均衡發生之前提交偏移量。

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