每当我们调用poll()时,它都会返回之前被写入Kafka的记录,即我们组中的消费者还没有读过的记录。 这意味着我们有一种方法可以跟踪该组消费者读取过的记录。 如前所述,Kafka的一个独特特征是它不会像许多JMS队列那样跟踪消费过的记录。 相反,它允许消费者使用Kafka跟踪每个分区中的位置(偏移)。
我们将更新分区中当前位置的操作称为提交(commits)。
那么消费者是如何提交偏移量(offset)的呢? 它向Kafka生成一条消息,指向一个特殊的 __consumer_offsets主题,包含每个分区需要提交的偏移量。 但是,如果消费者崩溃或新的消费者加入消费者群体,这将触发重新平衡(rebalance)。 在重新平衡之后,可以为每个消费者分配一组新的分区而不是之前处理的分区。 然后消费者将读取每个分区的已提交偏移量并从那里继续。
如果提交的偏移量小于客户端处理的最后一条消息的偏移量,那么最后处理的偏移量与提交的偏移量之间的消息将被处理两次,如下图:
如果提交的偏移量大于客户端实际处理的最后一条消息的偏移量,那么消费者组将忽略上次处理的偏移量与提交的偏移量之间的所有消息,如下图:
显然,管理偏移对客户端应用程序有很大影响。 因此KafkaConsumer API提供了多种提交偏移的方法:
提交偏移量的最简单方法是允许消费者来完成。 如果配置 enable.auto.commit=true,则消费者每五秒钟将提交客户端从poll()收到的最大偏移量。 五秒间隔是默认值,可通过设置auto.commit.interval.ms来控制。 就像消费者中的其他机制一样,自动提交由poll loop驱动。 无论您何时轮询,消费者都会检查是否需要提交,如果是,它将提交它在上次轮询中返回的偏移量。
虽然这个选取很方便,但是它也有一定的不足。
请注意,默认情况下,自动提交每五秒钟发生一次。 假设我们在最近的提交之后三秒钟并且触发了重新平衡。 在重新平衡之后,所有消费者将从最后提交的偏移开始消费。 在这种情况下,偏移量是三秒钟,因此在这三秒内到达的所有事件将被处理两次。 可以将提交间隔配置为更频繁地提交并减少记录将被复制的窗口,但是不可能完全消除它们。
启用自动提交后,对poll的调用将始终提交上一轮询返回的最后一个偏移量。 它不知道实际处理了哪些事件,因此在再次调用poll()之前,始终处理完poll()返回的所有事件至关重要, 因为和poll()一样,close()方法也会自动提交偏移量。
自动提交很方便,但它们不能给开发人员足够的控制以避免重复的消息。
大多数开发人员对提交偏移的时间进行更多控制,以消除丢失消息的可能性并减少重新平衡期间重复的消息数量。 消费者API可以选择将当前偏移量记录在对应用程序开发人员有意义的点上,而不是基于计时器。
通过设置auto.commit.offset=false,只有在应用程序明确选择时才会提交偏移量。 最简单和最可靠的提交API是commitSync()。 此API将提交poll()返回的最新偏移量,并在提交偏移量后返回,如果由于某种原因提交失败则抛出异常。
重要的是要记住commitSync()将提交poll()返回的最新偏移量,因此请确保在处理完集合中的所有记录后调用commitSync(),否则您可能会丢失消息,如前所述。 触发重新平衡时,从最新批次开始到重新平衡时间的所有消息将被处理两次。
以下是我们在处理完最新一批消息后使用commitSync提交偏移量的方法:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset =
%d, customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value()); //(1)
} try {
consumer.commitSync(); //(2)
} catch (CommitFailedException e) {
log.error("commit failed", e) //(3)
}
}
(1) - 让我们假设通过打印记录的内容,我们已经完成了处理。 你的应用程序可能会对记录进行更多操作 - 修改它们,丰富它们,聚合它们,在仪表板上显示它们,或者通知用户重要事件。 你应根据用例确定何时“完成”记录。
(2) - 一旦我们完成了“处理”当前批次中的所有记录,我们在轮询其他消息之前调用commitSync来提交批次中的最后一个偏移量。
(3) - 只要没有无法恢复的错误,commitSync就会重试提交。 如果发生这种情况,除了记录错误外,我们无能为力。
手动提交的一个缺点是应用程序被阻塞,直到代理响应提交请求。 这将限制应用程序的吞吐量。 通过较少的提交可以提高吞吐量,但一旦发生重新平衡,将会产生的更多重复提交的消息。
另一种选择是异步提交API。 我们只是发送请求并继续执行以下操作,而不是等待代理响应提交:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(); //(1)
}
(1) - 提交最后一个偏移并继续。
commitSync()的缺点是会重试提交直到它成功或遇到不可恢复的失败,而commitAsync()将不会重试。 它不重试的原因是,当commitAsync()从服务器接收响应时,可能已经有一个已经成功的提交。 想象一下,我们发送了一个提交偏移量2000的请求。存在临时通信问题,因此代理永远不会收到请求,因此永远不会响应。 同时,我们处理了另一个批处理并成功提交了偏移量3000。如果commitAsync()现在重试先前失败的提交,它可能成功提交偏移2000,已经处理并提交了偏移量3000。 在重新平衡的情况下,这将导致更多重复。
我们提到了这种复杂性以及正确提交顺序的重要性,因为commitAsync()还为您提供了传递回调的选项,该回调将在代理响应时触发。 通常使用回调来记录提交错误或将其计入度量标准,但是如果要使用回调进行重试,则需要了解提交顺序的问题:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition,
OffsetAndMetadata> offsets, Exception exception) {
if (e != null)
log.error("Commit failed for offsets {}", offsets, e);
} }); //(1)
}
(1) - 我们发送提交并继续,但如果提交失败,将记录失败和偏移。
重试异步提交
获得异步重试的提交顺序的简单模式是使用单调递增的序列号。 每次提交时增加序列号,并在提交commitAsync回调时添加序列号。 当您准备发送重试时,检查回调获得的提交序列号是否等于实例变量; 如果是,则没有更新的提交,重试是安全的。 如果实例序列号较高,请不要重试,因为已经发送了较新的提交。
通常,在不重试的情况下偶尔提交失败不是一个大问题,因为如果问题是暂时的,则下一次提交将成功。 但是如果我们知道这是我们关闭消费者之前的最后一次提交,或者在关闭消费者之前,我们要确保提交成功。
因此,常见的模式是在关闭之前将commitAsync()与commitSync()组合在一起。 以下是它的工作原理(我们将在讨论重新平衡监听器的部分时讨论如何在重新平衡之前提交):
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value());
}
consumer.commitAsync(); //(1)
}
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(); //(2)
} finally {
consumer.close();
}
}
(1) - 虽然一切都很好,但我们使用commitAsync。 它更快,如果一次提交失败,下一次提交将作为重试。
(2) - 但是如果我们正在关闭,则没有“下一次提交。”我们调用commitSync,因为它会重试,直到成功或遭受不可恢复的失败。
提交最新的偏移量只允许你在完成处理批次时提交。 但是如果你想更频繁地提交呢? 如果poll()返回一个庞大的批处理并且你希望在批处理中间提交偏移量以避免在发生重新平衡时再次处理所有这些行,该怎么办? 你不能只调用commitSync()或commitAsync(),以为它们将提交返回的最后一个偏移,而你并没有处理。
幸运的是,消费者API允许我们调用commitSync()和commitAsync()并传递我们希望提交的分区和偏移的映射。 如果我你正在处理一批记录,并且你从主题“customers”中的分区3获得的最后一条消息的偏移量为5000,则可以调用commitSync(),为主题“customers”中的分区3提交偏移量5000。 由于你的消费者可能消耗多个分区,因此您需要跟踪所有这些分区的偏移量,这会增加代码的复杂性。
以下是特定偏移量的提交的简单例子:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets =
new HashMap<>();//(2)
int count = 0;
....
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());//(2)
currentOffsets.put(new TopicPartition(record.topic(),
record.partition()), new
OffsetAndMetadata(record.offset()+1, "no metadata")); //(3)
if (count % 1000 == 0) //(4)
consumer.commitAsync(currentOffsets, null); //(5)
count++;
} }
(1) - 这是我们用于手动跟踪偏移的映射。
(2) - 我们用println来代替对所使用记录进行的任何处理。
(3) - 在读取每条记录后,我们使用我们希望处理的下一条消息的偏移量更新偏移量图。 这是我们下次开始阅读的地方。
(4) - 在这里,我们决定每1,000条记录提交当前的偏移量。 在您的应用程序中,您可以根据时间或可能的记录内容进行提交。
(5) - 我选择调用commitAsync,但commitSync在这里也完全有效。 当然,在提交特定偏移时,您仍然需要执行我们在前面部分中看到的所有错误处理。
Chapter 4. Kafka Consumers: Reading Data from Kafka