Kafka 2.2.0 Java客户端开发——消息消费者

快速入门

Kafka消息消费者逻辑应当具备以下步骤:

  1. 配置消费者参数,创建KafkaConsumer实例;
  2. 订阅至少一个主题;
  3. 拉取消息获得ConsumerRecords,遍历ConsumerRecords获取ConsumerRecord对象,从中提取消息的内容;
  4. 程序退出或者无需消费消息时关闭KafkaConsumer
public class Consumer {
    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.142.128:9092");  //Broker列表
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());   //key反序列化器
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); //value反序列化器
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "group.1");  //消费者组
        p.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");  //Client ID

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
        consumer.subscribe(Collections.singletonList("topic-test"));  //订阅主题topic-test
		
		while(true) {
	        ConsumerRecords<String, String> record = consumer.poll(Duration.ofSeconds(5));  //拉取消息,阻塞时间5秒
	        if(record.isEmpty())
	        	break;
	        //遍历消息并打印value
	        record.forEach(rec -> System.out.println("topic:" + rec.topic() + " val:" + rec.value()));
        }
		//关闭消费者
        consumer.close();
    }
}

需要注意的是,虽然KafkaProducer是线程安全的,但是KafkaConsumer不是线程安全的(除了wakeup方法)。

必要的参数

  • bootstrap.serversConsumerConfig.BOOTSTRAP_SERVERS_CONFIG):意义和生产者相同,指定了生产者客户端连接Kafka集群所需的Broker列表,格式为host:port,用逗号分隔。这里并非需要所有的Broker,生产者可以根据一个Broker来自动获取其它Broker的信息。建议设置为2个以上,当客户端连接的Broker因为网络故障或者该Broker宕机时可以根据该配置连接至其它Broker。
  • group.idConsumerConfig.GROUP_ID_CONFIG):当前消费者隶属消费者组的名称,默认为"",不能设置为null否则会抛出异常,建议设置为和业务功能相关的名称。
  • key.deserializervalue.deserializerConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIGConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG):和生产者序列化器参数对应,这里是反序列化器,负责将字节流转换为Java对象,并将结果赋值给ConsumerRecordkeyvalue字段。必须填写反序列化器的全限定名。

消费者组概念

和其它消息中间件不同,Kafka有一层消费者组的概念,每个消费者都必须具有一个消费者组。消息通过生产者发送到主题后,只会投递到订阅它的每个消费组中的一个消费者,也就是说,如果两个消费者都是同一个消费者组,那么消息会均摊给这两个消费者,每个消费者收到的消息不同。如果两个消费者隶属不同消费者组,那么它们获得到的消息将是一致的,类似于广播。

例如下图中有主题topic-test,该主题拥有4个分区 P 0 P0 P 1 P1 P 2 P2 P 3 P3 。两个消费者组 A A B B 订阅了该主题,消费者 C 0 C0 C 1 C1 C 2 C2 所属消费者组 A A C 3 C3 C 4 C4 所属消费者组 B B 。那么各个消费者获取消息的规律可能是这样的:
在这里插入图片描述
因为有4个分区,所以消费者组 A A 中的消费者必须有且一个消费者消费2个分区,其它两个各自消费一个分区,也就是说, C 0 C0 消费 P 0 P0 C 1 C1 消费 P 1 P1 C 2 C2 消费 P 2 P2 P 3 P3 。消费组 B B 有两个消费者,所以每个消费者负责两个分区,可能就是 C 3 C3 消费 P 0 P0 P 1 P1 C 4 C4 消费 P 2 P2 P 3 P3 。两个消费者组之间互不影响,每个消费者只能消费所分配到的分区中的消息。

需要注意的是,如果消费组中的消费者多于一个主题中分区数,那么多出的消费者由于无法分配到分区而无法消费到该主题的消息,也就是下面这种情况:
在这里插入图片描述
需要注意消费组是一个逻辑概念,它可以将消费者归为一类。消费者并非逻辑概念,它是应用的实例,可以是一个线程或是一个进程,每个KafkaConsumer对象可以看成是一个消费者。

主题订阅

创建完KafkaConsumer后,就需要为消费者订阅相关的主题,一个消费者可以订阅一或多个主题,可通过调用KafkaConsumersubscribe方法订阅:

public void subscribe(Collection<String> topics);
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener);
public void subscribe(Pattern pattern);
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener);

最简单的方式调用subscribe(Collection<String>)传入一个集合,集合中需要包含主题的名称。或者调用subscribe(Pattern)传入一个正则表达式,Broker中所有和该正则表达式匹配的主题都将被当前消费者订阅。此外还可以传入一个参数ConsumerRebalanceListener,这个参数是用来设置再均衡监听器的,我们稍后再讨论。

如果在没有订阅主题的情况下就尝试进行消费,KafkaConsumer会抛出IllegalStateException异常。

此外,如果需要指定订阅的分区,可以调用KafkaConsumerassign方法:

public void assign(Collection<TopicPartition> partitions)

TopicPartition对象包含了主题名称分区编号,用户可以直接构造该对象并传入assign方法。

为了方便用户能够获知Kafka集群中一个主题的分区信息,KafkaConsumer提供了partitionsFor方法供用户查询:

public List<PartitionInfo> partitionsFor(String topic);

PartitionInfo类型为主题的分区元数据信息,包含以下字段:

public class PartitionInfo {
    private final String topic;    //主题
    private final int partition;   //分区
    private final Node leader;     //分区的leader副本所在位置
    private final Node[] replicas; //分区的AR集合
    private final Node[] inSyncReplicas;   //分区的ISR集合
    private final Node[] offlineReplicas;  //分区的OSR集合
}

如果需要取消所有主题的订阅,直接调用KafkaConsumerunsubscribe方法即可。或者,也可以通过调用subscribeassign方法时传入一个空的集合来取消所有的订阅。

反序列化器

因为从Broker拉取的消息都是字节流的形式,所以需要通过反序列化器将消息中的keyvalue转换为Java对象。
反序列化器必须实现Deserializer接口,它包含以下几个方法:

public interface Deserializer<T> extends Closeable {
    void configure(Map<String, ?> configs, boolean isKey);

    T deserialize(String topic, byte[] data);

    default T deserialize(String topic, Headers headers, byte[] data) {
        return deserialize(topic, data);
    }

    @Override
    void close();
}

Kafka提供了几个基本的Deserializer实现类,它们有:StringDeserializerUUIDDeserializerIntegerDeserializerLongDeserializerByteArrayDeserializerByteBufferDeserializerShortDeserializerFloatDeserializerDoubleDeserializer

消息消费

Kafka的消费模式是基于拉(poll)模式的,需要消费者主动从消息队列拉取消息。从开头的示例代码可以看出,Kafka的消费是一个轮询的过程,需要重复地调用poll方法,它会返回所订阅的主题中的消息。poll方法定义如下:

public ConsumerRecords<K, V> poll(final Duration timeout);

如果此时消费者的缓冲区没有可用的消息数据时就会发生阻塞,超时参数timeout用于控制其阻塞时间。timeout的设置取决于应用程序对响应速度的要求,如果将timeout设置为0,poll方法会立刻返回,不论是否已经拉取到了消息,需要注意的是有可能会造成CPU的高占用。如果当前线程是专门拉取消息的,将timeout设置为Long.MAX_VALUE也未尝不可,因为poll方法是可以捕获线程的interrupt的,如果当前线程被interrupt,那么会抛出org.apache.kafka.common.errors.InterruptException异常,注意这个异常是RuntimeException,需要用户手动捕获。此外,中止消费还可以调用KafkaConsumerwakeup方法,调用后再调用poll方法会抛出WakeupException

poll方法返回的ConsumerRecords包含了多个ConsumerRecord,与生产者发送的ProducerRecord一一对应,只不过ConsumerRecord包含了更多的字段:

public class ConsumerRecord<K, V> {
    private final String topic;		  //主题
    private final int partition;      //分区
    private final long offset;        //偏移量
    private final long timestamp;     //时间戳
    private final TimestampType timestampType;  //时间戳类型
    private final int serializedKeySize;    //key经过序列化后的大小,如果key为null那么该值为-1
    private final int serializedValueSize;  //value经过序列化后的大小
    private final Headers headers;  //消息头
    private final K key;      //key经过反序列化后的对象
    private final V value;    //value经过反序列化后的对象
    private final Optional<Integer> leaderEpoch;  //leader的epoch版本
    private volatile Long checksum;  //检验和,该值为CRC32的校验值
}

时间戳有两种类型:CREATE_TIMELOG_APPEND_TIME,前者表示消息创建的时间戳,后者表示消息追加到日志的时间戳。

消费位移

一个分区中的每条消息都有一个唯一offset,用来表示消息在分区中的位置。对于消费者而言也有一个offset的概念,消费者通过它来表示消费到分区中某个消息所在的位置。对于前者,我们称offset为偏移量,对于后者,我们称之为消费位移。

每次调用poll方法时,它返回仅仅只是一个消息集合,并没有真正将消息从分区中“拉取”出来。如果我们在构造KafkaConsumer时加入以下参数:

p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

反复运行上述程序,会发现每次取出来的消息是一样的,看上去像是peek而不是poll
这是因为KafkaConsumer默认打开了enable.auto.commit配置,从而导致KafkaConsumer会自动将消费位移提交给Broker。如果关闭了enable.auto.commit,那么需要显式调用KafkaConsumercommitSynccommitAsync方法来提交消费位移,前者是同步提交,后者是异步提交。

消费位移存储在Kafka内部的主题__consumer_offsets中。把消费位移存储起来持久化的动作称为提交
在这里插入图片描述
在上图中, x x 表示某一次拉取操作中此分区的消息的最大偏移量,那么我们可以说消费者的消费位移为 x x 。不过需要明确的是,当前消费者提交的消费位移不是 x x ,而是 x + 1 x+1 ,表示下一条需要拉取的消息的位置。

KafkaConsumer提供了以下方法来获取上面的值:

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

position方法可以获取上图中 p o s i t i o n position 的值,comrnitted方法可以获取上次提交过的消费位移。

重复消费问题
在自动提交打开的情况下(默认情况),消费者每隔5秒就会将拉取到的每个分区中最大的消息位移进行提交。自动提交虽然简便,它免去了复杂的位移提交逻辑,使得客户端代码更加简洁。但是会造成重复消费和消息丢失的问题:假设刚刚提交完一次消费位移,然后此时又拉取了一批消息,然后在提交本次消费位移时消费者宕机,那么消费者重启后就会收到这批重复消费过的消息。我们可以通过增大位移提交时间间隔来减少重复消息的窗口大小,但是依然不能从根本上避免重复消费。

同步提交和异步提交
之前提到过,手动提交可以分为同步提交和异步提交,对应KafkaConsumercommitSync方法和commitAsync方法。
同步提交使用起来很简单,例如:

List<ConsumerRecord<String, String>> cache = new ArrayList<>();
while(Thread.currentThread().isInterrupted()) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(3));
	records.forEach(cache::add);
	if(cache.size() > 100) {
		//Do something...
		consumer.commitSync();
		cache.clear();
	}
}

这里批量拉取消息并存入本地List缓存,缓存容量达到100后再对消息进行批量执行,然后再提交消费位移。如果在业务处理完成之后,同步位移前程序宕机就会发生重复消费问题。
这里的commitSync方法会根据poll方法拉取的最新位移进行提交。如果没有发生不可恢复的错误,就会阻塞当前线程直到位移提交完成。不可恢复的异常需要手动捕获并进行处理。

如果需要进行更细粒度的提交,那么需要使用到另外一个重载的commitSync方法:

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

offsets参数可用来指定分区的位移,例如:

while(Thread.currentThread().isInterrupted()) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(3));
	records.forEach(e -> {
		TopicPartition tp = new TopicPartition(e.topic(), e.partition());
		consumer.commitSync(Collections.singletonMap(tp, new OffsetAndMetadata(e.offset() + 1)));
	});
}

这里仅仅只是演示使用方法,生产环境中不要遍历一个消息就同步一次位移。

对于异步提交方式,在执行该方法时不会阻塞消费者线程,可在提交消费位移后还未收到响应后就可以执行一次消息拉取操作。异步提交可以提高消费者消息吞吐量。commitAsync方法具有三个不同的重载方法:

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

相比commitSync就是多了一个OffsetCommitCallback选项,在位移提交完成后会执行这个回调。
commitAsync同样也会有提交失败情况产生。异步提交有一个情景需要考虑,在某次提交的消费位移为 x x ,但是提交失败了,下一次异步提交的消费位移为 x + y x+y ,这次成功了,考虑到重试机制,如果前者进行重试并且这次提交成功了,那么此时消费位移又变成了 x x 。如果此时发生了宕机,那么重启之后消费者又从 x x 开始消费消息,这样就产生了重复消费的问题了。为此我们可以增加一种机制:如果重试时发现更大的位移已经提交了,那么就不应当进行重试操作了。一般情况下,位移提交失败的操作很少发生,不重试的话问题也不大,后面的提交操作应该也会提交成功。如果消费者异常退出,那么重复消费的问题也较难得到避免,我们可以使用try...finally...机制并使用同步提交来尽可能避免重复消费问题:

try {
	while(Thread.currentThread().isInterrupted()) {
		//poll message
		consumer.commitAsync();
	}
} finally {
	try {
		consumer.commitSync();
	} finally {
		consumer.close();
	}
}

消费者拦截器

和生产者拦截器类似,消费者也有拦截器ConsumerInterceptor

public interface ConsumerInterceptor<K, V> extends Configurable, AutoCloseable {
	public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
	public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
	public void close();
}

KafkaConsumer会在poll方法返回消息之前调用拦截器的onConsume方法,并传入本次poll方法拉取到的消息。在这里可以进行一些定制化操作,比如过滤一些不合法的消息等,写一些日志等。

onCommit方法则是在KafkaConsumer提交消费位移时被触发,并传入消费位移的具体细节。可以使用该方法来跟踪位移提交记录。
close方法和ProducerInterceptor作用一样。

消费控制

使用KafkaConsumerpausedresume方法可以实现暂停某些分区在拉取操作时返回给客户端和恢复指定分区向客户端返回数据的操作:

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

同时也可以调用paused方法来获取被暂停的分区集合:

public Set<TopicPartition> paused();

消费起始位置的控制

Kafka服务器通过持久化消费位移的方式,来让消费者客户端在关闭、崩溃或者遇到再均衡的时候,能让接替的消费者根据存储的消费位移继续进行消费。

如果一个消费者组建立的时候,如果因为没有可供查找的消费惟一或者__consumer_offsets主题中有关这个消费者组的位移信息过期而被删除,就会根据客户端参数auto.offset.reset配置来决定从何处开始消费,该参数取值可以为:

  • latest(默认值),表示从分区尾端开始消费
  • earlist:从分区起始处开始消费
  • none:抛出NoOffsetForPartitionException异常

需要注意的是,位移越界也会触发参数auto.offset.reset的执行。

如果需要更细粒度地掌控消费的起始位置,可以通过调用seek方法:

public void seek(TopicPartition partition, long offset) 

partition存储了主题信息和分区信息,offset参数指定从分区的哪个位置开始消费。seek方法只能重置消费者分配的分区消费位置,而分区的分配是在poll方法中实现的。也就是说在执行seek方法前需要执行一次poll方法,并且poll方法的阻塞时间不能过短,否则可能会因为分区分配的逻辑来不及得到执行就返回。

在调用seek方法时,如果需要获知分区消息的起始位置的offset(一个分区的起始位置为0,但是Kafka会清理旧的数据,所以起始位置也会增加),那么可以调用KafkaConsumerbeginningOffsets方法:

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

参数partitions表示分区集合,timeout参数用于控制阻塞时间。如果没有指定timeout参数,则阻塞时间由参数request.timeout.ms来决定,该参数默认为30000。

我们还可以获取一个分区末尾消息位置,通过方法endOffsets来获取:

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

再均衡

再均衡是指分区所属权从一个消费者转移到另外一个消费者的行为,它为消费者组的高可用性和伸缩性提供了保障。再均衡机制可以让我们既方便又安全地删除消费者组内的消费者或是添加消费者。

在再均衡执行期间,消费者组内的消费者是无法拉取消息的。此外,当一个分区被分配到另外一个消费者时,消费者当前的状态也会发生丢失。例如一个消费者在消费一批消息后还没有提交位移就发生了再均衡操作,之后该分区被分配到的新的消费者会把那批消息再进行一次消费,也就是发生了重复消费问题。

再均衡监听器ConsumerRebalanceListener可以在再均衡动作前后做一些准备和收尾工作:

public interface ConsumerRebalanceListener {
	/**
	 * 该方法会在再均衡开始之前和消费停止拉取消息之后调用。可以通过该回调方法来处理消费位移的提交,
	 * 以避免一些不必要的重复消息发生
	 * @param partitions 再均衡前所订阅的主题分区信息
	 */
	void onPartitionsRevoked(Collection<TopicPartition> partitions);

	/**
	 * 重新分配分区之后和消费者开始拉取消息之前调用
	 * @param partitions 再均衡后所分配的主题分区信息
	 */
	void onPartitionsAssigned(Collection<TopicPartition> partitions);
}

我们可以在onPartitionsRevoked方法内同步提交位移,以避免不必要的重复消费。

其它重要的消费者参数

讨论完上述概念后,我们再来看看还有哪些重要的消费者参数。

  • fetch.min.bytes: Consumer 在一次拉取请求中能从 Kafka 中拉取的最小数据量,默认为1字节。如果调用poll方法返回给Consumer的数据量小于这个参数,那么会进行等待直到数据量超出该参数值。对于时间敏感性型消息不建议将该参数设置得过大。
  • fetch.max.bytes:Consumer 在一次拉取请求中能从 Kafka 中拉取的最大数据量,默认为52428800字节,也就是50MB。该参数并不是绝对的,如果第一条消息大于该值,那么仍然可以被消费,只不过每次只能消费一条。
  • fetch.max.wait.ms:该参数用来指定等待时间,默认为500。即使本次拉取的数据量小于fetch.min.bytes,只要超出该时间就会返回。
  • max.poll.records:一次拉取请求中的最大消息数,默认为500条。
  • connections.max.idle.ms:多久之后关闭空闲的Kafka连接,默认540000,9分钟。
  • receive.buffer.bytes:Socket接收缓冲区(SO_RECBUF)大小,默认为65536字节,设置为-1则采用操作系统默认值。
  • send.buffer.bytes:Socket发送缓冲区(SO_SNDBUF)大小,默认131072字节,设置为-1则采用操作系统默认值。
  • request.timeout.ms:消费者等待请求响应的最长时间,默认为30000。
  • metadata.max.age.ms:配置元数据过期时间,默认为300000ms,5分钟。
  • reconnect.backoff.ms:配置尝试重新连接指定主机之前的等待时间,避免频繁地连接主机,默认为50ms。
  • isolation.level:消费者事务隔离级别。有效值为read_uncommittedread_commited
发布了117 篇原创文章 · 获赞 96 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/abc123lzf/article/details/100597068