手记

kafka-05-消费者

1. 消费者和消费者群组

消费者从属于消费者群组,一个群组里的消费者订阅的是同一个主题,每个消费者接收主题一部分分区的消息。
可以增加更多的消费者,让它们分担负载,每个消费者只处理部分分区的消息,这就是横向伸缩的主要手段。我们有必要为主题创建大量的分区,在负载增长时可以加入更多的消费者。
要注意,不要让消费者的数量超过主题分区的数量,多余的消费者只会被闲置。

1 个消费者收到 4 个分区的消息

假设主题 T1 有 4 个分区,我们创建了消费者 C1 ,它是群组 G1 里唯一的消费者,群组 G1 订阅主题 T1 。消费者 C1 将收到主题 T1 全部 4 个分区的消息。

2 个消费者收到 4 个分区的消息
如果在群组 G1 里新增一个消费者 C2 ,那么每个消费者接收两个分区的消息。

4 个消费者收到 4 个分区的消息
如果群组 G1 有 4 个消费者,那么每个消费者可以分配到一个分区

5 个消费者收到 4 个分区的消费
如果我们往群组里添加更多的消费者,超过主题的分区数量,那么有一部分消费者就会被闲置,不会接收到任何消息。

2. 多个应用程序从同一个主题读取数据

多个应用程序从同一个主题读取数据时,每个应用程序可以获取到所有的消息,而不只是其中的一部分。只要保证每个应用程序有自己的消费者群组,就可以让它们获取到主题所有的消息。

在上面的例子里,如果新增一个只包含一个消费者的群组 G2 ,那么这个消费者将从主题T1 上接收所有的消息,与群组 G1 之间互不影响。群组 G2 可以增加更多的消费者,每个消费者可以消费若干个分区,就像群组 G1 那样。总的来说,群组 G2 还是会接收到所有消息,不管有没有其他群组存在。

两个消费者群组对应一个主题

3. 分区再均衡

分区的所有权从一个消费者转移到另一个消费者,这样的行为被称为再均衡。再均衡非常重要, 它为消费者群组带来了高可用性和伸缩性(我们可以放心地添加或移除消费者),

群组里的消费者共同读取主题的分区,下面情况会发生再均衡

  • 一个新的消费者加入群组时,它读取的是原本由其他消费者读取的消息。
  • 当一个消费者被关闭或发生崩溃时,它就离开群组,原本由它读取的分区将由群组里的其他消费者来读取。
  • 在主题发生变化时 , 比如管理员添加了新的分区,会发生分区重分配

不过在正常情况下,我们并不希望发生这样的行为。

  • 在再均衡期间,消费者无法读取消息,造成整个群组一小段时间的不可用
  • 另外,当分区被重新分配给另一个消费者时,消费者当前的读取状态会丢失,它有可能还需要去刷新缓存 ,在它重新恢复状态之前会拖慢应用程序。

消费者通过向被指派为 群组协调器的 broker,不同的群组可以有不同的协调器,发送心跳来维持它们和群组的从属关系以及它们对分区的所有权关系。
只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明它还在读取分区里的消息。消费者会 在轮询消息(获取消息)或提交偏移量时发送心跳。如果消费者长时间未发送心,会话就会过期,群组协调器认为它已经死亡,就会触发一次 再均衡

3.1 分配分区

当消费者要加入群组时,它会向群组协调器发送一个 Join Group 请求。第一个加入群组的消费者将成为“群主”。群主从协调器那里获得群组的成员列表(列表中包含了所有最近发送过心跳的消费者,它们被认为是活跃的),并负责给每一个消费者分配分区。它使用一个实现了 PartitionAssignor 接口的类来决定哪些分区应该被分配给哪个消费者。
分配完毕之后,群主把分配情况列表发送给群组协调器,协调器再把这些信息发送给所有消费者。每个消费者只能看到自己的分配信息,只有群主知道群组里所有消费者的分配信息。

4. 消费者读消息

4.1 使用kafka自带工具发送和接收消息

kafka-console-producer.sh 发送消息

[root@docker01 ~]# /usr/local/kafka_2.13-2.6.0/bin/kafka-console-producer.sh --bootstrap-server 192.168.8.31:9092 --topic test
>hello1
>hello test
>

kafka-console-consumer.sh 接收消息

[root@docker01 kafka_2.13-2.6.0]# /usr/local/kafka_2.13-2.6.0/bin/kafka-console-consumer.sh --bootstrap-server 192.168.8.31:9092 --topic test 
hello1
hello test

4.2 Java应用程序创建Kafka 消费者读取消息

在读取消息之前,需要先创建一个 KafkaConsumer 对象 。 创建 KafkaProducer 对象与创建 KafkaProducer 对象非常相似一一把想要传给消费者的属性放在 Properties 对象里。

消费者 Properties 可配置项,请参考 org.apache.kafka.clients.consumer.ConsumerConfig

public class ConsumerConfig extends AbstractConfig {
    private static final ConfigDef CONFIG;
    public static final String GROUP_ID_CONFIG = "group.id";
    public static final String GROUP_INSTANCE_ID_CONFIG = "group.instance.id";
    public static final String MAX_POLL_RECORDS_CONFIG = "max.poll.records";
    public static final String MAX_POLL_INTERVAL_MS_CONFIG = "max.poll.interval.ms";
    public static final String SESSION_TIMEOUT_MS_CONFIG = "session.timeout.ms";
    public static final String HEARTBEAT_INTERVAL_MS_CONFIG = "heartbeat.interval.ms";
......
......
}

3个必要的属性

  • bootstrap.servers: 指定了 Kafka broker的连接字符串
  • key.deserializervalue.deserializer:key和value的反序列化器,把字节数组序列化成 Java 对象。

第 4 个属性 group.id 不是必需的,它指定了 KafkaConsumer 属于哪一个消费者群组,创建不属于任何一个群组的消费者也是可以的。

4.2.1 创建 KafkaConsumer 对象

        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "docker01:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");//从头开始读取kafka消息
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);

4.2.2 订阅主题

创建好消费者之后,下一步可以开始订阅主题了。subscribe() 方法接受一个主题列表作为参数 ,使用起来很简单:
consumer.subscribe(Collections.singletonList("test"));
为了简单起见,我们创建了一个只包含单个元素的列表,主题的名字叫作“test”。

我们也可以在调用 subscribe() 方法时传入一个正则表达式。正则表达式可以匹配多个主题, 如果有人创建了新的主题,并且主题的名字与正则表达式匹配 ,那么会立即触发一次再均衡 ,消费者就可以读取新添加的主题。如果应用程序需要读取多个主题,并且可以处理不同类型的数据,那么这种订阅方式就很管用。

要订阅所有与 test 相关的主题,可以这样做 :
consumer.subscribe(Collections.singletonList("test.*"));

4.2.2 轮询

消息轮询是消费者 API 的核心,通过一个简单的轮询向服务器请求数据。一旦消费者订阅了主题 ,轮询就会处理所有的细节,包括群组协调、分区再均衡、发送心跳和获取数据,开发者只需要使用一组简单的 API 来处理从分区返回的数据。
消费者代码的主要部分如下所示 :

        try {
            while (true) {
                //200ms轮询一次
                ConsumerRecords<String, String> rec = consumer.poll(Duration.ofMillis(200));
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }

            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();//记住关闭消费者
        }

poll ()方法的参数是一个超时时间,用于控制 poll ()方法的阻塞时间(在消费者的缓冲区里没有可用数据时会发生阻塞)。如果该参数被设为 0, poll ()会立即返回 ,否则它会在指定的毫秒数内一直等待 broker 返回数据。
poll ()方法返回一个记录列表。每条记录 ConsumerRecord 都包含了记录所属主题的信息、记录所在分区的信息、 记录在分区里的偏移量 ,以及记录的键值对。
poll ()方法有一个超时参数 , 它指定了方法在多久之后可以返回,不管有没有可用的数据都要返回

轮询 poll ()方法的作用:

  • 查找 GroupCoordinator(群组协调器) , 然后加入群组,接受分配的分区, 如果发生了再均衡,整个过程也是在轮询期间进行的;
  • 获取数据;
  • 发送 心跳

消费者读取消息代码:

public class FirstConsumer {

    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "docker01:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");//没有偏移量的分区或者偏移量无效时如何处理,从头开始读取kafka消息
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
        consumer.subscribe(Collections.singletonList("test"));


        try {
            while (true) {
                //200ms轮询一次
                ConsumerRecords<String, String> rec = consumer.poll(Duration.ofMillis(200));
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }

            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();//记住关闭消费者
        }
    }
}

运行结果:

--------------------------------------------
主题:test
分区:0
偏移量:0
key:SendAndForgetKey
value:SendAndForgetMsg1
SendAndForgetMsg1

--------------------------------------------
主题:test
分区:0
偏移量:1
key:syncKey
value:syncMsg1
syncMsg1

--------------------------------------------
主题:test
分区:0
偏移量:2
key:asyncKey
value:asyncMsg1
asyncMsg1

--------------------------------------------
主题:test
分区:0
偏移量:3
key:null
valuhello test
hello test

--------------------------------------------
主题:test
分区:0
偏移量:4
key:null
value:hello1
hello1

--------------------------------------------
主题:test
分区:0
偏移量:5
key:null
value:hello test
hello test

5. 消费者的配置

Kafka 的文档列出了所有与消费者相关的配置说明。大部分参数都有合理的默认值,一般不需要修改它们,不过有一些参数与消费者的性能和可用性有很大关系。

5.1. fetch.min.bytes 从服务器获取记录的最小字节数

如果可用的数据量小于 fetch.min.bytes 指定的大小,那么它会等到有足够的可用数据时才把它返回给消费者。这样可以降低消费者和 broker 的工作负载,因为它们在主题不是很活跃的时候(或者一天里的低谷时段)就不需要来来回回地处理消息。

5.2. fetch.max.wait.ms 指定获取记录的最大等待时间

我们通过 fetch.min.bytes 告诉 Kafka ,等到有足够的数据时才把它返回给消费者。而 fetch.max.wait.ms则用于 指定获取记录的最大等待时间,默认是 500ms

场景:
如果 fetch.max.wait.ms被设为 100ms ,并且 fetch.min.bytes 被设为 1MB ,那么 Kafka 在收到消费者的请求后,要么返回 1MB 数据,要么在100ms 后返回所有可用的数据 , 就看哪个条件先得到满足

5.3. max.partition.fetch.bytes 从每个分区里返回给消费者的最大字节数

默认值是 1MB , 也就是说, KafkaConsumer.poll() 方法从每个分区里返回的记录最多不超过max.partition.fetch.bytes 指定的字节。如果一个主题有 20 个分区和 5 个消费者,那么每个消费者需要至少 4MB 的可用内存来接收记录。
在为消费者分配内存时,可以给它们多分配一些,因为如果群组里有消费者发生崩溃,剩下的消费者需要处理更多的分区。 max.partition.fetch.bytes 的值必须 比 broker 能够接收的最大消息的字节数(通过message.max.bytes属性配置 ,主题配置)大 , 否则消费者可能无法读取这些消息,导致消费者一直挂起重试.

5.4 . session.timeout.ms 会话过期时间

默认是 3s 。如果消费者没有在 session.timeout.ms 指定的时间内发送心跳给群组协调器,就被认为已经死亡,协调器就会触发再均衡,把它的分区分配给群组里的其他消费者。
该属性与 heartbeat.interval.ms 紧密相关。heartbeat.interval.ms 指定了 poll() 方法向协调器发送心跳的频率,session.timeout.ms 则指定了消费者可以多久不发送心跳。所以, 一般需要同时修改这两个属性, heartbeat.interval.ms 必须比session.timeout.ms小, 一般是session.timeout.ms 的三分之 一。

5.5. auto.offset.reset 没有偏移量的分区或者偏移量无效时如何处理

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

5.6. enable.auto.commit 是否自动提交偏移量

默认值是 true。为了尽量避免出现重复数据和数据丢失,可以把它设为 false ,由自己控制何时提交偏移量。如果把它设为 true ,还可以通过配置 auto.commit.interval.ms 属性来控制提交的频率。

5.7. partition.assignment.strategy 分区分配给消费者的策略

Kafka支持下面两个内置的分配策略,默认使用的是org.apache.kafka.clients.consumer.RangeAssignor, 这个类实现了 Range 策略

  • Range:org.apache.kafka.clients.consumer.RangeAssignor
    该策略会把主题的若干个连续的分区分配给消费者。
    假设消费者 C1 和消费者 C2 同时订阅了主题 T1 和主题 T2 ,并且每个主题有 3 个分区。那么消费者 C1 有可能分配到这两个主题的分区0 和分区1 ,而消费者 C2 分配到这两个主题的分区 2 。因为每个主题拥有奇数个分区,而分配是在主题内独立完成的,第一个消费者最后分配到比第二个消费者更多的分区。

  • RoundRobin:org.apache.kafka.clients.consumer.RoundRobinAssignor
    该策略把主题的所有分区逐个分配给消费者。
    如果使用 RoundRobin 策略来给消费者 C1和消费者 C2 分配分区,那么消费者 C1 将分到主题 T1 的分区 0 和分区 2 以及主题 T2的分区 1 ,消费者 C2 将分配到主题 T1 的分区1 以及主题口的分区 0 和分区 2 。
    一般来说 ,如果所有消费者都订阅相同的主题(这种情况很常见) , RoundRobin 策略会给所有消费者分配相同数量的分区(或最多就差一个分区)。

可以通过设置 partition.assignment.strategy 来选择分区策略。默认使用的是org.apache.kafka.clients.consumer.RangeAssignor, 这个类实现了 Range 策略,不过也可以把它改成 org.apache.kafka.clients.consumer.RoundRobinAssignor。我们还可以使用自定义策略,在这种情况下 ,partition.assignment.strategy 属性的值就是自定义类的名字。

5.8. max.poll.records 单次请求能够返回的记录数量

该属性用于控制单次调用 poll() 方法能够返回的记录数量,可以帮你控制在轮询里需要处理的数据量。

6. 提交和偏移量

每次调用 poll() 方法,它总是返回由生产者写入 Kafka 但还没有被消费者读取过的记录 ,我们因此可以追踪到哪些记录是被群组里的哪个消费者读取的。 Kafka不会像其他 JMS 队列那样需要得到消费者的确认,这是 Kafka 的一个独特之处。相反,消费者可以使用 Kafka 来追踪消息在分区里的位置(偏移量)。
我们把更新分区当前位置的操作叫作提交
那么消费者是如何提交偏移量的呢?消费者往一个叫作 _consume_offset的特殊主题发送消息,消息里包含每个分区的偏移量
如果消费者一直处于运行状态,那么偏移量就没有什么用处。不过,如果消费者发生崩溃或者有新的消费者加入群组,就会触发再均衡,完成再均衡之后,每个消费者可能分配到新的分区,而不是之前处理的那个。为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的地方继续处理

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

如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失

KafkaConsumer API 提供了很多种方式来提交偏移量。

6.1 自动提交

最简单的提交方式是让消费者自动提交偏移量。如果 enable.auto.commit 被设为 true ,那么每过5s,消费者会自动把从 poll() 方法接收到的最大偏移量提交上去。提交时间间隔由auto.commit.interval.ms 控制,默认值是 5s 。与消费者里的其他东西一样,自动提交也是在轮询里进行的。消费者每次在进行轮询时会检查是否该提交偏移量了,如果是,那么就会提交从上一次轮询返回的偏移量。

不过,在使用这种简便的方式之前,需要知道它将会带来怎样的结果。
假设我们仍然使用默认的 5s 提交时间间隔,在最近一次提交之后的 3s 发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了 3s ,所以在这 3s 内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量,减小可能出现重复消息的时间窗,不过这种情况是无也完全避免的。

在使用自动提交时 ,每次调用轮询方法都会把上一次调用返回的偏移量提交上去,它并不知道具体哪些消息已经被处理了,所以在再次调用之前最好确保所有当前调用返回的消息都已经处理完毕(在调用 close() 方法之前也会进行自动提交)。 一般情况下不会有什么问题,不过在处理异常或提前退出轮询时要格外小心。
自动提交虽然方便 , 不过并没有为开发者留有余地来避免重复处理消息

6.2 提交当前偏移量

消费者 API 提供了另一种提交偏移量的方式 , 开发者可以在必要的时候提交当前偏移盘,而不是基于时间间隔。
enable.auto.commit 被设为 false ,让应用程序决定何时提交偏移量。使用 commitSync() 提交偏移量最简单也最可靠。这个 API 会提交由poll() 方法返回的最新偏移量,提交成功后马上返回,如果提交失败就抛出异常。
要记住,commitSync() 将会提交由poll() 返回的最新偏移量 , 所以在处理完所有记录后要确保调用了commitSync(),否则还是会有丢失消息的风险。如果发生了再均衡,从最近一批消息到发生再均衡之间的所有消息都将被重复处理。

下面是我们在处理完最近一批消息后使用 commitSync()方怯提交偏移量的例子。

            while (true) {
                ConsumerRecords<String, String> rec = consumer.poll(100);
//                System.out.println("We got record count " + rec.count());
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }
                try {
                    // 处理完当前批次的消息,在轮询更多的消息之前,调用 commitSync()方法提交当前批次最新的偏移量。
                    consumer.commitSync();
                } catch (Exception e) {
                    // 只要没有发生不可恢复的错误, commitSync()方法会一直尝试直至提交成功。如果提交失败,打印错误日志。
                    System.out.println("commit failed");
                    e.printStackTrace();
                }
            }

6.3 异步提交

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

            while (true) {
                ConsumerRecords<String, String> rec = consumer.poll(100);
//                System.out.println("We got record count " + rec.count());
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }
                // 提交最后一个偏移量,然后继续做其他事情。
                consumer.commitAsync();
            }

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

我们之所以提到这个问题的复杂性和提交顺序的重要性,是因为 commitAsync()也支持回调,在 broker 作出响应时会执行回调。回调经常被用于记录提交错误或生成度量指标, 不过如果你要用它来进行重试, 一定要注意提交的顺序。

            while (true) {
                ConsumerRecords<String, String> rec = consumer.poll(100);
//                System.out.println("We got record count " + rec.count());
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }
                // 异步提交偏移量+回调,发送提交请求然后继续做其他事情,如果提交失败,错误信息和偏移量会被记录下来。
                consumer.commitAsync(new OffsetCommitCallback() {
                    @Override
                    public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
                        if(e != null) {
                            System.out.println("Commit failed for offsets " + map);
                            e.printStackTrace();
                        }
                    }
                });
          }      

6.4 同步和异步组合提交(解决关闭消费者的问题)

→般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。
因此,在消费者关闭前一般会组合使用commitAsync()commitSync() 。它们的工作原理如下 (后面讲到再均衡监听器时,我们会讨论如何在发生再均衡前提交偏移量):

        try {
            while (true) {
                ConsumerRecords<String, String> rec = consumer.poll(100);
//                System.out.println("We got record count " + rec.count());
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                }
                // 异步提交偏移量
                consumer.commitAsync();
                
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                // 同步提交偏移量
                consumer.commitSync();
            } finally {
                consumer.close();
            }
        }

如果一切正常,我们使用 commitAsync() 方法来提交。这样速度更快,而且即使这次提交失败,下一次提交很可能会成功。
如果直接关闭消费者,就没有所谓的“下一次提交”了。使用 commitSync() 方法会一直重试,直到提交成功或发生无法恢复的错误。

6.5 提交特定的偏移量

提交偏移量的频率与处理消息批次的频率是一样的。但如果想要更频繁地提交出怎么办?
如果poll() 方法返回一大批数据,为了避免因再均衡引起的重复处理整批消息,想要在批次中间提交偏移量该怎么办?这种情况无法通过调用 commitAsync()commitSync() 来实现,因为它们只会提交最后一个偏移量,而此时该批次里的消息还没有处理完。
幸运的是,消费者 API 允许在调用 commitAsync()commitSync()方法时传入希望提交的分区和偏移量的 map 。
假设你处理了半个批次的消息 , 最后一个来自主题“customers ”分区3 的消息的偏移量是 5000 , 你可以调用 commitSync() 方法来提交它。不过,因为消费者可能不只读取一个分区, 你需要跟踪所有分区的偏移量,所以在这个层面上控制偏移量的提交会让代码变复杂。

提交特定偏移量的例子: 每处理1000条记录,提交一次偏移量

public class CommitSpecificOffset {

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

    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "docker01:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group2");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");//从头开始读取kafka消息
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
        consumer.subscribe(Collections.singletonList("test"));


        try {
            while (true) {
                //200ms轮询一次
                ConsumerRecords<String, String> rec = consumer.poll(Duration.ofMillis(200));
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                    // 记录每个分区的偏移量
                    currentOffsets.put(new TopicPartition(r.topic(), r.partition()), new OffsetAndMetadata(r.offset() + 1, "no metadata"));
                    if (count % 1000 == 0) {
                        //每处理1000条记录,提交一次偏移量
                        consumer.commitAsync(currentOffsets, null);
                    }
                    count++;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }
    }
}

7. 再均衡监昕器

在提交偏移量一节中提到过,消费者在退出和进行分区再均衡之前,会做一些清理工作。
你会在消费者失去对一个分区的所有权之前提交最后一个已处理记录的偏移量。如果消费者准备了一个缓冲区用于处理偶发的事件,那么在失去分区所有权之前, 需要处理在缓冲区累积下来的记录。你可能还需要关闭文件句柄、数据库连接等。

在为消费者分配新分区或移除旧分区时,可以通过消费者 API 执行一些应用程序代码,在调用 subscribe() 方法时传进去一个ConsumerRebalanceListener实例就可以了。
ConsumerRebalanceListener 有两个需要实现的方法。
(1) public void onPartitionsRevoked(Collection<TopicPartition> collection)方法会在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了。
(2) public void onPartitionsAssigned(Collection<TopicPartition> collection)方法会在重新分配分区之后和消费者开始读取消息之前被调用。

演示如何在失去分区所有权之前通过 onPartitionsRevoked()方法来提交偏移量

public class ConsumerRebalance {

    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "docker01:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group2");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");//从头开始读取kafka消息
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        final KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(p);

        // 已经处理的消息偏移量
        final Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();

        // 再均衡监昕器
        ConsumerRebalanceListener handleRebalance = new ConsumerRebalanceListener() {

            /**
             * 在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接
             * 管分区的消费者就知道该从哪里开始读取了。
             *
             * 如果发生再均衡,我们要在即将失去分区所有权时提交偏移量。要注意,提交的是最近
             * 处理过的偏移量,而不是批次中还在处理的最后一个偏移量。因为分区有可能在我们还
             * 在处理消息的时候被撤回。我们要提交所有分区的偏移量 ,而不只是那些即将失去所有
             * 权的分区的偏移量一一因为提交的偏移量是已经处理过的,所以不会有什么问题。调用
             * commitSync()方法,确保在再均衡发生之前提交偏移量。
             * @param collection
             */
            @Override
            public void onPartitionsRevoked(Collection<TopicPartition> collection) {
                System.out.println("ConsumerRebalanceListener---onPartitionsRevoked");
                System.out.println("Lost partitions in rebalance. Committing current offsets:" + currentOffsets);
                // 提交已经处理的消息偏移量
                consumer.commitSync(currentOffsets);
            }


            /**
             * 在重新分配分区之后和消费者开始读取消息之前被调用。
             * @param collection
             */
            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> collection) {
                System.out.println("ConsumerRebalanceListener---onPartitionsAssigned");
            }
        };

        List<String> topics = Collections.singletonList("test");
        // 订阅的主题列表+监听在均衡监听器
        consumer.subscribe(topics, handleRebalance);

        try {
            while (true) {
                ConsumerRecords<String, String> rec = consumer.poll(100);
//                System.out.println("We got record count " + rec.count());
                for (ConsumerRecord<String, String> r : rec) {
                    System.out.println("\n--------------------------------------------");
                    System.out.println("主题:" + r.topic());
                    System.out.println("分区:" + r.partition());
                    System.out.println("偏移量:" + r.offset());
                    System.out.println("key:" + r.key());
                    System.out.println("value:" + r.value());
                    System.out.println(r.value());
                    TimeUnit.MILLISECONDS.sleep(5000);
                    currentOffsets.put(new TopicPartition(r.topic(), r.partition()), new OffsetAndMetadata(r.offset() + 1, "no metadata"));
                }
                // 异步提交偏移量
                consumer.commitAsync();
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                // 同步提交偏移量
                consumer.commitSync();
            } finally {
                consumer.close();
            }
        }
    }
}

代码:
github.com/wengxingxia/004Kafka.git

0人推荐
随时随地看视频
慕课网APP