Kafka消费者

发布时间 2023-05-07 13:54:50作者: 青山新雨

Kafka消费者

消费者和消费者群组

Kafka消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者接收一部分分区的消息。若分区的数量大于等于消费者的数量,则消费者会消费一个或多个分区的数据。若分区的数量小于消费者的数量就会出现闲置消费者。

image-20230502144132566

image-20230502144144611

image-20230502144156719

上面为1个组群,下面是2个组群的情况,组群之间不存在任何联系。

image-20230502144543670

消费者群组和分区再均衡

再均衡是指分区的所有权从一个消费者转移到另外一个消费者上,或者消费者新增分区所有权。

再均衡是为了面对消费者程序突然崩溃或者新增新的分区这种动态变化所带来的一系列问题所提出的。在再均衡期间,消费者无法读取消息,造成整个群组一小段时间不可用,另外当分区被重新分配给另外一个消费者时,消费者当前的读取状态也会丢失,有可能还需要刷新缓存。

消费者通过向被指派为群组协调器的 broker(不同的群组可以有不同的协调器)发送心跳 来维持它们和群组的从属关系以及它们对分区的所有权关系。只要消费者以正常的时间 间隔发送心跳,就被认为是活跃的,说明它还在读取分区里的消息。消费者会在轮询消息 (为了获取消息)或提交偏移量时发送心跳。如果消费者停止发送心跳的时间足够长,会 话就会过期,群组协调器认为它已经死亡,就会触发一次再均衡。如果一个消费者发生崩溃,并停止读取消息,群组协调器会等待几秒钟,确认它死亡了才 会触发再均衡。在这几秒钟时间里,死掉的消费者不会读取分区里的消息。在清理消费者 时,消费者会通知协调器它将要离开群组,协调器会立即触发一次再均衡,尽量降低处理停顿。

创建Kafka消费者

package com.kone.test

import org.apache.kafka.clients.consumer.{ConsumerRecords, KafkaConsumer}

import java.util.Properties
import scala.collection.JavaConverters._

object KafkaConsumerWithUserObject {
  def main(args:Array[String]):Unit={
    val props: Properties = new Properties()
    props.put("group.id","test")
    props.put("bootstrap.servers","localhost:9092")
    props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer")
    props.put("value.deserializer","om.kone.test.User.UserDeserializer")
    props.put("enable.auto.commit","true")
    props.put("auto.commit.interval.ms","1000")

    val consumer: KafkaConsumer[String, User] = new KafkaConsumer[String, User](props)

    try{
      consumer.subscribe(List("topic").asJava)
      while(true){
        val records: ConsumerRecords[String, User] = consumer.poll(10)
        for(record <- records.asScala){
          println(s"topic: ${record.topic()}  key:${record.key()}  value:${record.value()}")
        }
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      consumer.close()
    }
  }
}

线程安全

在同一个群组中,按照规则最好一个消费者一个线程的消费数据。

消费者的配置

group.id 群组id
key.deserializer key反序列
value.deserializer value反序列
fetch.min.bytes 消费者从服务器获取记录的最小字节数
fetch.max.wait.ms 通知服务器等待足够的数据才返回给消费者
max.partition.fetch.bytes 指定服务器从每个分区里返回的记录最多不超过的字节数
session.timeout.ms 确认消费者在被认为死亡之前可以与服务器断开连接的时间

提交与偏移量

在每次轮询时,总是返回由生产者写入Kafka但还没有被消费者读取过的记录,把更新分区当前位置的操作叫做提交

自动提交

设置enable.auto.commit设置为true,那么每过5s,消费者会自动从poll方法收到的最大偏移量提交上去。消费者每次进行轮询时会检查是否该提交该偏移量。若在最近一次提交后3s发生了再均衡,消费者从最后一次提交的偏移量位置开始读取消息。所以这3s内的消息是重复消息。所以得到的数据会有重复数据。

提交当前偏移量

把 auto.commit.offset 设为 false,使用commitSync()提交偏移量,该方法提交由poll方法返回的最新偏移量,提交后马上返回,如果提交失败会抛出异常。

    try{
      consumer.subscribe(List("topic").asJava)
      while(true){
        val records: ConsumerRecords[String, User] = consumer.poll(10)
        for(record <- records.asScala){
          println(s"topic: ${record.topic()}  key:${record.key()}  value:${record.value()}")
          try{
            consumer.commitSync()
          }catch {
            case e:Exception => e.printStackTrace()
          }
        }
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      consumer.close()
    }

异步提交

提交当前偏移量有一个不好的地方,在broker对提交请求做出回应之前,应用程序会一直阻塞。所以可以使用异步提交。

    try{
      consumer.subscribe(List("topic").asJava)
      while(true){
        val records: ConsumerRecords[String, User] = consumer.poll(10)
        for(record <- records.asScala){
          println(s"topic: ${record.topic()}  key:${record.key()}  value:${record.value()}")
        }
        consumer.commitAsync()
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      consumer.close()
    }

commitAsync()在遇到异常后,并不会重试,因为commitAsync()支持回调,回调经常被用于记录提交错误或生成度量指标。

可以使用一个单调递增的序列号来维护异步提交的顺序。在每次提交偏移量之后或者在回调里提交偏移量时递增序列号。

同步和异步组合提交

在一般请款下,就算是提交失败,也没有多大关系,因为后续总会成功,可是需要保证在关闭消费者或者再均衡前的最后一次提交,就要确保能够提交成功。

所以在消费者关闭前一般组合使用commitAsync()和commitSync()

    try{
      consumer.subscribe(List("topic").asJava)
      while(true){
        val records: ConsumerRecords[String, User] = consumer.poll(10)
        for(record <- records.asScala){
          println(s"topic: ${record.topic()}  key:${record.key()}  value:${record.value()}")
        }
        consumer.commitAsync()
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      try{
        consumer.commitSync()
      }finally {
        consumer.close()
      }
    }

提交特定的偏移量

当提交偏移量的频率和消息批次的频率一样,为了避免因为再均衡引起的重复处理整批消息,允许在调用commitAsync()和commitSync()时传入希望提交的分区和偏移量的map。

private val currentOffsets: mutable.HashMap[TopicPartition, OffsetAndMetadata] = new mutable.HashMap[TopicPartition, OffsetAndMetadata]()
private var count: Int = 0

    try{
      consumer.subscribe(List("topic").asJava)
      while(true){
        val records: ConsumerRecords[String, User] = consumer.poll(10)
        for(record <- records.asScala){
          println(s"topic: ${record.topic()}  key:${record.key()}  value:${record.value()}")

          currentOffsets.put(
            new TopicPartition(record.topic(), record.partition()),
            new OffsetAndMetadata(record.offset()+1, "no date")
          )
          if(count%1000==0){
            consumer.commitAsync(currentOffsets, null)
          }
          count+=1
        }
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      consumer.close()
    }

再均衡监听器

消费者在推出和进行分区再均衡之前会做一些清理工作。如果消费者准备了一个缓冲区用于处理偶发的事件,那么在失去分区所有权之前,需要处理在缓冲区累计下来的记录。

再均衡监听器在为消费者分配新分区或者移除旧分区时,通过消费者执行一些程序,调用subscribe()方法时传入ConsumerRebalanceListener即可完成。ConsumerRebalanceListener有两个需要实现的方法:

  • public void onPartitionsRevoked(Collection partitions):在在均衡开始之前和消费者停止读取之后被调用。如果提交偏移量,下一个管分区的消费者就知道该从哪里开始读取。
  • public void onPartitionsAssigned(Collection partitions):在重新分配分区之后和消费者开始读取消息之前被调用。
package com.kone.test

import org.apache.kafka.clients.consumer._
import org.apache.kafka.common.TopicPartition

import java.util
import java.util.Properties
import scala.jdk.CollectionConverters.IterableHasAsScala


object ConsumerDemo {
  def main(agrs:Array[String]):Unit={
    // Kafka配置
    val props: Properties = new Properties()
    props.put("bootstrap.servers","localhost:9092")
    props.put("group.id","test-graoup")
    props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer")
    props.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer")

    val consumer: KafkaConsumer[String, String] = new KafkaConsumer[String, String](props)
    
    //创建在均衡监听器
    val listener: ConsumerRebalanceListener = new ConsumerRebalanceListener {
      //撤销分区时
      override def onPartitionsRevoked(partitions: util.Collection[TopicPartition]): Unit = {
        println(s"Partitions revoked: ${partitions.toString}")
        // 可以执行一些清除操作
      }

      //新分配分区时
      override def onPartitionsAssigned(partitions: util.Collection[TopicPartition]): Unit = {
        println(s"Partitions assigned: ${partitions.toString}")
        // 可以执行一些清除操作
      }
    }
    
    // 订阅主题, 并指定在均衡器
    consumer.subscribe(util.Arrays.asList("my_topic"), listener)
    
    try{
      while(true){
        val records: ConsumerRecords[String, String] = consumer.poll(10)
        for(record <- records.asScala){
          println(record.key(), record.value())
        }
      }
    }catch {
      case e:Exception => e.printStackTrace()
    }finally {
      consumer.close()
    }
  }
}

从特定偏移量处开始处理记录

一般情况下,我们使用poll方法从各个分区的最新偏移量开始处理消息。但是有时候,我们需要从特定的偏移量处开始读取消息。

Kafka提供了用于特定偏移量的API,比如向后回退几个消息或者向前跳过几个消息。在使用Kafka以外的系统来存储偏移量时,会有更好的操作,比如保证多种操作的原子性。

Kafka的消费者提供了seek方法来指定特定偏移量开始处理数据,下面是具体的代码:

package com.kone.test

import org.apache.kafka.clients.consumer.{ConsumerRecords, KafkaConsumer}
import org.apache.kafka.common.TopicPartition

import java.util
import java.util.Properties
import scala.jdk.CollectionConverters.IterableHasAsScala

object ConsumerSeekDemo {
  def main(args:Array[String]):Unit={
    val props: Properties = new Properties()
    props.put("bootstrap.servers", "localhost:9092")
    props.put("group.id", "test-graoup")
    props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer")
    props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer")

    val consumer: KafkaConsumer[String, String] = new KafkaConsumer[String, String](props)
    //订阅主题
    consumer.subscribe(util.Arrays.asList("test-topic"))

    val offsetToSeek: Long = 10L  //偏移量
    val partitionToSeek: Int = 0  //分区

    consumer.poll(0)  //确保在调用seek之前订阅分区

    // 将消费者指定到消费的分区,并从指定偏移量开始读取消息
    consumer.seek(new TopicPartition("test-topic", partitionToSeek), offsetToSeek)

    //已经移动到指定的分区和偏移量,从这个位置开始读取消息
    while(true){
      val records: ConsumerRecords[String, String] = consumer.poll(100)
      for(record <- records.asScala){
        println(s"key:${record.key()}, value:${record.value()}")
      }
    }
    consumer.close()
  }
}

关键代码为:

    val offsetToSeek: Long = 10L  //偏移量
    val partitionToSeek: Int = 0  //分区

    consumer.poll(0)  //确保在调用seek之前订阅分区

    // 将消费者指定到消费的分区,并从指定偏移量开始读取消息
    consumer.seek(new TopicPartition("test-topic", partitionToSeek), offsetToSeek)

下面我们将之前的再均衡监听器指定到特定分区偏移结合起来。想要达到的目的就是,当发生再均衡时,既分区撤销时,将消费者分区的所有偏移量存入到三方数据库中,当重新分配分区后,从数据库中取出这些分区和偏移量,再从新指定偏移量。代码如下:

package com.kone.test

import org.apache.kafka.clients.consumer.{ConsumerRebalanceListener, ConsumerRecords, KafkaConsumer}
import org.apache.kafka.common.TopicPartition

import java.util
import java.util._
import scala.jdk.CollectionConverters.IterableHasAsScala

object ConsumerLisenDemo {
  def main(args:Array[String]):Unit={
    val props: Properties = new Properties()
    props.put("bootstrap.servers", "localhost:9092")
    props.put("group.id", "test-graoup")
    props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer")
    props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer")

    val consumer: KafkaConsumer[String, String] = new KafkaConsumer[String, String](props)
    //订阅主题
    
    // 自定义再均衡监听器
    val listener: ConsumerRebalanceListener = new ConsumerRebalanceListener {
      // 分区撤销时
      override def onPartitionsRevoked(partitions: util.Collection[TopicPartition]) = {
        commitDBTransaction()   //将分区和偏移量存入第三方数据库的方法
      }
      // 分区分配时
      override def onPartitionsAssigned(partitions: util.Collection[TopicPartition]) = {
        for(partition <- partitions){
          consumer.seek(partition, getOffsetFromDB(partition))  //getOffsetFromDB方法表示从三方数据库查询偏移量
        }
      }
    }
    consumer.subscribe(util.Arrays.asList("test-topic"), listener)
    
    consumer.poll(0)  // 保证不在初始偏移量
    
    for(partition <- consumer.assignment()){    //这一步是为了恢复偏移量
      consumer.seek(partition, getOffsetFromDB(partition)) //getOffsetFromDB方法表示从三方数据库查询偏移量
    }
    
    while(true){
      val records: ConsumerRecords[String, String] = consumer.poll(100)
      for(record <- records.asScala){
        //将数据保存在三方数据库中
        //将每个信息的分区和偏移量保存在三方数据库
        storeRecordInDB(record)
        storeOffsetInDB(record.topic(), record.partition(), record.offset())
      }
    }
    
  }
}

安全退出

在前面所有消费者消费数据都是使用while死循环。如果确定要退出循环,就需要通过另一个线程调用consumer.wakeup()方法。注意:consumer.wakeup()是消费者唯一一个可以从其他线程里安全调用的方法。在调用comsumer.wakeup方法后,如果consumer正处于poll时会抛出WakeupException异常,如果消费者不在poll状态,也会在下一个poll抛出WakeupException异常。WakeupException异常只是为了跳出while死循环。关键代码如下:

    // 消费数据线程
    val consumerDataThread: Thread = new Thread(() => {
      while(true){
        val records: ConsumerRecords[String, String] = consumer.poll(100)
        for(record <- records.asScala){
          println(s"key:${record.key()}, value:${record.value()}")
        }
      }
    })
    
    // 结束消费线程
    val wakeupConsumeThread: Thread = new Thread(() => {
      Thread.sleep(5000) //等待5秒
      println("调用wakeup方法,将结束消费者消费数据")
      consumer.wakeup()
    })
    
    consumerDataThread.start()
    wakeupConsumeThread.start()
    
    consumerDataThread.join()
    wakeupConsumeThread.join()
    
    consumer.close()