阅读 173

中间件系列之Kafka-4-消费者

在之前的内容中,我们已经简单介绍过一些Kafka消费者的要素,对于消费者的使用,可以用一句话概括:应用需要读取全量消息,那么请为该应用设置一个消费组;如果该应用消费能力不足,那么可以考虑在这个消费组里增加消费者。

1.Kafka消费

1.1 创建消费者

Kafka消费者的创建与生产者非常相似,只需要创建一个kafkaConsumer对象即可,例如

Properties kafkaProps = new Properties();
kafkaProps.put("bootstrap.servers", "broker1:9092,broker2:9092");
kafkaProps.put("group.id", "NameGroup");
kafkaProps.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
kafkaProps.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
KafkaConsumer kafkaConsumer = new KafkaConsumer<String, String>(kafkaProps);
复制代码

其中,只有group.id是不同的属性,而它也不是严格必须的,这个参数是消费者的消费组。

1.2 订阅主题

创建完毕消费者后,就可以订阅主题了,很简单,只需要通过调用subscribe()方法即可,接受一个主题列表,也可以通过正则表达式的方式来匹配多个主题,而且订阅之后如果又有匹配的新主题,那么这个消费组会立即对其进行消费:

kafkaConsumer.subscribe(Collections.singletonList("name"));
kafkaConsumer.subscribe(Collections.singletonList("test.*"));
复制代码

1.3 拉取循环

获取消费数据也很简单,由于生产者产生的数据消费者是不知道的,KafkaConsumer 采用轮询的方式定期去 Kafka Broker 中进行数据的检索,如果有数据就用来消费,如果没有就再继续轮询等待,我们只需要循环不断拉取消息即可。

try {
    while (true) {
        
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));//{1}
        for (ConsumerRecord<String, String> record : records){
            System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());//{2}
        }
    }
} finally {
    kafkaConsumer.close();//{3}
}
复制代码

其中,对以上标注的地方需要说明下:

  1. 这是最核心的一行代码。我们不断调用poll拉取数据,如果停止拉取,那么Kafka会认为此消费者已经死亡并进行重平衡。参数值是一个超时时间,指明线程如果没有数据时等待多长时间,0表示不等待立即返回。
  2. poll()方法返回记录的列表,每条记录包含key/value以及主题、分区、位移信息。
  3. 主动关闭可以使得Kafka立即进行重平衡而不需要等待会话过期。

2.消费者配置

与生产者一样,消费者还有很多自定义参数可以根据实际情况自由配置,下面对一些比较重要的参数进行说明。

  • fetch.min.bytes

这个参数允许消费者指定从broker读取消息时最小的数据量。当消费者从broker读取消息时,如果数据量小于这个阈值,broker会等待直到有足够的数据,然后才返回给消费者。默认设置为1字节。

  • fetch.max.wait.ms

该参数则指定了消费者读取时最长等待时间,从而避免长时间阻塞。默认为500ms。

  • max.partition.fetch.bytes

这个参数指定了每个分区返回的最多字节数,默认为1M。如果一个主题有10个分区,同时有5个消费者,那么每个消费者需2M的空间来处理消息。实际情况中,我们需要设置更多的空间,这样当存在消费者宕机时,其他消费者可以承担更多的分区。

需要注意的是,max.partition.fetch.bytes必须要比broker能够接收的最大的消息(由max.message.size设置)大,否则会导致消费者消费不了消息。另外,在上面的样例可以看到,我们通常循环调用poll方法来读取消息,如果max.partition.fetch.bytes设置过大,那么消费者需要更长的时间来处理,可能会导致没有及时poll而会话过期。对于这种情况,要么减小max.partition.fetch.bytes,要么加长会话时间。

  • session.timeout.ms

这个参数设置消费者会话过期时间,默认为3秒。也就是说,如果消费者在这段时间内没有发送心跳,那么broker将会认为会话过期而进行分区重平衡。session.timeout.ms越小,越可以让Kafka快速发现故障进行重平衡,但另一方面也加大了误判的概率(比如消费者可能只是处理消息慢了而不是宕机)。

  • heartbeat.interval.ms

heartbeat.interval.ms控制KafkaConsumerpoll()方法多长时间发送一次心跳,这个值需要比session.timeout.ms小,一般为1/3,也就是1秒。

  • auto.offset.reset

该属性指定了消费者在读取一个没有偏移量的分区或者偏移量无效的情况下的该如何处理。可以取值为latest(从最新的消息开始消费)或者earliest(从最老的消息开始消费)。默认为latest

  • enable.auto.commit

指定消费者是否自动提交消费位移,默认为true。如果需要减少重复消费或者数据丢失,你可以设置为false。如果为true,你可能需要关注自动提交的频率,该频率由auto.commit.interval.ms设置。

  • partition.assignment.strategy

我们已经知道当消费组存在多个消费者时,主题的分区需要按照一定策略分配给消费者。这个策略由PartitionAssignor类决定,默认有两种策略:

  • 范围(Range):对于每个主题,每个消费者负责一定的连续范围分区。假如消费者C1和消费者C2订阅了两个主题,这两个主题都有3个分区,那么使用这个策略会导致消费者C1负责每个主题的分区0和分区1(下标基于0开始),消费者C2负责分区2。可以看到,如果消费者数量不能整除分区数,那么第一个消费者会多出几个分区(由主题数决定)。
  • 轮询(RoundRobin):对于所有订阅的主题分区,按顺序一一的分配给消费者。用上面的例子来说,消费者C1负责第一个主题的分区0、分区2,以及第二个主题的分区1;其他分区则由消费者C2负责。可以看到,这种策略更加均衡,所有消费者之间的分区数的差值最多为1。

partition.assignment.strategy设置了分配策略,默认为org.apache.kafka.clients.consumer.RangeAssignor(使用范围策略),你可以设置为org.apache.kafka.clients.consumer.RoundRobinAssignor(使用轮询策略),或者自己实现一个分配策略然后将partition.assignment.strategy指向该实现类。

  • client.id

这个参数可以为任意值,用来指明消息从哪个客户端发出,一般会在打印日志、衡量指标、分配配额时使用。

  • max.poll.records

这个参数控制一个poll()调用返回的记录数,这个可以用来控制应用在拉取循环中的处理数据量。

  • receive.buffer.bytes、send.buffer.bytes

socket 在读写数据时用到的 TCP 缓冲区也可以设置大小。如果它们被设置为 -1,就使用操作系统默认值。如果生产者或消费者与 broker 处于不同的数据中心内,可以适当增大这些值,因为跨数据中心的网络一般都有比较高的延迟和比较低的带宽。

3.提交(commit)与位移(offset)

我们调用poll()时,该方法会返回我们没有消费的消息。当消息从broker返回消费者时,broker并不跟踪这些消息是否被消费者接收到,Kafka而是让消费者自身来管理消费的位移,并向消费者提供更新位移的接口,这种更新位移方式称为提交(commit)。

消费者会向一个叫做 _consumer_offset 的特殊主题中发送消息,这个主题会保存每次所发送消息中的分区偏移量,这个主题的主要作用就是消费者触发重平衡后记录偏移使用的,消费者每次向这个主题发送消息,正常情况下不触发重平衡,这个主题是不起作用的,当触发重平衡后,消费者停止工作,每个消费者可能会分到对应的分区,这个主题就是让消费者能够继续处理消息所设置的。

如果提交的偏移量小于客户端最后一次处理的偏移量,那么位于两个偏移量之间的消息就会被重复处理,如下所示:

dup

如果提交的偏移量大于最后一次消费时的偏移量,那么处于两个偏移量中间的消息将会丢失,如下所示:

miss

因此,提交位移的方式会对应用有比较大的影响,下面来介绍下不同的提交方式:

自动提交

最简单的方式就是自动提交,我们只需要设置enable.auto.commit为true即可,消费者会在poll()方法调用后每隔5秒(由auto.commit.interval.ms指定)提交一次位移。但需要注意,这种方式可能会导致消息重复消费。

同步提交

为了减少消息重复消费或者避免消息丢失,一般都会使用手动提交,设置auto.commit.offset为false。手动提交我们又可以分为同步和异步两种。

对于同步,应用需要自己通过调用commitSync()来主动提交位移,该方法会提交poll返回的最后位移。

try {
    kafkaConsumer.commitSync();
} catch (CommitFailedException e) {
    e.printStackTrace();
}
复制代码

异步提交

同步的缺点大家都很清楚,当发起提交调用时应用会阻塞,自然想到使用异步的方式,应用可以通过调用commitAsync()来发起提交请求后立即返回。但是异步提交也有个缺点,那就是如果服务器返回提交失败,异步提交不会进行重试,如果同时存在多个异步提交,进行重试可能会导致位移覆盖。相比较起来,同步提交会进行重试直到成功或者最后抛出异常给应用。

因此,基于这种情况,一般都会对异步提交通过回调的方式记录提交结果,例如这样:

kafkaConsumer.commitAsync((offsets, exception) -> {
    if (exception != null){
        exception.printStackTrace();
    }
});
复制代码

另外,如果想进行重试同时又保证提交顺序的话,一种简单的办法是使用单调递增的序号,就像是乐观锁一样,每次发起异步提交时增加此序号,并且将此时的序号作为参数传给回调方法;当消息提交失败回调时,检查参数中的序号值与全局的序号值,如果相等那么可以进行重试提交,否则放弃。

混合同步提交与异步提交

一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大的问题,只要后续的提交成功就可以了。但是如果是需要确保提交成功的情况(例如程序退出、重平衡),一种非常普遍的方式是混合异步提交和同步提交,如下所示:

try {
    while (true) {
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records){
            System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
        }
        kafkaConsumer.commitAsync();
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    try {
        kafkaConsumer.commitSync();
    } finally {
        kafkaConsumer.close();
    }
}
复制代码

我们使用异步提交来提高性能,但最后使用同步提交来保证位移提交成功。

提交特定位移

如果poll()返回的消息数量非常多,我们可能会希望在处理这些批量消息过程中提交位移,以免重平衡导致从头开始消费和处理。commitSync()commitAsync()允许我们指定特定的位移参数,参数为一个分区与位移的map。例如如下,每处理1000条消息就会异步提交

private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
int count = 0;

.....

    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records){
    System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
    currentOffsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset()+1, "no metadata"));
    if (count % 1000 == 0){
        kafkaConsumer.commitAsync(currentOffsets, null);
    }
    count++;
}
复制代码

4.重平衡监听器(Rebalance Listener)

在分区重平衡前,如果消费者知道它即将不再负责某个分区,那么它可能需要将已经处理过的消息位移进行提交。Kafka允许我们在消费者新增分区或者失去分区时进行处理,我们只需要在调用subscribe()方法时传入ConsumerRebalanceListener对象,该对象主要有两个方法:

  • public void onPartitionRevoked(Collection partitions):此方法会在消费者停止消费消费后,在重平衡开始前调用。
  • public void onPartitionAssigned(Collection partitions):此方法在分区分配给消费者后,在消费者开始读取消息前调用。

下面来看一个的例子,该例子在消费者失去某个分区时提交位移(以便其他消费者可以接着消费消息并处理):

private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
private class HandleRebalance implements ConsumerRebalanceListener {
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
    }

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        kafkaConsumer.commitSync(currentOffsets);
    }
}

.....
    
try {
    kafkaConsumer.subscribe(Collections.singletonList("name"),new HandleRebalance());
    while (true) {
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records){
            currentOffsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset()+1, "no metadata"));
            kafkaConsumer.commitAsync();
        }
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    try {
        kafkaConsumer.commitSync();
    } finally {
        kafkaConsumer.close();
    }
}
复制代码

5.从指定位移开始消费

我们除了使用poll()来从最后的提交位移开始消费,但我们也可以从一个指定的位移开始消费。

  • seekToBeginning(TopicPartition tp):从分区开始端重新开始消费
  • seekToEnd(TopicPartition tp):从分区的最末端消费最新的消息
  • seek(TopicPartition partition, long offset):从指定位移开始消费。

从指定位移开始消费的应用场景有很多,其中最典型的一个是:位移存在其他系统(例如数据库)中,并且以其他系统的位移为准。

考虑以下场景:我们从Kafka中读取消费,然后进行处理,最后把结果写入数据库;我们既不想丢失消息,也不想数据库中存在重复的消息数据。对于这样的场景,我们可能会按如下逻辑处理:

while (true) {
    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records)
    {
        currentOffsets.put(new TopicPartition(record.topic(), record.partition()), record.offset());
        processRecord(record);
        storeRecordInDB(record);
        kafkaConsumer.commitAsync(currentOffsets);
    }
}
复制代码

这个逻辑似乎没什么问题,但是要注意到,在持久化到数据库成功后,提交位移到Kafka可能会失败,那么这可能会导致消息会重复处理。对于这种情况,我们首先想到,将持久化到数据库与提交位移实现为原子性操作,也就是要么同时成功,要么同时失败,但可惜这个是不可能的。因此我们可以在保存记录到数据库的同时,也保存位移,然后在消费者开始消费时使用数据库的位移开始消费。这个方案是可行的,我们只需要通过seek()来指定分区位移开始消费即可。下面是一个改进的样例代码:

public class SaveOffsetsOnRebalance implements ConsumerRebalanceListener {
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        //在消费者负责的分区被回收前提交数据库事务,保存消费的记录和位移
        commitDBTransaction();
    }
    
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        //在开始消费前,从数据库中获取分区的位移,并使用seek()来指定开始消费的位移
        for(TopicPartition partition: partitions)
            kafkaConsumer.seek(partition, getOffsetFromDB(partition));
    } 
}

    kafkaConsumer.subscribe(topics, new SaveOffsetOnRebalance());
    //在subscribe()之后poll一次,并从数据库中获取分区的位移,使用seek()来指定开始消费的位移
    kafkaConsumer.poll(0);
    for (TopicPartition partition: kafkaConsumer.assignment()){
         kafkaConsumer.seek(partition, getOffsetFromDB(partition));
    }
       
    while (true) {
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records)
        {
            processRecord(record);
            //保存记录结果
            storeRecordInDB(record);
            //保存位移
            storeOffsetInDB(record.topic(), record.partition(), record.offset());
        }
        //提交数据库事务,保存消费的记录以及位移
        commitDBTransaction();
    }
复制代码

6.优雅退出

一般而言,我们会在一个主线程中循环poll消息并进行处理。当需要退出poll循环时,我们可以使用另一个线程调用consumer.wakeup(),调用此方法会使得poll()抛出WakeupException。如果调用wakup()时,主线程正在处理消息,那么在下一次主线程调用poll()时会抛出异常。主线程在抛出WakeUpException后,需要调用consumer.close(),此方法会提交位移,同时发送一个退出消费组的消息到Kafka的组协调者。组协调者收到消息后会立即进行重平衡(而无需等待此消费者会话过期)。

Thread mainThread = Thread.currentThread();
...   
//注册JVM关闭时的回调钩子,当JVM关闭时调用此钩子。
Runtime.getRuntime().addShutdownHook(new Thread() {
    public void run() {
        System.out.println("Starting exit...");
        kafkaConsumer.wakeup();
        try {
            // 主线程继续执行,以便可以关闭consumer,提交偏移量
            mainThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
...
try {
    while (true) {
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
            System.out.println("topic = " + record.topic() + ", partition = " + record.partition() 
                               + ", offset = " + record.offset());
        }
        kafkaConsumer.commitSync();
    }
}catch (WakeupException e) {
    // 不处理异常
} finally {
    // 在退出线程之前调用close()是很有必要的,它会提交任何还没有提交的东西,并向组协调器发送消息,告知自己要离开群组。 接下来就会触发再均衡,而不需要等待会话超时。
    kafkaConsumer.commitSync();
    kafkaConsumer.close();
    System.out.println("Closed consumer");
}   
复制代码
文章分类
后端
文章标签