Kafka消费者(四)

Kafka中的offset

对于 Kafka 中的分区而言,它的每条消息都有唯一的 offset,用来表示消息在分区中对应的位置。对于消费者而言,它也有一个 offset 的概念,消费者使用 offset 来表示消费到分区中某个消息所在的位置。为了区分,对于消息在分区中的位置,我们将 offset 称为“偏移量”;对于消费者消费到的位置,将 offset 称为“位移”或者“消费位移”。

 

消费者的位移提交

在每次调用 poll() 方法时,它返回的是还没有被消费过的消息集(暂不考虑异常情况的发生),要做到这一点,就需要记录上一次消费时的消费位移offset。并且这个消费位移必须做持久化保存,而不是单单保存在内存中,否则消费者重启之后就无法知晓之前的消费位移。再考虑一种情况,当有新的消费者加入时,那么必然会有再均衡的动作,对于同一分区而言,它可能在再均衡动作之后分配给新的消费者,如果不持久化保存消费位移,那么这个新的消费者也无法知晓之前的消费位移。

在旧消费者客户端中(scala版本),消费位移是存储在 ZooKeeper 中的。而在新消费者客户端中(java版本),消费位移存储在 Kafka 内部的主题__consumer_offsets 中。这里把将消费位移存储起来(持久化)的动作称为“提交”,消费者在消费完消息之后需要执行消费位移的提交。

 

last Consumed offset和position

参考下图中的消费位移,x表示某一次拉取操作中此分区消息的最大偏移量,假设当前消费者已经消费了x位置的消息,那么我们就可以说消费者的消费位移为x,图中也用了 lastConsumedOffset 这个单词来标识它。而当前当前消费者需要提交的消费位移并不是x,而是x+1,对应于上图中的 position,它表示下一条需要拉取的消息的位置

KafkaConsumer 类提供了 position(TopicPartition) 和 committed(TopicPartition) 两个方法来分别获取上面所说的 position 和 committed offset 的值。这两个方法的定义如下所示。

public long position(TopicPartition partition)
public OffsetAndMetadata committed(TopicPartition partition)

验证一下 lastConsumedOffset、committed offset 和 position 的值。

//代码清单11-1 消费位移的演示
TopicPartition tp = new TopicPartition(topic, 0);
consumer.assign(Arrays.asList(tp));
long lastConsumedOffset = -1;//当前消费到的位移
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    if (records.isEmpty()) {
        break;
    }
    List<ConsumerRecord<String, String>> partitionRecords
            = records.records(tp);
    lastConsumedOffset = partitionRecords
            .get(partitionRecords.size() - 1).offset();
    consumer.commitSync();//同步提交消费位移
}
System.out.println("comsumed offset is " + lastConsumedOffset);
OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
System.out.println("commited offset is " + offsetAndMetadata.offset());
long posititon = consumer.position(tp);
System.out.println("the offset of the next record is " + posititon);

结果:

comsumed offset is 377

commited offset is 378

the offset of the next record is 378

可以看出,消费者消费到此分区消息的最大偏移量为377,对应的消费位移 lastConsumedOffset 也就是377。在消费完之后就执行同步提交,但是最终结果显示所提交的位移 committed offset 为378,并且下一次所要拉取的消息的起始偏移量 position 也为378。在本示例中,position = committed offset = lastConsumedOffset + 1,不过 position 和 committed offset 并不一定相等。

 

重复消费和消息丢失

对于位移提交的具体时机的把握也很有讲究,有可能会造成重复消费和消息丢失的现象。

参考下图,当前一次 poll() 操作所拉取的消息集为 [x+2, x+7],x+2 代表上一次提交的消费位移,说明已经完成了 x+1 之前(包括 x+1 在内)的所有消息的消费,x+5 表示当前正在处理的位置。如果拉取到消息之后就进行了位移提交,即提交了 x+8,那么当前消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+8 开始的。也就是说,x+5 至 x+7 之间的消息并未能被消费,如此便发生了消息丢失的现象。

再考虑另外一种情形,位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+2 开始的。也就是说,x+2 至 x+4 之间的消息又重新消费了一遍,故而又发生了重复消费的现象。

 

Kafka的自动提交

在 Kafka 中默认的消费位移提交方式是自动提交,由消费者客户端参数 enable.auto.commit 配置,默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数 auto.commit.interval.ms 配置,默认值为5秒,此参数生效的前提是 enable.auto.commit 参数为 true

在默认的方式下,消费者每隔5秒会将拉取到的每个分区中最大的消息位移进行提交。自动位移提交的动作是在 poll() 方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。

在 Kafka 消费的编程逻辑中位移提交是一大难点,自动提交消费位移的方式非常简便,它免去了复杂的位移提交逻辑,让编码更简洁。但是可能会造成重复消费和消息丢失的问题。

 

手动提交(同步提交和异步提交)

由于自动位移提交的方式在发生异常的情况下可能会发生消息丢失或重复消费的现象,而且自动位移提交也无法做到精确的位移管理。在 Kafka 中还提供了手动位移提交的方式,可以更加灵活的控制消费位移。在很多时候并不是说拉取到消息就算消费完成,而是需要将消息写入数据库、写入本地缓存,或者是更加复杂的业务处理。在这些场景下,所有的业务处理完成才能认为消息被成功消费。开启手动提交功能的前提是消费者客户端参数 enable.auto.commit 配置为 false,示例如下:

props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

手动提交可以细分为同步提交和异步提交,对应于 KafkaConsumer 中的 commitSync() 和 commitAsync() 两种类型的方法。我们这里先讲述同步提交的方式,commitSync() 方法的定义如下:包括有参和无参2种类型的方法

public void commitSync()
public void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)

无参方法很简单,下面使用它演示同步提交的简单用法:

while (isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        //do some logical processing.
    }
    consumer.commitSync();
}

示例中先对拉取到的每一条消息做相应的逻辑处理,然后对整个消息集做同步提交。很明显看出这种方式会有重复消费的风险。commitSync() 方法会根据 poll() 方法拉取的最新位移来进行提交(注意提交的值对应于第1张图中 position 的位置),只要没有发生不可恢复的错误(Unrecoverable Error),它就会阻塞消费者线程直至位移提交完成。对于不可恢复的错误,比如 CommitFailedException、WakeupException、InterruptException、AuthenticationException、AuthorizationException 等,我们可以将其捕获并做针对性的处理。

对于采用 commitSync() 的无参方法而言,它提交消费位移的频率和拉取批次消息、处理批次消息的频率是一样的,如果想寻求更细粒度的、更精准的提交,那么就需要使用 commitSync() 的另一个含参方法,该方法提供了一个 offsets 参数,用来提交指定分区的位移。无参的 commitSync() 方法只能提交当前批次对应的 position 值。如果需要提交一个中间值,比如业务每消费一条消息就提交一次位移(实际中很少这样),那么就可以使用这种方式,如下所示。

//代码清单11-3 按分区粒度同步提交消费位移
try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(1000);
        for (TopicPartition partition : records.partitions()) {
            List<ConsumerRecord<String, String>> partitionRecords =
                    records.records(partition);
            for (ConsumerRecord<String, String> record : partitionRecords) {
                //do some logical processing.
            }
            long lastConsumedOffset = partitionRecords
                    .get(partitionRecords.size() - 1).offset();
            consumer.commitSync(Collections.singletonMap(partition,
                    new OffsetAndMetadata(lastConsumedOffset + 1)));
        }
    }
} finally {
    consumer.close();
}

由于commitSync() 方法本身是同步执行的,与 commitSync() 方法相反,异步提交的方式(commitAsync())在执行的时候消费者线程不会被阻塞,可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作。异步提交可以使消费者的性能得到一定的增强。commitAsync 方法有三个不同的重载方法,具体定义如下:

public void commitAsync()
public void commitAsync(OffsetCommitCallback callback)
public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets,
            OffsetCommitCallback callback)

其中第二个方法的 callback 参数,提供了一个异步提交的回调方法,当位移提交完成后会回调 OffsetCommitCallback 中的 onComplete() 方法。如下:

while (isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        //do some logical processing.
    }
    consumer.commitAsync(new OffsetCommitCallback() {
        @Override
        public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
                               Exception exception) {
            if (exception == null) {
                System.out.println(offsets);
            }else {
                log.error("fail to commit offsets {}", offsets, exception);
            }
        }
    });
}

commitAsync() 提交的时候同样会有失败的情况发生,那么我们应该怎么处理呢?

首先想到的是重试,问题的关键也就在这里了。如果某一次异步提交的消费位移为x,但是提交失败了,然后下一次又异步提交了消费位移为x+y,这次成功了。如果这里引入了重试机制,前一次的异步提交的消费位移在重试的时候提交成功了,那么此时的消费位移又变为了x。如果此时发生异常(或者再均衡),那么恢复之后的消费者(或者新的消费者)就会从x处开始消费消息,这样就发生了重复消费的问题。

为此我们可以设置一个递增的序号来维护异步提交的顺序,每次位移提交之后就增加序号相对应的值。在遇到位移提交失败需要重试的时候,可以检查所提交的位移和序号的值的大小,如果前者小于后者,则说明有更大的位移已经提交了,不需要再进行本次重试;如果两者相同,则说明可以进行重试提交。除非程序编码错误,否则不会出现前者大于后者的情况。

如果位移提交失败的情况经常发生,那么说明系统肯定出现了故障,在一般情况下,位移提交失败的情况很少发生,不重试也没有关系,后面的提交也会有成功的。重试会增加代码逻辑的复杂度,不重试会增加重复消费的概率。如果消费者异常退出,那么这个重复消费的问题就很难避免,因为这种情况下无法及时提交消费位移;如果消费者正常退出或发生再均衡的情况,那么可以在退出或再均衡执行之前使用同步提交的方式做最后的把关。

try {
    while (isRunning.get()) {
        //poll records and do some logical processing.
        consumer.commitAsync();
    }
} finally {
    try {
        consumer.commitSync();
    }finally {
        consumer.close();
    }
}

 

控制或关闭消费

KafkaConsumer 提供了对消费速度进行控制的方法,在有些应用场景下我们可能需要暂停某些分区的消费而先消费其他分区,当达到一定条件时再恢复这些分区的消费。KafkaConsumer 中使用 pause() 来实现暂停某些分区在拉取操作时返回数据给客户端 和 resume() 方法来恢复某些分区向客户端返回数据的操作。具体定义如下:

public void pause(Collection<TopicPartition> partitions)
public void resume(Collection<TopicPartition> partitions)

KafkaConsumer 还提供了一个无参的 paused() 方法来返回被暂停的分区集合,此方法的具体定义如下:

public Set<TopicPartition> paused()

在while 循环来包裹住 poll() 方法及相应的消费逻辑,除了演示中使用的退出这个循环的方式外。还有一种方式是调用 KafkaConsumer .wakeup()方法,wakeup() 方法是 KafkaConsumer 中唯一可以从其他线程里安全调用的方法(KafkaConsumer 是非线程安全的),调用 wakeup() 方法后可以退出 poll() 的逻辑,并抛出 WakeupException 的异常,我们也不需要处理 WakeupException 的异常,它只是一种跳出循环的方式。

跳出循环以后一定要显式地执行关闭动作以释放运行过程中占用的各种系统资源,包括内存资源、Socket 连接等。KafkaConsumer 提供了 close() 方法来实现关闭,close() 方法有三种重载方法,分别如下:

/**没有 timeout 参数,这并不意味着会无限制地等待,它内部设定了最长等待时间(30秒)*/
public void close()

/**通过 timeout 参数来设定关闭方法的最长执行时间,有些内部的关闭逻辑会耗费一定的时间,比如设置了自动提交消费位移,这里还会做一次位移提交的动作*/
public void close(Duration timeout)

@Deprecated
public void close(long timeout, TimeUnit timeUnit)

 

 

指定位移消费

当一个新的消费组建立的时候,它根本没有可以查找的消费位移。或者消费组内的一个新消费者订阅了一个新的主题,它也没有可以查找的消费位移。当 __consumer_offsets 主题中有关这个消费组的位移信息过期而被删除后,它也没有可以查找的消费位移。在 Kafka 中每当消费者查找不到所记录的消费位移时(除了查找不到消费位移,位移越界也会),就会根据消费者客户端参数 auto.offset.reset 的配置来决定从何处开始进行消费,这个参数的默认值为“latest”,表示从分区末尾开始消费消息。

3-9

参考上图,按照默认的配置,消费者会从9开始进行消费(9是下一条要写入消息的位置),更加确切地说是从9开始拉取消息。如果将 auto.offset.reset 参数配置为“earliest”,那么消费者会从起始处,也就是0开始消费。auto.offset.reset 参数还有一个可配置的值:“none”,配置为此值就意味着出现查到不到消费位移的时候,既不从最新的消息位置处开始消费,也不从最早的消息位置处开始消费,此时会报出 NoOffsetForPartitionException 异常,如果能够找到消费位移,那么配置为“none”不会出现任何异常。如果配置的不是“latest”、“earliest”和“none”中的一个,则会报出 ConfigException 异常

 

我们知道消息的拉取是根据 poll() 方法中的逻辑来处理的,这个 poll() 方法中的逻辑对于普通的开发人员而言是一个黑盒,无法精确地掌控其消费的起始位置。提供的 auto.offset.reset 参数也只能在找不到消费位移或位移越界的情况下粗粒度地从开头或末尾开始消费。有些时候,我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而 KafkaConsumer 中的 seek() 方法正好提供了这个功能,让我们得以追前消费或回溯消费。seek() 方法的具体定义如下:

public void seek(TopicPartition partition, long offset)

seek() 方法中的参数 partition 表示分区,而 offset 参数用来指定从分区的哪个位置开始消费。seek() 方法只能重置消费者分配到的分区的消费位置,而分区的分配是在 poll() 方法的调用过程中实现的。也就是说,在执行 seek() 方法之前需要先执行一次 poll() 方法,等到分配到分区之后才可以重置消费位置。seek() 方法的使用如下:

//代码清单12-1 seek方法的使用示例
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
consumer.poll(Duration.ofMillis(10000));                      	//①
Set<TopicPartition> assignment = consumer.assignment();    	//②
for (TopicPartition tp : assignment) {
    consumer.seek(tp, 10);   	                                //③
}
while (true) {
    ConsumerRecords<String, String> records = 
            consumer.poll(Duration.ofMillis(1000));
    //consume the record.
}

③设置了每个分区的消费位置为10

②行中的 assignment() 方法是用来获取消费者所分配到的分区信息的,这个方法的具体定义如下:

public Set<TopicPartition> assignment()

如果我们上面代码的第①行 poll() 方法的参数设置为0,即这一行替换为:

consumer.poll(Duration.ofMillis(0));

在此之后,会发现 seek() 方法并未有任何作用。因为当 poll() 方法中的参数为0时,此方法立刻返回,那么 poll() 方法内部进行分区分配的逻辑就会来不及实施。也就是说,消费者此时并未分配到任何分区,如此第②行中的 assignment 便是一个空列表,第③行代码也不会执行。那么这里的 timeout 参数设置为多少合适呢?太短会使分配分区的动作失败,太长又有可能造成一些不必要的等待。我们可以通过 KafkaConsumer 的 assignment() 方法来判定是否分配到了相应的分区,参考下面的代码:


KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {//如果不为0,则说明已经成功分配到了分区
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}
for (TopicPartition tp : assignment) {
    consumer.seek(tp, 10);
}
while (true) {
    ConsumerRecords<String, String> records =
            consumer.poll(Duration.ofMillis(1000));
    //consume the record.
}

如果对未分配到的分区执行 seek() 方法,那么会报出 IllegalStateException 的异常。类似在调用 subscribe() 方法之后直接调用 seek() 方法:

consumer.subscribe(Arrays.asList(topic));
consumer.seek(new TopicPartition(topic,0),10);

会报出如下的异常:

java.lang.IllegalStateException: No current assignment for partition topic-demo-0

如果消费组内的消费者在启动的时候能够找到消费位移,除非发生位移越界,否则 auto.offset.reset 参数并不会奏效,此时如果想指定从开头或末尾开始消费,就需要 seek() 方法,如下指定从分区末尾开始消费。

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while (assignment.size() == 0) {
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}
Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);    
for (TopicPartition tp : assignment) {
    consumer.seek(tp, offsets.get(tp));							        
}

其中:endOffsets() 方法用来获取指定分区的末尾的消息位置,参考第一个图中9的位置,是将要写入最新消息的位置。endOffsets 的具体方法定义如下:

public Map<TopicPartition, Long> endOffsets(
            Collection<TopicPartition> partitions)
public Map<TopicPartition, Long> endOffsets(
            Collection<TopicPartition> partitions,
            Duration timeout)

其中 partitions 参数表示分区集合,而 timeout 参数用来设置等待获取的超时时间。如果没有指定 timeout 参数的值,那么 endOffsets() 方法的等待时间由客户端参数 request.timeout.ms 来设置,默认值为30000。与 endOffsets 对应的是 beginningOffsets() 方法,一个分区的起始位置起初是0,但并不代表每时每刻都为0,因为日志清理的动作会清理旧的数据,所以分区的起始位置会自然而然地增加。beginningOffsets() 方法的具体定义如下:

public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions)
public Map<TopicPartition,Long>beginningOffsets(Collection<TopicPartition>partitions,Duration timeout)

KafkaConsumer 中直接提供了 seekToBeginning() 方法和 seekToEnd() 方法来实现从分区的开头或末尾开始消费,这两个方法的具体定义如下:

public void seekToBeginning(Collection<TopicPartition> partitions)
public void seekToEnd(Collection<TopicPartition> partitions)

KafkaConsumer 中提供了一个 offsetsForTimes() 方法,通过 timestamp 来查询与此对应的分区位置。比如我们并不知道特定的消费位置,却知道一个相关的时间点,比如我们想要消费昨天8点之后的消息,这个需求更符合正常的思维逻辑。此时我们无法直接使用 seek() 方法来追溯到相应的位置,就可以使用 offsetsForTimes() 方法

public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)
public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout)

 timestampsToSearch 是一个 Map 类型,key 为待查询的分区,而 value 为待查询的时间戳,该方法会返回时间戳大于等于待查询时间的第一条消息对应的位置和时间戳,对应于 OffsetAndTimestamp 中的 offset 和 timestamp 字段。

演示了 offsetsForTimes() 和 seek() 之间的使用方法,首先通过 offsetForTimes() 方法获取一天之前的消息位置,然后使用 seek() 方法追溯到相应位置开始消费:

Map<TopicPartition, Long> timestampToSearch = new HashMap<>();
for (TopicPartition tp : assignment) {
    timestampToSearch.put(tp, System.currentTimeMillis()-1*24*3600*1000);
}
Map<TopicPartition, OffsetAndTimestamp> offsets =
        consumer.offsetsForTimes(timestampToSearch);
for (TopicPartition tp : assignment) {
    OffsetAndTimestamp offsetAndTimestamp = offsets.get(tp);
    if (offsetAndTimestamp != null) {
        consumer.seek(tp, offsetAndTimestamp.offset());
    }
}

 

前面说过位移越界也会触发 auto.offset.reset 参数的执行,位移越界是指知道消费位置却无法在实际的分区中查找到,比如想要从上图中的位置10处拉取消息时就会发生位移越界。注意拉取上图中位置9处的消息时并未越界,这个位置代表特定的含义(LEO)。我们通过 seek() 方法来演示发生位移越界时的情形,此时客户端会报出如下的提示信息:

[2018-08-19 16:13:44,700] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-3, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-0, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-2, resetting offset 
[2018-08-19 16:13:44,701] INFO [Consumer clientId=consumer-1, groupId=group.demo] Fetch offset 101 is out of range for partition topic-demo-1, resetting offset 
[2018-08-19 16:13:44,708] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-3 to offset 100. 
[2018-08-19 16:13:44,708] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-0 to offset 100. 
[2018-08-19 16:13:44,709] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-2 to offset 100. 
[2018-08-19 16:13:44,713] INFO [Consumer clientId=consumer-1, groupId=group.demo] Resetting offset for partition topic-demo-1 to offset 100. 

通过上面加粗的提示信息可以了解到,原本拉取位置为101(fetch offset 101),但已经越界了(out of range),所以此时会根据 auto.offset.reset 参数的默认值来将拉取位置重置(resetting offset)为100,我们也能知道此时分区 topic-demo-3 中最大的消息 offset为99。

seek() 方法可以突破 Kafka 中的消费位移是存储在一个内部主题中的限制,使得消费位移可以保存在任意的存储介质中,例如数据库、文件系统等。以数据库为例,我们将消费位移保存在其中的一个表中,在下次消费的时候可以读取存储在数据表中的消费位移并通过 seek() 方法指向这个具体的位置,参考如下

//消费位移保存在DB中
consumer.subscribe(Arrays.asList(topic));
//省略poll()方法及assignment的逻辑
for(TopicPartition tp: assignment){
    long offset = getOffsetFromDB(tp);//从DB中读取消费位移
    consumer.seek(tp, offset);
}
while(true){
    ConsumerRecords<String, String> records =
            consumer.poll(Duration.ofMillis(1000));
    for (TopicPartition partition : records.partitions()) {
        List<ConsumerRecord<String, String>> partitionRecords =
                records.records(partition);
        for (ConsumerRecord<String, String> record : partitionRecords) {
            //process the record.
        }
        long lastConsumedOffset = partitionRecords
                .get(partitionRecords.size() - 1).offset();
         //将消费位移存储在DB中
        storeOffsetToDB(partition, lastConsumedOffset+1);
    }
}

seek() 方法为我们提供了从特定位置读取消息的能力,我们可以通过这个方法来向前跳过若干消息,也可以通过这个方法来向后回溯若干消息,这样为消息的消费提供了很大的灵活性。seek() 方法也为我们提供了将消费位移保存在外部存储介质中的能力,还可以配合再均衡监听器来提供更加精准的消费能力。

 

再均衡

前面提到过,再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。也就是说,在再均衡发生期间的这一小段时间内,消费组会变得不可用。

另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息又被重新消费一遍,也就是发生了重复消费。一般情况下,应尽量避免不必要的再均衡的发生。

订阅主题时候,我们使用subscribe() 方法会使用再均衡监听器 ConsumerRebalanceListener

subscribe(Collection<String> topics, ConsumerRebalanceListener listener)

subscribe(Pattern pattern, ConsumerRebalanceListener listener)

其中再均衡监听器用来设定发生再均衡动作前后的一些准备或收尾的动作。ConsumerRebalanceListener 是一个接口,包含2个方法如下:

void onPartitionsRevoked(Collection partitions) 
//这个方法会在再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的重复消费现象的发生。参数 partitions 表示再均衡前所分配到的分区。

void onPartitionsAssigned(Collection partitions) 
//这个方法会在重新分配分区之后和消费者开始读取消费之前被调用。参数 partitions 表示再均衡后所分配到的分区。

如下ConsumerRebalanceListener,将消费位移暂存到一个局部变量 currentOffsets 中,这样在正常消费的时候可以通过 commitAsync() 方法来异步提交消费位移,在发生再均衡动作之前可以通过再均衡监听器的 onPartitionsRevoked() 回调执行 commitSync() 方法同步提交消费位移,以尽量避免一些不必要的重复消费。

Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        consumer.commitSync(currentOffsets);
	        currentOffsets.clear();
    }
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        //do nothing.
    }
});

try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records =
                consumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
            //process the record.
            currentOffsets.put(
                    new TopicPartition(record.topic(), record.partition()),
                    new OffsetAndMetadata(record.offset() + 1));
        }
        consumer.commitAsync(currentOffsets, null);
    }
} finally {
    consumer.close();
}

再均衡监听器还可以配合外部存储使用。我们将消费位移保存在数据库中,这里可以通过再均衡监听器查找分配到的分区的消费位移,并且配合 seek() 方法来进一步优化代码逻辑,如下:
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        //store offset in DB (storeOffsetToDB)
    }
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        for(TopicPartition tp: partitions){
            consumer.seek(tp, getOffsetFromDB(tp));//从DB中读取消费位移
        }
    }
});

消费者拦截器

除了生产者拦截器,对应的消费者也有相应的拦截器的概念。消费者拦截器主要在消费到消息或在提交消费位移时进行一些定制化的操作。与生产者拦截器对应的,消费者拦截器需要自定义实现 org.apache.kafka.clients.consumer. ConsumerInterceptor 接口,ConsumerInterceptor 接口包含3个方法:

KafkaConsumer 会在 poll() 方法返回之前调用拦截器的 onConsume() 方法来对消息进行相应的定制化操作,
比如修改返回的消息内容、按照某种规则过滤消息(可能会减少 poll() 方法返回的消息的个数)。如果
onConsume() 方法中抛出异常,那么会被捕获并记录到日志中,但是异常不会再向上传递
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);


KafkaConsumer 会在提交完消费位移之后调用拦截器的 onCommit() 方法,可以使用这个方法来记录跟踪所提
交的位移信息,比如当消费者使用 commitSync 的无参方法时,我们不知道提交的消费位移的具体细节,而使用
拦截器的 onCommit() 方法却可以做到这一点
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);

public void close()。

在某些业务场景中会对消息设置一个有效期的属性,如果某条消息在既定的时间窗口内无法到达,那么就会被视为无效,它也就不需要再被继续处理了(消息TTL的功能)。自定义的消费者拦截器 ConsumerInterceptorTTL 使用消息的 timestamp 字段来判定是否过期,如果消息的时间戳与当前的时间戳相差超过10秒则判定为过期,那么这条消息也就被过滤而不投递给具体的消费者。

public class ConsumerInterceptorTTL implements 
        ConsumerInterceptor<String, String> {
    private static final long EXPIRE_INTERVAL = 10 * 1000;

    @Override
    public ConsumerRecords<String, String> onConsume(
            ConsumerRecords<String, String> records) {
        long now = System.currentTimeMillis();
        Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords 
                = new HashMap<>();
        for (TopicPartition tp : records.partitions()) {
            //拿到分区的消息集合
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
            for (ConsumerRecord<String, String> record : tpRecords) {
                if (now - record.timestamp() < EXPIRE_INTERVAL) {
                    newTpRecords.add(record);
                }
            }
            if (!newTpRecords.isEmpty()) {
                newRecords.put(tp, newTpRecords);
            }
        }
        return new ConsumerRecords<>(newRecords);
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp, offset) -> 
                System.out.println(tp + ":" + offset.offset()));
    }

    @Override
    public void close() {}

    @Override
    public void configure(Map<String, ?> configs) {}
}

配置使用消费者拦截器:

props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG,
        ConsumerInterceptorTTL.class.getName());

不过使用这种功能时需要注意的是:在使用带参数的位移提交的方式时,有可能提交了错误的位移信息。因为在一次消息拉取的批次中,可能含有最大偏移量的消息会被消费者拦截器过滤。

在消费者中也有拦截链的概念,和生产者的拦截链一样,也是按照 interceptor.classes 参数配置的拦截器的顺序来一一执行的(配置的时候,各个拦截器之间使用逗号隔开)。同样也要提防“副作用”的发生。如果在拦截链中某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。

 

Kakfa中多线程的实现

KafkaProducer 是线程安全的,然而 KafkaConsumer 却是非线程安全的。KafkaConsumer 中定义了一个 acquire() 方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出 ConcurrentModifcationException 异常:

java.util.ConcurrentModificationException: KafkaConsumer is not safe for multi-threaded access.

KafkaConsumer 中的每个公用方法在执行所要执行的动作之前都会调用这个 acquire() 方法,(只有 wakeup() 方法是个例外)acquire() 方法和我们通常所说的锁(synchronized、Lock 等)不同,它不会造成阻塞等待,可以将其看作一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire() 方法和 release() 方法成对出现,表示相应的加锁和解锁操作,具体定义如下:

//require()方法
private final AtomicLong currentThread
    = new AtomicLong(NO_CURRENT_THREAD); //KafkaConsumer中的成员变量

private void acquire() {
    long threadId = Thread.currentThread().getId();
    if (threadId != currentThread.get() &&
            !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
        throw new ConcurrentModificationException
                ("KafkaConsumer is not safe for multi-threaded access");
    refcount.incrementAndGet();
}

//release()方法
private void release() {
    if (refcount.decrementAndGet() == 0)
        currentThread.set(NO_CURRENT_THREAD);
}

acquire() 方法和 release() 方法都是私有方法,因此在实际应用中不需要我们显式地调用,但了解其内部的机理之后可以促使我们正确、有效地编写相应的程序逻辑。

多线程的方式来实现消息消费

KafkaConsumer 非线程安全并不意味着我们在消费消息的时候只能以单线程的方式执行。如果生产者发送消息的速度大于消费者处理消息的速度,那么就会有越来越多的消息得不到及时的消费,造成了一定的延迟。除此之外,由于 Kafka 中消息保留机制的作用,有些消息有可能在被消费之前就被清理了,从而造成消息的丢失。所以可以通过多线程的方式来实现消息消费,多线程的目的就是为了提高整体的消费能力。

多线程实现消息消费的方式

第一种也是最常见的方式:线程封闭,即为每个线程实例化一个 KafkaConsumer 对象,

一个线程对应一个 KafkaConsumer 实例,我们可以称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组。这种实现方式的并发度受限于分区的实际个数,前面介绍的消费者与分区数的关系,当消费线程的个数大于分区数时,就有部分消费线程一直处于空闲的状态。

å¾3-10

第二种方式是多个消费者线程同时消费同一个分区,这个通过 assign()、seek() 等方法实现,这样可以打破原有的消费线程的个数不能超过分区数的限制,进一步提高了消费的能力。不过这种实现方式对于位移提交和顺序控制的处理就会变得非常复杂,也并不推荐。一般而言,分区是消费线程的最小划分单位

//演示多线程消费情况(方式一)
public class FirstMultiConsumerThreadDemo {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";

    public static Properties initConfig(){
        Properties props = new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        //分区数,如果不知道分区数:通过KafkaConsumer的partitionFor()获取 
        int consumerThreadNum = 4;
        for(int i=0;i<consumerThreadNum;i++) {
            new KafkaConsumerThread(props,topic).start();
        }
    }

    public static class KafkaConsumerThread extends Thread{
        private KafkaConsumer<String, String> kafkaConsumer;

        public KafkaConsumerThread(Properties props, String topic) {
            this.kafkaConsumer = new KafkaConsumer<>(props);
            this.kafkaConsumer.subscribe(Arrays.asList(topic));
        }

        @Override
        public void run(){
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    for (ConsumerRecord<String, String> record : records) {
                        //处理消息模块	①
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}

上面的消费线程的数量由 consumerThreadNum 变量指定。可以将 consumerThreadNum 设置成不大于分区数的值,如果不知道主题的分区数,那么也可以通过 KafkaConsumer 类的 partitionsFor() 方法来间接获取,这种多线程的实现方式和开启多个消费进程的方式没有本质上的区别,它的优点是每个线程可以按顺序消费各个分区中的消息。缺点也很明显,每个消费线程都要维护一个独立的TCP连接,如果分区数和 consumerThreadNum 的值都很大,那么会造成不小的系统开销

在处理消息模块中,如果这里对消息的处理非常迅速,那么 poll() 拉取的频次也会更高,进而整体消费的性能也会提升;相反,如果在这里对消息的处理缓慢,比如进行一个事务性操作,或者等待一个RPC的同步响应,那么 poll() 拉取的频次也会随之下降,进而造成整体消费性能的下降。可以通过异步和多线程的方式处理这里面的逻辑,当然这样的话,在处理位移提交的时候,在每一个处理消息的的类在处理完消息之后都将对应的消费位移保存到共享变量 offsets 中,KafkaConsumerThread 在每一次 poll() 方法之后都读取 offsets 中的内容并对其进行位移提交。注意在实现的过程中对 offsets 读写需要加锁处理,防止出现并发问题。并且在写入 offsets 的时候需要注意位移覆盖的问题,如下:

多线程消费逻辑
for (TopicPartition tp : records.partitions()) {
    List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
    //处理tpRecords.
    long lastConsumedOffset = tpRecords.get(tpRecords.size() - 1).offset();
    synchronized (offsets) {
        if (!offsets.containsKey(tp)) {
            offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
        }else {
            long position = offsets.get(tp).offset();
            if (position < lastConsumedOffset + 1) {
                offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
            }
        }
    }
}
消费完提交的逻辑
synchronized (offsets) {
    if (!offsets.isEmpty()) {
        kafkaConsumer.commitSync(offsets);
        offsets.clear();
    }
}

这种方式依然存在风险。对于同一个分区中的消息,假设一个处理线程 RecordHandler1 正在处理 offset 为0~99的消息,而另一个处理线程 RecordHandler2 已经处理完了 offset 为100~199的消息并进行了位移提交,此时如果 RecordHandler1 发生异常,则之后的消费只能从200开始而无法再次消费0~99的消息,从而造成了消息丢失的现象。这里虽然针对位移覆盖做了一定的处理,但还没有解决异常情况下的位移覆盖问题。

 

关于消费者的参数总结

除了默认必须设定的参数外,还有以下参数:

1. fetch.min.bytes

该参数用来配置 Consumer 在一次拉取请求(调用 poll() 方法)中能从 Kafka 中拉取的最小数据量,默认值为1(B)。Kafka 在收到 Consumer 的拉取请求时,如果返回给 Consumer 的数据量小于这个参数所配置的值,那么它就需要进行等待,直到数据量满足这个参数的配置大小。可以适当调大这个参数的值以提高一定的吞吐量,不过也会造成额外的延迟(latency),对于延迟敏感的应用可能就不可取了。

2. fetch.max.bytes

该参数与 fetch.min.bytes 参数对应,它用来配置 Consumer 在一次拉取请求中从Kafka中拉取的最大数据量,默认值为52428800(B),也就是50MB。

如果这个参数设置的值比任何一条写入 Kafka 中的消息要小,那么会不会造成无法消费呢?很多资料对此参数的解读认为是无法消费的,比如一条消息的大小为10B,而这个参数的值是1(B),既然此参数设定的值是一次拉取请求中所能拉取的最大数据量,那么显然1B<10B,所以无法拉取。这个观点是错误的,该参数设定的不是绝对的最大值,如果在第一个非空分区中拉取的第一条消息大于该值,那么该消息将仍然返回,以确保消费者继续工作。也就是说,上面问题的答案是可以正常消费。

与此相关的,Kafka 中所能接收的最大消息的大小通过服务端参数 message.max.bytes(对应于主题端参数 max.message.bytes)来设置。

3. fetch.max.wait.ms

这个参数也和 fetch.min.bytes 参数有关,如果 Kafka 仅仅参考 fetch.min.bytes 参数的要求,那么有可能会一直阻塞等待而无法发送响应给 Consumer,显然这是不合理的。fetch.max.wait.ms 参数用于指定 Kafka 的等待时间,默认值为500(ms)。如果 Kafka 中没有足够多的消息而满足不了 fetch.min.bytes 参数的要求,那么最终会等待500ms。这个参数的设定和 Consumer 与 Kafka 之间的延迟也有关系,如果业务应用对延迟敏感,那么可以适当调小这个参数。

4. max.partition.fetch.bytes

这个参数用来配置从每个分区里返回给 Consumer 的最大数据量,默认值为1048576(B),即1MB。这个参数与 fetch.max.bytes 参数相似,只不过前者用来限制一次拉取中每个分区的消息大小,而后者用来限制一次拉取中整体消息的大小。同样,如果这个参数设定的值比消息的大小要小,那么也不会造成无法消费,Kafka 为了保持消费逻辑的正常运转不会对此做强硬的限制。

5. max.poll.records

这个参数用来配置 Consumer 在一次拉取请求中拉取的最大消息数,默认值为500(条)。如果消息的大小都比较小,则可以适当调大这个参数值来提升一定的消费速度。

6. connections.max.idle.ms

这个参数用来指定在多久之后关闭闲置的连接,默认值是540000(ms),即9分钟。

7. exclude.internal.topics

Kafka 中有两个内部的主题: __consumer_offsets 和 __transaction_state。exclude.internal.topics 用来指定 Kafka 中的内部主题是否可以向消费者公开,默认值为 true。如果设置为 true,那么只能使用 subscribe(Collection)的方式而不能使用 subscribe(Pattern)的方式来订阅内部主题,设置为 false 则没有这个限制。

8. receive.buffer.bytes

这个参数用来设置 Socket 接收消息缓冲区(SO_RECBUF)的大小,默认值为65536(B),即64KB。如果设置为-1,则使用操作系统的默认值。如果 Consumer 与 Kafka 处于不同的机房,则可以适当调大这个参数值。

9. send.buffer.bytes

这个参数用来设置Socket发送消息缓冲区(SO_SNDBUF)的大小,默认值为131072(B),即128KB。与receive.buffer.bytes参数一样,如果设置为-1,则使用操作系统的默认值。

10. request.timeout.ms

这个参数用来配置 Consumer 等待请求响应的最长时间,默认值为30000(ms)。

11. metadata.max.age.ms

这个参数用来配置元数据的过期时间,默认值为300000(ms),即5分钟。如果元数据在此参数所限定的时间范围内没有进行更新,则会被强制更新,即使没有任何分区变化或有新的 broker 加入。

12. reconnect.backoff.ms

这个参数用来配置尝试重新连接指定主机之前的等待时间(也称为退避时间),避免频繁地连接主机,默认值为50(ms)。这种机制适用于消费者向 broker 发送的所有请求。

13. retry.backoff.ms

这个参数用来配置尝试重新发送失败的请求到指定的主题分区之前的等待(退避)时间,避免在某些故障情况下频繁地重复发送,默认值为100(ms)。

14. isolation.level

这个参数用来配置消费者的事务隔离级别。字符串类型,有效值为“read_uncommitted”和“read_committed”,表示消费者所消费到的位置,如果设置为“read_committed”,那么消费者就会忽略事务未提交的消息,即只能消费到LSO(LastStableOffset)的位置,默认情况下为“read_uncommitted”,即可以消费到 HW(High Watermark)处的位置。有关事务和 LSO 的内容可以参考《图解Kafka之核心原理》的相关章节。

还有一些消费者参数在本节没有提及,这些参数同样非常重要,它们需要用单独的章节或场景中描述。部分参数在前面的章节内容中已经提及,比如 boostrap.servers;还有部分参数会在后面的《图解Kafka之核心原理》中提及,比如 heartbeat.interval.ms。下表罗列了部分消费者客户端的重要参数。

参 数 名 称默 认 值参 数 释 义
bootstrap.servers“”指定连接 Kafka 集群所需的 broker 地址清单
key.deserializer 消息中 key 所对应的反序列化类,需要实现 org.apache.kafka.common.serialization.Deserializer 接口
value.deserializer 消息中 key 所对应的反序列化类,需要实现 org.apache.kafka.common.serialization.Deserializer 接口
group.id“”此消费者所隶属的消费组的唯一标识,即消费组的名称
client.id“”消费者客户端的id。
heartbeat.interval.ms3000当使用 Kafka 的分组管理功能时,心跳到消费者协调器之间的预计时间。心跳用于确保消费者的会话保持活动状态,当有新消费者加入或离开组时方便重新平衡。该值必须比 session.timeout.ms 小,通常不高于1/3。它可以调整得更低,以控制正常重新平衡的预期时间
session.timeout.ms10000组管理协议中用来检测消费者是否失效的超时时间
max.poll.interval.ms300000当通过消费组管理消费者时,该配置指定拉取消息线程最长空闲时间,若超过这个时间间隔还没有发起 poll 操作,则消费组认为该消费者已离开了消费组,将进行再均衡操作
auto.offset.resetlatest参数值为字符串类型,有效值为“earliest”“latest”“none”,配置为其余值会报出异常
enable.auto.committrueboolean 类型,配置是否开启自动提交消费位移的功能,默认开启
auto.commit.interval.ms5000当enbale.auto.commit参数设置为 true 时才生效,表示开启自动提交消费位移功能时自动提交消费位移的时间间隔
partition.assignment.strategyorg.apache.kafka.clients.consumer.RangeAssignor消费者的分区分配策略
interceptor.class“”用来配置消费者客户端的拦截器

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值