Kafka(三) 消费者Consumer
一个正常的消费逻辑需要具备以下几个步骤:
1. 消息订阅
1.1 subscribe订阅主题
subscribe有如下重载方法:
1.1 前面两种是通过集合的方式订阅一到多个topic
public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener)
public void subscribe(Collection<String> topics)
1.2 后两种主要是采用正则的方式订阅一到多个topic
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener)
public void subscribe(Pattern pattern)
如果消费则采用正则表达式的方式订阅,如果新创建的新的主题并且符合正则表达式,那么该消费者就可以消费到新添加主题中的消息。如果应用程序需要消费多个主题,并且可以处理不同类型的消息,正则表达式匹配的方式就比较适用了。使用方式如consumer.subscribe(Pattern.compile("topic_k_*"));
1.2 assign订阅主题
assign订阅主题,相比较subscribe的订阅在订阅主题的同时还可以指定消费的分区,具体用法如下:
subscribe和assign的区别:
通过subscribe的方式订阅主题具有消费者自动再均衡功能;
在多个消费者的情况下可以根据分区分配策略来自动分配各消费者与分区的关系。当消费组的消费者增加或者减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移。
assign订阅分区时是不具备消费者自动均衡功能的;
其实在subscribe和assign两者方法如参就可以比较出来,subscribe的重载的方法中有ConsumerRebalanceListener,而assign中没有该参数
1.3 取消订阅
consumer取消订阅很简单,调用方法cosumer.unsubscribe()。当然也可以通过将consumer.subscribe(Collection) 或 consumer.assign(Collection)集合参数设置为空集合的方式实现同样的效果。示例如下: consumer.subscribe(new ArrayList<String>()); consumer.assgin(new ArrayList<TopicPartation>());
2. 消息的消费模式
Kafka的消费模式是基于拉取模式的。消息的消费一般有两种模式:推送模式和拉取模式。推送模式是服务器端主动向消费者端推送消息,而拉取模式是消费者主动请求服务器端拉取消息。
2.1 poll()拉取消息
Kafka中消息的消费是一个不断循环的过程,消费者所要做的就是不断的重复调用poll()方法,poll()方法返回的是所订阅的主题(分区)上的一组消息。对于poll()方法而言,如果某些分区中没有可供消费的消息,
那么对应该分区的消息拉取结果就为空。如果订阅的所有分区中都没有消费的消息,那么poll()方法返回为空的消息集。方法示例如下: public ConsumerRecords<K, V> poll(final Duration timeout)
超时时间参数timeout,用来控制poll()方法的阻塞时间,在消费者缓冲区没有可用的消息时会发生阻塞。如果消费者只是单纯的拉取消息并消费数据,则为了提高吞吐率,可以把timeout设置为Long.MAX_VALUE;
2.2 seek()从指定的偏移量拉取消息
有时候我们需要对消息消费做到更细粒度的控制,可以让我们从指定的位移拉取消息,而KafkaConsumer中的seek()方法,可以让我们追前消息和回溯消息。示例如下:
public void seek(TopicPartition partition, long offset)
2.3 ConsumerRebalanceListener再均衡监听器
一般同一个消费组中,一旦有触发消费者的增减变化,都会触发消费组的rebalance再均衡,如果消费者a消费一批消息后还没来得及提交偏移量offset,而它所负责的分区在rebalance中转移给了消费者b,则有可能发生消息的重复消费,那么此时可以通过再均衡器做一定程度的补救。
示例代码如下:
consumer.subscribe(Arrays.asList("brian_t"), new ConsumerRebalanceListener(){ @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) { log.info("<><> Before start consume the message <><>"); } @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) { log.info("<><> After stop consume the message <><>"); } });
2.4 offset位移提交
2.4.1 offset自动位移提交
Kafka的默认位移提交是自动提交的方式,对应的消费者客户端配置为enable.auto.commit配置,默认值为true。但是这个默认提交不是消费一条记录就提交一次,而是定期提交,对应的配置为auto.commit.interval.ms,默认值为5秒,次参数生效的前提是enabe.auto.commit为true。
在默认的方式下, 消费者客户端每隔5秒会拉取到每个分区中最大消费位移进行提交。自动位移提交是在poll()方法中完成的,在每次真正向服务器端发起拉取消息请求会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮训的位移。
Kafka消费者客户端编程逻辑中位移提交是一个大难点,自动位移提交免去了复杂的位移提交逻辑,让编码更简洁,但同时也带来了重复消费和消息丢失的问题。
重复消费
假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次进行自动位移提交之前,消费者崩溃了或者发生再均衡,那么又得从上一次的位移处重新开始消费。
我们可以通过减少自动位移提交的时间间隔来减少重复消息的窗口大小,但这样不能从根本上解决重复消费的问题,而且会使位移提交更加频繁。
消息丢失
比如在如下图场景:拉取线程不断拉取消息并存入本地缓存,比如存入到BlockingQueue中,另外一个线程负责从缓存中读取消息并进行相应的逻辑处理。假设目前已经进行第y+1次拉取和第Z次位移提交,也就是第X+9以前的位移一经提交,但是处理消息的线程还在处理第X+4条消息,此时如果处理消息的线程发生异常,然后恢复正常后,则再次拉取消息会从第Z次提交的位置X+9处开始拉取消息然后处理,此时从X+4到X+9处的消息就被丢失了。
2.4.2 offset手动位移提交
自动位移提交的方式在正常情况下不会发生消息的重复消费和消息的丢失,但是在编程的世界异常是无法避免的。同时,kafka的自动位移提交是无法做到精确的位移管理。很多时候并不是说拉取到消息就算消息消费完成,而是需要将消息写入数据库,写入缓存 或者进行复杂的业务逻辑处理才算完成。手动提交的方式可以让开发人员根据业务逻辑在合适的位置进行位移提交。开启手动提交前,要将消费者客户端参数enable.auto.commit设置为false。
手动提交位移分为同步提交( consumer.commitSync() )和异步提交( consumer.commitAsync() ).
consumer.commitSync()
示例代码如下:
public void getMessageManualCommit() { consumer.subscribe(Arrays.asList("brian_t")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(2)); if (records.isEmpty()) { log.info("===== topic: brian_t not have data! ====="); continue; } records.forEach(r -> { log.info("{ key:{}, value:{}, offset:{} }", r.key(), r.value(), r.offset()); }); // manual commit consumer.commitSync(); log.info("===== manual commit the offset ====="); } }
对于采用consumer.commitSync()无参方式提交,它提交消费位移的频次,拉取消息的频次和处理批次消息的频次是一样的。如果需要精心更加细粒度的提交,就要采用由参的提交方式consumer.commitSync(offsets).示例代码如下:
public void getMessageManualCommitWithOffser() { consumer.subscribe(Arrays.asList("brian_t")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(2)); if (records.isEmpty()) { log.info("===== topic: brian_t not have data! ====="); continue; } records.forEach(r -> { log.info("{ key:{}, value:{}, offset:{} }", r.key(), r.value(), r.offset()); TopicPartition tp = new TopicPartition(r.topic(), r.partition()); // commit with offset consumer.commitSync(Collections.singletonMap(tp, new OffsetAndMetadata(r.offset()+1))); log.info("===== manual commit the offset ====="); }); } }
consumer.commitAsync()
代码示例如下:
public void getMessageManualAsyncCommit() { consumer.subscribe(Arrays.asList("brian_t")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(2)); if (records.isEmpty()) { log.info("===== topic: brian_t not have data! ====="); continue; } records.forEach(r -> { log.info("{ key:{}, value:{}, offset:{} }", r.key(), r.value(), r.offset()); TopicPartition tp = new TopicPartition(r.topic(), r.partition()); // commit with offset consumer.commitAsync(Collections.singletonMap(tp, new OffsetAndMetadata(r.offset()+1)), new OffsetCommitCallback() { @Override public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) { if(exception == null){ log.info("===== manual commit the offset success =====: {}", offsets.size()); } else { log.info("===== manual commit error =====: {}", exception.getMessage()); } } }); }); } }
3.消费者客户端其他重要参数
fetch.min.bytes=1B #一次拉取最小字节数
fetch.max.bytes=50M #一次拉取最大字节数
fetch.max.wat.ms=500ms #拉取时最大等待时长
max.partation.fetch.bytes=1MB #每个分区一次拉取最大字节数
max.poll.records=500 #一次拉取的最大条数
connection.max.idle.ms=540000ms #网络连接的最大闲置时长
request.timeout.ms=30000ms #一次请求等待响应的最大超时时间
metadata.max.age.ms=300000 #元数据在限定的时间内没有更新,则会被强制更新
reconnect.backoff.ms=50ms #尝试重试连接指定主机之前的间隔时间
retry.backoff.ms=100ms #尝试重新拉取数据之前的时间间隔
isolation.level=read_uncommitted #隔离级别 决定消费者能读到什么样的数据
read_uncommitted: 可以消费到LSO(LastStableOffset)位置
read_committed: 可以消费到 HW(High Watermark)位置
max.poll.interval.ms=60000ms #超时限没有发起poll,消费额组认为该消费者离开消费组
enable.auto.commit=true #开启自动位移提交
auto.commit.interval.ms=5000 #自动提交位移的时间间隔