再均衡监听器
在提交偏移量文章中提到过,在退出和进行分区再均衡之前,消费者会做一些清理工作。
你会在消费者失去对一个分区的所有权之前提交最后一个已处理记录的偏移量。
如果消费者准备了一个缓冲区用于处理偶发的事件,那么在失去分区所有权之前,需要处理在缓冲区累积下来的记录。你可能还需要关闭文件句柄、数据库连接等。
在调用 subscribe() 方法时传进去一个 ConsumerRebalanceListener 实例,可以在在为消费者分配新分区或移除旧分区时执行一些自定义操作。
ConsumerRebalanceListener 有两个需要实现的方法:
public void onPartitionsRevoked(Collection partitions)
方法会在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了。
public void onPartitionsAssigned(Collection partitions) 方法会在
重新分配分区之后和消费者开始读取消息之前被调用。这里你可以准备或加载任何你想在分区中使用的状态,或者可以寻求正确的偏移量等。在这里做的任何准备工作都应该保证能在max.poll.timeout.ms内返回,这样消费者就可以成功地加入组
public void onPartitionsLost(Collection partitions)
只有在使用协作式再平衡算法时才会被调用,而且只有在特殊情况下才会被调用,即分区被分配给其他消费者而没有首先被再平衡算法撤销(在正常情况下,onPartitions Revoked()将被调用)。
在这里,你可以清理与这些分区一起使用的相关状态或资源。因为分区的新所有者可能已经保存了自己的状态,要注意要避免冲突。
同时,如果您不实现此方法,则会调用 onPartitionsRevoked()。
使用协作式再平衡算法
如果您使用协作式再平衡算法,要注意下:
• **onPartitionsAssigned() **将在每次重新平衡时被调用,作为通知消费者发生重新平衡的一种方式。然而,如果没有新的分区可以分配给消费者,它将以一个空集合被调用.
• **onPartitionsRevoked() **将在正常的再平衡条件下被调用,但只有当消费者放弃了分区的所有权时才会被调用。它将不会在一个空的集合中被调用。
• onPartitionsLost() 将在异常的重新平衡条件下调用,并且在调用该方法时集合中的分区已经拥有新的所有者。
如果你实现了这三个方法,你就能保证在正常的再平衡过程中,onPartitionsAssigned()将被新的分区所有者调用,而这些分区只有在前任所有者完成onPartitionsRevoked()并放弃其所有权之后才会被重新分配。
下面的例子将演示如何在失去分区所有权之前通过 onPartitionsRevoked() 方法来提交偏移量:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets= new HashMap<>();
private class HandleRebalance implements ConsumerRebalanceListener { // ➊
public void onPartitionsAssigned(Collection<TopicPartition> partitions) { // ➋
}
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
System.out.println("Lost partitions in rebalance.Committing currentoffsets:" + currentOffsets);
consumer.commitSync(currentOffsets); // ➌
}
}
try {
consumer.subscribe(topics, new HandleRebalance()); // ➍
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.println("topic = %s, partition = %s, offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
currentOffsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset()+1, "no metadata"));
}
consumer.commitAsync(currentOffsets, null);
}
} catch (WakeupException e) {
// 忽略异常,正在关闭消费者
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(currentOffsets);
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
}
- ➊首先要实现 ConsumerRebalanceListener 接口。
- ➋在获得新分区后开始读取消息,不需要做其他事情。
- ➌如果发生再平衡,我们要在即将失去分区所有权时提交偏移量。要注意,提交的是最近处理过的偏移量,而不是批次中还在处理的最后一个偏移量**。因为分区有可能在我们还在处理消息的时候被撤回**。我们要提交所有分区的偏移量,而不只是那些即将失去所有权的分区的偏移量——因为提交的偏移量是已经处理过的,所以不会有什么问题。调用commitSync() 方法,确保在再均衡发生之前提交偏移量。
- ➍把 ConsumerRebalanceListener 对象传给 subscribe() 方法,这是最重要的一步。
从特定偏移量处开始处理记录
我们已经知道了如何使用 poll() 方法从各个分区的最新偏移量处开始处理消息。不过,有时候也需要从特定的偏移量处开始读取消息。
从始末位置处理
如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使用 seekToBeginning(Collection tp) 和 seekToEnd(Collection tp) 这两个方法。
从特定位置
不过,Kafka 也为我们提供了用于查找特定偏移量的 API。
它有很多功能,比如向后回退几个消息或者向前跳过几个消息。这个能力有很多用处,比如当对时间比较敏感的应用程序在处理滞后的情况下希望能够向前跳过若干个消息;或一个负责将数据写入文件的消费者可以被重置到一个特定的时间点,以便在文件丢失时恢复数据。
下面简单的例子将说明如何将所有分区上的当前偏移量设置为在特定时间点产生的记录:
Long oneHourEarlier = Instant.now().atZone(ZoneId.systemDefault()).minusHours(1).toEpochSecond();
Map<TopicPartition, Long> partitionTimestampMap = consumer.assignment()
.stream()
.collect(Collectors.toMap(tp -> tp, tp -> oneHourEarlier)); // 1
Map<TopicPartition, OffsetAndTimestamp> offsetMap =
consumer.offsetsForTimes(partitionTimestampMap); // 2
for(Map.Entry<TopicPartition,OffsetAndTimestamp> entry : offsetMap.entrySet()) {
consumer.seek(entry.getKey(), entry.getValue().offset()); // 3
}
- 这里我们根据分配给这个消费者的所有分区(通过 consumer.assignment()获取)创建一个映射到我们想要将消费者恢复到的位置的时间戳。
- 这里会获取时间戳当时的偏移量。此方法向代理发送请求,其中使用时间戳索引返回相关偏移量。
- 最后,我们将每个分区上的偏移重置为第二步中对应的偏移量。
如何退出消费
当你决定关闭消费者,并且想立即退出,即使消费者可能正在等待一个长的poll();你需要另一个线程来调用consumer.wakeup()方法来实现。
如果循环运行在主线程里,可以在 ShutdownHook 里调用该方法。
要记住,consumer.wakeup() 是消费者唯一一个可以从其他线程里安全调用的方法。
调用 consumer.wakeup() 可以退出 poll(),并抛出 WakeupException 异常,或者如果调用consumer.wakeup() 时线程没有等待轮询,那么异常将在下一轮调用 poll() 时抛出。
我们不需要处理 WakeupException,因为它只是用于跳出循环的一种方式。
不过,在退出线程之前一定要调用 consumer.close()。
如果需要的话,该方法将提交偏移量,并将向组协调器发送消费者离开组的消息。
然后消费者协调器将立即触发再平衡,应用不需要等待会话超时,同时应用正在关闭的消费者的分区将被分配给组中的另一个消费者。
下面是如果消费者在主应用程序线程中运行时,退出消费的例子:
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
System.out.println("Starting exit...");
consumer.wakeup(); // 1
try {
mainThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
...
Duration timeout = Duration.ofMillis(10000); // 2
try {
// looping until ctrl-c, the shutdown hook will cleanup on exit
while (true) {
ConsumerRecords<String, String> records =
movingAvg.consumer.poll(timeout);
System.out.println(System.currentTimeMillis() + "-- waiting for data...");
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s\n",
record.offset(), record.key(), record.value());
}
for (TopicPartition tp: consumer.assignment())
System.out.println("Committing offset at position:" + consumer.position(tp));
movingAvg.consumer.commitSync();
}
} catch (WakeupException e) {
// ignore for shutdown // 3
} finally {
consumer.close(); // 4
System.out.println("Closed consumer and we are done");
}
- ShutdownHook运行在一个独立的线程中,所以可以采取的唯一安全的方式是调用wakeup来跳出轮询循环。
- 一个特别长的轮询超时。如果轮询循环足够短,且愿意在退出前等待,就不需要调用wake,只需在每次迭代中检查一个原子布尔值就足够了。当消费低吞吐量的主题时,长的轮询超时是有用的;这样,当代理没有新的数据返回时,客户端使用较少的CPU来不断循环。
- 另一个调用wakeup的线程将导致poll抛出一个WakeupException。你可能想捕捉这个异常,但没有必要对它做任何处理。
- 在退出消费者之前,确保将其关闭清理干净.