[TOC]
KafkaConsumer是从kafka集群消费消息的客户端。这是kafka的高级消费者,而SimpleConsumer是kafka的低级消费者。何为高级?何为低级?
我们所谓的高级,就是可以自动处理kafka集群的失败信息,也可以适应kafka集群中消息的分区迁移。这个客户端也可以与服务端进行交互,使用消费者分组负载平衡消费,下面我们具体会讲解。
消费者与对应的broker保持TCP连接,来获取数据。使用完成后关闭消费者连接失败,会泄露连接。这个消费者不是线程安全的。
一、偏移量和消费者位置Offsets And Consumer position
Kafka在分区中为每条记录维护了一个数字形式的偏移量。这个偏移量是数据在分区中的唯一值,也可以表示为消费者在分区中的偏移量。例如,一个消费者的偏移量为5,表示偏移量为0到4的消息已经被消费过。关于消费者使用的偏移量,有两个比较重要的概念。
1.1 TopicPartition
消费者的偏移量表示消费者下一个需要消费的消息的偏移量。这个值会比当前消费者在那个分区刚刚消费的消息偏移量大一。这个值在下面情况下会自动增长:消费者调用了poll(long)并且获取到了消息。
1.2 committed position提交偏移量
这个committed position表示最新的被安全保存的偏移量。如果当前过程中失败然后重启了,这个是重启后消费的偏移量起点。消费者有三种方式来提交偏移量:
- 自动定时提交
- 同步提交。手动提交偏移量,使用的方法是commitSync(),这个方法会一直被阻塞,直到偏移量被成功提交了,或者在提交过程中发生了严重的错误。
- 异步提交。使用的方法是commitAsync(OffsetCommitCallback),在成功或者发生严重错误后,会触发OffsetCommitCallback方法。
二、消费分组和主题订阅Consumer Groups and Topic Subscriptions
Kafka使用消费分组的概念,允许一个处理池来将消费和处理过程分开。这些处理可以在同一台机器上运行,也可以分布在多台机器上,来提供扩展性和容错。
每个Kafka消费者都可以配置自己所属的消费分组,并且可以通过接口subscribe(Collection, ConsumerRebalanceListener)动态设置订阅的主题。Kafka会把每条消息传递到分组中的某个运行过程。这是通过平衡消费分组中的每个消费者对应的分区来实现的,最终实现的是每个分组正好被分配到分组的某个消费者。所以如果一个主题有4个分区,一个消费分组有两个消费者process,每个process会消费两个分区。
消费分组中的成员是动态的:如果某个process挂了,分配给他的分区会被分到组中其他的process。类似的,如果一个新的消费者加入了分组,分区会迁移到新的分组上。这被称为分组平衡。需要注意的是,当订阅的主题中新的分区出现的时候,相同的情况也会出现:分组不断地检测新的分区,平衡分组,最终每个分区都被分配到某个组成员上。
概念上,你可以把消费分组想象成一个单独的逻辑上的消费者,恰巧有多个消费进程。作为一个多订阅的系统,kafka天然支持某个主题有多个消费分组,而数据不会重复。
这些功能对于一个消息系统来说很普通。和传统的消息队列不同,你可以同时有很多的分组。在传统消息系统中,每个消费者都会有自己的消费分组,所以每个消费者会订阅主题下的所有记录,也就是会收到所有的消息。
而且,当分组重新分配自动出现时,会通过ConsumerRebalanceListener通知消费者,然后消费者自身处理一些应用级的逻辑,比如状态清除,手动提交offset等等。
对消费者来说,还可以手动分配分区,使用的方法是assign(类似于SimpleConsumer)。在这种情况下,动态分区调整和消费分组协调功能会被禁用。
三、检测消费者失败Detecting Consumer Failures
订阅一批主题之后,消费者在调用poll的时候,会自动加入分组。poll是用于确保消费者的存活。只要消费者不停地调用poll,那么他就会一直存在于分组中,并且不断地收到对应分区推送给他的消息。另外,poll方法也会定时发送心跳给服务端,当你停止调用poll时,心跳也会停止。如果server超过session.timeout时间没有收到心跳,消费者会被踢出分组,分区也会重新分配。这是为了防止消费者挂掉之后,还占用分区的情况发生(这种情况下分组中的其他消费者无法消费到消息)。为了继续存在于分组中,消费者必须调用poll方法证明还活着。
这个设计的目的还在于,一个poll循环中的消息处理过程的时间必须是有界的,那样心跳才能在session.timeout之前发出去。消费者提供两个配置来控制这种行为:
- session.timeout.ms:通过增加这个值,消费者可以有更多的时间来处理poll返回的一批消息。唯一的缺点就是服务端要耗费更多的时间来检测消费者是否存活,这可能会导致分组平衡的延迟。然后,这不会影响close方法的调用,因为一旦调用了这个方法,消费者会发送一个明确的消息给服务端,离开分组,分组平衡会被立即触发。
- max.poll.records:一个poll循环的处理时间应该和消息的数量成正比。所以应该设置一次最多处理多少条数据。默认情况下,这个值没有限制消息的数量。
三、举例
当前消费客户端提供了多种方法来消费,下面是几个例子。
3.1 自动提交Automatic Offset Committing
这个方法说明了kafka消费客户端的简单使用,依赖于自动提交offset。
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "test");
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("foo", "bar"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
System.out.printf("offset = %d, key = %s, value = %s", record.offset(), record.key(), record.value());
}
配置enable.auto.commit表示是自动提交offset,并且提交的频率为auto.commit.interval.ms。服务器可以通过bootstrap.servers来配置,可以不用配置全部的服务器,因为会自动发现集群中所有的服务器。当然不建议只配置一个,因为如果这个挂掉了,就找不到其他的机器了。
反序列化配置表示如何把二进制消息转换为消息对象。例如,使用string反序列化,表示消息的key和value都是字符串。
3.2 手动提交偏移量Manual Offset Control
与自动提交不同,我们可以通过配置来控制消息什么时候消费完成,并提交偏移量。在一条消息需要多个任务处理,所有任务完成后才能提交偏移量的场景下,需要手动提交。在下面的例子中,我们会一次消费一批数据,然后把他们放到内存中,当消息达到一定的数量时,我们会把他们插入数据库中。如果这种情况下,我们配置为自动提交,那么就会出现消息被消费,但是实际上并没有插入到数据库的情况。为了避免这种情况,我们必须在消息插入数据库之后,手动提交偏移量。这也会出现另一种情况,就是消息插入数据库成功,但是在提交偏移量的过程中失败。这种情况下,其他的消费者会继续读取偏移量,然后重新执行批量插入数据库的操作。这么使用的话,kafka提供的是“至少一次”的消息保证,也就是消息至少会被传递一次,但是消费失败的情况下会重复消费。
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "test");
props.put("enable.auto.commit", "false");
props.put("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("foo", "bar"));
final int minBatchSize = 200;
List<ConsumerRecord<String, String>> buffer = new ArrayList<>();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
insertIntoDb(buffer);
consumer.commitSync();
buffer.clear();
}
}
上面的例子使用的是同步提交commitSync()方法。在某些情况下,你可能需要对消息消费有更加精确的控制,下面的例子中,我们按照分区提交偏移量。
try {
while(running) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
System.out.println(record.offset() + ": " + record.value());
}
long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
}
}
} finally {
consumer.close();
}
注意:提交的偏移量应该是下一次消费的消息的偏移量,所以commitSync(offsets)这个方法中的内容,应该是当前消息偏移量加上一。
3.3 手动分配分区Manual Partition Assignment
前面的例子中,消费者订阅主题,然后服务端动态分配了分区给消费者。在某些情况下,我们需要精确控制消费的分区,例如:
消费者维护了与分区相关的本地状态(例如本地磁盘键值存储),那么他应该只读取特定分区的数据。如果消费者本身是高可用的,在挂掉之后会自动重启(可能正在使用集群管理框架,比如YARN,Mesos或者AWS,或者作为流式处理框架)。这种情况下,kafka没必要检测消费者的存活,重新分配分区,因为消费进程会在同样的机器上重启。
为了使用这种模式,我们不能使用subscribe(Collection),而是应该使用assign(Collection)方法,来指定一批分区。
String topic = "foo";
TopicPartition partition0 = new TopicPartition(topic, 0);
TopicPartition partition1 = new TopicPartition(topic, 1);
consumer.assign(Arrays.asList(partition0, partition1));
这种情况下,不会使用到分组协调器,所以消费者挂掉的情况,也不会重新分配分区。所以每个消费者都是独立的,为了避免偏移量提交冲突,每个消费者的分组信息应该是唯一的。
3.4 在kafka外存储偏移量Storing Offsets Outside Kafka
消费者应用可能不想把offset存到kafka中,所以kafka也提供了把offset存储到其他地方的接口。这种情况下,就能自己实现消息只消费一次的场景,比至少一次强很多。那么我们如何使用呢?
- 首先需要配置enable.auto.commit=false
- 使用ConsumerRecord提供的偏移量来存储你的offset
- 重启时使用seek(TopicPartition, long)来发现重启前的offset
这种场景在手动分配分区的情况下很简单。如果分区分配是自动的,我们需要特殊处理分区分配改变的情况。这个可以通过提供ConsumerRebalanceListener实例,在调用subscribe(Collection, ConsumerRebalanceListener)和subscribe(Pattern, ConsumerRebalanceListener)时实现。
3.5 控制消费者位点Controlling The Consumer's Position
大多数情况下,消费者会简单的从头到尾消费消息,定时提交位点(自动或手动)。然后,kafka允许消费者手动控制位点,可以设置位点的位置。这意味着消费者可以消费已经消费过的消息,也可以跳过最新的消息。
kafka可以通过seek(TopicPartition, long)方法来指定消费起点,寻找早的或者新的位点,也可以通过seekToBeginning(Collection)和seekToEnd(Collection)来指定。
3.6 消费流量控制
如果一个消费者被分配到了多个分区,他会尝试同时消费所有的分区,所有的分区的权重一样。然而,在某些情况下,消费者需要首先全速消费某些特定的分区,当这个分区没有消息后再消费其他的分区。
例如,流式处理。消费者同时从两个主题消费,然后把消息合并。当某个主题落后于另一个主题很多时,消费者应该停止消费快的那个主题,等慢的那个赶上来。再比如,有个主题有很多历史数据需要被消费,这种情况下,消费者应该优先消费那些有最新消息的主题。
kafka支持动态控制消费流量,通过pause(Collection)和resume(Collection)方法。
四、多线程消费Multi-threaded Processing
kafka消费者不是线程安全的。所有的网络IO操作都在发起调用的一个线程中执行。他需要保证多线程时的线程安全。不同的操作会引起ConcurrentModificationException。
我们在外部线程中可以调用wakeup()方法来停止当前的操作。这种情况下,可能会从阻塞操作的线程抛出org.apache.kafka.common.errors.WakeupException异常。这可以用于在另一个线程中停止当前的消费者。
public class KafkaConsumerRunner implements Runnable {
private final AtomicBoolean closed = new AtomicBoolean(false);
private final KafkaConsumer consumer;
public void run() {
try {
consumer.subscribe(Arrays.asList("topic"));
while (!closed.get()) {
ConsumerRecords records = consumer.poll(10000);
// Handle new records
}
} catch (WakeupException e) {
// Ignore exception if closing
if (!closed.get()) throw e;
} finally {
consumer.close();
}
}
// Shutdown hook which can be called from a separate thread
public void shutdown() {
closed.set(true);
consumer.wakeup();
}
}
在另一个线程中,可以通过closed标识来关闭或者启动消费者。