kafka教程1:Consumer,消费者

该文章主要翻译自java类KafkaProducer的Doc文档,基于Kafka2.3版本,Kafka客户端2.3.0版本

1、官方Doc链接如下

https://kafka.apache.org/23/javadoc/index.html?org/apache/kafka/clients/consumer/KafkaConsumer.html


2、Kafka的Maven依赖如下

<dependency>
    <groupId>org.apache.kafkagroupId>
    <artifactId>kafka-clientsartifactId>
    <version>2.3.0version>
dependency>


3、基于官方文档整理的内容

KafkaConsumer用来从kafka集群消费消息。KafkaConsumer会自动处理所链接节点的失效,自动适应主题(topic)分区在kafka集群内的转移,自动利用 消费者分组(consumer groups) 来实现消息消费的负载均衡。
KafkaConsumer与kafka集群的节点之间维持一个TCP链接,不再使用的KafkaConsumer需要关闭,否则这个TCP链接就泄露了。需要注意的是 KafkaConsumer不是线程安全 的。


跨版本兼容

这个版本的KafkaConsumer支持kafka 0.10.0 版本,低与这个版本的kafka不兼容。


偏移量和消费者位置

kafka集群将消息存储在集群中的一个个分区上,这一个个分区中的消息在分区中都有一个唯一的用数字表示的位置,也就是偏移量(Offsets)。消费者就是顺着分区中消息的这个偏移量,一个接着一个的消费消息的。那么如何得知当前消费者已经消费到哪个消息了呢?其实就是根据消息的偏移量来得知的,是消息的偏移量+1,比如当前消费者消费到了偏移量为4的消息,那么消费者位置就是5,就像是java遍历数组的操作,遍历到第4个数组元素的时候,位置是5,这就是消费者位置。
对于消费者来说,有两个位置概念需要理解;

  • 消费者位置:也就是上述的那个位置,表示当前消费者消费到了哪个消息,每次调用 poll() 方法消费消息的时候这个值都会自动往上加,类似上述所说遍历数组的操作。
  • 已提交位置(committed position):这个主要是为了容灾用的,因为消费者和和kafka集群中间的网络连接可能中断,上述文章也说了,消费者会自动重连到kafka集群,但是重连之后消费者如何才能知道上次处理到哪条消息了呢?这个时候 已提交位置 就派上用场了。已提交位置 是保存在kafka集群那里的,不是保存在消费者那里。当消费者连接到kafka集群的时候,kafka集群会自动检测连接到它的消费者是否在它那里有 已提交位置 ,如果有的话,kafka集群就会告诉消费者从哪里继续开始消费消息,否则的话就由消费者自己决定从哪里开始消费消息。读者可能会问,已提交位置 是永久的吗?答案是:不是。在一定时间内,kafka集群如果检测到某个 已提交位置 所对应的消费者已经不再与它有通信了,则这个 已提交位置 就会被kafka集群清理掉。已提交位置 是怎么来的呢?答案是:消费者通过一定的配置自动提交这个 已提交位置 ,或者通过调用 commitSync 或者 commitAsync 方法手动提交。

这些不同的概念就给了消费者一些手段来自主决定哪些消息是已经消费了的,下文会有更详细的介绍。


消费者组和主题订阅

group.id:这是一个KafkaConsumer的配置,拥有同样 group.id 的消费者在kafka集群看来就是属于一个组的。

消费者组中的每个消费者都能通过调用 subscribe() 方法来自主决定订阅哪些主题。kafka集群将把被订阅主题中的某个消息发送到订阅这个主题的某个消费者组中的 一个 消费者,kafka集群通过将主题所涉及的分区均匀的分给订阅这个主题的消费者组中的消费者来实现这个功能。比如:如果一个主题有 4 个分区,一个消费者组有 2 个消费者并且这个消费者组订阅了这个主题,那么这个消费者组中的每个消费者都将被分配到 2 个分区。
消费者组中消费者的关系是动态的,也就是说,如果消费者组中的某个消费者失效了,那么分配给它的那些分区就会被自动重新分配给这个消费者组中的其他消费者,也就是实现了容灾。同样的,如果某个消费者组中新加入了一个消费者,那么就会从这个消费者组中的其他消费者身上取一些分区给这个新加入的消费者,也就是实现了负载均衡。下文有这方面的详细讨论。类似的负载均衡也会在主题增加了新的分区或者一个新主题被创建并且被订阅的时候发生。
另外,当消费者组内的负载均衡发生时,消费者可以通过 ConsumerRebalanceListener 来获取通知。消费者也可以通过调用 assign() 方法手动指定主题分区,若手动指定分区,则消费者组内分区的动态分配和负载均衡将会被禁用。


消费者失效检测
  • session.timeout.ms:消费者在调用 poll() 方法后会自动加入到消费者组中(消费者组和组内消费者信息是保存在kafka集群端的,调用poll()方法后kafka集群会自动创建、保存和维护相应消费者组信息)。poll() 方法会自动维持消费者和kafka集群的链接,也就是保活KafkaConsumer。只要消费者持续调用 poll() 方法,消费者就会一直待在消费者组内,并从kafka集群消费消息。保活的实现原理就是KafkaConsumer会持续向kafka集群发送 心跳(heartbeats) 。如果消费者失效了,不能在一定时间内向kafka集群发送心跳,kafka集群就会认为这个客户端掉线,并把分配给这个客户端的分区自动负载均衡给消费者组内的其他消费者。这里,这个发送心跳的时间间隔就是这个配置的值。
  • max.poll.interval.ms:某些情况下,消费者也会出现 活锁(livelock) 的情况,活锁的意思就是消费者正常的向kafka集群发送心跳来保活,但就是不调用 poll() 方法消费消息,也就是俗话说的 zhanzhemaokengbulashi,这种情况下消费者白白占着kafka集群的分区和资源,但是不进行实际的消息消费操作,为了检测这种情况,可以配置 max.poll.interval.ms 参数,这个参数控制消费者调用 poll() 方法的最大时间间隔,如果消费者在这个时间内没有调用 poll() 方法消费消息,则消费者会 主动 离开消费者组,把占着的分区让给其他在消费的消费者。如果发生了这种情况,主动失效的那个消费者在调用 commitSync() 后会报一个 CommitFailedException 的异常。这是官方KafkaConsumer的一种安全机制,只有活着的消费者才能提交 已提交位置 并从kafka集群消费消息,所以为了一直待在消费者组里,消费者就必须持续调用 poll() 消费消息。
    KafkaConsumer调用 poll() 方法的行为可以被两个配置控制:
  1. max.poll.interval.ms:该配置含义如上述解释,默认值:300000。个人不建议更改这个配置,保持默认就好。这个参数对 poll() 方法的具体影响就是:增大该配置,可以给消费者更多时间来处理 poll() 方法返回的消息,但是可能会延迟kafka集群可能发生的自动分区负载均衡,因为消费者只有在调用 poll() 方法后才会参与分区负载均衡,同时会允许消费者更少调用 poll() 方法,从而导致kafka集群内存储的消息无法被及时处理。减小该配置,可能会导致消费者因为无法及时处理上一个 poll() 方法返回的消息而无法在这个时间内调用下一个 poll() 方法,然后如上述解释,主动退出消费者组,从而导致一系列其他问题。所以该配置保持默认就好。
  2. max.poll.records:该配置决定每次调用 poll() 方法最多返回的消息的个数,默认值:500。增大该配置可能会导致消费者无法及时处理消息,减小该配置可能会导致kafka集群端存储的消息无法被及时处理。该参数保持默认就好。

官方Doc代码示例
  1. 自动 已提交位置
     Properties props = new Properties();
     props.setProperty("bootstrap.servers", "localhost:9092");
     props.setProperty("group.id", "test");
     props.setProperty("enable.auto.commit", "true");
     props.setProperty("auto.commit.interval.ms", "1000");
     props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
     consumer.subscribe(Arrays.asList("foo", "bar"));
     while (true) {
         ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
         for (ConsumerRecord<String, String> record : records)
             System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
     }

上述代码中的一些配置前文已经解释过,有些前文没讲到的配置解释如下:

  • bootstrap.servers:一个或多个kafka集群节点地址和端口,用 “,” 隔开(英文逗号,csdn的这个markdown编辑器真的无力吐槽了,要么把正常语句自动识别成链接,要么显示的中英文逗号肉眼无法区分)。消费者通过这个配置连接到kafka集群。
  • enable.auto.commit,auto.commit.interval.ms:这两个配置搭配使用,enable.auto.commit 控制是否自动 已提交位置 ,默认值:true。auto.commit.interval.ms 控制自动提交的时间间隔。
  • key.serializer,value.serializer:这俩配置不用改,就按照上述实例代码那样写就行了,作用是控制消息对象 ProducerRecord 如何转成 byte 来发送。Kafka客户端包中自带的有俩现成能用的,分别是 ByteArraySerializerStringSerializer ,其中 StringSerializer 就是上述示例代码中的那些配置。
  1. 手动 已提交位置
    除了KafkaConsumer提供的自动 已提交位置 外,消费者还可以手动 已提交位置
     Properties props = new Properties();
     props.setProperty("bootstrap.servers", "localhost:9092");
     props.setProperty("group.id", "test");
     props.setProperty("enable.auto.commit", "false");
     props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
     KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
     consumer.subscribe(Arrays.asList("foo", "bar"));
     final int minBatchSize = 200;
     List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
     while (true) {
         ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
         for (ConsumerRecord<String, String> record : records) {
             buffer.add(record);
         }
         if (buffer.size() >= minBatchSize) {
             insertIntoDb(buffer);
             consumer.commitSync();
             buffer.clear();
         }
     }

自动 已提交位置 的意思是只要消息从 poll() 返回了,则KafkaConsumer就认为这些消息消费者已消费。如上示例代码,如果在实际消费过程中,比如向数据库插入数据时失败了,在自动 已提交位置 的情况下,这些消息就丢了。但是在手动 已提交位置 的情况下,消费者可以自己决定哪些消息是已消费的,哪些是消费失败的,如上代码,这些消费失败的消息就能重新被消费者消费,从而避免消息丢失。手动 已提交位置 也有弊端,在消费者消费完消息后,调用 commitSync() 的时候如果失败了,就会导致下一次调用 poll() 方法取到的是旧的消息,在上述代码的情况下,发生的情况就是会向数据库中插入重复数据。具体怎么取舍,需要消费者自己决定。
上述代码示例调用 commitSync 方法手动 已提交位置 ,某些情况下消费者可能希望对这一步有更进一步的控制,如下官方Doc代码示例,可以表明手动 已提交位置 具体到哪个位置:

     try {
         while(running) {
             ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
             for (TopicPartition partition : records.partitions()) {
                 List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
                 for (ConsumerRecord<String, String> record : partitionRecords) {
                     System.out.println(record.offset() + ": " + record.value());
                 }
                 long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                 consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
             }
         }
     } finally {
       consumer.close();
     }

从上述代码可以看出, 已提交位置 对应的应该是下一次调用 poll() 方法应该取得消息的位置,一般就是最新处理成功的那个消息的偏移量+1。

  1. 手动分配主题分区
    在前面的代码示例中,消费者订阅主题,kafka集群将主题相关的分区自动分配给消费者组中的消费者。但某些情况下消费者不需要kafka集群自动来分配分区,而是希望手动分配分区,这时,消费者应该调用 assign(Collection) 方法,而不是 subscribe 方法,官方Doc示例代码如下:
     String topic = "foo";
     TopicPartition partition0 = new TopicPartition(topic, 0);
     TopicPartition partition1 = new TopicPartition(topic, 1);
     consumer.assign(Arrays.asList(partition0, partition1));

一旦手动分配主题分区成功,消费者就可以调用 poll() 方法消费消息了。手动主题分区会禁用掉消费者组内消费者的自动负载均衡功能,这时如果某个消费者失效了,这个消费者所分配的主题分区不会自动重新分配给消费者组内的其他消费者,也就是说这个分区的消息就会没有消费者来消费,除非那个失效的消费者又重新恢复了消费功能。手动分区也可能导致 已提交位置 冲突,从而导致消息的丢失或者重复消费,所以 建议给手动分配主题分区的每个消费者都配置不同的消费者组id(group.id)

Note that it isn’t possible to mix manual partition assignment (i.e. using assign) with dynamic partition assignment through topic subscription (i.e. using subscribe).
解释:注意,不能混合使用自动主题分区和手动主题分区,即对一个消费者实例,不能即调用 assign 方法,又调用 subscribe 方法。

  1. 在kafka集群以外存储消费者位置( 精确只消费一次(exactly once) 语义的实现)
    消费者不一定非要使用kafka内建的消费者位置存储功能,消费者可以按照自己的需求将消费者位置存在任何地方。比如,消费者可以将消费者位置和消息存在支持事务功能的数据库中,这样的话消息的消费和消费者位置的存储可以放在一个事务里面进行操作,也就是原子操作,这也就变相实现了 精确只消费一次(exactly once) 的语义(类似KafkaConsumer的精确只发送一次的语义)。为了实现这种操作,需要做一下配置和准备:
  • 配置 enable.auto.commit=false ,即取消自动 已提交位置功能。
  • 存储 poll() 方法返回的消息的位置,即 ConsumerRecord 提供的消息的位置。
  • 在消费者重启的时候调用 seek(TopicPartition, long) 方法重置消费者位置。这样消费者就能紧接着从上次消费成功的消息后继续消费消息。
    精确只消费一次的语义在 手动分配主题分区 的时候是很容易实现的,因为不用考虑主题分区的自动负载均衡问题。如果想在kafka集群自动分配主题分区的情况下实现这个语义,需要做一些额外处理。通过在消费者调用 **subscribe(Collection, ConsumerRebalanceListener) ** 和 subscribe(Pattern, ConsumerRebalanceListener) 方法的时候设置 ConsumerRebalanceListener ,消费者可以在主题分区变动(比如从一个消费者转移到了另一消费者)的时候通过实现 ConsumerRebalanceListener 接口的 onPartitionsRevoked(Collection) 方法来向数据库保存最后成功消费的消息和消费者位置。同理,在消费者被分配到了新的分区的时候,可以通过实现 ConsumerRebalanceListener 接口的 onPartitionsAssigned(Collection) 方法来重新提交消费者位置,即从数据库取出变动的分区的消费者位置,然后通过调用 commitSync 方法来重新设置自己的消费者位置。

注意: 在kafka集群自动分配主题分区的情况下,上述消费者 精确只消费一次 语义的实现是不保险的,因为你不知道 onPartitionsRevoked 回调和 onPartitionsAssigned 哪个会先执行,如果 onPartitionsAssigned 先执行的话,就出错了,所以还是推荐在手动分配主题分区的情况下实现消费者 精确只消费一次 的语义,简单又好用,还不怕出错。当然如果有办法解决上述两个回调调用顺序的问题,肯定是kafka集群自动分配主题分区的情况下容灾更好。

  1. 控制消费者位置
    一般情况下,消费者是从头到尾顺序消费消息的。某些情况下消费者也可能需要从某些特定位置开始消费消息,如跳到消息队列的开头或结尾,或跳到某个特定位置开始消费消息。官方KafkaConsumer提供了几个方法来实现这些功能,如下:
  • seek(TopicPartition, long):跳到主题分区某个特定位置。
  • seekToBeginning(Collection):跳到开头。
  • seekToEnd(Collection):跳到结尾。
  1. 消费者消费流程控制
    kafka支持暂停和继续消费某些主题某些分区的消息。提供的方法如下:
  • pause(Collection):暂停处理某分区消息。
  • resume(Collection):继续处理某分区消息。
  1. 读事务内消息
  • isolation.level:这部分跟KafkaProducer的事务模式是搭配使用的,kafka从0.11.0开始引入了事务的功能。为了让KafkaProducer的事务功能正常运行,KafkaConsumer相应的需要配置隔离级别为 isolation.level=read_committed
  1. 多线程处理
    前文已经说过,KafkaConsumer不是线程安全 的。多线程同时访问一个KafkaConsumer实例将导致 ConcurrentModificationException 异常。wakeup() 方法例外,可以在另一个线程里调用该方法来关闭KafkaConsumer实例,官方Doc示例代码如下:
 public class KafkaConsumerRunner implements Runnable {
     private final AtomicBoolean closed = new AtomicBoolean(false);
     private final KafkaConsumer consumer;

     public KafkaConsumerRunner(KafkaConsumer consumer) {
       this.consumer = consumer;
     }

     public void run() {
         try {
             consumer.subscribe(Arrays.asList("topic"));
             while (!closed.get()) {
                 ConsumerRecords records = consumer.poll(Duration.ofMillis(10000));
                 // Handle new records
             }
         } catch (WakeupException e) {
             // Ignore exception if closing
             if (!closed.get()) throw e;
         } finally {
             consumer.close();
         }
     }

     // Shutdown hook which can be called from a separate thread
     public void shutdown() {
         closed.set(true);
         consumer.wakeup();
     }
 }

在另一个线程里,可以这样关闭KafkaConsumer实例:

     closed.set(true);
     consumer.wakeup();

注意: 官方文档推荐使用 wakeup() 方法来打断KafkaConsumer的执行,而不是通过 interrupt() 方法。原文如下:Note that while it is possible to use thread interrupts instead of wakeup() to abort a blocking operation (in which case, InterruptException will be raised), we discourage their use since they may cause a clean shutdown of the consumer to be aborted. Interrupts are mainly supported for those cases where using wakeup() is impossible, e.g. when a consumer thread is managed by code that is unaware of the Kafka client.

官方文档还说:

We have intentionally avoided implementing a particular threading model for processing. This leaves several options for implementing multi-threaded processing of records.

  1. One Consumer Per Thread
    A simple option is to give each thread its own consumer instance. Here are the pros and cons of this approach:
    PRO: It is the easiest to implement
    PRO: It is often the fastest as no inter-thread co-ordination is needed
    PRO: It makes in-order processing on a per-partition basis very easy to implement (each thread just processes messages in the order it receives them).
    CON: More consumers means more TCP connections to the cluster (one per thread). In general Kafka handles connections very efficiently so this is generally a small cost.
    CON: Multiple consumers means more requests being sent to the server and slightly less batching of data which can cause some drop in I/O throughput.
    CON: The number of total threads across all processes will be limited by the total number of partitions.
  2. Decouple Consumption and Processing
    Another alternative is to have one or more consumer threads that do all data consumption and hands off ConsumerRecords instances to a blocking queue consumed by a pool of processor threads that actually handle the record processing. This option likewise has pros and cons:
    PRO: This option allows independently scaling the number of consumers and processors. This makes it possible to have a single consumer that feeds many processor threads, avoiding any limitation on partitions.
    CON: Guaranteeing order across the processors requires particular care as the threads will execute independently an earlier chunk of data may actually be processed after a later chunk of data just due to the luck of thread execution timing. For processing that has no ordering requirements this is not a problem.
    CON: Manually committing the position becomes harder as it requires that all threads co-ordinate to ensure that processing is complete for that partition.
    There are many possible variations on this approach. For example each processor thread can have its own queue, and the consumer threads can hash into these queues using the TopicPartition to ensure in-order consumption and simplify commit.

原创不易,转帖请注明出处 — ShiZhongqi


你可能感兴趣的:(kafka教程1:Consumer,消费者)