【3】消费者

189 阅读14分钟

概述

KafkaConsumer客户端的开发,包括参数配置的讲解、订阅、反序列化、位移提交、再均衡、消费者拦截器、多线程的使用。

开发逻辑流程

image.png

消费者实现

配置参数初始化

在创建真正的消费者实例之前需要做相应的参数配置,例如设置消费者所属的消费组的名称、连接地址等

  • bootstrap.servers:用来指定连接Kafka集群所需的broker地址清单
  • group.id:消费者隶属的消费组的名称
  • key.deserializer 和 value.deserializer:指定消息中key和value所需反序列化操作的反序列化器 代码示例
public static Properties initConfig(){
    Properties props = new Properties();
    // 配置连接集群地址
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    // 配置生产消息topic序列号类型
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 配置生产消息序列号类型
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 消费组
    props.put(ConsumerConfig.GROUP_ID_CONFIG, "group.demo");
    // 客户端id
    props.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
    return props;
}

备注:消费者客户端所有的配置参数如下:

属性描述默认值
fetch.min.bytes该参数用来配置Consumer在一次拉取请求(调用poll()方法)中能从Kafka中拉取的最小数据量,默认值为1(B)。Kafka在收到Consumer的拉取请求时,如果返回给Consumer的数据量小于这个参数所配置的值,那么它就需要进行等待,直到数据量满足这个参数的配置大小。可以适当调大这个参数的值以提高一定的吞吐量,不过也会造成额外的延迟(latency),对于延迟敏感的应用可能就不可取了。1B
fetch.max.bytes它用来配置Consumer在一次拉取请求中从Kafka中拉取的最大数据量52428800B(54MB)
fetch.max.wait.ms这个参数也和fetch.min.bytes参数有关,如果Kafka仅仅参考fetch.min.bytes参数的要求,那么有可能会一直阻塞等待而无法发送响应给 Consumer,显然这是不合理的。fetch.max.wait.ms参数用于指定Kafka的等待时间,默认值为500(ms)。如果Kafka中没有足够多的消息而满足不了fetch.min.bytes参数的要求,那么最终会等待500ms。这个参数的设定和Consumer与Kafka之间的延迟也有关系,如果业务应用对延迟敏感,那么可以适当调小这个参数。500(ms)
max.partition.fetch.bytes这个参数用来配置从每个分区里返回给Consumer的最大数据量,默认值为1048576(B),即1MB。这个参数与 fetch.max.bytes 参数相似,只不过前者用来限制一次拉取中每个分区的消息大小,而后者用来限制一次拉取中整体消息的大小。1048576B(1MB)
max.poll.records这个参数用来配置Consumer在一次拉取请求中拉取的最大消息数,默认值为500(条)。如果消息的大小都比较小,则可以适当调大这个参数值来提升一定的消费速度。500(条)
connections.max.idle.ms这个参数用来指定在多久之后关闭限制的连接540000(ms),即9分钟
exclude.internal.topicsKafka中有两个内部的主题:__consumer_offsets和__transaction_state。exclude.internal.topics用来指定Kafka中的内部主题是否可以向消费者公开,默认值为true。如果设置为true,那么只能使用subscribe(Collection)的方式而不能使用subscribe(Pattern)的方式来订阅内部主题,设置为false则没有这个限制。true
receive.buffer.bytes这个参数用来设置Socket接收消息缓冲区(SO_RECBUF)的大小,默认值为65536(B),即64KB。如果设置为-1,则使用操作系统的默认值。如果Consumer与Kafka处于不同的机房,则可以适当调大这个参数值131072(B),即128KB
request.timeout.ms这个参数用来配置Consumer等待请求响应的最长时间30000(ms)
metadata.max.age.ms这个参数用来配置元数据的过期时间,默认值为300000(ms),即5分钟。如果元数据在此参数所限定的时间范围内没有进行更新,则会被强制更新,即使没有任何分区变化或有新的broker加入。300000(ms),即5分钟
reconnect.backoff.ms这个参数用来配置尝试重新连接指定主机之前的等待时间(也称为退避时间),避免频繁地连接主机,默认值为50(ms)。这种机制适用于消费者向broker发送的所有请求。50(ms)
retry.backoff.ms这个参数用来配置尝试重新发送失败的请求到指定的主题分区之前的等待(退避)时间,避免在某些故障情况下频繁地重复发送。100(ms)
isolation.level这个参数用来配置消费者的事务隔离级别。字符串类型,有效值为“read_uncommitted”和“read_committed”,表示消费者所消费到的位置,如果设置为“read_committed”,那么消费者就会忽略事务未提交的消息,即只能消费到 LSO(LastStableOffset)的位置,默认情况下为“read_uncommitted”,即可以消费到HW(High Watermark)处的位置 read_uncommitted
bootstrap.servers指定kafka集群所需的broker地址清单
key.deserializer消息中key对应的反序列化类,需要实现 org.apache.kafka.common.serialization.Deserializer接口
value.deserializer消息中value对应的反序列化类,需要实现 org.apache.kafka.common.serialization.Deserializer接口
gourp.id消费者所属的消费组唯一标识
clinet.id消费这客户端id
heartbcat.interval.id当使用kafka的分组管理功能时,心跳到消费者协调器之间的预计时间,心跳用于保证消费者的会话保持活动状态,当有新消费者加入或离开组时方便重新平衡。该值必须比session.timeout.ms小,通常小于1/3,它可以调整得更低,以控制正常重新平衡的预期时间3000
Session.timeout.ms组管理协议中用来检测消费者是否失效的超时时间1000
Max.poll.interval.ms当通过消费组管理消费者时,改配置指定拉取消息线程最长的空闲时间,若超过这个时间间隔还没有发起poll操作,则消费组人为该消费者已离开了消费组,将进行再均衡操作300000
auto.offset.reset参数值为字符串类型,有效值为“earliest”“lastest”“none”,配置为其余值会报出异常lastest
enable.auto.commitboolean类型,配置是否开启自动提交消费位移的功能true
auto.commit.interval.ms当enable.auto.commit参数设置为true时才生效,表示开启自动提交消费位移功能时自动提交消费位移的时间间隔5000
partition.assignment.strategy消费者的分区分配策略
interceptor.class配置消费者客户端的拦截器

自定义反序列化

通过对字节码进行反序列化将其转换为对应类型的数据,这里建议生产者或消费者约定好用json进行序列化和反序列化

自定义拦截器

消费者拦截器需要自定义实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口,并实现以下四个函数

  • onConsume:会在poll方法返回前调用,可以用于对消息进行定制化的操作
  • onCommit:提交完offset之后会被调用,用于记录追踪所提交的offset信息
  • configure:用来获取配置信息及初始化数据
  • close:用于资源释放 示例:
public class CustomConsumerInterceptor implements ConsumerInterceptor {

    // 会在poll方法返回前调用,可以用于对消息进行定制化的操作
    @Override
    public ConsumerRecords onConsume(ConsumerRecords records) {
        return null;
    }

    // 提交完offset之后会被调用,用于记录追踪所提交的offset信息
    @Override
    public void onCommit(Map offsets) {

    }

    // 用来获取配置信息及初始化数据
    @Override
    public void configure(Map<String, ?> configs) {

    }

    // 用于释放资源
    @Override
    public void close() {

    }
}

订阅主题与分区

KafkaConsumer 提供两种订阅主题的方式,一种为subscribe,另外一种为assign

subscribe 订阅方式

subscribe方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配策略来自动分配各个消费者与分区的关系。当消费组内的消费者增加或减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移

提供构造函数:

// 确定匹配
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)

示例代码

// 确定匹配示例
consumer.subscribe(Arrays.asList("test-topic"));

// 模糊匹配示例
consumer.subscribe(Pattern.compile("test-*"), new ConsumerRebalanceListener() {
    // 再均衡开始之前和消费者停止读取消息之后被调用
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {

    }

    // 重新分配分区之后和消费者开始读取消息之前被调用
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {

    }
});

备注:再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。也就是说,在再均衡发生期间的这一小段时间内,消费组会变得不可用。另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。一般情况下,应尽量避免不必要的再均衡的发生。

assign订阅方式

assign方法订阅主题,是指定需要订阅的分区集合

提供构造函数

public void assign(Collection<TopicPartition> partitions) 

示例代码:

// 获取topic分区元数据
List<PartitionInfo> partitions = consumer.partitionsFor("test-topic");
// 获取某个分区编号
int partition = partitions.get(0).partition();
// 订阅指定的分区
consumer.assign(Arrays.asList(new TopicPartition("test-topic", partition)));

消息消费

Kafka中的消费是基于拉模式的。消息的消费一般有两种模式:推模式和拉模式。推模式是服务端主动将消息推送给消费者,而拉模式是消费者主动向服务端发起请求来拉取消息。

KafkaConsumer 提供poll的方法进行消息的消费

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

由于kafka的消费是基于拉模式,则需要轮询的调用poll方法来进行消息的消费。 消费的可以分为三个维度: 第一个维度:不做任何区分 第二个维度:按照分区的维度进行消费 第三个维度:按照topic维度进行消费

不做任何区分消费

while (isRunning.get()){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1));
    for (ConsumerRecord<String, String> record : records){
        System.out.println("topic=" + record.topic() + ", partition=" + record.partition());
    }
}

按照分区维度来进行消费

while (isRunning.get()){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1));
    for (TopicPartition tp : records.partitions()){
        for(ConsumerRecord<String, String> record : records.records(tp)){
            System.out.println("partition=" + record.partition() + " value=" + record.value());
        }
    }
}

按照topic维度进行消费

List<String> topicList = Arrays.asList("test-topic");
while (isRunning.get()){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1));
    for (String topic : topicList){
        for(ConsumerRecord<String, String> record : records.records(topic)){
            System.out.println("topic=" + topic + " value=" + record.value());
        }
    }
}

offset 提交

消费者使用offset来表示消费到分区中某个消息所在的位置,offset的提交有两种方式:一种为自动提交,另外一种为手动提交

自动提交

消费者客户端参数enable.auto.commit配置为true,当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数auto.commit.interval.ms配置,默认值为5秒

// 配置自动提交offset
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
// 配置自动提交offset的时间间隔
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5);

手动提交

消费者客户端参数enable.auto.commit配置为false, 使用提供commitSync()和commitAsync()方法进行offset的提交

提供方法:

同步提交
public void commitSync()
public void commitSync(Duration timeout)
public void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)

异步提交
public void commitAsync()
public void commitAsync(OffsetCommitCallback callback)
public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)

示例:

实现按分区粒度同步提交offset
while (isRunning.get()){
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (TopicPartition tp : records.partitions()){
        List<ConsumerRecord<String, String>> partitionRecords = records.records(tp);
        for(ConsumerRecord<String, String> record : partitionRecords){
            System.out.println("partition=" + record.partition() + " value=" + record.value());
        }

        long lastConsumedOffSet = partitionRecords.get(partitionRecords.size() - 1).offset();
        consumer.commitSync(Collections.singletonMap(tp, new OffsetAndMetadata(lastConsumedOffSet + 1)));
    }
}

offset 恢复

当发生异常的时候,无法精确的定位当前的offset的时候,可以通过指定特定的offset以追前消费或回溯消费。回溯的方式有如下: 1、通过seek指定分区的某个offset 2、通过offsetsForTimes某个时间点,回溯到对应的offset

指定分区的offset

提供的方法:

// 指定某个分区的某个offset
void seek(TopicPartition partition, long offset);
void seek(TopicPartition partition, OffsetAndMetadata offsetAndMetadata);

// 指定某个分区的开始位置
public void seekToBeginning(Collection<TopicPartition> partitions)

// 指定某个分区的结束位置
public void seekToEnd(Collection<TopicPartition> partitions)

示例:

KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
// 订阅主题
consumer.subscribe(Arrays.asList("test-topic"));
// 拉取消费事件
consumer.poll(Duration.ofSeconds(100));
// 回溯分区的某个offset
Set<TopicPartition> assignments = consumer.assignment();
for (TopicPartition tp : assignments) {
    consumer.seek(tp, 10);
}

备注:使用seek方法的时候要注意先执行poll

通过时间回溯offset

提供方法:

Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch);
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout);

示例:

KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
// 订阅主题
consumer.subscribe(Arrays.asList("test-topic"));
// 拉取消费事件
consumer.poll(Duration.ofSeconds(100));
// 获取消费的分区号
Set<TopicPartition> assignments = consumer.assignment();
// 获取某个时间戳对应的offset
Map<TopicPartition, Long> timestampsToSearch = new HashMap<>();
for (TopicPartition tp : assignments) {
    timestampsToSearch.put(tp, System.currentTimeMillis());
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestampsToSearch);
// 回溯某个分区的某个offset
for (TopicPartition tp : assignments) {
    OffsetAndTimestamp offsetAndTimestamp= offsets.get(tp);
    consumer.seek(tp, offsetAndTimestamp.offset());
}

控制消费

KafkaConsumer中使用pause()和resume()方法来分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作

暂停操作

提供方法:

// 暂停所有分区
public Set<TopicPartition> paused()
// 暂定某个分区
public void pause(Collection<TopicPartition> partitions)

示例:

// 暂停消费
consumer.pause(Arrays.asList(new TopicPartition("test-topic", partition)));

恢复操作

提供方法:

public Set<TopicPartition> paused()
public void resume(Collection<TopicPartition> partitions)

示例:

// 恢复消费
consumer.resume(Arrays.asList(new TopicPartition("test-topic", partition)));

消费关闭

通过执行关闭动作以释放运行过程中占用的各种系统资源,包括内存资源、Socket连接等

提供方法:

// 立即关闭
public void close()
// 延迟关闭
public void close(Duration timeout)

示例:

consumer.close();

多线程实现

KafkaConsumer是非线程安全的。KafkaConsumer中定义了一个 acquire()方法和release()方法来保证线程安全。

acquire:用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出ConcurrentModifcationException异常。KafkaConsumer中的每个公用方法在执行所要执行的动作之前都会调用这个acquire()方法。acquire方法和我们通常所说的锁(synchronized、Lock等)不同,它不会造成阻塞等待,我们可以将其看作一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。

release:释放锁。

实现多线程的方式如下:

  • 线程封闭,即为每个线程实例化一个KafkaConsumer对象,它的优点是每个线程可以按顺序消费各个分区中的消息。缺点也很明显,每个消费线程都要维护一个独立的TCP连接,如果分区数和consumerThreadNum的值都很大,那么会造成不小的系统开销。

image.png

实现示例:

public class FirstMultiConsumerThreadDemo {
    final static public String brokerList = "localhost:9092";
    final static public String topic = "topic-demo";
    final static public String group = "group.demo";

    public static Properties initConfig() {
        Properties props = new Properties();
        // 配置连接集群地址
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        // 配置生产消息topic序列号类型
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 配置生产消息序列号类型
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 消费组
        props.put(ConsumerConfig.GROUP_ID_CONFIG, group);
        // 客户端id
        props.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        // 配置自动提交offset
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        // 配置自动提交offset的时间间隔
        props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        int consumerThreadNum = 4;
        for (int i = 0; i < consumerThreadNum; i++) {
            new KafkaConsumerThread(props, topic).start();
        }
    }

    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;

        public KafkaConsumerThread(Properties props, String topic) {
            this.kafkaConsumer = new KafkaConsumer<String, String>(props);
            this.kafkaConsumer.subscribe(Arrays.asList(topic));
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
                    for (ConsumerRecord<String, String> record : records) {
                        //处理消息
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}
  • 处理消息模块使用多线程的实现方式,它的优点除了横向扩展的能力,还可以减少TCP连接对系统资源的消耗,不过缺点就是对于消息的顺序处理就比较困难了。

image.png

实现示例:

public class SecondMultiConsumerThreadDemo {
    final static public String brokerList = "localhost:9092";
    final static public String topic = "topic-demo";
    final static public String group = "group.demo";

    public static Properties initConfig() {
        Properties props = new Properties();
        // 配置连接集群地址
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        // 配置生产消息topic序列号类型
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 配置生产消息序列号类型
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 消费组
        props.put(ConsumerConfig.GROUP_ID_CONFIG, group);
        // 客户端id
        props.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        // 配置自动提交offset
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
        // 配置自动提交offset的时间间隔
        props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumerThread consumerThread = new KafkaConsumerThread(props, topic,
                Runtime.getRuntime().availableProcessors());
        consumerThread.start();
    }

    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;
        private Map<TopicPartition, OffsetAndMetadata> offsets;

        public KafkaConsumerThread(Properties props, String topic, int threadNumber) {
            kafkaConsumer = new KafkaConsumer<String, String>(props);
            kafkaConsumer.subscribe(Arrays.asList(topic));
            this.threadNumber = threadNumber;
            offsets = new HashMap<>();
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber, 0L,
                    TimeUnit.MINUTES, new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
                    if (!records.isEmpty()) {
                        executorService.submit(new RecordsHandler(records, offsets));
                        synchronized (offsets){
                            if (!offsets.isEmpty()) {
                                kafkaConsumer.commitSync(offsets);
                                offsets.clear();
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }

        public static class RecordsHandler extends Thread {
            public final ConsumerRecords<String, String> records;
            public final Map<TopicPartition, OffsetAndMetadata> offsets;

            public RecordsHandler(ConsumerRecords<String, String> records,
                                  Map<TopicPartition, OffsetAndMetadata> offsets) {
                this.records = records;
                this.offsets = offsets;
            }

            @Override
            public void run() {
                for(TopicPartition tp : records.partitions()) {
                    List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
                    long lastConsumedOffset = tpRecords.get(tpRecords.size() - 1).offset();
                    synchronized (offsets){
                        if (!offsets.containsKey(tp)) {
                            offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
                        }else{
                            long position = offsets.get(tp).offset();
                            if (position < lastConsumedOffset + 1) {
                                offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
                            }
                        }
                    }
                }
            }
        }
    }
}