Bootstrap

Kafka权威指南(第2版)读书笔记

Kafka生产者——向Kafka写入数据

不管是把Kafka作为消息队列、消息总线还是数据存储平台,总是需要一个可以往Kafka写入数据的生产者、一个可以从Kafka读取数据的消费者,或者一个兼具两种角色的应用程序。

生产者概览

一个应用程序会在很多情况下向Kafka写入消息:记录用户的活动(用于审计和分析)​、记录指标、记录日志、记录从智能家电收集到的信息、与其他应用程序进行异步通信、缓冲即将写入数据库的数据,等等。不同的应用场景直接影响如何使用和配置生产者API。尽管生产者API使用起来很简单,但消息的发送过程还是有点儿复杂。下图展示了向Kafka发送消息的主要步骤:
在这里插入图片描述
先从创建一个ProducerRecord对象开始,其中需要包含目标主题和要发送的内容。另外,还可以指定键、分区、时间戳或标头。在发送ProducerRecord对象时,生产者需要先把键和值对象序列化成字节数组,这样才能在网络上传输。

接下来,如果没有显式地指定分区,那么数据将被传给分区器。分区器通常会基于ProducerRecord对象的键选择一个分区。选好分区以后,生产者就知道该往哪个主题和分区发送这条消息了。紧接着,该消息会被添加到一个消息批次里,这个批次里的所有消息都将被发送给同一个主题和分区。有一个独立的线程负责把这些消息批次发送给目标broker。

broker在收到这些消息时会返回一个响应。如果消息写入成功,就返回一个RecordMetaData对象,其中包含了主题和分区信息,以及消息在分区中的偏移量。如果消息写入失败,则会返回一个错误。生产者在收到错误之后会尝试重新发送消息,重试几次之后如果还是失败,则会放弃重试,并返回错误信息。

创建Kafka生产者

要向Kafka写入消息,首先需要创建一个生产者对象,并设置一些属性。Kafka生产者有3个必须设置的属性。

bootstrap.servers

broker的地址。可以由多个host:port组成,生产者用它们来建立初始的Kafka集群连接。它不需要包含所有的broker地址,因为生产者在建立初始连接之后可以从给定的broker那里找到其他broker的信息。不过还是建议至少提供两个broker地址,因为一旦其中一个停机,则生产者仍然可以连接到集群。

key.serializer

一个类名,用来序列化消息的键。broker希望接收到的消息的键和值都是字节数组。生产者可以把任意Java对象作为键和值发送给broker,但它需要知道如何把这些Java对象转换成字节数组。key.serializer必须被设置为一个实现了org.apache.kafka.common.serialization.Serializer接口的类,生产者会用这个类把键序列化成字节数组。Kafka客户端默认提供了ByteArraySerializer、StringSerializer和IntegerSerializer等,如果你只使用常见的几种Java对象类型,就没有必要实现自己的序列化器。需要注意的是,必须设置key.serializer这个属性,尽管你可能只需要将值发送给Kafka。如果只需要发送值,则可以将Void作为键的类型,然后将这个属性设置为VoidSerializer。

value.serializer

一个类名,用来序列化消息的值。与设置key.serializer属性一样,需要将value.serializer设置成可以序列化消息值对象的类。

发送消息到Kafka

同步发送消息

同步发送消息很简单,当Kafka返回错误或重试次数达到上限时,生产者可以捕获到异常。这里需要考虑性能问题。根据Kafka集群繁忙程度的不同,broker可能需要2毫秒或更长的时间来响应请求。如果采用同步发送方式,那么发送线程在这段时间内就只能等待,什么也不做,甚至都不发送其他消息,这将导致糟糕的性能。因此,同步发送方式通常不会被用在生产环境中​。

KafkaProducer一般会出现两种错误。一种是可重试错误,这种错误可以通过重发消息来解决。例如,对于连接错误,只要再次建立连接就可以解决。对于“not leader for partition”​(非分区首领)错误,只要重新为分区选举首领就可以解决,此时元数据也会被刷新。可以通过配置启用KafkaProducer的自动重试机制。如果在多次重试后仍无法解决问题,则应用程序会收到重试异常。另一种错误则无法通过重试解决,比如“Message size too large”​(消息太大)​。对于这种错误,KafkaProducer不会进行任何重试,而会立即抛出异常。

异步发送消息

假设一条消息在应用程序和Kafka集群之间往返需要10毫秒。如果在发送完每条消息后都需要等待响应,那么发送100条消息将需要1秒。如果只发送消息但不需要等待响应,那么发送100条消息所需要的时间就会少很多。大多数时候,并不需要等待响应——尽管Kafka会把消息的目标主题、分区信息和偏移量返回给客户端,但对客户端应用程序来说可能不是必需的。不过,当消息发送失败,需要抛出异常、记录错误日志或者把消息写入“错误消息”文件以便日后分析诊断时,就需要用到这些信息了。为了能够在异步发送消息时处理异常情况,生产者提供了回调机制。

为了使用回调,需要一个实现了org.apache.kafka.clients.producer.Callback接口的类,这个接口只有一个onCompletion方法。

回调的执行将在生产者主线程中进行,如果有两条消息被发送给同一个分区,则这可以保证它们的回调是按照发送的顺序执行的。这就要求回调的执行要快,避免生产者出现延迟或影响其他消息的发送。不建议在回调中执行阻塞操作,阻塞操作应该被放在其他线程中执行。

生产者配置

生产者还有很多其他的可配置的参数,Kafka文档中都有说明。它们大部分有合理的默认值,没有必要进行修改。不过有几个参数在内存使用、性能和可靠性方面对生产者影响比较大,接下来将详细介绍它们。

client.id

client.id是客户端标识符,它的值可以是任意字符串,broker用它来识别从客户端发送过来的消息。client.id可以被用在日志、指标和配额中。选择一个好的客户端标识符可以让故障诊断变得更容易些,这就好比“我们看到很多来自IP地址104.27.155.134的身份验证失败了”要比“好像订单验证服务的身份验证失败了,你能不能让Laura看看”更容易诊断问题。

acks

acks指定了生产者在多少个分区副本收到消息的情况下才会认为消息写入成功。在默认情况下,Kafka会在首领副本收到消息后向客户端回应消息写入成功(Kafka 3.0预计会改变这个默认行为)​。这个参数对写入消息的持久性有重大影响,对于不同的场景,使用默认值可能不是最好的选择。

  • acks=0:如果acks=0,则生产者不会等待任何来自broker的响应。也就是说,如果broker因为某些问题没有收到消息,那么生产者便无从得知,消息也就丢失了。不过,因为生产者不需要等待broker返回响应,所以它们能够以网络可支持的最大速度发送消息,从而达到很高的吞吐量。
  • acks=1:如果acks=1,那么只要集群的首领副本收到消息,生产者就会收到消息成功写入的响应。如果消息无法到达首领副本(比如首领副本发生崩溃,新首领还未选举出来)​,那么生产者会收到一个错误响应。为了避免数据丢失,生产者会尝试重发消息。不过,在首领副本发生崩溃的情况下,如果消息还没有被复制到新的首领副本,则消息还是有可能丢失。
  • acks=all:如果acks=all,那么只有当所有副本全部收到消息时,生产者才会收到消息成功写入的响应。这种模式是最安全的,它可以保证不止一个broker收到消息,就算有个别broker发生崩溃,整个集群仍然可以运行​。不过,它的延迟比acks=1高,因为生产者需要等待不止一个broker确认收到消息。

你会发现,为acks设置的值越小,生产者发送消息的速度就越快。也就是说,我们通过牺牲可靠性来换取较低的生产者延迟。不过,端到端延迟是指从消息生成到可供消费者读取的时间,这对3种配置来说都是一样的。这是因为为了保持一致性,在消息被写入所有同步副本之前,Kafka不允许消费者读取它们。因此,如果你关心的是端到端延迟,而不是生产者延迟,那么就不需要在可靠性和低延迟之间做权衡了:你可以选择最可靠的配置,但仍然可以获得相同的端到端延迟。1

消息传递时间

有几个参数可用来控制开发人员最感兴趣的生产者行为:在调用send()方法后多长时间可以知道消息发送成功与否。这也是等待Kafka返回成功响应或放弃重试并承认发送失败的时间。多年来,这些配置参数和相应的行为经历了多次变化。这里将介绍在Kafka 2.1中引入的最新实现。从Kafka 2.1开始,我们将ProduceRecord的发送时间分成如下两个时间间隔,它们是被分开处理的:

  • 异步调用send()所花费的时间。在此期间,调用send()的线程将被阻塞。
  • 从异步调用send()返回到触发回调(不管是成功还是失败)的时间,也就是从ProduceRecord被放到批次中直到Kafka成功响应、出现不可恢复异常或发送超时的时间。

下图展示了生产者的内部数据流以及不同的配置参数如何相互影响:
在这里插入图片描述

max.block.ms

这个参数用于控制在调用send()或通过partitionsFor()显式地请求元数据时生产者可以发生阻塞的时间。当生产者的发送缓冲区被填满或元数据不可用时,这些方法就可能发生阻塞。当达到max.block.ms配置的时间时,就会抛出一个超时异常。

delivery.timeout.ms

这个参数用于控制从消息准备好发送(send()方法成功返回并将消息放入批次中)到broker响应或客户端放弃发送(包括重试)所花费的时间。这个时间应该大于linger.ms和request.timeout.ms。如果配置的时间不满足这一点,则会抛出异常。通常,成功发送消息的速度要比delivery.timeout.ms快得多。

如果生产者在重试时超出了delivery.timeout.ms,那么将执行回调,并会将broker之前返回的错误传给它。如果消息批次还没有发送完毕就超出了delivery.timeout.ms,那么也将执行回调,并会将超时异常传给它。

可以将这个参数配置成你愿意等待的最长时间,通常是几分钟,并使用默认的重试次数(几乎无限制)​。基于这样的配置,只要生产者还有时间(或者在发送成功之前)​,它都会持续重试。这是一种合理的重试方式。我们的重试策略通常是:​“在broker发生崩溃的情况下,首领选举通常需要30秒才能完成,因此为了以防万一,我们会持续重试120秒。​”为了避免烦琐地配置重试次数和重试时间间隔,只需将delivery.timeout.ms设置为120。

request.timeout.ms

这个参数用于控制生产者在发送消息时等待服务器响应的时间。需要注意的是,这是指生产者在放弃之前等待每个请求的时间,不包括重试、发送之前所花费的时间等。如果设置的值已触及,但服务器没有响应,那么生产者将重试发送,或者执行回调,并传给它一个TimeoutException。

retries 和retry.backoff.ms

当生产者收到来自服务器的错误消息时,这个错误有可能是暂时的(例如,一个分区没有首领)​。在这种情况下,retries参数可用于控制生产者在放弃发送并向客户端宣告失败之前可以重试多少次。在默认情况下,重试时间间隔是100毫秒,但可以通过retry.backoff.ms参数来控制重试时间间隔。

并不建议在当前版本的Kafka中使用这些参数。相反,你可以测试一下broker在发生崩溃之后需要多长时间恢复(也就是直到所有分区都有了首领副本)​,并设置合理的delivery.timeout.ms,让重试时间大于Kafka集群从崩溃中恢复的时间,以免生产者过早放弃重试。

生产者并不会重试所有的错误。有些错误不是暂时的,生产者就不会进行重试(例如,​“消息太大”错误)​。通常,对于可重试的错误,生产者会自动进行重试,所以不需要在应用程序中处理重试逻辑。你要做的是集中精力处理不可重试的错误或者当重试次数达到上限时的情况。

如果想完全禁用重试,那么唯一可行的方法是将retries设置为0。

linger.ms

这个参数指定了生产者在发送消息批次之前等待更多消息加入批次的时间。生产者会在批次被填满或等待时间达到linger.ms时把消息批次发送出去。在默认情况下,只要有可用的发送者线程,生产者都会直接把批次发送出去,就算批次中只有一条消息。把linger.ms设置成比0大的数,可以让生产者在将批次发送给服务器之前等待一会儿,以使更多的消息加入批次中。虽然这样会增加一点儿延迟,但也极大地提升了吞吐量。这是因为一次性发送的消息越多,每条消息的开销就越小,如果启用了压缩,则计算量也更少了。

buffer.memory

这个参数用来设置生产者要发送给服务器的消息的内存缓冲区大小。如果应用程序调用send()方法的速度超过生产者将消息发送给服务器的速度,那么生产者的缓冲空间可能会被耗尽,后续的send()方法调用会等待内存空间被释放,如果在max.block.ms之后还没有可用空间,就抛出异常。需要注意的是,这个异常与其他异常不一样,它是send()方法而不是Future对象抛出来的。

compression.type

在默认情况下,生产者发送的消息是未经压缩的。这个参数可以被设置为snappy、gzip、lz4或zstd,这指定了消息被发送给broker之前使用哪一种压缩算法。snappy压缩算法由谷歌发明,虽然占用较少的CPU时间,但能提供较好的性能和相当可观的压缩比。如果同时有性能和网络带宽方面的考虑,那么可以使用这种算法。gzip压缩算法通常会占用较多的CPU时间,但提供了更高的压缩比。如果网络带宽比较有限,则可以使用这种算法。使用压缩可以降低网络传输和存储开销,而这些往往是向Kafka发送消息的瓶颈所在。

batch.size

当有多条消息被发送给同一个分区时,生产者会把它们放在同一个批次里。这个参数指定了一个批次可以使用的内存大小。需要注意的是,该参数是按照字节数而不是消息条数来计算的。当批次被填满时,批次里所有的消息都将被发送出去。但是生产者并不一定都会等到批次被填满时才将其发送出去。那些未填满的批次,甚至只包含一条消息的批次也有可能被发送出去。所以,就算把批次大小设置得很大,也不会导致延迟,只是会占用更多的内存而已。但如果把批次大小设置得太小,则会增加一些额外的开销,因为生产者需要更频繁地发送消息。

max.in.flight.requests.per.connection

这个参数指定了生产者在收到服务器响应之前可以发送多少个消息批次。它的值越大,占用的内存就越多,不过吞吐量也会得到提升。Apache wiki页面上的实验数据表明,在单数据中心环境中,该参数被设置为2时可以获得最佳的吞吐量,但使用默认值5也可以获得差不多的性能。

顺序保证

Kafka可以保证同一个分区中的消息是有序的。也就是说,如果生产者按照一定的顺序发送消息,那么broker会按照这个顺序把它们写入分区,消费者也会按照同样的顺序读取它们。在某些情况下,顺序是非常重要的。例如,向一个账户中存入100元再取出来与先从账户中取钱再存回去是截然不同的!不过,有些场景对顺序不是很敏感。

假设我们把retries设置为非零的整数,并把max.in.flight.requests.per.connection设置为比1大的数。如果第一个批次写入失败,第二个批次写入成功,那么broker会重试写入第一个批次,等到第一个批次也写入成功,两个批次的顺序就反过来了。

我们希望至少有2个正在处理中的请求(出于性能方面的考虑)​,并且可以进行多次重试(出于可靠性方面的考虑)​,这个时候,最好的解决方案是将enable.idempotence设置为true。这样就可以在最多有5个正在处理中的请求的情况下保证顺序,并且可以保证重试不会引入重复消息。

max.request.size

这个参数用于控制生产者发送的请求的大小。它限制了可发送的单条最大消息的大小和单个请求的消息总量的大小。假设这个参数的值为1 MB,那么可发送的单条最大消息就是1 MB,或者生产者最多可以在单个请求里发送一条包含1024个大小为1 KB的消息。另外,broker对可接收的最大消息也有限制(message.max.bytes),其两边的配置最好是匹配的,以免生产者发送的消息被broker拒绝。

receive.buffer.bytes和send.buffer.bytes

这两个参数分别指定了TCP socket接收和发送数据包的缓冲区大小。如果它们被设为–1,就使用操作系统默认值。如果生产者或消费者与broker位于不同的数据中心,则可以适当加大它们的值,因为跨数据中心网络的延迟一般都比较高,而带宽又比较低

enable.idempotence

从0.11版本开始,Kafka支持精确一次性(exactly once)语义。幂等生产者是它的一个简单且重要的组成部分。假设为了最大限度地提升可靠性,你将生产者的acks设置为all,并将delivery.timeout.ms设置为一个比较大的数,允许进行尽可能多的重试。这些配置可以确保每条消息被写入Kafka至少一次。但在某些情况下,消息有可能被写入Kafka不止一次。假设一个broker收到了生产者发送的消息,然后消息被写入本地磁盘并成功复制给了其他broker。此时,这个broker还没有向生产者发送响应就发生了崩溃。而生产者将一直等待,直到达到request.timeout.ms,然后进行重试。重试发送的消息将被发送给新的首领,而这个首领已经有这条消息的副本,因为之前写入的消息已经被成功复制给它了。现在,你就有了一条重复的消息。

为了避免这种情况,可以将enable.idempotence设置为true。当幂等生产者被启用时,生产者将给发送的每一条消息都加上一个序列号。如果broker收到具有相同序列号的消息,那么它就会拒绝第二个副本,而生产者则会收到DuplicateSequenceException,这个异常对生产者来说是无害的。

如果要启用幂等性,那么max.in.flight.requests.per.connection应小于或等于5、retries应大于0,并且acks被设置为all。如果设置了不恰当的值,则会抛出ConfigException异常。

序列化器

我们已经知道如何使用默认的字符串序列化器,除此之外,Kafka还提供了整型和字节数组等序列化器,但它们并不能覆盖大多数应用场景。毕竟,我们还需要序列化更多通用的记录类型。
如果要发送给Kafka的对象不是简单的字符串或整型,则既可以用通用的序列化框架(比如Avro、Thrift或Protobuf)来创建消息,也可以使用自定义序列化器。建议使用已有的序列化器和反序列化器,比如JSON、Avro、Thrift或Protobuf。

分区

ProducerRecord对象包含了主题名称、记录的键和值。Kafka消息就是一个个的键–值对,ProducerRecord对象可以只包含主题名称和值,键默认情况下是null。不过,大多数应用程序还是会用键来发送消息。键有两种用途:一是作为消息的附加信息与消息保存在一起,二是用来确定消息应该被写入主题的哪个分区(键在压缩主题中也扮演了重要角色)​。具有相同键的消息将被写入同一个分区。如果一个进程只从主题的某些分区读取数据,那么具有相同键的所有记录都会被这个进程读取。要创建一个包含键和值的记录,只需像下面这样创建一个ProducerRecord即可:ProducerRecord<String, String> record = new ProducerRecord<>("CustomerCountry", "Laboratory Equipment", "USA");

如果键为null,并且使用了默认的分区器,那么记录将被随机发送给主题的分区。分区器使用轮询调度(round-robin)算法将消息均衡地分布到各个分区中。从Kafka 2.4开始,在处理键为null的记录时,默认分区器使用的轮询调度算法具备了黏性。也就是说,在切换到下一个分区之前,它会将同一个批次的消息全部写入当前分区。这样就可以使用更少的请求发送相同数量的消息,既降低了延迟,又减少了broker占用CPU的时间。

如果键不为空且使用了默认的分区器,那么Kafka会对键进行哈希(使用Kafka自己的哈希算法,即使升级Java版本,哈希值也不会发生变化)​,然后根据哈希值把消息映射到特定的分区。这里的关键在于同一个键总是被映射到同一个分区,所以在进行映射时,会用到主题所有的分区,而不只是可用的分区。这也意味着,如果在写入数据时目标分区不可用,那么就会出错。不过这种情况很少发生。

自定义分区策略

我们已经讨论了默认分区器的特点,它也是最为常用的分区器。除了哈希分区,有时也需要使用不一样的分区策略。假设你是B2B供应商,你有一个大客户,它是手持设备Banana的制造商。你的日常交易中有10%以上的交易与这个客户有关。如果使用默认的哈希分区算法,那么与Banana相关的记录就会和其他客户的记录一起被分配给相同的分区,导致这个分区比其他分区大很多。服务器可能会出现存储空间不足、请求处理缓慢等问题。因此,需要给Banana分配单独的分区,然后使用哈希分区算法将其他记录分配给其他分区。下面是一个自定义分区器的例子:

import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.record.InvalidRecordException;
import org.apache.kafka.common.utils.Utils;

public class BananaPartitioner implements Partitioner {

    public void configure(Map<String, ?> configs) {}public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes,
                         Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();

        if ((keyBytes == null) || (!(key instanceOf String)))throw new InvalidRecordException("We expect all messages "+
                "to have customer name as key");

        if (((String) key).equals("Banana"))
            return numPartitions - 1; // Banana的记录总是被分配到最后一个分区

        // 其他记录被哈希到其他分区
        return Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1);
    }

    public void close() {}
}

Partitioner接口包含了configure、partition和close这3个方法。这里只实现partition方法。不能在partition方法里硬编码客户的名字,而应该通过configure方法传进来。只接受字符串作为键,如果不是字符串,就抛出异常。

标头

除了键和值,记录还可以包含标头。可以在不改变记录键–值对的情况下向标头中添加一些有关记录的元数据。标头指明了记录数据的来源,可以在不解析消息体的情况下根据标头信息来路由或跟踪消息(消息有可能被加密,而路由器没有访问加密数据的权限)​。标头由一系列有序的键–值对组成。键是字符串,值可以是任意被序列化的对象,就像消息里的值一样。

拦截器

有时候,你希望在不修改代码的情况下改变Kafka客户端的行为。这或许是因为你想给公司所有的应用程序都加上同样的行为,或许是因为无法访问应用程序的原始代码。Kafka的ProducerInterceptor拦截器包含两个关键方法:

  • ProducerRecord<K, V> onSend(ProducerRecord<K, V> record):这个方法会在记录被发送给Kafka之前,甚至是在记录被序列化之前调用。如果覆盖了这个方法,那么你就可以捕获到有关记录的信息,甚至可以修改它。只需确保这个方法返回一个有效的ProducerRecord对象。这个方法返回的记录将被序列化并发送给Kafka。
  • void onAcknowledgement(RecordMetadata metadata, Exception exception):这个方法会在收到Kafka的确认响应时调用。如果覆盖了这个方法,则不可以修改Kafka返回的响应,但可以捕获到有关响应的信息。

配额和节流

Kafka可以限制生产消息和消费消息的速率,这是通过配额机制来实现的。Kafka提供了3种配额类型:生产、消费和请求。生产配额和消费配额限制了客户端发送和接收数据的速率(以字节 / 秒为单位)​。请求配额限制了broker用于处理客户端请求的时间百分比。

可以为所有客户端(使用默认配额)​、特定客户端、特定用户,或特定客户端及特定用户设置配额。特定用户的配额只在集群配置了安全特性并对客户端进行了身份验证后才有效。

Kafka消费者——从Kafka读取数据

Kafka消费者相关概念

要想知道如何从Kafka读取消息,需要先了解消费者和消费者群组的概念。接下来将解释这些概念。

消费者和消费者群组

Kafka消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者负责读取这个主题的部分消息。如图所示:
在这里插入图片描述
如果向群组里添加更多的消费者,以致超过了主题的分区数量,那么就会有一部分消费者处于空闲状态,不会接收到任何消息。

向群组里添加消费者是横向扩展数据处理能力的主要方式。Kafka消费者经常需要执行一些高延迟的操作,比如把数据写到数据库或用数据做一些比较耗时的计算。在这些情况下,单个消费者无法跟上数据生成的速度,因此可以增加更多的消费者来分担负载,让每个消费者只处理部分分区的消息,这是横向扩展消费者的主要方式。于是,我们可以为主题创建大量的分区,当负载急剧增长时,可以加入更多的消费者。不过需要注意的是,不要让消费者的数量超过主题分区的数量,因为多余的消费者只会被闲置。

除了通过增加消费者数量来横向伸缩单个应用程序,我们还经常遇到多个应用程序从同一个主题读取数据的情况。实际上,Kafka的一个主要设计目标是让Kafka主题里的数据能够满足企业各种应用场景的需求。在这些应用场景中,我们希望每一个应用程序都能获取到所有的消息,而不只是其中的一部分。只要保证每个应用程序都有自己的消费者群组就可以让它们获取到所有的消息。不同于传统的消息系统,横向伸缩消费者和消费者群组并不会导致Kafka性能下降。

在之前的例子中,如果新增一个只包含一个消费者的群组G2,那么这个消费者将接收到主题T1的所有消息,与群组G1之间互不影响。群组G2可以增加更多的消费者,每个消费者会读取若干个分区,就像群组G1里的消费者那样。作为整体来说,群组G2还是会收到所有消息,不管有没有其他群组存在,如图所示:
在这里插入图片描述
总的来说,就是为每一个需要获取主题全部消息的应用程序创建一个消费者群组,然后向群组里添加更多的消费者来扩展读取能力和处理能力,让群组里的每个消费者只处理一部分消息。一个消费者最多只能消费一个分区,也是靠这个限制保证了单个分区消费的有序性。

消费者群组和分区再均衡

消费者群组里的消费者共享主题分区的所有权。当一个新消费者加入群组时,它将开始读取一部分原本由其他消费者读取的消息。当一个消费者被关闭或发生崩溃时,它将离开群组,原本由它读取的分区将由群组里的其他消费者读取。主题发生变化(比如管理员添加了新分区)会导致分区重分配。

分区的所有权从一个消费者转移到另一个消费者的行为称为再均衡。再均衡非常重要,它为消费者群组带来了高可用性和伸缩性(你可以放心地添加或移除消费者)​。不过,在正常情况下,我们并不希望发生再均衡。

主动再均衡

在进行主动再均衡期间,所有消费者都会停止读取消息,放弃分区所有权,重新加入消费者群组,并获得重新分配到的分区。这样会导致整个消费者群组在一个很短的时间窗口内不可用。这个时间窗口的长短取决于消费者群组的大小和几个配置参数。

协作再均衡

协作再均衡(也称为增量再均衡)通常是指将一个消费者的部分分区重新分配给另一个消费者,其他消费者则继续读取没有被重新分配的分区。这种再均衡包含两个或多个阶段。

在第一个阶段,消费者群组首领会通知所有消费者,它们将失去部分分区的所有权,然后消费者会停止读取这些分区,并放弃对它们的所有权。在第二个阶段,消费者群组首领会将这些没有所有权的分区分配给其他消费者。虽然这种增量再均衡可能需要进行几次迭代,直到达到稳定状态,但它避免了主动再均衡中出现的“停止世界”停顿。这对大型消费者群组来说尤为重要,因为它们的再均衡可能需要很长时间。

消费者会向被指定为群组协调器的broker(不同消费者群组的协调器可能不同)发送心跳,以此来保持群组成员关系和对分区的所有权关系。心跳是由消费者的一个后台线程发送的,只要消费者能够以正常的时间间隔发送心跳,它就会被认为还“活着”​。

如果消费者在足够长的一段时间内没有发送心跳,那么它的会话就将超时,群组协调器会认为它已经“死亡”​,进而触发再均衡。如果一个消费者发生崩溃并停止读取消息,那么群组协调器就会在几秒内收不到心跳,它会认为消费者已经“死亡”​,进而触发再均衡。在这几秒时间里,​“死掉”的消费者不会读取分区里的消息。在关闭消费者后,协调器会立即触发一次再均衡,尽量降低处理延迟。

分配分区过程

当一个消费者想要加入消费者群组时,它会向群组协调器发送JoinGroup请求。第一个加入群组的消费者将成为群组首领。首领从群组协调器那里获取群组的成员列表(列表中包含了所有最近发送过心跳的消费者,它们被认为还“活着”​)​,并负责为每一个消费者分配分区。它使用实现了PartitionAssignor接口的类来决定哪些分区应该被分配给哪个消费者。

Kafka内置了一些分区分配策略,后文将深入介绍它们。分区分配完毕之后,首领会把分区分配信息发送给群组协调器,群组协调器再把这些信息发送给所有的消费者。每个消费者只能看到自己的分配信息,只有首领会持有所有消费者及其分区所有权的信息。每次再均衡都会经历这个过程。

群组固定成员

在默认情况下,消费者的群组成员身份标识是临时的。当一个消费者离开群组时,分配给它的分区所有权将被撤销;当该消费者重新加入时,将通过再均衡协议为其分配一个新的成员ID和新分区。

可以给消费者分配一个唯一的group.instance.id,让它成为群组的固定成员。通常,当消费者第一次以固定成员身份加入群组时,群组协调器会按照分区分配策略给它分配一部分分区。当这个消费者被关闭时,它不会自动离开群组——它仍然是群组的成员,直到会话超时。当这个消费者重新加入群组时,它会继续持有之前的身份,并分配到之前所持有的分区。群组协调器缓存了每个成员的分区分配信息,只需要将缓存中的信息发送给重新加入的固定成员,不需要进行再均衡。如果两个消费者使用相同的group.instance.id加入同一个群组,则第二个消费者会收到错误,告诉它具有相同ID的消费者已存在。

创建Kafka消费者

在读取消息之前,需要先创建一个KafkaConsumer对象。创建KafkaConsumer对象与创建KafkaProducer对象非常相似——把想要传给消费者的属性放在Properties对象里。本章后续部分将深入介绍所有的配置属性。为简单起见,这里只提供3个必要的属性:bootstrap.servers、key.deserializer和value.deserializer。

第一个属性bootstrap.servers指定了连接Kafka集群的字符串。它的作用与KafkaProducer中的bootstrap.servers一样。另外两个属性key.deserializer和value.deserializer与生产者的key.serializer和value.serializer类似,只不过它们不是使用指定类把Java对象转成字节数组,而是把字节数组转成Java对象。

group.id指定了一个消费者属于哪一个消费者群组。也可以创建不属于任何一个群组的消费者,只是这种做法不太常见。要求必须指定group.id进行消费!

订阅主题

如果你的Kafka集群包含了大量分区(比如30 000个或更多)​,则需注意,主题的过滤是在客户端完成的。当你使用正则表达式而不是指定列表订阅主题时,消费者将定期向broker请求所有已订阅的主题及分区。然后,客户端会用这个列表来检查是否有新增的主题,如果有,就订阅它们。如果主题很多,消费者也很多,那么通过正则表达式订阅主题就会给broker、客户端和网络带来很大的开销。在某些情况下,主题元数据使用的带宽会超过用于发送数据的带宽。另外,为了能够使用正则表达式订阅主题,需要授予客户端获取集群全部主题元数据的权限,即全面描述整个集群的权限。

轮询

消费者API最核心的东西是通过一个简单的轮询向服务器请求数据。消费者实际上是一个长时间运行的应用程序,它通过持续轮询来向Kafka请求数据。稍后我们将介绍如何退出循环,并关闭消费者。

消费者必须持续对Kafka进行轮询,否则会被认为已经“死亡”​,它所消费的分区将被移交给群组里其他的消费者。传给poll()的参数是一个超时时间间隔,用于控制poll()的阻塞时间(当消费者缓冲区里没有可用数据时会发生阻塞)​。如果这个参数被设置为0或者有可用的数据,那么poll()就会立即返回,否则它会等待指定的毫秒数。poll()方法会返回一个记录列表。列表中的每一条记录都包含了主题和分区的信息、记录在分区里的偏移量,以及记录的键–值对。我们一般会遍历这个列表,逐条处理记录。

轮询不只是获取数据那么简单。在第一次调用消费者的poll()方法时,它需要找到GroupCoordinator,加入群组,并接收分配给它的分区。如果触发了再均衡,则整个再均衡过程也会在轮询里进行,包括执行相关的回调。所以,消费者或回调里可能出现的错误最后都会转化成poll()方法抛出的异常。需要注意的是,如果超过max.poll.interval.ms没有调用poll(),则消费者将被认为已经“死亡”​,并被逐出消费者群组。因此,要避免在轮询循环中做任何可能导致不可预知的阻塞的操作。

线程安全

我们既不能在同一个线程中运行多个同属一个群组的消费者,也不能保证多个线程能够安全地共享一个消费者。按照规则,一个消费者使用一个线程。如果要在应用程序的同一个消费者群组里运行多个消费者,则需要让每个消费者运行在自己的线程中。最好是把消费者的逻辑封装在自己的对象里,然后用Java的ExecutorService启动多个线程,让每个消费者运行在自己的线程中。Confluent博客上的教程展示了具体该怎么做。

配置消费者

Kafka的文档中列出了所有与消费者相关的配置属性。大部分属性有合理的默认值,一般不需要修改它们。不过,有一些属性与消费者的性能和可用性有很大关系,接下来将介绍这些比较重要的属性。

fetch.min.bytes

这个属性指定了消费者从服务器获取记录的最小字节数,默认是1字节。broker在收到消费者的获取数据请求时,如果可用数据量小于fetch.min.bytes指定的大小,那么它就会等到有足够可用数据时才将数据返回。这样可以降低消费者和broker的负载,因为它们在主题流量不是很大的时候(或者一天里的低流量时段)不需要来来回回地传输消息。如果消费者在没有太多可用数据时CPU使用率很高,或者在有很多消费者时为了降低broker的负载,那么可以把这个属性的值设置得比默认值大。但需要注意的是,在低吞吐量的情况下,加大这个值会增加延迟。

fetch.max.wait.ms

这个属性指定了Kafka返回的数据的最大字节数(默认为50 MB)​。消费者会将服务器返回的数据放在内存中,所以这个属性被用于限制消费者用来存放数据的内存大小。需要注意的是,记录是分批发送给客户端的,如果broker要发送的批次超过了这个属性指定的大小,那么这个限制将被忽略。这样可以保证消费者能够继续处理消息。值得注意的是,broker端也有一个与之对应的配置属性,Kafka管理员可以用它来限制最大获取数量。broker端的这个配置属性可能很有用,因为请求的数据量越大,需要从磁盘读取的数据量就越大,通过网络发送数据的时间就越长,这可能会导致资源争用并增加broker的负载。

max.poll.records

这个属性用于控制单次调用poll()方法返回的记录条数。可以用它来控制应用程序在进行每一次轮询循环时需要处理的记录条数(不是记录的大小)​。

max.partition.fetch.bytes

这个属性指定了服务器从每个分区里返回给消费者的最大字节数(默认值是1 MB)​。当KafkaConsumer.poll()方法返回ConsumerRecords时,从每个分区里返回的记录最多不超过max.partition.fetch.bytes指定的字节。需要注意的是,使用这个属性来控制消费者的内存使用量会让事情变得复杂,因为你无法控制broker返回的响应里包含多少个分区的数据。因此,对于这种情况,建议用fetch.max.bytes替代,除非有特殊的需求,比如要求从每个分区读取差不多的数据量。

session.timeout.ms和heartbeat.interval.ms

session.timeout.ms指定了消费者可以在多长时间内不与服务器发生交互而仍然被认为还“活着”​,默认是10秒。如果消费者没有在session.timeout.ms指定的时间内发送心跳给群组协调器,则会被认为已“死亡”​,协调器就会触发再均衡,把分区分配给群组里的其他消费者。session.timeout.ms与heartbeat.interval.ms紧密相关。heartbeat.interval.ms指定了消费者向协调器发送心跳的频率,session.timeout.ms指定了消费者可以多久不发送心跳。因此,我们一般会同时设置这两个属性,heartbeat.interval.ms必须比session.timeout.ms小,通常前者是后者的1/3。如果session.timeout.ms是3秒,那么heartbeat.interval.ms就应该是1秒。把session.timeout.ms设置得比默认值小,可以更快地检测到崩溃,并从崩溃中恢复,但也会导致不必要的再均衡。把session.timeout.ms设置得比默认值大,可以减少意外的再均衡,但需要更长的时间才能检测到崩溃。

max.poll.interval.ms

这个属性指定了消费者在被认为已经“死亡”之前可以在多长时间内不发起轮询。前面提到过,心跳和会话超时是Kafka检测已“死亡”的消费者并撤销其分区的主要机制。我们也提到了心跳是通过后台线程发送的,而后台线程有可能在消费者主线程发生死锁的情况下继续发送心跳,但这个消费者并没有在读取分区里的数据。要想知道消费者是否还在处理消息,最简单的方法是检查它是否还在请求数据。但是,请求之间的时间间隔是很难预测的,它不仅取决于可用的数据量、消费者处理数据的方式,有时还取决于其他服务的延迟。在需要耗费时间来处理每个记录的应用程序中,可以通过max.poll.records来限制返回的数据量,从而限制应用程序在再次调用poll()之前的等待时长。但是,即使设置了max.poll.records,调用poll()的时间间隔仍然很难预测。于是,设置max.poll.interval.ms就成了一种保险措施。它必须被设置得足够大,让正常的消费者尽量不触及这个阈值,但又要足够小,避免有问题的消费者给应用程序造成严重影响。这个属性的默认值为5分钟。当这个阈值被触及时,后台线程将向broker发送一个“离开群组”的请求,让broker知道这个消费者已经“死亡”​,必须进行群组再均衡,然后停止发送心跳。

default.api.timeout.ms

如果在调用消费者API时没有显式地指定超时时间,那么消费者就会在调用其他API时使用这个属性指定的值。默认值是1分钟,因为它比请求超时时间的默认值大,所以可以将重试时间包含在内。poll()方法是一个例外,因为它需要显式地指定超时时间。

request.timeout.ms

这个属性指定了消费者在收到broker响应之前可以等待的最长时间。如果broker在指定时间内没有做出响应,那么客户端就会关闭连接并尝试重连。它的默认值是30秒。不建议把它设置得比默认值小。在放弃请求之前要给broker留有足够长的时间来处理其他请求,因为向已经过载的broker发送请求几乎没有什么好处,况且断开并重连只会造成更大的开销。

auto.offset.reset

这个属性指定了消费者在读取一个没有偏移量或偏移量无效(因消费者长时间不在线,偏移量对应的记录已经过期并被删除)的分区时该做何处理。它的默认值是latest,意思是说,如果没有有效的偏移量,那么消费者将从最新的记录(在消费者启动之后写入Kafka的记录)开始读取。另一个值是earliest,意思是说,如果没有有效的偏移量,那么消费者将从起始位置开始读取记录。如果将auto.offset.reset设置为none,并试图用一个无效的偏移量来读取记录,则消费者将抛出异常。

enable.auto.commit

这个属性指定了消费者是否自动提交偏移量,默认值是true。你可以把它设置为false,选择自己控制何时提交偏移量,以尽量避免出现数据重复和丢失。如果它被设置为true,那么还有另外一个属性auto.commit.interval.ms可以用来控制偏移量的提交频率。

partition.assignment.strategy

我们知道,分区会被分配给群组里的消费者。PartitionAssignor根据给定的消费者和它们订阅的主题来决定哪些分区应该被分配给哪个消费者。Kafka提供了几种默认的分配策略:

区间(range)

这个策略会把每一个主题的若干个连续分区分配给消费者。假设消费者C1和消费者C2同时订阅了主题T1和主题T2,并且每个主题有3个分区。那么消费者C1有可能会被分配到这两个主题的分区0和分区1,消费者C2则会被分配到这两个主题的分区2。因为每个主题拥有奇数个分区,并且都遵循一样的分配策略,所以第一个消费者会分配到比第二个消费者更多的分区。只要使用了这个策略,并且分区数量无法被消费者数量整除,就会出现这种情况。

轮询(roundRobin)

这个策略会把所有被订阅的主题的所有分区按顺序逐个分配给消费者。如果使用轮询策略为消费者C1和消费者C2分配分区,那么消费者C1将分配到主题T1的分区0和分区2以及主题T2的分区1,消费者C2将分配到主题T1的分区1以及主题T2的分区0和分区2。一般来说,如果所有消费者都订阅了相同的主题(这种情况很常见)​,那么轮询策略会给所有消费者都分配相同数量(或最多就差一个)的分区。

黏性(sticky)

设计黏性分区分配器的目的有两个:一是尽可能均衡地分配分区,二是在进行再均衡时尽可能多地保留原先的分区所有权关系,减少将分区从一个消费者转移给另一个消费者所带来的开销。如果所有消费者都订阅了相同的主题,那么黏性分配器初始的分配比例将与轮询分配器一样均衡。后续的重新分配将同样保持均衡,但减少了需要移动的分区的数量。如果同一个群组里的消费者订阅了不同的主题,那么黏性分配器的分配比例将比轮询分配器更加均衡。

协作黏性(cooperative sticky)

这个分配策略与黏性分配器一样,只是它支持协作(增量式)再均衡,在进行再均衡时消费者可以继续从没有被重新分配的分区读取消息。

client.id

这个属性可以是任意字符串,broker用它来标识从客户端发送过来的请求,比如获取请求。它通常被用在日志、指标和配额中。

client.rack

在默认情况下,消费者会从每个分区的首领副本那里获取消息。但是,如果集群跨越了多个数据中心或多个云区域,那么让消费者从位于同一区域的副本那里获取消息就会具有性能和成本方面的优势。要从最近的副本获取消息,需要设置client.rack这个参数,用于标识客户端所在的区域。然后,可以将broker的replica.selector.class参数值改为org.apache.kafka.common.replica.RackAwareReplicaSelector。

group.instance.id

这个属性可以是任意具有唯一性的字符串,被用于消费者群组的固定名称。

receive.buffer.bytes和send.buffer.bytes

这两个属性分别指定了socket在读写数据时用到的TCP缓冲区大小。如果它们被设置为–1,就使用操作系统的默认值。如果生产者或消费者与broker位于不同的数据中心,则可以适当加大它们的值,因为跨数据中心网络的延迟一般都比较高,而带宽又比较低。

offsets.retention.minutes

这是broker端的一个配置属性,需要注意的是,它也会影响消费者的行为。只要消费者群组里有活跃的成员(也就是说,有成员通过发送心跳来保持其身份)​,群组提交的每一个分区的最后一个偏移量就会被Kafka保留下来,在进行重分配或重启之后就可以获取到这些偏移量。但是,如果一个消费者群组失去了所有成员,则Kafka只会按照这个属性指定的时间(默认为7天)保留偏移量。一旦偏移量被删除,即使消费者群组又“活”了过来,它也会像一个全新的群组一样,没有了过去的消费记忆。需要注意的是,这个行为在不同的版本中经历了几次变化,如果你使用的Kafka版本小于2.1.0,那么请仔细查阅相关文档以了解其对应的行为。

提交和偏移量

每次调用poll()方法,它总是会返回还没有被消费者读取过的记录,这意味着我们有办法来追踪哪些记录是被群组里的哪个消费者读取过的。之前提到过,Kafka不像其他JMS队列系统那样需要收到来自消费者的确认,这是Kafka的一个独特之处。相反,消费者可以用Kafka来追踪已读取的消息在分区中的位置(偏移量)​。

我们把更新分区当前读取位置的操作叫作偏移量提交。与传统的消息队列不同,Kafka不会提交每一条记录。相反,消费者会将已成功处理的最后一条消息提交给Kafka,并假定该消息之前的每一条消息都已成功处理。

那么消费者是如何提交偏移量的呢?消费者会向一个叫作 __consumer_offset的主题发送消息,消息里包含每个分区的偏移量。如果消费者一直处于运行状态,那么偏移量就没有什么实际作用。但是,如果消费者发生崩溃或有新的消费者加入群组,则会触发再均衡。再均衡完成之后,每个消费者可能会被分配新的分区,而不是之前读取的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的位置继续读取消息。

如果最后一次提交的偏移量小于客户端处理的最后一条消息的偏移量,那么处于两个偏移量之间的消息就会被重复处理,如图所示:
在这里插入图片描述
如果最后一次提交的偏移量大于客户端处理的最后一条消息的偏移量,那么处于两个偏移量之间的消息就会丢失,如图所示:
在这里插入图片描述
所以,如何管理偏移量对客户端应用程序有很大的影响。KafkaConsumerAPI提供了多种提交偏移量的方式。

自动提交

最简单的提交方式是让消费者自动提交偏移量。如果enable.auto.commit被设置为true,那么每过5秒,消费者就会自动提交poll()返回的最大偏移量。提交时间间隔通过auto.commit.interval.ms来设定,默认是5秒。与消费者中的其他处理过程一样,自动提交也是在轮询循环中进行的。消费者会在每次轮询时检查是否该提交偏移量了,如果是,就会提交最后一次轮询返回的偏移量。

假设我们使用默认的5秒提交时间间隔,并且消费者在最后一次提交偏移量之后3秒会发生崩溃。再均衡完成之后,接管分区的消费者将从最后一次提交的偏移量的位置开始读取消息。这个偏移量实际上落后了3秒,所以在这3秒内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量,缩小可能导致重复消息的时间窗口,但无法完全避免。虽然自动提交很方便,但是没有为避免开发者重复处理消息留有余地。

提交当前偏移量

把enable.auto.commit设置为false,让应用程序自己决定何时提交偏移量。使用commitSync()提交偏移量是最简单可靠的方式。这个API会提交poll()返回的最新偏移量,提交成功后马上返回,如果由于某些原因提交失败就抛出异常。

需要注意的是,commitSync()将会提交poll()返回的最新偏移量,所以,如果你在处理完所有记录之前就调用了commitSync(),那么一旦应用程序发生崩溃,就会有丢失消息的风险(消息已被提交但未被处理)​。如果应用程序在处理记录时发生崩溃,但commitSync()还没有被调用,那么从最近批次的开始位置到发生再均衡时的所有消息都将被再次处理——这或许比丢失消息更好,或许更坏。

交失败,就把异常记录到错误日志里。

手动提交有一个缺点,在broker对请求做出回应之前,应用程序会一直阻塞,这样会限制应用程序的吞吐量。可以通过降低提交频率来提升吞吐量,但如果发生了再均衡,则会增加潜在的消息重复。这个时候可以使用异步提交API。只管发送请求,无须等待broker做出响应。

在提交成功或碰到无法恢复的错误之前,commitSync()会一直重试,但commitAsync()不会,这是commitAsync()的一个缺点。之所以不进行重试,是因为commitAsync()在收到服务器端的响应时,可能已经有一个更大的偏移量提交成功。假设我们发出一个提交偏移量2000的请求,这个时候出现了短暂的通信问题,服务器收不到请求,自然也不会做出响应。与此同时,我们处理了另外一批消息,并成功提交了偏移量3000。如果此时commitAsync()重新尝试提交偏移量2000,则有可能在偏移量3000之后提交成功。这个时候如果发生再均衡,就会导致消息重复。

可以用一个单调递增的消费者序列号变量来维护异步提交的顺序。每次调用commitAsync()后增加序列号,并在回调中更新序列号变量。在准备好进行重试时,先检查回调的序列号与序列号变量是否相等。如果相等,就说明没有新的提交,可以安全地进行重试。如果序列号变量比较大,则说明已经有新的提交了,此时应该停止重试。

提交特定的偏移量

消费者API允许在调用commitSync()和commitAsync()时传给它们想要提交的分区和偏移量。假设你正在处理一个消息批次,刚处理好来自主题“customers”的分区3的消息,它的偏移量是5000,那么就可以调用commitSync()来提交这个分区的偏移量5001。需要注意的是,因为一个消费者可能不止读取一个分区,你需要跟踪所有分区的偏移量,所以通过这种方式提交偏移量会让代码变得复杂。

从特定偏移量位置读取记录

如果你想从分区的起始位置读取所有的消息,或者直接跳到分区的末尾读取新消息,那么Kafka API分别提供了两个方法:seekToBeginning(Collection tp) 和seekToEnd(Collection tp)。Kafka还提供了用于查找特定偏移量的API。这个API有很多用途,比如,对时间敏感的应用程序在处理速度滞后的情况下可以向前跳过几条消息,或者如果消费者写入的文件丢失了,则它可以重置偏移量,回到某个位置进行数据恢复。

如何退出

如果你确定马上要关闭消费者(即使消费者还在等待一个poll()返回)​,那么可以在另一个线程中调用consumer.wakeup()。如果轮询循环运行在主线程中,那么可以在ShutdownHook里调用这个方法。需要注意的是,consumer.wakeup()是消费者唯一一个可以在其他线程中安全调用的方法。调用consumer.wakeup()会导致poll()抛出WakeupException,如果调用consumer.wakeup()时线程没有在轮询,那么异常将在下一次调用poll()时抛出。不一定要处理WakeupException,但在退出线程之前必须调用consumer.close()。消费者在被关闭时会提交还没有提交的偏移量,并向消费者协调器发送消息,告知自己正在离开群组。协调器会立即触发再均衡,被关闭的消费者所拥有的分区将被重新分配给群组里其他的消费者,不需要等待会话超时。

深入Kafka


  1. 所以好多网上的说法是错误的,ack不影响端到端的延迟。 ↩︎

;