深入分析Kafka生产者和消费者

深入Kafka生产者和消费者

  • Kafka生产者
    • 消息发送的流程
    • 发送方式
      • 发送并忘记
      • 同步发送
      • 异步发送
    • 生产者属性配置
    • 序列化器
    • 分区器
      • 自定义分区器
  • Kafka消费者
    • 消费者属性配置
    • 消费者基础概念
      • 消费者群组
      • 订阅主题
      • 轮询拉取
      • 提交和偏移量
        • 提交偏移量带来的问题
        • 自动提交
        • 手动提交
        • 异步提交
        • 同步和异步提交
        • 特定提交
    • 消费者核心概念
      • 群组协调
      • 分区再均衡
        • 再均衡监听器
      • 从特定偏移量处开始记录
      • 优雅退出
      • 反序列化器
      • 独立消费者

Kafka生产者

消息发送的流程

深入分析Kafka生产者和消费者_第1张图片
生产者每发送一条消息都需要先创建一个ProducerRecord对象,并且需要指定目标主题、消息内容,当然还可以指定消息键和分区。之后就会调用send()方法发送该对象,由于生产者需要与Kafka Broker建立网络传输,必然需要先通过序列化器对消息的键和值对象序列化成字节数组,才能进行传输。
之后,分区器就会接收到数据,然后先确认ProducerRecord对象中是否指定了分区,如果指定分区那么就直接把指定的分区返回,如果未指定分区,分区器就会根据消息键进行选择分区。确认分区后,那么消息才能确认发送到哪个主题的哪个分区上。接下来,消息又会被添加到批次里,同一个批次的消息总是发送到同一个主题和分区上,最后生产者端会有一个独立线程负责将批次发送到相应的Broker上。
Broker接收到消息后会进行响应,消息写入成功,会返回生产者端一个RecordMetaData对象,这个对象记录了消息在哪个主题的分区上,同时还记录了消息在分区中的偏移量。消息如果写入失败,则会返回一个错误,而生产者会根据配置的重试次数进行重试,当超过重试次数还是失败,就会将错误信息返回给生产者端。

发送方式

发送并忘记

producer.send(record);//忽略返回值

同步发送

//接收返回值
Future<RecordMetadata> future = producer.send(record);
//调用get方法进行阻塞,获取结果
RecordMetadata recordMetadata = future.get();

异步发送

//发送时,指定Callback回调
producer.send(record, new Callback() {
   public void onCompletion(RecordMetadata metadata,Exception exception) {
         if(null!=exception){
           //异常处理
         }
         if(null!=metadata){
              System.out.println("message offset:"+metadata.offset()+" "
                                +"message partition:"+metadata.partition());
         }
   }
);

生产者属性配置

创建KafkaProducer时都需要为其指定属性,属性的配置可以参考org.apache.kafka.clients.producer 包下的 ProducerConfig 类,大部分属性都配置了合理的默认值,如果对内存使用、性能和可靠性方面有要求可以相应调整一些属性,下面介绍一些常用的配置属性:

  1. acks: 确认机制,控制生产者发送消息时,必须有指定多少个分区副本接收到信息后,生产者才能认为消息写入成功,可选配置如下:
    acks=0:生产者在成功写入消息之前是不会等待任何的来自服务器的响应。如果在此期间出现了异常,造成Broker没能收到消息,而此时生产者又得不到反馈,消息也就丢失了。但是因为生产者不需要去等待服务器的响应,吞吐量相对更高;
    acks=1:只要集群中分区的首领节点接收到消息,生产者就会收到来自服务器的成功响应。如果消息无法到达首领节点,生产者会收到一个错误响应,为了避免数据丢失,生产者会重发消息。不过,如果一个没有收到消息的节点成为新首领,消息还是会丢失。缺省使用这个配置;
    acks=all:只有当集群中所有的分区副本都接收到消息后,生产者才会受到一个来自服务器的成功响应。
  2. batch.size: 对于发往同一个分区的消息,生产者发送过程中会先将消息记录在一个批次中。该参数的作用就是控制一个批次占用的内存大小。一个批次内存被填满后,会一次性将批次里的所有消息发送给Broker,但生产者并一定会等到批次被填满后才进行发送,即使是一条消息也有可能被发送。该参数的大小是按照字节数计算的,缺省为16384(16k),批次内存满了新的消息就写不进去了。
  3. linger.ms: 该参数用于配合batch.size使用,作用是控制生产者在发送批次前等待更多消息加入批次的时间。当然如果batch.size指定的批次内存已经填满,就不会进行等待而是直接发送,反之发送的消息字节数远比batch.size小,那么就能够在linger.ms指定的时间内获得更多的消息,从而减少请求次数,提升消息的吞吐量。
  4. max.request.size: 该参数用来控制生产者发送请求最大大小,缺省为1M。如果请求只有一条消息,则约束消息大小不能超过1M,如果请求是一个批次,则约束批次中所有消息的总大小不能超过1M。需要注意这个参数与Kafka的server.properties配置文件中指定的message.max.bytes参数有关,如果生产者发送的消息超过 message.max.bytes 设置的大小,就会被 Kafka Broker拒绝。
  5. buffer.memory: 控制生产者内存缓冲区的大小。
  6. retries: 控制生产者在消息发送失败后,可以进行重试的次数,默认情况下每次重试过程都会等待100ms再进行重试,重试等待时间可以通过retry.backoff.ms 参数来调整。
  7. request.timeout.ms: 控制生产者发送消息后等待请求响应的最大时间,超过这个时间没有收到响应,那么生产者端就会重试,超过重试次数将会抛出异常,缺省为30s。
  8. max.in.flight.requests.per.connection: 控制生产者在接收到服务器响应之前可以发送多少个消息,如果需要保证消息在一个分区上的严格顺序,这个值应该设为 1,不过这样会严重影响生产者的吞吐量。
  9. compression.type: 该参数控制生产者进行压缩数据的压缩类型,可选值包括:[none,gzip,snappy],缺省是none。压缩数据适用于消息批次处理,处理的批次消息越多,压缩性能就越好。snappy 占用 cpu 少,提供较好的性能和可观的压缩比,更关注性能和网络带宽建议使用这个。而如果网络带宽紧张,可以用gzip,虽然会占用较多的 cpu,但提供更高的压缩比。

序列化器

创建KafkaProducer对象时,必须指定键和值的序列化器,一些业务场景可能需要自定义序列化器,那么只需要实现org.apache.kafka.common.serialization.Serializer 接口,重写serialize()方法定义序列化逻辑即可。但自定义序列化器可能更多会去结合特定业务场景使用,所以容易导致程序的脆弱性,如果需求做了调整相应的序列化器实现也可能需要调整。因此使用序列化器更推荐使用自带格式描述以及语言无关的序列化框架,比如Kafka 官方推荐的 Apache Avro。
Avro在文件的读写是依据schema而进行的,而schema是通过一个JSON文件进行描述数据的,可以把这个schema 内嵌在数据文件中。这样,不管数据格式如何变动,消费者都知道如何处理数据。但是内嵌的消息,自带格式,会导致消息的大小不必要的增大,消耗了资源。我们可以使用 schema 注册表机制,将所有写入的数据用到的 schema 保存在注册表中,然后在消息中引用 schema 的标识符,而读取的数据的消费者程序使用这个标识符从注册表中拉取 schema 来反序列化记录。

分区器

生产者在发送消息时需要创建ProducerRecord对象,ProducerRecord对象可以指定一个消息键。指定了消息键,那么分区器就会将拥有相同键的消息指定给同一个主题的同一个分区。如果没有指定消息键,那么会通过默认分区器,使用轮询算法将消息均衡发布到主题下的各个分区。默认分区器会对消息键进行散列,然后根据散列值将消息映射到特定的分区上,这样同一个消息键总是能够被映射到同一个分区,但是只有不改变主题分区数量的情况下,键和分区之间的映射才能保持不变,一旦增加了新的分区,就无法保证了,所以如果要使用键来映射分区,那就要在创建主题的时候把分区规划好,不要增加新分区。

自定义分区器

一些业务场景中数据可能会有侧重,比如按地区进行划分数据时,不同地区的消息量是不同的,那么这种情况下就可以根据消息值中的一些标识,去针对消息值进行做分区,会更适合对应的业务场景。自定义一个分区器只需要去实现org.apache.kafka.clients.producer.Partitioner该接口,重写partition()方法完成相应的分区逻辑。

Kafka消费者

消费者属性配置

消费者需要创建KafkaConsumer,创建该对象时也需要指定消费者相关属性,可以参考org.apache.kafka.clients.consumer 包下 ConsumerConfig 类,大部分属性都配置了合理的默认值,如果需要关注内存使用、性能和可靠性方面可以相应调整一些属性,下面介绍一些常用的配置属性:

  1. auto.offset.reset: 控制消费者读取一个没有偏移量的分区或者偏移量无效的情况下,消费者应该如何处理。可选值包括:[latest、earliest],缺省为latest,表示从最新的记录开始读取。而earliest则表示消费者从起始位置开始读取分区的记录。
  2. enable .auto.commit: 表示消费者是否自动提交偏移量,缺省为true。这个参数很关键,通常情况都需要设置为false,自行控制何时提交偏移量,这样可以尽量避免消息重复处理和消息丢失。
  3. partition.assignment.strategy: 指定分区分配给消费者的策略。可选值包括:[Range、RoundRobin],缺省为Range,表示当分区数量无法被消费者数整除时,会把主题下的连续分区分配给消费者,第一个消费者通常会分到更多的分区。而RoundRobin则表示会把主题下的分区轮询分配给消费者。
    深入分析Kafka生产者和消费者_第2张图片
  4. max.poll.records: 控制执行poll() 方法返回的记录数量。
  5. max.partition.fetch.bytes:指定了服务器从每个分区里返回给消费者的最大字节数,缺省为1MB。需要注意,这个参数要比服务器的 message.max.bytes 更大,否则消费者可能无法读取消息。

消费者基础概念

消费者群组

在一些高并发的情况下,当Kafka生产者发送消息的速度远快于消费者消费速度时,如果只配置单个消费者,容易造成消息堆积,消息不能及时处理。这种情况下通常考虑的就是对消费者进行横向伸缩,通过增加消费者个数对同一个主题多个分区的消息进行分流。而Kafka中多个消费者通常会构成一个消费者群组,往群组中增加消费者是进行横向伸缩的主要方式。
深入分析Kafka生产者和消费者_第3张图片
在一个消费者群组中所有消费者都是订阅同一个主题,主题下一个分区只能由一个消费者消费,而一个消费者可以消费多个分区。
深入分析Kafka生产者和消费者_第4张图片

订阅主题

//消费者订阅主题(可以多个),主题值允许使用正则表达式  
consumer.subscribe(Collections.singletonList(BusiConst.HELLO_TOPIC));

消费端创建KafkaConsumer对象后,会使用subscribe()方法进行订阅主题,而一个消费者是可以订阅多个主题的,该方法可以传递一个主题列表或者正则表达式作为参数。正则表达式也能够匹配多个主题,比如,想订阅所有order相关的主题,可以使用subscribe(“order.*”) 。
需要注意: 在通过正则表达式订阅主题时,如果新建的一个主题正好与表达式匹配,那么会立即触发一次再均衡,消费者就可以读取新添加的主题了。

轮询拉取

//轮询获取消息
while(true){
      //拉取
      ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
      for(ConsumerRecord<String, String> record:records){
           System.out.println(String.format("topic:%s,分区:%d,偏移量:%d," + "key:%s,value:%s",record.topic(),record.partition(),
                            record.offset(),record.key(),record.value()));
       }
}

Kafka消费端是通过拉取的方式获取消息,消费者为了不断获取消息,只能在循环中不断调用poll()方法进行拉取。其中poll()方法需要指定超时时间,它会让消费者在指定的毫秒数内一直等待 broker 返回数据。poll()方法会返回一个ConsumerRecords列表对象,而其中每一个ConsumerRecord对象都包含了消息所属的主题信息、所在分区信息、在分区里的偏移量,以及键值对。

提交和偏移量

消费者可以使用 Kafka来追踪消息在分区里的位置,称之为偏移量。消费者更新自己读取到哪个消息的操作,称之为提交。消费者提交偏移量本质上就是向一个_consumer_offset 的特殊主题发送一个消息,里面会包括每个分区的偏移量。

提交偏移量带来的问题

深入分析Kafka生产者和消费者_第5张图片

如果提交的偏移量小于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息会被重复处理。
深入分析Kafka生产者和消费者_第6张图片

如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。
深入分析Kafka生产者和消费者_第7张图片

自动提交

auto.commit. offset缺省情况下为true,消费者会自动提交偏移量,自动提交存在一个时间间隔由auto.commit.interval.ms进行控制,缺省为5s。自动提交是在轮询拉取过程中触发的,消费者每次轮询时都会检查是否提交偏移量,如果是,则会将poll()方法返回的最新偏移量进行提交。
注意:自动提交由于是基于时间间隔的提交,如果在未达到提交时间时触发了分区再均衡,就容易造成在此之前一部分已经处理的消息被其它消费者重复处理了。并且自动提交总是将poll()方法返回的最新偏移量进行提交,它并不知道哪些消息处理成功了,所以再次调用之前最好确保所有当前调用poll()方法返回的消息都处理完成,否则可能造成消息丢失。

手动提交

将auto.commit. offset设置为false,然后调用commitsync()方法提交偏移量。这个方法会提交调用poll()方法返回的最新偏移量,只要没有发生不可恢复的错误,该方法会一直阻塞,直到提交成功后返回,如果提交失败就会抛出异常。
注意:手动提交由于也是提交poll()方法返回的最新偏移量,所以在处理完所有的消息后要确保调用了commitsync()方法,否则可能造成消息丢失。

异步提交

调用commitAsync()方法进行异步提交,相比与手动提交,它不会使应用程序阻塞,无需等待Broker响应。并且它支持回调,能够在Broker响应时执行相应回调方法。

//异步提交偏移量
consumer.commitAsync();
//支持回调
consumer.commitAsync(new OffsetCommitCallback() {
     public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
                            Exception exception) {
           if(exception!=null){
                System.out.print("Commmit failed for offsets "+ offsets);
           }
     }
});
同步和异步提交

一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。这个时候就需要使用同步异步组合提交。

try {
      while(true){
          ConsumerRecords<String, String> records= consumer.poll(Duration.ofMillis(500));
          for(ConsumerRecord<String, String> record:records){
               System.out.println(String.format("topic= %s,partition= %d,offset= %d,key= %s,value= %s",
                    record.topic(),record.partition(),record.offset(),
                    record.key(),record.value()));
                   
          }
          //每次轮询进行异步提交
          consumer.commitAsync();
      }
}  finally {
       try {
         //同步提交下
         consumer.commitSync();
       } finally {
         consumer.close();
       }
}
特定提交

支持在批次中间进行提交偏移量,在调用 commitsync()和 commitAsync()方法时传递希望提交的分区和偏移量构成的一个Map参数。

Map<TopicPartition, OffsetAndMetadata> currOffsets= 
                   new HashMap<TopicPartition, OffsetAndMetadata>();
int count = 0;
try {
     while(true){
        ConsumerRecords<String, String> records= consumer.poll(Duration.ofMillis(500));
        for(ConsumerRecord<String, String> record:records){
            System.out.println(String.format("topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
                            record.topic(),record.partition(),record.offset(),
                            record.key(),record.value()));
            currOffsets.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset()+1,"null")); 
            if(count%10==0){
                 //特定提交,指定一个记录希望提交的分区和偏移量的map
                 consumer.commitAsync(currOffsets,null);
            }
             count++;
        }
}
} finally {
    try {
         //同步提交
         consumer.commitSync();
    } finally {
         consumer.close();
    }
}

消费者核心概念

群组协调

深入分析Kafka生产者和消费者_第8张图片
消费者要加入群组时,会向群组协调器发送一个 JoinGroup 请求,第一个加入群主的消费者成为群主,群主会获得群组的成员列表,并负责给每一个消费者分配分区。分配完毕后,群主把分配情况发送给群组协调器,协调器再把这些信息发送给所有的消费者,每个消费者只能看到自己的分配信息, 只有群主知道群组里所有消费者的分配信息。群组协调的工作会在消费者发生变化,主题中分区发生了变化时发生。

分区再均衡

在Kafka中,消费者群组中存在着消费者对分区的所有权关系,这样在一个群组中如果新增一个消费者,那么新的消费者会分配到原先由其他消费者读取的分区,而减少一个消费者,那原本由它负责的分区就会分配给其它消费者。除此之外,如果增加了分区,新增的分区也需划分由哪个消费者读取,这一系列的行为,都会导致分区所有权的变化,这种变化就称为分区再均衡。
在消费者群组中我们介绍了它有一个群组协调器,而群组协调器它会接收群组中每个消费者发来的心跳,然后维持每个消费者和群组的从属关系以及对分区所有权关系。如果长时间未收到消费者发送的心跳,群组协调器就会认为当前消费者已经死亡,就会触发一次再均衡。
分区再均衡在Kafka中是非常重要的,这是消费者群组带来高可用性和伸缩性的关键所在。但是发生分区再均衡的期间,消费者会无法接收到消息,会造成整个群组一段时间的不可用,因此都需要尽量减少发生分区再均衡。
深入分析Kafka生产者和消费者_第9张图片

再均衡监听器

消费者调用subscribe()订阅主题时,指定一个ConsumerRebalanceListener,在再均衡开始之前和分区再均衡完成之后做一些操作。

//指定一个ConsumerRebalancelistener
consumer.subscribe(Collections.singletonList("test1"), new ConsumerRebalanceListener() {
            
     //分区再均衡之前
     @Override
     public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
         //1、将偏移量提交到Kafka
         //2、偏移量写入数据库
     }
            
     //分区再均衡完成以后
     @Override
     public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
         //1、从数据库中获取偏移量
         //2、通过seek()方法从指定偏移量位置开始读取
     }
});

从特定偏移量处开始记录

通常情况下消费者没有通过seek()方法指定读取位置时,调用poll()方法默认都会从分区的最新偏移量处开始读取消息。当然如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使 seekToBeginning(Collection tp)和 seekToEnd( Collectiontp)这两个方法。而调用seek()是可以从从特定的偏移量处开始读取消息的。

//从指定分区中的指定偏移量开始消费
consumer.seek(topicPartition,2);

优雅退出

如果确定要退出循环,需要通过另一个线程调用 consumer. wakeup()方法。如果循环运行在主线程里,可以在 ShutdownHook 里调用该方法。要记住, consumer. wakeup()是消费者唯一一个可以从其他线程里安全调用的方法。

反序列化器

创建KafkaConsumer对象时需要指定反序列化器,将从Kafka接收到的字节数组转换成 java对象,发送消息指定的序列化器必须与接收消息使用的反序列化器一一对应的。一些业务场景可能需要自定义反序列化器,那么只需要实现org.apache.kafka.common.serialization.Deserializer接口,重写deserialize()方法定义反序列化逻辑即可。

独立消费者

一个消费者从一个主题的所有分区或者某个特定的分区读取数据,不需要消费者群组和再均衡,只需要把主题或者分区分配给消费者,然后开始读取消息并提交偏移量。

Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"127.0.0.1:9092");
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//独立消费者(不需要订阅主题,只需要分配主题中分区即可)
KafkaConsumer<String,String>  consumer= new KafkaConsumer<String, String>(properties);
//拿到主题的分区信息
List<PartitionInfo> partitionInfos = consumer.partitionsFor("independ-consumer");
List<TopicPartition> topicPartitionList = new ArrayList<TopicPartition>();
if(null!=partitionInfos){
   for(PartitionInfo partitionInfo:partitionInfos){
       topicPartitionList.add(new TopicPartition(partitionInfo.topic(),
                        partitionInfo.partition()));
       }
}
//独立消费者需要执行哪些分区(这里全部的分区分配给一个消费者)
consumer.assign(topicPartitionList);
try {

    while(true){
       ConsumerRecords<String, String> records
                        = consumer.poll(Duration.ofMillis(1000));
       for(ConsumerRecord<String, String> record:records){
           System.out.println(String.format(
                            "主题:%s,分区:%d,偏移量:%d,key:%s,value:%s",
                            record.topic(),record.partition(),record.offset(),
                            record.key(),record.value()));
       }
    }
} finally {
     consumer.close();
}

你可能感兴趣的:(kafka系列,kafka)