初识Kafka之消费者

241 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第28天,点击查看活动详情

消费者

Consumer,消费消息。

消费的时候使用需要记录消费者位移(Consumer Offset)定时同步给Broker,记录消费进度。

消费组

Consumer Group是Kafka提供的可扩展且具有容错性的消费者机制。共享一个公共的Group ID,组内的所有消费者一起消费订阅主题的所有分区。当然每个分区只能由同一个消费者组内的一个Consumer实例来消费。不同的消费组可以消费同一个主题的分区。

Rebalance

本质上是一种协议,规定了一个Consumer Group下的所有Consumer如何达成一致,来分配订阅Topic的每个分区。

触发条件

  1. 组成员数发生变更。有新的Consumer实例“加入”或者“离开”。
  2. 订阅主题数发生变更。Consumer Group可以使用正则表达式的方式订阅主题,如果增加匹配的主题就会触发。
  3. 订阅主题的分区数发生变更。

缺点

  1. Rebalance过程中所有Consumer实例都会停止消费,等待Rebalance完成,会导致消息堆积。
  2. 连接的分区发生变化会重新创建连接不能复用。
  3. offset还没同步到broker就发生了,会导致重复消费。

前面两个点是可以避免的,kafka 0.11.0.0版本推出了StickyAssignor,尽量减少对现有分配带来变化。

如何避免

主要是避免异常情况导致的Rebalance,正常因为需要增加Consumer实例、增加主题、主题分区变更不需要考虑。异常情况主要是Consumer实例"离开",有时候不是真正的离开,可能是因为网络原因、压力突增等。而避免这些异常情况,主要是通过调参:

  • heartbeat.interval.ms:心跳间隔,太长不能及时发现“离开”的Consumer,太短可以及时的发现并将REBALANCE_NEEDED 标志封装进心跳请求的响应体中进行Rebalance,但是会额外消耗带宽资源,推荐设置成2s。
  • session.timeout.ms:Consumer心跳超时时间,推荐设置成6s,>= 3 * heartbeat.interval.ms。
  • max.poll.interval.ms:拉取消息的时间间隔,超过这个时间还没拉取就认为Consumer离开了,但是有时候因为压力突增、第三方异常、db异常、GC频繁等原因也会导致消费变慢,所以可以根据具体项目情况设置的大点。

提交位移

可以通过enable.auto.commit参数控制自动提交还是手动提交。默认为true,Consumer在后台定期提交位移,可以使用auto.commit.interval.ms(默认为5s)来控制。

因为位移提交非常灵活,你完全可以提交任何位移值,利用这点你可以重复消费想要消费的消息。

自动提交

Properties props = new Properties();
     props.put("bootstrap.servers", "localhost:9092");
     props.put("group.id", "test");
     props.put("enable.auto.commit", "true");
     props.put("auto.commit.interval.ms", "2000");
     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.subscribe(Arrays.asList("foo", "bar"));
     while (true) {
         ConsumerRecords<String, String> records = consumer.poll(100);
         for (ConsumerRecord<String, String> record : records)
             System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
     }

Kafka会保证在开始调用poll方法时,提交上次poll返回的所有消息。

缺点

容易出现重复消费,在自动提交前进行了Rebalance。

优点

省事

手动提交

while (true) {
            ConsumerRecords<String, String> records =
                        consumer.poll(Duration.ofSeconds(1));
            process(records); // 处理消息
            try {
                        consumer.commitSync();
            } catch (CommitFailedException e) {
                        handle(e); // 处理提交失败异常
            }
}

KafkaConsumer#commitSync(),该方法会提交KafkaConsumer#poll()返回的最新位移,该方法会一直等待,直到位移被成功提交才会返回。如果在消费完成前就调用可能会导致消息丢失的情况。

KafkaConsumer#commitSync()会阻塞影响TPS,可以使用KafkaConsumer#commitAsync():

while (true) {
            ConsumerRecords<String, String> records = 
  consumer.poll(Duration.ofSeconds(1));
            process(records); // 处理消息
            consumer.commitAsync((offsets, exception) -> {
  if (exception != null)
  handle(exception);
  });
}

KafkaConsumer#commitAsync()因为是异步的,所以不好进行重试,会导致提交旧的位移。

可以结合使用:

   try {
           while(true) {
                        ConsumerRecords<String, String> records = 
                                    consumer.poll(Duration.ofSeconds(1));
                        process(records); // 处理消息
                        commitAysnc(); // 使用异步提交规避阻塞
            }
} catch(Exception e) {
            handle(e); // 处理异常
} finally {
            try {
                        consumer.commitSync(); // 最后一次提交使用同步阻塞式提交
  } finally {
       consumer.close();
}
}

上面都是在poll了消费完成后再commit的,可以阶段提交:


private Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
int count = 0;
……
while (true) {
            ConsumerRecords<String, String> records = 
  consumer.poll(Duration.ofSeconds(1));
            for (ConsumerRecord<String, String> record: records) {
                        process(record);  // 处理消息
                        offsets.put(new TopicPartition(record.topic(), record.partition()),
                                   new OffsetAndMetadata(record.offset() + 1);
                       if(count % 100 == 0)
                                    consumer.commitAsync(offsets, null); // 回调处理逻辑是null
                        count++;
  }
}

缺点

  • 需要手动控制,不够方便且容易出错。
  • 使用KafkaConsumer#commitSync()会阻塞,影响TPS。

优点

  • 自主控制,重复消费粒度可控。

建议

自动和手动的区别在于手动可以控制重复消费粒度,但是手动消费编程不够友好容易出错,至于重复消费业务代码层面本应保证幂等性。所以建议还是使用幂等性,可以把auto.commit.interval.ms由默认的5s改为3s防止业务代码没有保证幂等性,减少带来的影响。

CommitFailedException异常怎么处理?

CommitFailedException,Consumer客户端在提交(通常是手动提交)位移时出现了错误或者异常,而且还是那种不可恢复的严重异常。

Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. This means that the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time message processing. You can address this either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll.records.

失败原因是消费组已经开启了Rebalance过程,并且将要提交位移的分区分配给了另一个消费者实例。出现的原因是消费者实例连续两次调用poll方法的时间间隔超过了期望的max.poll.interval.ms参数值。这通常表明,你的消费者实例花费了太长的时间进行消费处理,并给出了两个相应的解决方法:

  1. 增加max.poll.interval.ms参数值。
  2. 减少max.poll.records参数值。

除了上面两个官方提供的两个调参方法,还可以通过两外两个方法:

  1. 缩短消费处理时间。
  2. 多线程加速消费,需要处理消费位移提交问题。

多线程消费的实现方案

Kafka 0.10.1.0开始,Kafka就变成了双线程的设计,即用户主线程和心跳线程。当你启动Consumer应用程序main方法的那个线程,而新引入的心跳线程(Heartbeat Thread)只负责定期给对应的Broker机器发送心跳请求,以标识消费者应用的存活性(liveness)。

多线程方案

KafkaConsumer类不是线程安全的。所有的网络I/O处理都是发生在用户主线程中,因此,你在使用过程中必须确保线程安全。在多个线程中共享KafkaConsumer实例会抛出ConcurrentModificationException异常。

  1. 消费者程序启动多个线程,每个线程维护专属的KafkaConsumer实例,负责完整的消息获取、消息处理:

image.png

  1. 消费者程序使用单或者多线程获取消息,同时创建多个消费线程执行消息处理逻辑:

image.png

优缺点

image.png

如何管理TCP连接

创建

TCP连接是在调用KafkaConsumer.poll方法时被创建的。poll方法内部有三个时机可以创建TCP连接。

  1. 发起FindCoordinator请求时
  2. 连接协调者时
  3. 消费数据时,消费者会为每个要消费的分区创建与该分区领导者副本所在Broker连接TCP。

第一类TCP连接成功创建后,消费者程序就会废弃第一类TCP连接,之后在定期请求元数据时,会使用第三类TCP连接。

关闭

主动关闭

手动调用KafkaConsumer.close()方法或者执行kill命令(Kill -2或者Kill -9)

自动关闭

消费者端参数 connection.max.idle.ms,默认为9分钟,如果没有任何请求就会关闭。