生产者每发送一条消息都需要先创建一个ProducerRecord对象,并且需要指定目标主题、消息内容,当然还可以指定消息键和分区。之后就会调用send()方法发送该对象,由于生产者需要与Kafka Broker建立网络传输,必然需要先通过序列化器对消息的键和值对象序列化成字节数组,才能进行传输。
之后,分区器就会接收到数据,然后先确认ProducerRecord对象中是否指定了分区,如果指定分区那么就直接把指定的分区返回,如果未指定分区,分区器就会根据消息键进行选择分区。确认分区后,那么消息才能确认发送到哪个主题的哪个分区上。接下来,消息又会被添加到批次里,同一个批次的消息总是发送到同一个主题和分区上,最后生产者端会有一个独立线程负责将批次发送到相应的Broker上。
Broker接收到消息后会进行响应,消息写入成功,会返回生产者端一个RecordMetaData对象,这个对象记录了消息在哪个主题的分区上,同时还记录了消息在分区中的偏移量。消息如果写入失败,则会返回一个错误,而生产者会根据配置的重试次数进行重试,当超过重试次数还是失败,就会将错误信息返回给生产者端。
producer.send(record);//忽略返回值
//接收返回值
Future<RecordMetadata> future = producer.send(record);
//调用get方法进行阻塞,获取结果
RecordMetadata recordMetadata = future.get();
//发送时,指定Callback回调
producer.send(record, new Callback() {
public void onCompletion(RecordMetadata metadata,Exception exception) {
if(null!=exception){
//异常处理
}
if(null!=metadata){
System.out.println("message offset:"+metadata.offset()+" "
+"message partition:"+metadata.partition());
}
}
);
创建KafkaProducer时都需要为其指定属性,属性的配置可以参考org.apache.kafka.clients.producer 包下的 ProducerConfig 类,大部分属性都配置了合理的默认值,如果对内存使用、性能和可靠性方面有要求可以相应调整一些属性,下面介绍一些常用的配置属性:
创建KafkaProducer对象时,必须指定键和值的序列化器,一些业务场景可能需要自定义序列化器,那么只需要实现org.apache.kafka.common.serialization.Serializer 接口,重写serialize()方法定义序列化逻辑即可。但自定义序列化器可能更多会去结合特定业务场景使用,所以容易导致程序的脆弱性,如果需求做了调整相应的序列化器实现也可能需要调整。因此使用序列化器更推荐使用自带格式描述以及语言无关的序列化框架,比如Kafka 官方推荐的 Apache Avro。
Avro在文件的读写是依据schema而进行的,而schema是通过一个JSON文件进行描述数据的,可以把这个schema 内嵌在数据文件中。这样,不管数据格式如何变动,消费者都知道如何处理数据。但是内嵌的消息,自带格式,会导致消息的大小不必要的增大,消耗了资源。我们可以使用 schema 注册表机制,将所有写入的数据用到的 schema 保存在注册表中,然后在消息中引用 schema 的标识符,而读取的数据的消费者程序使用这个标识符从注册表中拉取 schema 来反序列化记录。
生产者在发送消息时需要创建ProducerRecord对象,ProducerRecord对象可以指定一个消息键。指定了消息键,那么分区器就会将拥有相同键的消息指定给同一个主题的同一个分区。如果没有指定消息键,那么会通过默认分区器,使用轮询算法将消息均衡发布到主题下的各个分区。默认分区器会对消息键进行散列,然后根据散列值将消息映射到特定的分区上,这样同一个消息键总是能够被映射到同一个分区,但是只有不改变主题分区数量的情况下,键和分区之间的映射才能保持不变,一旦增加了新的分区,就无法保证了,所以如果要使用键来映射分区,那就要在创建主题的时候把分区规划好,不要增加新分区。
一些业务场景中数据可能会有侧重,比如按地区进行划分数据时,不同地区的消息量是不同的,那么这种情况下就可以根据消息值中的一些标识,去针对消息值进行做分区,会更适合对应的业务场景。自定义一个分区器只需要去实现org.apache.kafka.clients.producer.Partitioner该接口,重写partition()方法完成相应的分区逻辑。
消费者需要创建KafkaConsumer,创建该对象时也需要指定消费者相关属性,可以参考org.apache.kafka.clients.consumer 包下 ConsumerConfig 类,大部分属性都配置了合理的默认值,如果需要关注内存使用、性能和可靠性方面可以相应调整一些属性,下面介绍一些常用的配置属性:
在一些高并发的情况下,当Kafka生产者发送消息的速度远快于消费者消费速度时,如果只配置单个消费者,容易造成消息堆积,消息不能及时处理。这种情况下通常考虑的就是对消费者进行横向伸缩,通过增加消费者个数对同一个主题多个分区的消息进行分流。而Kafka中多个消费者通常会构成一个消费者群组,往群组中增加消费者是进行横向伸缩的主要方式。
在一个消费者群组中所有消费者都是订阅同一个主题,主题下一个分区只能由一个消费者消费,而一个消费者可以消费多个分区。
//消费者订阅主题(可以多个),主题值允许使用正则表达式
consumer.subscribe(Collections.singletonList(BusiConst.HELLO_TOPIC));
消费端创建KafkaConsumer对象后,会使用subscribe()方法进行订阅主题,而一个消费者是可以订阅多个主题的,该方法可以传递一个主题列表或者正则表达式作为参数。正则表达式也能够匹配多个主题,比如,想订阅所有order相关的主题,可以使用subscribe(“order.*”) 。
需要注意: 在通过正则表达式订阅主题时,如果新建的一个主题正好与表达式匹配,那么会立即触发一次再均衡,消费者就可以读取新添加的主题了。
//轮询获取消息
while(true){
//拉取
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
System.out.println(String.format("topic:%s,分区:%d,偏移量:%d," + "key:%s,value:%s",record.topic(),record.partition(),
record.offset(),record.key(),record.value()));
}
}
Kafka消费端是通过拉取的方式获取消息,消费者为了不断获取消息,只能在循环中不断调用poll()方法进行拉取。其中poll()方法需要指定超时时间,它会让消费者在指定的毫秒数内一直等待 broker 返回数据。poll()方法会返回一个ConsumerRecords列表对象,而其中每一个ConsumerRecord对象都包含了消息所属的主题信息、所在分区信息、在分区里的偏移量,以及键值对。
消费者可以使用 Kafka来追踪消息在分区里的位置,称之为偏移量。消费者更新自己读取到哪个消息的操作,称之为提交。消费者提交偏移量本质上就是向一个_consumer_offset 的特殊主题发送一个消息,里面会包括每个分区的偏移量。
如果提交的偏移量小于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息会被重复处理。
如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。
auto.commit. offset缺省情况下为true,消费者会自动提交偏移量,自动提交存在一个时间间隔由auto.commit.interval.ms进行控制,缺省为5s。自动提交是在轮询拉取过程中触发的,消费者每次轮询时都会检查是否提交偏移量,如果是,则会将poll()方法返回的最新偏移量进行提交。
注意:自动提交由于是基于时间间隔的提交,如果在未达到提交时间时触发了分区再均衡,就容易造成在此之前一部分已经处理的消息被其它消费者重复处理了。并且自动提交总是将poll()方法返回的最新偏移量进行提交,它并不知道哪些消息处理成功了,所以再次调用之前最好确保所有当前调用poll()方法返回的消息都处理完成,否则可能造成消息丢失。
将auto.commit. offset设置为false,然后调用commitsync()方法提交偏移量。这个方法会提交调用poll()方法返回的最新偏移量,只要没有发生不可恢复的错误,该方法会一直阻塞,直到提交成功后返回,如果提交失败就会抛出异常。
注意:手动提交由于也是提交poll()方法返回的最新偏移量,所以在处理完所有的消息后要确保调用了commitsync()方法,否则可能造成消息丢失。
调用commitAsync()方法进行异步提交,相比与手动提交,它不会使应用程序阻塞,无需等待Broker响应。并且它支持回调,能够在Broker响应时执行相应回调方法。
//异步提交偏移量
consumer.commitAsync();
//支持回调
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,
Exception exception) {
if(exception!=null){
System.out.print("Commmit failed for offsets "+ offsets);
}
}
});
一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。这个时候就需要使用同步异步组合提交。
try {
while(true){
ConsumerRecords<String, String> records= consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
System.out.println(String.format("topic= %s,partition= %d,offset= %d,key= %s,value= %s",
record.topic(),record.partition(),record.offset(),
record.key(),record.value()));
}
//每次轮询进行异步提交
consumer.commitAsync();
}
} finally {
try {
//同步提交下
consumer.commitSync();
} finally {
consumer.close();
}
}
支持在批次中间进行提交偏移量,在调用 commitsync()和 commitAsync()方法时传递希望提交的分区和偏移量构成的一个Map参数。
Map<TopicPartition, OffsetAndMetadata> currOffsets=
new HashMap<TopicPartition, OffsetAndMetadata>();
int count = 0;
try {
while(true){
ConsumerRecords<String, String> records= consumer.poll(Duration.ofMillis(500));
for(ConsumerRecord<String, String> record:records){
System.out.println(String.format("topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(),record.partition(),record.offset(),
record.key(),record.value()));
currOffsets.put(new TopicPartition(record.topic(),record.partition()),new OffsetAndMetadata(record.offset()+1,"null"));
if(count%10==0){
//特定提交,指定一个记录希望提交的分区和偏移量的map
consumer.commitAsync(currOffsets,null);
}
count++;
}
}
} finally {
try {
//同步提交
consumer.commitSync();
} finally {
consumer.close();
}
}
消费者要加入群组时,会向群组协调器发送一个 JoinGroup 请求,第一个加入群主的消费者成为群主,群主会获得群组的成员列表,并负责给每一个消费者分配分区。分配完毕后,群主把分配情况发送给群组协调器,协调器再把这些信息发送给所有的消费者,每个消费者只能看到自己的分配信息, 只有群主知道群组里所有消费者的分配信息。群组协调的工作会在消费者发生变化,主题中分区发生了变化时发生。
在Kafka中,消费者群组中存在着消费者对分区的所有权关系,这样在一个群组中如果新增一个消费者,那么新的消费者会分配到原先由其他消费者读取的分区,而减少一个消费者,那原本由它负责的分区就会分配给其它消费者。除此之外,如果增加了分区,新增的分区也需划分由哪个消费者读取,这一系列的行为,都会导致分区所有权的变化,这种变化就称为分区再均衡。
在消费者群组中我们介绍了它有一个群组协调器,而群组协调器它会接收群组中每个消费者发来的心跳,然后维持每个消费者和群组的从属关系以及对分区所有权关系。如果长时间未收到消费者发送的心跳,群组协调器就会认为当前消费者已经死亡,就会触发一次再均衡。
分区再均衡在Kafka中是非常重要的,这是消费者群组带来高可用性和伸缩性的关键所在。但是发生分区再均衡的期间,消费者会无法接收到消息,会造成整个群组一段时间的不可用,因此都需要尽量减少发生分区再均衡。
消费者调用subscribe()订阅主题时,指定一个ConsumerRebalanceListener,在再均衡开始之前和分区再均衡完成之后做一些操作。
//指定一个ConsumerRebalancelistener
consumer.subscribe(Collections.singletonList("test1"), new ConsumerRebalanceListener() {
//分区再均衡之前
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
//1、将偏移量提交到Kafka
//2、偏移量写入数据库
}
//分区再均衡完成以后
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
//1、从数据库中获取偏移量
//2、通过seek()方法从指定偏移量位置开始读取
}
});
通常情况下消费者没有通过seek()方法指定读取位置时,调用poll()方法默认都会从分区的最新偏移量处开始读取消息。当然如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使 seekToBeginning(Collection tp)和 seekToEnd( Collectiontp)这两个方法。而调用seek()是可以从从特定的偏移量处开始读取消息的。
//从指定分区中的指定偏移量开始消费
consumer.seek(topicPartition,2);
如果确定要退出循环,需要通过另一个线程调用 consumer. wakeup()方法。如果循环运行在主线程里,可以在 ShutdownHook 里调用该方法。要记住, consumer. wakeup()是消费者唯一一个可以从其他线程里安全调用的方法。
创建KafkaConsumer对象时需要指定反序列化器,将从Kafka接收到的字节数组转换成 java对象,发送消息指定的序列化器必须与接收消息使用的反序列化器一一对应的。一些业务场景可能需要自定义反序列化器,那么只需要实现org.apache.kafka.common.serialization.Deserializer接口,重写deserialize()方法定义反序列化逻辑即可。
一个消费者从一个主题的所有分区或者某个特定的分区读取数据,不需要消费者群组和再均衡,只需要把主题或者分区分配给消费者,然后开始读取消息并提交偏移量。
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"127.0.0.1:9092");
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//独立消费者(不需要订阅主题,只需要分配主题中分区即可)
KafkaConsumer<String,String> consumer= new KafkaConsumer<String, String>(properties);
//拿到主题的分区信息
List<PartitionInfo> partitionInfos = consumer.partitionsFor("independ-consumer");
List<TopicPartition> topicPartitionList = new ArrayList<TopicPartition>();
if(null!=partitionInfos){
for(PartitionInfo partitionInfo:partitionInfos){
topicPartitionList.add(new TopicPartition(partitionInfo.topic(),
partitionInfo.partition()));
}
}
//独立消费者需要执行哪些分区(这里全部的分区分配给一个消费者)
consumer.assign(topicPartitionList);
try {
while(true){
ConsumerRecords<String, String> records
= consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String, String> record:records){
System.out.println(String.format(
"主题:%s,分区:%d,偏移量:%d,key:%s,value:%s",
record.topic(),record.partition(),record.offset(),
record.key(),record.value()));
}
}
} finally {
consumer.close();
}