Kafka消费者详解

消费者和消费者组

消费者负责定义kafka中的topic,并且从订阅的消息中拉取消息。

消费者组是指把多个消费者逻辑上分配到一个组里,以组为单位进行消费数据。

preview

上图的含义指的是某个topic有4个分区,每个分区分布在不同的server上,有两个消费者组,ConsumerGroup A 和ConsumerGroup B, ConsumerGroup A 有两个消费者C1和C2,ConsumerGroup B有4个消费者C3、C4、C5和C6,两个消费者组是独立的,彼此不影响的,每个消费者组里的消费者共同订阅某个topic,一起消费里面的消息。

消费者和消费者组的这样模式,可以让整体的消费能力具备横向伸缩,我们可以增加或者减少消费者的个数,来提供或者降低整体的消费能力。有以下特点:

1、每个分区只能被一个消费者组里的一个消费者进行消费,两个消费者组互不影响
2、消费者的数量小于等于分区的数量,如果多于分区的数量,消费者是收不到消息的

 上面分区分配逻辑是基于默认的分区分配策略。可以通过参数来控制消费者和主题分区之间的分配策略。

partition.assignment.strategy

 

消息投递方式

(具体可参见博客https://blog.csdn.net/zytmaster/article/details/107562917)

一般有两种消息投递方式:点对点模式和发布订阅模式

  • 点对点模式,是基于队列,生产者发送消息到消息队列,消息消费者从队列中接收消息
  • 发布订阅模式,基于主题,生产者发送消息到主题,消费者消费主题中的消息,生产者和消费者是相互独立的,发布订阅模式在消息的一对多广播时采用。

kafka同时支持这两种模式:

如果所有消费者属于一个消费者组,那么消息都会被均衡的投递给每个消费者,也就是每个消费者只会被一个消费者处理,这就类似点对点模式

如果所有消费者属于不同的消费者组,那么所有消息会被广播到所有的消费者,也就是每条消息会被所有消费者处理,这就相当于发布订阅模式 

 

客户端开发

基本过程

  1. 配置消费者参数
  2. 创建消费者实例
  3. 订阅主题
  4. 拉取消息进行消费
  5. 提交位移offset
  6. 关闭消费者实例
public class KafkaConsumerAnalysis {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";
    public static final AtomicBoolean isRunning = new AtomicBoolean(true);
 
    public static Properties initConfig() {
        Properties props = new Properties();
        props.put("key.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("bootstrap.servers", brokerList);
        props.put("group.id", groupId);
        props.put("client.id", "consumer.client.id.demo");
 
        //props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG,ConsumerInterceptorTTL.class.getName());
 
        return props;
    }
 
    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList(topic));
//        consumer.assign(Arrays.asList(new TopicPartition("topic-demo",0)));
//        List<PartitionInfo> partitionInfos = consumer.partitionsFor("topic-demo");
        consumer.unsubscribe();
        consumer.subscribe(new ArrayList<String>());
        try {
            while (isRunning.get()) {
                ConsumerRecords<String, String> records =
                        consumer.poll(Duration.ofMillis(1000));
//                for(TopicPartition tp:records.partitions()){
//                    for (ConsumerRecord<String, String> record : records.records(tp)) {
//                        System.out.println(record.partition()+"--"+record.value());
//                    }
//                }
 
                for (ConsumerRecord<String, String> record : records) {
                    System.out.println("topic = " + record.topic()
                            + ", partition = " + record.partition()
                            + ", offset = " + record.offset());
                    System.out.println("key = " + record.key()
                            + ", value = " + record.value());
                    //do something to process record.
                }
            }
        } catch (Exception e) {
            log.error("occur exception ", e);
        } finally {
            consumer.close();
        }
    }
}

关键参数

properties.put("bootstrap.servers", "localhost:9092"); // kafka 集群broker地址情况
properties.put("key.serializer",StringDeserializer.class.getName()); // key 反序列化
properties.put("value.serializer",StringDeserializer.class.getName()); // value 反序列化
props.put("group.id", "fk.group");// 消费者组,默认值为"",如果为"",会抛错

 

订阅主题和分区

订阅主题

创建好消费者实例之后,就需要为消费者订阅相关主题了,消费者可以订阅一个或者多个,通过subscribe方法

 consumer.subscribe(Arrays.asList(topic));
 consumer.subscribe(Pattern.compile("topic-.*"));

 如果前后两次订阅了不同的主题,那么消费者以最后一次为准

consumer.subscribe(Arrays.asList(topic1));
consumer.subscribe(Arrays.asList(topic2));
//最终订阅的是topic2

 

订阅分区

订阅某些主题的特定分区,通过assign()方法实现,下面含义是订阅topic-demo主题的0分区数据

 consumer.assign(Arrays.asList(new TopicPartition("topic-demo",0)));

 可以通过consumer.partitionsFor("topic-demo");获取所有分区信息

取消订阅

 consumer.unsubscribe();//  取消订阅
 consumer.subscribe(new ArrayList<String>());// 订阅一个空集合

订阅状态

AUTO_TOPICS,AUTO_PATTERN,USER_ASSIGNED,NONE

注意:

使用subscribe方法订阅主题,具有消费者自动再均衡的功能,多个消费者的情况下,可以根据分区分配策略来自动分配各个消费者和分区的关系。当消费者组里的消费者增加或者减少时候,分区分配关系会自动调整,实现消息负载均衡和故障自动转移。而同assign方法订阅的,是不具备自动均衡的功能 

反序列化

和生产者类似,需要对消息进行序列化和反序列化,也可以自定义字节的反序列化器,只需要实现相关的类Deserializer,然后重写相关方法就可以。反序列化要和序列化器一一对应。

消息消费

1、kafka中的消费是基于拉模式,消息消费一般有两种,一种是拉模式,另外一种是推模式。推模式是服务端主动将消息推送给消费者,拉模式是消费者主动去服务器上拉去消息。从代码中可以看到,kafka中的消息消费是一个不断轮询的过程,消费者需要做的就是重复的pull,pull方法返回订阅主题分区上的一组消息,如果这分区中没有消息,那么pull回来的就是空。

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

2、从代码上可以看到pull方法里有一个时间维度的参数,用来控制pull方法的阻塞时间,在消费者的缓冲区里没有可用的数据时会发生阻塞,时间参数的设置取决于应用程序对相应速度的要求,比如需要在多长时间里将控制权交给执行轮询的线程。

3、消费者消费到的消息是ConsumerRecord,这个类对消费到的消息进行了分装,包含主题分区offset时间k-v序列化的大小等等。

 ConsumerRecords<String, String> records =consumer.poll(Duration.ofMillis(1000));
 for (ConsumerRecord<String, String> record : records) {
 System.out.println("topic = " + record.topic()+ ", partition = " + record.partition()
   + ", offset = " + record.offset());
 System.out.println("key = " + record.key()+ ", value = " + record.value());
}

4、还可以按照分区维度进行消费

for(TopicPartition tp:records.partitions()){
   for (ConsumerRecord<String, String> record : records.records(tp)) {
       System.out.println(record.partition()+"--"+record.value());
   }
}

位移提交

位移指的是消费消息后,记录消息消费的位置,在每次调用pull方法时,它返回的是还没有被消费国的消息集,消息的位移是需要进行持久化的保存的,而不单纯的在内存中保存。在旧的消费者客户端中,消费位移是存储在zookeeper中的,在新的消费者客户端中,位移被存储在kafka内部主题__cocnsumer_offsets中。

这里把消费位移存储起来的动作就是提交,消费者在消费完消息后需要执行消费者位移的提交

x表示某一次拉取操作中,此分区消息中的最大偏移量,假设当前消费者已经消费了x位置的消息,那么我们就可以说消费者的消费位移为x。但是当前消费者需要提交的消费位移不是x,而是x+1,表示下一条需要拉取的消息的位置。

自动提交

在kafka中默认的消费位移提交方式是自动提交,这个可以通过客户端的参数控制

enable.auto.commit=true // 默认值为true
auto.commit.interval.ms // 提交频率

默认方式下,消费者每隔5秒回将拉取到每个分区中最大的消费位移进行提交。自动位移提交的动作是在pull方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。

手动提交

(实例参见博客:)

很多时候,不是拉取到消息就算消费完成,而是需要将消息写入数据库,写入本地缓存,或者是更加复杂的处理,在这样的场景下,所有的业务处理完成才能被认为是消息被成功消费,手动提交方式可以让开发人员根据业务需求,灵活的进行位移提交。如果是手动提交,需要开启手动提交功能。

enable.auto.commit=false

手动提交可以分为同步提交和异步提交,对应KafkaConsumer的两个方法

//同步提交
 while (running.get()) {
         ConsumerRecords<String, String> records = consumer.poll(1000);
         for (ConsumerRecord<String, String> record : records) {
             //do some logical processing.
         }
         consumer.commitSync();    
     }

上面的代码是对拉取到的消息进行处理,然后对整个消息集进行同步提交。它会更新pull方法拉取的最新位移进行提交,也可以提交指定分区中的位移,在commitSync()方法中存入offsets参数来进行控制指定分区信息。

//异步提交
while (running.get()) {
   ConsumerRecords<String, String> records = consumer.poll(1000);
   for (ConsumerRecord<String, String> record : records) {
       //do some logical processing.
   }
   consumer.commitAsync();
 //commitAsync(OffsetCommitCallback callback)
 //commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)
}

异步提交可以指定回调方法,执行相关的回调逻辑,异步提交的方式在执行的时候消费者线程不会被阻塞,可能在提交消费者位移的结果还没有返回之前,就开始新一次的拉取操作。

指定位移消费

当一个新的消费组建立的时候,它根本没有可用查找的消费位移,或者是一个消费组内的一个新消费者订阅了一个新的主题,也找不到消费的位移,在kafka中,每当消费者查找不到所记录消费位移时候,会根据消费者客户端的参数配置来决定从何处开始进行消费。

auto.offset.reset 
latest 默认,从分区末尾开始消费消息
earlier 消费者从开始处消费。
none 出现查找不到消费位移的时候,既不从开始位置消费,也不从最新位置消费,而是抛出异常

kafkaConsumer提供了seek方法,来让我们开发人员更加灵活的控制消费的位置,让我们得以消费或回溯消息。

 seek(TopicPartition partition, long offset)

消费者拦截器

消费者拦截器主要在消费到消息或在提交消费位移时进行一些定制化的操作。只需要实现ConsumerInterceptor类中的方法就可以。

 public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
 public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
 public void close();

它会在pull方法返回之前调用拦截器的onConsume方法来对消息进行相应的定制化操作,比如修改返回的消息内容,按照某些规则进行过滤数据。

它会在提交完消费位移之后调用拦截器的onCommit方法,可以使用这个方法来记录跟踪所提交的位移信息。

/*消息过期拦截器*/
public class ConsumerInterceptorTTL implements
        ConsumerInterceptor<String, String> {
    private static final long EXPIRE_INTERVAL = 10 * 1000;

    @Override
    public ConsumerRecords<String, String> onConsume(
            ConsumerRecords<String, String> records) {
        System.out.println("before:" + records);
        long now = System.currentTimeMillis();
        Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords
                = new HashMap<>();
        for (TopicPartition tp : records.partitions()) {
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
            for (ConsumerRecord<String, String> record : tpRecords) {
                if (now - record.timestamp() < EXPIRE_INTERVAL) {
                    newTpRecords.add(record);
               }
           }
            if (!newTpRecords.isEmpty()) {
                newRecords.put(tp, newTpRecords);
           }
       }
        return new ConsumerRecords<>(newRecords);
   }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp, offset) ->
                System.out.println(tp + ":" + offset.offset()));
   }

    @Override
    public void close() {
   }

    @Override
    public void configure(Map<String, ?> configs) {
   }
}
props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG,ConsumerInterceptorTTL.class.getName());

参数

fetch.min.bytes

配置consumer在一次拉去请求(pull方法)中,能从kafka中拉取的最小数据量,默认为1B,如果返回给consumer的数据量小于这个值,那么就会等待,知道满足这个数据量的大小。

fetch.max.bytes

配置consumer在一次拉取请求中从kafka中拉取的最大数据量,默认50MB,

max.partition.fetch.bytes

这个参数用来配置每个分区里返回给Consumer的最大数据量,即1MB,

max.pull.records

配置Consumer在一次拉取请求中拉取的最大消息数,默认500条

metadata.max.age.ms

配置元数据的过去时间,默认5分钟,如果元数据在这个参数限制的时间里没有进行更新,会被强制更新,即使没有任何分区变化,新broker的加入。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值