深入消费者

1 消费者参数配置

对于一个消费者来说,他要做的事情只有一件,那就是使用poll()来拉取消息。

至于他是从哪个分区拉取,则是靠消费者组来动态的调整这个消费者所消费的分区,又或者是由开发者来自定义。

但无论如何,这个消费者都需要通过poll()来拉取消息。

这也是这一节的内容:通过参数配置能够影响poll操作的哪些内容

首先需要确定一点,当消费者使用poll()拉取消息的时候,他只能拉到HW水位线以下的消息。

1.1 分区配置

我们可以让消费者针对于某一个分区进行消费。

为了实现这个目标,我们可以用assign()方法。

但是注意,当这个消费者不是单独的一个消费者,而是属于某个消费者组的时候,将不允许使用自定义的分区分配。

1.2 POLL操作拉取的字节数目

对应的配置分别是:

fetch.min.bytes
复制代码

对于每次拉取的最小字节数,默认是1。当拉取的消息大小小于设定的这个限度时,将会等待,直到这次被拉取的消息大小大于这个值。

于是我们可以得知,当我们即将要消费的消息比较小时,可以适当的调大这个参数的值,以提高吞吐量。

但是注意,这也可能造成消息的额外延迟。

fetch.max.bytes
复制代码

这个参数跟上面的一样,只不过他代表的意义是最大的字节数。

但是这存在一个问题,如果我们的消息大小全都大于这个参数的值,会发生什么情况呢?

答案是会返回即将拉取分区的第一条消息。

也就是说在这个参数中,不存在“不符合条件就不返回数据”的情况。

还有一个参数,叫做max.partition.fetch.bytes

这个参数跟上面提到的每次拉取的最大字节数工作原理是一样的,也是会保证当消息大于设定的值的时候,一定会返回数据。

而不同的地方在于,这个参数代表的是分区。也就是说,一个参数代表的是一次拉取请求,而另外一个参数代表的是针对于每一个分区的拉取请求。

1.3 拉取消息的超时时间

fetch.max.wait.ms
复制代码

这个参数的意义在于:如果拉取消息的时间达到了这个参数设定的值,那么无论符不符合其他条件,都会返回数据。

那么你很容易可以猜到,这个参数跟fetch.min.bytes是有关系的,这是为了防止当fetch.min.bytes参数设置的过大,导致无法返回消息的情况。

当然了,这个参数还有一个意义,如果你的业务需要更小的延迟,那么应该调小这个参数。

1.4 最大拉取消息数

如果我们的最大拉取字节数设置成了非常大,那么是不是代表我们每一次的poll(),都能直接拉到HW水位呢?

答案是否定的。

还存在一个参数:

max.poll.records
复制代码

这个参数的意义在于,每次拉取消息的最大数量。

同样的,如果消息的大小都比较小,那么可以调大这个参数,以提高消费速度。

1.5 消费者组相关的参数

另外,还存在一些消费者组相关的参数,我在这里先提一下,具体更详细的解释,将在后文给出。

heartbeat.interval.ms
复制代码

这个参数是设置消费者与消费者组对应的Coordinator发送心跳响应的间隔时间。

session.timeout.ms
复制代码

这个参数是用于Coordinator判断多长时间没收到消费者的心跳响应而认为这个消费者已经下线的时间。

max.poll.interval.ms
复制代码

这个参数用于Coordinator判断多长时间内消费者都没有拉取消息,而认为这个消费者已经下线的时间。

auto.offset.reset
复制代码

这个参数其实跟消费者组的联系不是很大,但是我认为可以写在这里。

因为有这么一个场景,当消费者Rebalance之后,如果位移主题之前保存的位移已经被删除了,那么这个参数就决定了消费者该从哪里开始消费。

当然了,关于消费者还有许多的参数,不仅仅是上文提到的这些。

而上文提到的这些参数,是我认为可以让初学者更好的理解消费者的工作原理。

2 Rebalance原理

在解释Rebalance的原理之前,我想先跟你说一下我的思路,免得你看的一头雾水。

当然了,这个思路是我认为更适合我自己去理解的。你也可以先看第三大节,再有了一个大概的认识后,再来看这一节的内容。

我希望先告诉你Rebalance的过程是怎么样的,这里说的过程指的是Rebalance已经发生了,那么在Rebalance的过程中,会发生哪些事情。

在这之后,我再跟你说说Rebalance的五种状态。

那么,我们开始。

2.1 寻找Coordinator

首先,应该有一个认识。Rebalance的所有操作都是通过Coordinator的协调下完成的,组内的消费者之间并不会进行相关的通信与交流。

Coordinator你可以理解为是一个服务,位于某个broker节点上。

假设当前的消费者已经保存了这个这个节点的信息,那么将会直接进入第二步。

如果当前的消费者没有保存这个信息(比如这是一个新加入这个消费者组的消费者),那么他需要先找到这个Coordinator所在的broker节点。

这里的broker节点,是这个消费者对应的消费者组对应的位移主题的分区的leader节点。

听起来有点绕,让我来再解释一下。

消费者 -> 消费者组 -> __consumer_offsets -> partition -> leader

关于位移主题,我已经在第二篇文章中提到过了,在这里不再赘述。

但是在这里,让我们来再来回忆一遍消费者组对应的partition是怎么找到的。

  • 先获取Group ID的hash值
  • 将这个hash值,对__consumer_offsets的分区数取模
  • 获得的数字,就是这个消费者组提交位移的分区
  • 找到这个分区对应的leader副本,即为Coordinator对应的broker节点

2.2 Join Group

在找到了对应的broker节点后,第二步是发送加入Group的请求。

在这一步中,无论是之前已经在Group内的成员,还是准备加入Group的成员,都需要发送Join Group的申请。

在发起的JoinGroupRequest中,需要包含如下的数据:

  • Group id

  • Session_timeout

  • Rebalance_timeout

  • Menber_id

  • Partition assignor

需要事先说明的是,这里的名称并不严格,是为了更好的理解而这样写的。如果你想要知道更加严谨的请求内容,可以去看厮大的《深入理解Kafka》。

下面我们挨个解释:

Group ID,消费者组ID,代表了即将加入的消费者组。

Session_timeout,上文中提到过这个参数,用于Coordinator判断多长时间内没收到客户端的心跳包而认为这个客户端已经下线。

Rebalance_timeout,值等同于max.poll.interval.ms,意义在于告知Coordinator用多长的时间来等待其他消费者加入这个消费者组。

我们在上文中提到,无论之前是不是这个消费者组的成员,只要开启了Rebalance,就需要重新加入这个消费者组。因此,Coordinator需要一段时间来接受JoinGroupRequest的请求。

至于为什么需要一段时间来接受请求,以及这段时间发生了什么,我将在后面给你解释。

menber_id,作为组内消费者的识别编号,如果是新加入组的消费者,这个字段留空。

Partition assignor,指的是分区分配方式。因为Rebalance这个过程,就是分区分配的一个过程。每个消费者将其接受的分配方式放在这个字段中,随后由Coordinator选出每个消费者都认可的分区分配方式。

然后我们来聊聊在这个阶段,Coordinator需要做什么。

Coordinator需要一段时间来接收来自客户端的JoinGroupRequest请求,是因为Coordinator需要收集每一个成员的信息,选出leader和分区分配方式,因此,Coordinator需要足够的时间来“收集信息”。这就回答了上文说到的为什么“Coordinator需要一段时间来接受JoinGroupRequest的请求”。

选举leader的算法很简单,第一个发送请求的consumer,就是leader。

选出分区分配策略的算法也很简单,首先Coordinator会收集所有消费者都支持的分区分配方式,然后每个消费者为它支持的分配方式投上一票。注意,这里的投票行为没有经过多一次的交互,而是在候选集中找到第一个该消费者支持的分区分配方式,作为这个消费者所投的票。

当Coordinator选取好Leader和分区分配方式后,将返回JoinGroupResponse给各个消费者。

在返回给各个消费者的JoinGroupResponse中,包含了menber_id,分区分配方式等。而对于leader消费者来说,还将获得组内其他消费者的元数据,包含了各个消费者的menber_id,分区分配方式。

至此,JoinGroup阶段完成。

注意,每个消费者从发送JoinGroupRequest到接收到JoinGroupResponse请求这段时间,是阻塞的。

2.3 分配分区

在第二步结束之后,每个消费者已经知道了自己的menber_id,以及Coordinator所选择的分区分配方式。

但是此时每个消费者还不知道自己应该消费哪个分区。

这个分区分配的过程,是交给Leader消费者来完成的。

但是注意,虽然说这个过程是Leader消费者完成的,但是Leader消费者并不会跟其他消费者直接通信,而是将分配方式告知Coordinator,由Coordinator来告知各个消费者。

这个过程,称为Sync_Group

在这个过程中,每一个消费者都会发送SyncGroupRequest给Coordinator。要注意的是,Leader消费者在这个Request中还附带了其他消费者的分区分配信息。

在Coordinator收到了这些请求后,会将这个分区分配方案等元数据保存在__consumer_offsets主题中。

随后,Coordinator将发送响应给各个消费者。

在这个响应中,包含了各个消费者应该负责消费的分区编号。

至此,每个消费者都了解了自己应该消费的分区是哪些了。

2.4 消费并发送心跳包

在上一个阶段中,组内各个消费者已经知道了自己负责的是哪些分区。

但是还存在一个问题,消费者应该从分区的哪个位置开始消费呢?

这就用到了__consumer_offsets主题了,这个主题保存了某个消费者组的各个分区的消费位移。

此外,每个消费者还需要不断地发送心跳包给Coordinator,以告知Coordinator自己没有下线。

这个发送心跳包的时间,就是我们设置的heartbeat.interval.ms参数。

在每个心跳包的响应中,Coordinator就会告知这个消费者,需不需要Rebalance。

那么也就说明了,这个参数设置的越小,消费者就越早能够得知是否需要Rebalance。

而对应的session.timeout.ms,指的就是Coordinator在这么长的时间内没收到消费者的心跳包,而认为这个消费者过期的参数。

如何避免不必要的rebalance

要避免 Rebalance,还是要从 Rebalance 发生的时机入手。我们在前面说过,Rebalance 发生的时机有三个:

  • 组成员数量发生变化
  • 订阅主题数量发生变化
  • 订阅主题的分区数发生变化

后两个我们大可以人为的避免,发生rebalance最常见的原因是消费组成员的变化。

消费者成员正常的添加和停掉导致rebalance,这种情况无法避免,但是时在某些情况下,Consumer 实例会被 Coordinator 错误地认为 “已停止” 从而被“踢出”Group。从而导致rebalance。

当 Consumer Group 完成 Rebalance 之后,每个 Consumer 实例都会定期地向 Coordinator 发送心跳请求,表明它还存活着。如果某个 Consumer 实例不能及时地发送这些心跳请求,Coordinator 就会认为该 Consumer 已经 “死” 了,从而将其从 Group 中移除,然后开启新一轮 Rebalance。这个时间可以通过Consumer 端的参数 session.timeout.ms进行配置。默认值是 10 秒。

除了这个参数,Consumer 还提供了一个控制发送心跳请求频率的参数,就是 heartbeat.interval.ms。这个值设置得越小,Consumer 实例发送心跳请求的频率就越高。频繁地发送心跳请求会额外消耗带宽资源,但好处是能够更加快速地知晓当前是否开启 Rebalance,因为,目前 Coordinator 通知各个 Consumer 实例开启 Rebalance 的方法,就是将 REBALANCE_NEEDED 标志封装进心跳请求的响应体中。

除了以上两个参数,Consumer 端还有一个参数,用于控制 Consumer 实际消费能力对 Rebalance 的影响,即 max.poll.interval.ms 参数。它限定了 Consumer 端应用程序两次调用 poll 方法的最大时间间隔。它的默认值是 5 分钟,表示你的 Consumer 程序如果在 5 分钟之内无法消费完 poll 方法返回的消息,那么 Consumer 会主动发起 “离开组” 的请求,Coordinator 也会开启新一轮 Rebalance。

通过上面的分析,我们可以看一下那些rebalance是可以避免的:

第一类非必要 Rebalance 是因为未能及时发送心跳,导致 Consumer 被 “踢出”Group 而引发的。这种情况下我们可以设置 session.timeout.ms 和 heartbeat.interval.ms 的值,来尽量避免rebalance的出现。(以下的配置是在网上找到的最佳实践,暂时还没测试过

  • 设置 session.timeout.ms = 6s。
  • 设置 heartbeat.interval.ms = 2s。
  • 要保证 Consumer 实例在被判定为 “dead” 之前,能够发送至少 3 轮的心跳请求,即 session.timeout.ms >= 3 * heartbeat.interval.ms。

将 session.timeout.ms 设置成 6s 主要是为了让 Coordinator 能够更快地定位已经挂掉的 Consumer,早日把它们踢出 Group。

第二类非必要 Rebalance 是 Consumer 消费时间过长导致的。此时,max.poll.interval.ms 参数值的设置显得尤为关键。如果要避免非预期的 Rebalance,你最好将该参数值设置得大一点,比你的下游最大处理时间稍长一点。

总之,要为业务处理逻辑留下充足的时间。这样,Consumer 就不会因为处理这些消息的时间太长而引发 Rebalance 。

3 消费者组的状态转移

在上面说完了Rebalance的核心原理后,我们再来聊聊消费者组的各个状态。

先来介绍一下消费者组有哪几种状态:

  • Empty:组内没有任何的成员,但是保留着这些成员的元数据,比如在发生Rebalance的时候,Coordinator在心跳包的响应中告知消费者应该要进行Rebalance了,这个时候所有的消费者都离开了消费者组,那么这个消费者组就会处于Empty状态。注意,一个新创建的消费者组,也处于这个状态。
  • Dead:组内没有任何的成员,并且在__consumer_offsets中也没有保存这个消费者组的元数据。通常发生在这个消费者组被删除了,或者__consumer_offsets分区leader发生了改变。(至于这个状态我了解的也不是很多,如果可以的话,麻烦你评论区告诉我。)
  • PreparingRebalance:这个状态为Coordinator正在等待Consumer加入。这个状态对应于JoinGroup阶段,会持续Rebalance_timeout这么长的时间。
  • CompletingRebalance:也被称为AwaitingSync,为Coordinator正在等待Leader消费者的分区分配方案。对应于SyncGroup阶段。
  • Stable:到了这个阶段,消费者组已经在正常工作了。

消费者组的状态介绍大概就是这样的。

简单的来讲,当一个消费者组需要Rebalance的时候,他就会进入PreparingRebalance阶段,然后一直流转到Stable阶段。

在这个期间,如果有任何的成员变动,就会回到PreparingRebalance阶段。

在这个期间,如果Coordinator改变,或者消费者组被删除等,就会进入Dead阶段。 下面是状态转移图,你可以结合上面的文字解释来查阅:

三、创建Kafka消费者

在创建消费者的时候以下以下三个选项是必选的:

  • bootstrap.servers :指定 broker 的地址清单,清单里不需要包含所有的 broker 地址,生产者会从给定的 broker 里查找 broker 的信息。不过建议至少要提供两个 broker 的信息作为容错;
  • key.deserializer :指定键的反序列化器;
  • value.deserializer :指定值的反序列化器。

除此之外你还需要指明你需要想订阅的主题,可以使用如下两个 API :

  • consumer.subscribe(Collection<String> topics) :指明需要订阅的主题的集合;
  • consumer.subscribe(Pattern pattern) :使用正则来匹配需要订阅的集合。

最后只需要通过轮询 API(poll) 向服务器定时请求数据。一旦消费者订阅了主题,轮询就会处理所有的细节,包括群组协调、分区再均衡、发送心跳和获取数据,这使得开发者只需要关注从分区返回的数据,然后进行业务处理。 示例如下:

String topic = "Hello-Kafka";
String group = "group1";
Properties props = new Properties();
props.put("bootstrap.servers", "hadoop001:9092");
/*指定分组 ID*/
props.put("group.id", group);
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

/*订阅主题 (s)*/
consumer.subscribe(Collections.singletonList(topic));

try {
    while (true) {
        /*轮询获取数据*/
        ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
        for (ConsumerRecord<String, String> record : records) {
            System.out.printf("topic = %s,partition = %d, key = %s, value = %s, offset = %d,\n",
           record.topic(), record.partition(), record.key(), record.value(), record.offset());
        }
    }
} finally {
    consumer.close();
}
复制代码

本篇文章的所有示例代码可以从 Github 上进行下载:kafka-basis

三、 自动提交偏移量

3.1 偏移量的重要性

Kafka 的每一条消息都有一个偏移量属性,记录了其在分区中的位置,偏移量是一个单调递增的整数。消费者通过往一个叫作 _consumer_offset 的特殊主题发送消息,消息里包含每个分区的偏移量。 如果消费者一直处于运行状态,那么偏移量就没有 什么用处。不过,如果有消费者退出或者新分区加入,此时就会触发再均衡。完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的地方继续处理。 因为这个原因,所以如果不能正确提交偏移量,就可能会导致数据丢失或者重复出现消费,比如下面情况:

  • 如果提交的偏移量小于客户端处理的最后一个消息的偏移量 ,那么处于两个偏移量之间的消息就会被重复消费;
  • 如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。

3.2 自动提交偏移量

Kafka 支持自动提交和手动提交偏移量两种方式。这里先介绍比较简单的自动提交:

只需要将消费者的 enable.auto.commit 属性配置为 true 即可完成自动提交的配置。 此时每隔固定的时间,消费者就会把 poll() 方法接收到的最大偏移量进行提交,提交间隔由 auto.commit.interval.ms 属性进行配置,默认值是 5s。

使用自动提交是存在隐患的,假设我们使用默认的 5s 提交时间间隔,在最近一次提交之后的 3s 发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了 3s ,所以在这 3s 内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量,减小可能出现重复消息的时间窗,不过这种情况是无法完全避免的。基于这个原因,Kafka 也提供了手动提交偏移量的 API,使得用户可以更为灵活的提交偏移量。

四、手动提交偏移量

用户可以通过将 enable.auto.commit 设为 false,然后手动提交偏移量。基于用户需求手动提交偏移量可以分为两大类:

  • 手动提交当前偏移量:即手动提交当前轮询的最大偏移量;
  • 手动提交固定偏移量:即按照业务需求,提交某一个固定的偏移量。

而按照 Kafka API,手动提交偏移量又可以分为同步提交和异步提交。

4.1 同步提交

通过调用 consumer.commitSync() 来进行同步提交,不传递任何参数时提交的是当前轮询的最大偏移量。

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
    for (ConsumerRecord<String, String> record : records) {
        System.out.println(record);
    }
    /*同步提交*/
    consumer.commitSync();
}
复制代码

如果某个提交失败,同步提交还会进行重试,这可以保证数据能够最大限度提交成功,但是同时也会降低程序的吞吐量。基于这个原因,Kafka 还提供了异步提交的 API。

4.2 异步提交

异步提交可以提高程序的吞吐量,因为此时你可以尽管请求数据,而不用等待 Broker 的响应。代码如下:

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
    for (ConsumerRecord<String, String> record : records) {
        System.out.println(record);
    }
    /*异步提交并定义回调*/
    consumer.commitAsync(new OffsetCommitCallback() {
        @Override
        public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
          if (exception != null) {
             System.out.println("错误处理");
             offsets.forEach((x, y) -> System.out.printf("topic = %s,partition = %d, offset = %s \n",
                                                            x.topic(), x.partition(), y.offset()));
            }
        }
    });
}
复制代码

异步提交存在的问题是,在提交失败的时候不会进行自动重试,实际上也不能进行自动重试。假设程序同时提交了 200 和 300 的偏移量,此时 200 的偏移量失败的,但是紧随其后的 300 的偏移量成功了,此时如果重试就会存在 200 覆盖 300 偏移量的可能。同步提交就不存在这个问题,因为在同步提交的情况下,300 的提交请求必须等待服务器返回 200 提交请求的成功反馈后才会发出。基于这个原因,某些情况下,需要同时组合同步和异步两种提交方式。

注:虽然程序不能在失败时候进行自动重试,但是我们是可以手动进行重试的,你可以通过一个 Map<TopicPartition, Integer> offsets 来维护你提交的每个分区的偏移量,然后当失败时候,你可以判断失败的偏移量是否小于你维护的同主题同分区的最后提交的偏移量,如果小于则代表你已经提交了更大的偏移量请求,此时不需要重试,否则就可以进行手动重试。

4.3 同步加异步提交

下面这种情况,在正常的轮询中使用异步提交来保证吞吐量,但是因为在最后即将要关闭消费者了,所以此时需要用同步提交来保证最大限度的提交成功。

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
        for (ConsumerRecord<String, String> record : records) {
            System.out.println(record);
        }
        // 异步提交
        consumer.commitAsync();
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    try {
        // 因为即将要关闭消费者,所以要用同步提交保证提交成功
        consumer.commitSync();
    } finally {
        consumer.close();
    }
}
复制代码

4.4 提交特定偏移量

在上面同步和异步提交的 API 中,实际上我们都没有对 commit 方法传递参数,此时默认提交的是当前轮询的最大偏移量,如果你需要提交特定的偏移量,可以调用它们的重载方法。

/*同步提交特定偏移量*/
commitSync(Map<TopicPartition, OffsetAndMetadata> offsets) 
/*异步提交特定偏移量*/    
commitAsync(Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)
复制代码

需要注意的是,因为你可以订阅多个主题,所以 offsets 中必须要包含所有主题的每个分区的偏移量,示例代码如下:

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
        for (ConsumerRecord<String, String> record : records) {
            System.out.println(record);
            /*记录每个主题的每个分区的偏移量*/
            TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition());
            OffsetAndMetadata offsetAndMetadata = new OffsetAndMetadata(record.offset()+1, "no metaData");
            /*TopicPartition 重写过 hashCode 和 equals 方法,所以能够保证同一主题和分区的实例不会被重复添加*/
            offsets.put(topicPartition, offsetAndMetadata);
        }
        /*提交特定偏移量*/
        consumer.commitAsync(offsets, null);
    }
} finally {
    consumer.close();
}
复制代码

五、监听分区再均衡

因为分区再均衡会导致分区与消费者的重新划分,有时候你可能希望在再均衡前执行一些操作:比如提交已经处理但是尚未提交的偏移量,关闭数据库连接等。此时可以在订阅主题时候,调用 subscribe 的重载方法传入自定义的分区再均衡监听器。

 /*订阅指定集合内的所有主题*/
subscribe(Collection<String> topics, ConsumerRebalanceListener listener)
 /*使用正则匹配需要订阅的主题*/    
subscribe(Pattern pattern, ConsumerRebalanceListener listener)    
复制代码

代码示例如下:

Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();

consumer.subscribe(Collections.singletonList(topic), new ConsumerRebalanceListener() {
    /*该方法会在消费者停止读取消息之后,再均衡开始之前就调用*/
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        System.out.println("再均衡即将触发");
        // 提交已经处理的偏移量
        consumer.commitSync(offsets);
    }

    /*该方法会在重新分配分区之后,消费者开始读取消息之前被调用*/
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {

    }
});

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
        for (ConsumerRecord<String, String> record : records) {
            System.out.println(record);
            TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition());
            OffsetAndMetadata offsetAndMetadata = new OffsetAndMetadata(record.offset() + 1, "no metaData");
            /*TopicPartition 重写过 hashCode 和 equals 方法,所以能够保证同一主题和分区的实例不会被重复添加*/
            offsets.put(topicPartition, offsetAndMetadata);
        }
        consumer.commitAsync(offsets, null);
    }
} finally {
    consumer.close();
}
复制代码

六 、退出轮询

Kafka 提供了 consumer.wakeup() 方法用于退出轮询,它通过抛出 WakeupException 异常来跳出循环。需要注意的是,在退出线程时最好显示的调用 consumer.close() , 此时消费者会提交任何还没有提交的东西,并向群组协调器发送消息,告知自己要离开群组,接下来就会触发再均衡 ,而不需要等待会话超时。

下面的示例代码为监听控制台输出,当输入 exit 时结束轮询,关闭消费者并退出程序:

/*调用 wakeup 优雅的退出*/
final Thread mainThread = Thread.currentThread();
new Thread(() -> {
    Scanner sc = new Scanner(System.in);
    while (sc.hasNext()) {
        if ("exit".equals(sc.next())) {
            consumer.wakeup();
            try {
                /*等待主线程完成提交偏移量、关闭消费者等操作*/
                mainThread.join();
                break;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}).start();

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
        for (ConsumerRecord<String, String> rd : records) {
            System.out.printf("topic = %s,partition = %d, key = %s, value = %s, offset = %d,\n",
                              rd.topic(), rd.partition(), rd.key(), rd.value(), rd.offset());
        }
    }
} catch (WakeupException e) {
    //对于 wakeup() 调用引起的 WakeupException 异常可以不必处理
} finally {
    consumer.close();
    System.out.println("consumer 关闭");
}
复制代码

七、独立的消费者

因为 Kafka 的设计目标是高吞吐和低延迟,所以在 Kafka 中,消费者通常都是从属于某个群组的,这是因为单个消费者的处理能力是有限的。但是某些时候你的需求可能很简单,比如可能只需要一个消费者从一个主题的所有分区或者某个特定的分区读取数据,这个时候就不需要消费者群组和再均衡了, 只需要把主题或者分区分配给消费者,然后开始读取消息井提交偏移量即可。

在这种情况下,就不需要订阅主题, 取而代之的是消费者为自己分配分区。 一个消费者可以订阅主题(井加入消费者群组),或者为自己分配分区,但不能同时做这两件事情。 分配分区的示例代码如下:

List<TopicPartition> partitions = new ArrayList<>();
List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);

/*可以指定读取哪些分区 如这里假设只读取主题的 0 分区*/
for (PartitionInfo partition : partitionInfos) {
    if (partition.partition()==0){
        partitions.add(new TopicPartition(partition.topic(), partition.partition()));
    }
}

// 为消费者指定分区
consumer.assign(partitions);


while (true) {
    ConsumerRecords<Integer, String> records = consumer.poll(Duration.of(100, ChronoUnit.MILLIS));
    for (ConsumerRecord<Integer, String> record : records) {
        System.out.printf("partition = %s, key = %d, value = %s\n",
                          record.partition(), record.key(), record.value());
    }
    consumer.commitSync();
}
复制代码

附录 : Kafka消费者可选属性

1. fetch.min.byte

消费者从服务器获取记录的最小字节数。如果可用的数据量小于设置值,broker 会等待有足够的可用数据时才会把它返回给消费者。

2. fetch.max.wait.ms

broker 返回给消费者数据的等待时间,默认是 500ms。

3. max.partition.fetch.bytes

该属性指定了服务器从每个分区返回给消费者的最大字节数,默认为 1MB。

4. session.timeout.ms

消费者在被认为死亡之前可以与服务器断开连接的时间,默认是 3s。

5. auto.offset.reset

该属性指定了消费者在读取一个没有偏移量的分区或者偏移量无效的情况下该作何处理:

  • latest (默认值) :在偏移量无效的情况下,消费者将从最新的记录开始读取数据(在消费者启动之后生成的最新记录);
  • earliest :在偏移量无效的情况下,消费者将从起始位置读取分区的记录。

6. enable.auto.commit

是否自动提交偏移量,默认值是 true。为了避免出现重复消费和数据丢失,可以把它设置为 false。

7. client.id

客户端 id,服务器用来识别消息的来源。

8. max.poll.records

单次调用 poll() 方法能够返回的记录数量。

9. receive.buffer.bytes & send.buffer.byte

这两个参数分别指定 TCP socket 接收和发送数据包缓冲区的大小,-1 代表使用操作系统的默认值。


 


 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值