文章目录
Kafka消费者
应用程序使用KafkaConsumer向Kafka订阅主题,并从订阅的主题上接受消息。
1. KafkaConsumer概念
1.1 消费者和消费者群组
Kafka消费者从属于消费者群组。一个群组里的消费者订阅的是同一个主题,每个消费者接受主题一部分分区的消息。
如果群组里消费者的数量超过主题的分区数量,那么有一部分消费者就会被闲置,不会接受到任何消息。
往群组里增加消费者是横向伸缩消费能力的主要方式。
1.2 消费者群组和分区再均衡
一个新的消费者加入群组时,它读取的是原本由其他消费者读取的消息。当一个消费者被关闭或发生崩溃时,它就离开群组,原本由它读取的分区将由群组里的其他消费者来读取。在主题发生变化时,比如管理员添加了新的分区,会发生分区重分配。
分区的所有权从一个消费者转移到另一个消费者,这样的行为被称为再均衡。在再均衡期间,消费者无法读取消息,造成整个群组一小段时间的不可用。另外,当分区被重新分配给另一个消费者时,消费者当前的读取状态会丢失,它有可能需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。
消费者通过向被指派为群组协调器的broker(不同的群组可以有不同的协调器)发送心跳来维持它们和群组的从属关系以及它们对分区的所有权关系。消费者会在轮询消息或提交偏移量时发送心跳。
如果一个消费者发生崩溃,并停止读取消息,群组协调器会等待几秒钟,确认它死亡了才会触发再均衡。在这几秒钟时间里,死掉的消费者不会读取分区里的消息。
分配分区是怎样的一个过程
当消费者要加入群组时,它会向群组协调器发送一个JoinGroup请求。第一个加入群组的消费者将成为”群主“。群主从协调器那里获得群组的成员列表(列表里包含了所有最近发送过心跳的消费者),并负责给每一个消费者分配分区。它使用一个实现了PartitionAssignor接口的类来决定哪些分区应该被分配给哪个消费者。
Kafka内置了两种分配策略。分配完毕后,群主把分配情况列表发送给群主协调器,协调器再把这些消息发送给所有消费者。每个消费者只能看到自己的分配信息,只有群主知道群组里所有消费者的分配信息。这个过程会在每次再均衡时重复发生。
2. 创建Kafka消费者
在读取消息之前,需要先创建一个KafkaConsumer对象。这里涉及到3个必要的属性:bootstrap.servers、key.deserializer、value.deserializer。
Properties props = new Properties();
props.put("bootstrap.servers","broker1:9092,broker2:9092");
//这个属性group.id不是必需的,它指定了KafkaConsumer属于哪一个消费者群组。
//创建不属于任何一个群组的消费者也是可以的。
props.put("group.id","CountryCounter");
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<String,String>(props);
3. 订阅主题
创建好消费者以后,下一步可以开始订阅主题了。subscribe()
方法接受一个主题列表作为参数:
//这里创建了一个只包含单个元素的列表,主题的名字叫作"customerCountries"
consumer.subscribe(Collections.singletonList("customerCountries"));
//这里使用正则表达式订阅所有与test相关的主题。
//如果有人创建了新的主题,并且主题的名字与正则表达式匹配,那么会立即触发一次再均衡,
//消费者就可以读取新添加的主题。
consumer.subscribe("test.*");
4. 轮询
消息轮询是消费者API的核心,通过一个简单的轮询向服务器请求数据。一旦消费者订阅了主题,轮询就会处理所有细节,包括群组协调、分区再平衡、发送心跳和获取数据,开发者只需要使用一组简单的API来处理从分区返回的数据。
try{
while(true){
//传给poll()方法的参数是一个超时时间,用于控制poll()方法的阻塞时间;
//在消费者的缓冲区里没有可用数据时会发生阻塞。
//如果该参数被设为0,poll()会立即返回,否则它会在指定的毫秒数内一直等待broker返回数据。
//poll()方法返回一个记录列表,每条记录都包含了记录所属主题的信息、
//记录所在分区的信息、记录在分区里的偏移量、以及记录的键值对。
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
}
}
}finally{
consumer.close();
}
在第一次调用新消费者的poll()方法时,它会负责查找GroupCoordinator,然后加入群组,接受分配的分区。如果发生了再均衡,整个过程也是在轮询期间进行的。
5. 消费者的配置
-
fetch.min.bytes
该属性指定了消费者从服务器获取记录的最小字节数。broker在收到消费者的数据请求时,如果可用的数据量小于fetch.min.bytes指定的大小,那么它会等到有足够的可用数据时才把它返回给消费者。
如果没有很多可用数据,但消费者的CPU使用率却很高,那么就需要把该属性的值设得比默认值大;如果消费者的数量比较多,把该属性的值设置得很大一点可以降低broker的工作负载。 -
fetch.max.wait.ms
该属性用于指定broker的等待时间,默认是500ms。在fetch.min.bytes和fetch.max.wait.ms中,只要有一个条件得到满足,则返回消费者请求的可用数据。 -
max.partition.fetch.bytes
该属性指定了服务器从每个分区里返回给消费者的最大字节数,默认是1MB。也就是说,KafkaConsumer.poll()方法从每个分区里返回的记录最多不超过max.partition.fetch.bytes指定的字节。如果一个主题有20个分区和5个消费者,那么每个消费者需要至少4MB的可用内存来接受记录。 -
session.timeout.ms
该属性指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认是3s。如果消费者没有在session.timeout.ms指定的时间内发送心跳给群组协调器,就被认为已经死亡,协调器就会触发再均衡,把它的分区分配给群组里的其他消费者。 -
heart.interval.ms
该属性指定了poll()方法向协调器发送心跳的频率,一般是session.timeout.ms的三分之一。 -
auto.offset.reset
该属性指定了消费者在读取一个没有偏移量的分区或偏移量无效的情况下(因消费者长时间失效,包含偏移量的记录已经过时并被删除)该作何处理。它的默认值是latest,意思是说,在偏移量无效的情况下,消费者将从最新的记录开始读取数据(在消费者启动之后生成的记录)。另一个值是earliest,意思是说,在偏移量无效的情况下,消费者将从起始位置读取分区的记录。 -
enable.auto.commit
该属性指定了消费者是否自动提交偏移量,默认值是true。 -
auto.commit.interval.ms
该属性用来控制提交的频率。 -
partition.assignment.strategy
这个属性用来指定分区策略。默认使用的是org.apache.kafka.clients.consumer.RangeAssignor,这个类实现了Range策略。另一种是org.apache.kafka.clients.consumer.RoundRobinAssignor。还可以自定义策略。
Kafka有两个默认的分配策略。
Range
该策略会把主题的若干个连续的分区分配给消费者。当分区数量无法被消费者数量整除时,前面的消费者会比后面的消费者分的更多的分区。
假设消费者C1和消费者C2同时订阅了主题T1和主题T2,并且每个主题有3个分区。那么消费者C1有可能分配到这两个主题的分区0和分区1,而消费者C2分配到这两个主题的分区2。
RoundRobin
该策略把主题的所有分区逐个分配给消费者。这种策略会给所有消费者分配相同数量的分区(或最多就差一个分区)。
消费者C1将分到主题T1的分区0和分区2以及主题T2的分区1,消费者C2将分配到主题T1的分区1以及主题T2的分区0和分区2。 -
client.id
该属性可以是任意字符串,broker用它来表示从客户端发送过来的消息。通常被用在日志、度量指标的配额里。 -
max.poll.records
该属性用于控制单次调用call()方法能够返回的记录数量,可以帮你控制在轮询里需要处理的数据量。 -
receive.buffer.bytes和send.buffer.bytes
socket在读写数据时用到的TCP缓冲区也可以设置大小。如果它们被设为-1,就使用操作系统的默认值。如果生产者或消费者与broker处于不同的数据中心内,可以适当增大这些值,因为跨数据中心的网络一般都有比较高的延迟和比较低的带宽。
6. 提交和偏移量
更新分区当前位置的操作叫作提交。
消费者往一个叫作_consumer_offset的特殊主题发送消息,消息里包含了每个分区的偏移量。如果消费者一直处于运行状态,那么偏移量就没有什么用处。不过,如果消费者发生崩溃或者有新的消费者加入群组,就会触发再均衡,完成再均衡后,每个消费者可能分配到新的分区,而不是之前处理的那个。
如果提交的偏移量小于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息就会被重复处理。
如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。
6.1 自动提交
最简单的提交方式是让消费者自动提交偏移量。如果enable.auto.commit被设为true,那么每过5秒,消费者会自动把从poll()方法接受到的最大偏移量提交上去。提交时间间隔由auto.commit.interval.ms控制,默认是5s。自动提交也是在轮询里进行的。消费者每次在进行轮询时会检查是否该提交偏移量了,如果是,那么就会提交从上一次轮询返回的偏移量。
6.2 提交当前偏移量
把auto.commit.offset设为false,让应用程序决定何时提交偏移量。使用commitSync()
提交偏移量最简单也最可靠。这个API会提交由poll()方法返回的最新偏移量,提交成功后马上返回,如果提交失败就抛出异常。
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
}
//处理完当前批次的消息,在轮询更多的消息之前,调用commitSync()方法提交当前批次最新的偏移量。
//只要没有发生不可恢复的错误,commitSync()方法会一直尝试直至提交成功。
//如果提交失败,可以把异常记录到错误日志里。
try{
consumer.commitSync();
}catch(CommitFailedException e){
log.error("commit failed",e)
}
}
手动提交有一个不足之处,在broker对提交请求作出回应之前,应用程序会一直阻塞,这样会限制应用程序的吞吐量。可以通过降低频率来提升吞吐量。
6.3 异步提交
针对手动提交的缺点,可以使用异步提交API,这时候只管发送提交请求,无需等待broker的响应。
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
}
//commitAsync在成功提交或无法恢复的错误之前,不会进行重试。
consumer.commitAsync();
//commitAsync也支持回调,在broker作出响应时会执行回调。
//发送提交请求然后继续做其他事情,如果提交失败,错误信息和偏移量会被记录下来。
/*
consumer.commitAsync(new OffsetCommitCallback(){
public void onComplete(Map<TopicPartition,OffsetAndMetadata> offsets, Exception e){
if(e!=null)
log.err("Commit failed");
}
}
);
*/
}
6.4 同步和异步组合提交
如果提交发生在关闭消费者或再均衡前,就要确保能够提交成功。
这时候可以结合同步和异步提交。
try{
while(true){
//传给poll()方法的参数是一个超时时间,用于控制poll()方法的阻塞时间;
//在消费者的缓冲区里没有可用数据时会发生阻塞。
//如果该参数被设为0,poll()会立即返回,否则它会在指定的毫秒数内一直等待broker返回数据。
//poll()方法返回一个记录列表,每条记录都包含了记录所属主题的信息、
//记录所在分区的信息、记录在分区里的偏移量、以及记录的键值对。
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
}
//异步提交
consumer.commitAsync();
}
}catch{
log.error("Unexpected error!");
}finally{
try{
//同步提交
consumer.commitSync();
}finally{
consumer.close();
}
}
6.5 提交特定的偏移量
消费者API允许在调用commitSync()和commitAsync()方法时传进去希望提交的分区和偏移量的map。因为消费者可能不只读取一个分区,这里需要跟踪所有分区的偏移量,所以在这个层面上控制偏移量的提交会让代码变复杂。
private Map<TopicPartition,OffsetAndMetadata> currentOffsets=new HashMap<>();
int count = 0;
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
currentOffsets.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset()+1,"no metadata"));
if(count % 1000 == 0)
consumer.commitAsync(currentOffsets,null);
count += 1;
}
}
7. 再均衡监听器
在消费者失去对一个分区的所有权之前提交最后一个已处理记录的偏移量。如果消费者准备了一个缓冲区用于处理偶发的事件,那么在失去分区所有权之前,需要处理在缓冲区累积下来的记录。可能还需要关闭文件句柄、数据库连接等。
在给消费者分配新分区或移除旧分区时,可以通过消费者API执行一些应用程序代码,在调用subscribe()方法时传进去一个ConsumerRebalanceListener实例。
ConsumerRebalanceListener有两个需要实现的方法:
//这个方法会在再均衡开始之前和消费者停止读取消息之后被调用。
//如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了。
public void onPartitionsRevoked(Collection<TopicPartition> partitions)
//这个方法会在重新分配分区之后和消费者开始读消息之前被调用。
public void onPartitionsAssigned(Collection<TopicPartition> partitions)
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
private class HandleRebalance implements ConsumerRebalanceListener{
public void onPartitionsAssigned(Collection<TopicPartition> partiitons){
}
public void onPartitionsRevoked(Collection<TopicPartition> partitions){
System.out.println(" ");
consumer.commitSync(currentOffsets);
}
}
try{
consumer.subscribe(topics,new HandleRebalance());
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecords<String,String> record:records){
}
consumer.commitAsync(currentOffsets);
}
}catch(WakeupException e){
}catch(Exception e){
log.error("Unexpected err");
}finally{
try{
consumer.commitSync(currentOffsets);
}finally{
consumer.close();
System.out.println("Closed");
}
}
8. 从特定偏移量出开始处理记录
如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使用:seekToBeginning(Collection<TopicPartition> tp)
和seekToEnd(Collection<TopicPartition> tp)
这两个方法。
Kafka也提供了用于查找特定偏移量的API。
如果保存记录和偏移量可以在一个原子操作里完成,那么记录和偏移量要么都被成功提交,要么都不提交。
在消费者启动或分配到新分区时,可以使用seek()
方法查找保存在数据库里的偏移量。
//这里把记录和偏移量保存在数据库里,使用ConsumerRebalanceListener和seek()方法
//确保是从数据库里保存的偏移量所指定的位置开始处理消息的。
public class SaveOffsetsOnRebalance implements ConsumerRebalanceListener{
public void onPartitionsRevoked(Collection<TopicPartition> partitions){
//使用一个虚拟的方法来提交数据库事务。
commitDBTransaction();
}
public void onPartitionsAssigned(Collection<TopicPartition> partitions){
//使用一个虚拟的方法来从数据库获取偏移量,在分配到新分区的时候,
//使用seek()方法定位到那些记录。
for(TopicPartition partition:partitions)
consumer.seek(partition,getOffsetFromDB(partition));
}
}
consumer.subscribe(topics,new SaveOffsetsOnRebalance(consumer));
consumer.poll(0);
//订阅主题之后,开始启动消费者,调用一次poll()方法,让消费者加入到消费者群组里,
//并获取分配到的分区,然后马上调用seek()方法定位分区的偏移量。
//要记住,seek()方法只更新正在使用的位置,在下一次调用poll()时就可以获得正确的消息。
//如果seek()发生错误,poll()就会抛出异常。
for(TopicPartition partition:consumer.assignment())
consumer.seek(partition,getOffsetFromDB(partition));
while(true){
ConsumerRecords<String,String> records = consumer.poll(100);
for(ConsumerRecord<String,String> record : records){
}
commitDBTransaction();
}
通过把偏移量和记录保存到同一个外部系统来实现单次语义可以有很多方式,不过它们都需要结合使用ConsumerRebalanceListener
和seek()
方法来确保及时保存偏移量,并保证消费者总是能够从正确的位置开始读取消息。
9. 如何退出
如果确定要退出循环,需要通过另一个线程调用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 ...");
//ShutdownHook运行在单独的线程里,所以退出循环最安全的方式只能是调用wakeup()方法。
consumer.wakeup();
try{
mainThread.join();
}catch(InterruptedException e){
e.printStackTrace();
}
}
});
try{
//循环,直到按下Ctrl+C键,关闭的钩子会在退出时进行清理
while(true){
ConsumerRecords<String,String> records = movingAvg.consumer.poll(1000);
System.out.println("...");
for(ConsumerRecord<String,String> record : records){
}
for(TopicPartition tp:consumer.assignment())
System.out.println();
movingAvg.consumer.commitSync();
}
}catch(WakeupException e){
//忽略关闭异常
//在另一个线程里调用wakeup()方法,导致poll()抛出WakeupException。
}finally{
//在退出之前,确保彻底关闭消费者。
consumer.close();
}
10. 反序列化器
消费者需要用反序列化器把从Kafka接受到的字节数组转换成Java对象。
生成消息使用的序列化器与读取消息使用的反序列化器应该是一一对应的。
使用Avro和schema注册表进行序列化和反序列化的优势在于:AvroSerializer
可以保证写入主题的数据与主题的schema
是兼容的,也就是说,可以使用相应的反序列化器和schema
来反序列化数据。另外在生产者或消费者里出现的任何一个与兼容性有关的错误都会被捕捉到,它们都带有消息描述,也就是说,在出现序列化错误时,就没必要再去调试字节数组了。
-
自定义反序列化器
不建议使用自定义的反序列化器。因为它们把生产者和消费者紧紧地耦合在一起,并且很容易出错。一般使用标准的消息格式,比如JSON、Thrift、Protobuf或Avro。 -
在消费者里进行Avro反序列化
Properties props = new Properties();
props.put("bootstrap.servers","broker1:9092,broker2:9092");
props.put("group.id","CountryCounter");
props.put("key.serializer","org.apache.kafka.common.serialization.StringDeserializer");
//使用KafkaAvroDeserializer来反序列化Avro消息。
props.put("value.serializer","io.confluent.kafka.serializers.KafkaAvroDeserializer");
//schema.registry.url是一个新的参数,它指向schema的存放位置。
//消费者可以使用由生产者注册的schema来反序列化消息。
props.put("schema.registry.url",schemaUrl);
String topic = "customerContacts";
KafkaConsumer consumer = new KafkaConsumer(createConsumerConfig(brokers,groupId,url));
consumer.subscribe(Collections.singletonList(topic));
System.out.println(" ")
while(true){
ConsumerRecords<String,Customer> records = consumer.poll(1000);
for(ConsumerRecord<String,Customer> record : records){
}
consumer.commitSync();
}
11. 独立消费者
有时候可能只需要一个消费者从一个主题的所有分区或者某个特定的分区读取数据,这个时候就不需要消费者群组和再均衡了,只需要把主题或者分区分配给消费者,然后开始读取消息并提交偏移量。
这种情况下,就不需要订阅主题,取而代之的是为自己分配分区。一个消费者可以订阅主题,或者为自己分配分区,但不能同时做这两件事。
List<PartitionInfo> partitionInfos = null;
//向集群请求主题可用的分区,如果只打算读取特定分区,可以跳过这一步。
partitionInfos = consumer.partitionsFor("topic");
if(partitionInfos != null){
for(PartitionInfo partition:partitionInfos)
partitions.add(new TopicPartition(partition.topic(),partition.partition()));
//知道需要哪些分区之后,调用assign()方法。
consumer.assign(partitions);
while(true){
ConsumerRecords<String,String> records = consumer.poll(1000);
for(ConsumerRecord<String,String> record : records){
}
consumer.commitSync();
}
}