1. 消费者位置(consumer position)
因为kafka服务端不保存消息的状态,所以消费端需要自己去做很多事情。我们每次调用poll()方法他总是返回已经保存在生产者队列中还未被消费者消费的消息。消息在每一个分区中都是顺序的,那么必然可以通过一个偏移量去确定每一条消息的位置。
偏移量在消费消息的过程中处于重要的作用。如果是自动提交消息,那么poll()方法会去在每次获取消息的时候自动提交获取最后一条消息的偏移量,告诉服务器我们已经消费到这个位置,下次从下一个位置开始消费。
我们把更新分区当前位置的操作叫做提交。消费者是如何提交偏移量的呢?kafka最新的api是这样做的:创建一个叫做_consumer_offset的特殊主题用来保存消息的偏移量。消费者每次消费都会往这个主题发送消息,消息包含每个分区的偏移量。
如果消费者一直处于运行的状态那么这个偏移量没有什么用。不过如果这个消费者崩溃或者有新的消费者加入群组触发再均衡策略,那么再均衡之后该分区的消费者如果不是之前的那一位,那么新的小伙伴怎么知道之前的伙计消费到哪里呢。所以提交他自己的offset就发挥作用了。
Consumer读取partition中的数据是通过调用发起一个fetch请求来执行的。而从KafkaConsumer来看,它有一个poll方法。但是这个poll方法只是可能会发起fetch请求。原因是:Consumer每次发起fetch请求时,读取到的数据是有限制的,通过配置项max.partition.fetch.bytes来限制的。而在执行poll方法时,会根据配置项个max.poll.records来限制一次最多pool多少个record。
那么就可能出现这样的情况: 在满足max.partition.fetch.bytes限制的情况下,假如fetch到了100个record,放到本地缓存后,由于max.poll.records限制每次只能poll出15个record。那么KafkaConsumer就需要执行7次才能将这一次通过网络发起的fetch请求所fetch到的这100个record消费完毕。其中前6次是每次pool中15个record,最后一次是poll出10个record。
在consumer中,还有另外一个配置项:max.poll.interval.ms ,它表示最大的poll数据间隔,如果超过这个间隔没有发起pool请求,但heartbeat仍旧在发,就认为该consumer处于 livelock状态。就会将该consumer退出consumer group。所以为了不使Consumer 自己被退出,Consumer 应该不停的发起poll(timeout)操作。而这个动作 KafkaConsumer Client是不会帮我们做的,这就需要自己在程序中不停的调用poll方法了。
当一个consumer因某种原因退出Group时,进行重新分配partition后,同一group中的另一个consumer在读取该partition时,怎么能够知道上一个consumer该从哪个offset的message读取呢?也是是如何保证同一个group内的consumer不重复消费消息呢?上面说了一次走网络的fetch请求会拉取到一定量的数据,但是这些数据还没有被消息完毕,Consumer就挂掉了,下一次进行数据fetch时,是否会从上次读到的数据开始读取,而导致Consumer消费的数据丢失吗?
为了做到这一点,当使用完poll从本地缓存拉取到数据之后,需要client调用commitSync方法(或者commitAsync方法)去commit 下一次该去读取 哪一个offset的message。
而这个commit方法会通过走网络的commit请求将offset在coordinator中保留,这样就能够保证下一次读取(不论是进行rebalance)时,既不会重复消费消息,也不会遗漏消息。
对于offset的commit,Kafka Consumer Java Client支持两种模式:由KafkaConsumer自动提交,或者是用户通过调用commitSync、commitAsync方法的方式完成offset的提交。
2. 位移管理(offset management)
2.1 自动提交
Kafka默认是定期帮你自动提交位移的(enable.auto.commit = true),使用这种简单的方式之前你需要知道可能会带来什么后果。
假设我们仍然使用默认的5s提交时间间隔,在最近一次提交之后的3s发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后
了3s,所以在这3s内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量,减小可能出现重复悄息的时间窗,不过这种情况是无也完全避免的。
在使用自动提交时,每次调用轮询方告都会把上一次调用返回的偏移量提交上去,它并不知道具体哪些消息已经被处理了,所以在再次调用之前最好确保所有当前调用返回的消息都已经处理完毕(在调用close()方法之前也会进行自动提交)。一般情况下不会有什么问题,不过在处理异常或提前退出轮询时要格外小心。
2.2 手动提交
在多partition多consumer的场景下自动提交总会发生一些不可控的情况。所以消费者API也为我们提供了另外一种提交偏移量的方式。开发者可以在程序中自己决定何时提交,而不是基于时间间隔。
在使用手动提交之前我们需要先将:
properties.put("enable.auto.commit", "false");
然后使用:
consumer.commitSync();
来提交。
commitSync()方法会提交由poll()方法返回的最新偏移量,提交成功后马上返回,否则跑出异常。
我们处理消息的逻辑可以变成这样:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
try {
consumer.commitSync();
} catch (Exception e) {
System.out.println("commit failed");
}
}
}
每处理一次消息我们提交一次offset。
异步手动提交
上面我们使用commitSync()的方式提交数据,每次提交都需要等待broker返回确认结果。这样没提交一次等待一次会限制我们的吞吐量。
如果采用降低提交频率来保证吞吐量,那么则有增加消息重复消费的风险。所以kafka消费者提供了异步提交的API。我们只管发送提交请求无需等待broker返回。
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
}
consumer.commitAsync();
}
commitAsync()方法提交最后一个偏移量。在成功提交或碰到无怯恢复的错误之前,commitAsync()会一直重试,但是commitAsync()不会,这也是commitAsync()不好的一个地方。它之所以不进行重试,是因为在它收到服务器响应的时候, 可能有一个更大的偏移量已经提交成功。假设我们发出一个请求用于提交偏移量2000,这个时候发生了短暂的通信问题,服务器收不到请求,自然也不会作出任何响应。与此同时,我们处理了另外一批消息,并成功提交了偏移量3000。如果commitAsync()重新尝试提交偏移量2000 ,它有可能在偏移量3000之后提交成功。这个时候如果发生再均衡,就会出现重复消息。
当然使用手动提交最大的好处就是如果发生了错误我们可以记录下来。commitAsync()也支持回调方法,提交offset发生错误我们可以记下当前的偏移量。
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
}
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
if(e != null){
System.out.println("commit failed"+map);
}
}
});
}
同步和异步组合提交
一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。因此,在消费者关闭前一般会组合使用commitAsync()和commitSync()。
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
}
consumer.commitAsync();
}
} catch (Exception e) {
System.out.println("commit failed");
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
如果一切正常我们使用commitAsync()来提交。如果直接关闭消费者,就没有所谓的下一次提交了。使用commitSync()会一直重试,直到提交成功。
2.3 提交特定偏移量
上面我们手动提交使用的commitAsync()和commitSync()都是提交每一次消费最后一条消息的偏移量,那么如果我们一次拉取了很多消息但是没有消费完,想提交我们消费完成的位置该怎么处理呢?kafka也有相应的对策。
Map<TopicPartition,OffsetAndMetadata> currentOffset = new HashMap<>();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
currentOffset.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset(),"metadata"));
try {
System.out.println("模拟消息处理失败的情况");
} catch (Exception e) {
consumer.commitAsync(currentOffset,null);
}
}
}
这里调用的是commitAsync(),调用commitSync()也是可以的。代码中模拟我们在处理消息的过程中可能会出错的情况,每次读消息都把当前的offset存入map中,如果出错就提交当前已经消费到的偏移量。
2.4 再均衡监听器
前面我们说过当发生consumer退出或者新增,partition新增的时候会触发再均衡。那么发生再均衡的时候如果某个consumer正在消费的任务没有消费完该如何提交当前消费到的offset呢?kafka提供了再均衡监听器,在发生再均衡之前监听到,当前consumer可以在失去分区所有权之前处理offset关闭句柄等。
消费者API中有一个()方法:
subscribe(Collection<TopicPartition> var1, ConsumerRebalanceListener var2);
ConsumerRebalanceListener对象就是监听器的接口对象,我们需要实现自己的监听器继承该接口。接口里面有两个方法需要实现:
void onPartitionsRevoked(Collection<TopicPartition> var1);
void onPartitionsAssigned(Collection<TopicPartition> var1);
第一个方法会在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了。
第二个会在重新分配分区之后和消费者开始读取消息之前被调用。、
我们来模拟一下再均衡的场景:
final Consumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("page_visits"));
final Map<TopicPartition,OffsetAndMetadata> currentOffset = new HashMap<>();
class HandleRebance implements ConsumerRebalanceListener{
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
System.out.println("partition is rebanlance");
consumer.commitAsync(currentOffset,null);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
}
}
consumer.subscribe(topic,new HandleRebance());
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
currentOffset.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset(),"metadata"));
try {
System.out.println("模拟消息处理失败的情况");
} catch (Exception e) {
consumer.commitAsync(currentOffset,null);
}
}
}
首先实现了ConsumerRebalanceListener接口,实现方法里面如果监听到发生再均衡我们提交当前处理过的偏移量。
2.5 从特定偏移量处开始处理
前面都是consumer.poll()之后读取该批次的消息,kafka还提供了从分区的开始或者末尾读消息的功能:
seekToEnd(Collection<TopicPartition> partitions)
seekToBeginning(Collection<TopicPartition> partitions)
另外kafka还提供了从指定偏移量处读取消息,可以通过seek()方法来处理:
seek(TopicPartition partition, long offset)
提交当前分区和当前消费位置信息。
2.6 独立消费者–不属于群组的消费者
到目前为止我们讨论的都是消费者群组,分区被自动分配给群组的消费者,群组的消费者有变动会触发再均衡。那么是不是可以回归到别的消息队列的方式:不需要群组消费者也可以自己订阅主题?
kafka也提供了这样的案例,因为kafka的主题有分区的概念,那么如果没有群组就意味着你的自己订阅到特定的一个分区才能消费内容。如果是这样的话,就不需要订阅主题,而是为自己分配分区。一个消费者可以订阅主题(井加入消费者群组),或者为自己分配分区,但不能同时做这两件事情。
下面的例子演示如何为自己分配分区并读取消息的:
final Consumer<String, String> consumer = new KafkaConsumer<>(props);
List<PartitionInfo> partitionInfoList = consumer.partitionsFor("page_visits");
List<TopicPartition> topicPartitionList = new ArrayList<>();
if(partitionInfoList != null){
for(PartitionInfo partitionInfo : partitionInfoList){
topicPartitionList.add(new TopicPartition(partitionInfo.topic(),partitionInfo.partition()));
consumer.assign(topicPartitionList);
}
}
final Map<TopicPartition,OffsetAndMetadata> currentOffset = new HashMap<>();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
currentOffset.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset(),"metadata"));
try {
System.out.println("模拟消息处理失败的情况");
} catch (Exception e) {
consumer.commitAsync(currentOffset,null);
}
}
}
- consumer.partitionsFor(“主题”)方法允许我们获取某个主题的分区信息。
- 知道想消费的分区后使用assign()手动为该消费者分配分区。
除了不会发生再均衡,也不需要手动查找分区,其他的看起来一切正常。不过要记住,如果主题增加了新的分区,消费者并不会收到通知。所以,要么周期性地调用consumer.partitionsFor()方法来检查是否有新分区加入,要么在添加新分区后重启应用程序。