kafka在非集群环境下生产与消费的流程
一、简单使用
<dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>3.4.0</version> </dependency>
1.生产者
kafka在发送消息时,必须先创建一个主题(topic),然后向这个topic中发送消息,kafka支持在发送时自动创建主题,可在server.properties配置文件中进行配置如下,默认为true:
auto.create.topics.enable:true
一个topic可以指定多个分区(partition),也可以在server.properties中配置如下,默认为1:
num.partitions=1
1.1.生产者的必选属性
-
bootstrap.servers
该属性为指定kafka的服务端地址,地址的格式为 host:port 。支持集群的方式,当一个kafka服务发生宕机时,其他的kafka服务可以继续提供服务,格式为 host:port,host:port。
- key.serializer
该属性为指定key的序列化方式,生产者在向服务(broker)发送消息时,broker希望收到的是字节数组,所以我们需要指定序列化方式。
key.serializer必须设置为实现org.apache.kafka.common.serialization.Serializer的接口类,Kafka的客户端默认提供了ByteArraySerializer,IntegerSerializer,StringSerializer,也可以实现自定义的序列化器。
- value.serializer
同key.serializer。
1.2.三种发送方式
可以通过生成者的send方法进行发送。send方法会返回一个包含RecordMetadata的Future对象。RecordMetadata里包含了目标主题,分区信息和消息的偏移量。
- 忽略并忘记
忽略返回值,大多数情况下消息都会送达,而且生产者会自动重试,但有时消息会丢失。
package com.kafka.gesiyu.procucer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
/**
* @Author: gesiyu
* @Date: 2022-12-25 16:37
* @Description: kafka生产者
*/
public class KafkaProducer1 {
public static void main(String[] args) {
Properties properties = new Properties();
//kafka服务所在地址
properties.put("bootstrap.servers", "127.0.0.1:9092");
//key的序列化方式
properties.put("key.serializer", StringSerializer.class);
//value的序列化方式
properties.put("value.serializer", StringSerializer.class);
//构建一个kafka生成者
KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
//构建一个消息发送器
ProducerRecord<String, String> producerRecord = new ProducerRecord<>("topic1", "name", "gesiyu");
//发送消息
producer.send(producerRecord);
//关闭连接
producer.close();
}
}
- 同步发送
发送消息后获得返回值Future,通过get方法获得RecordMetadata,从而获得消息的各种属性(offset,topic,partition等)。
package com.kafka.gesiyu.procucer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
/**
* @Author: gesiyu
* @Date: 2022-12-25 16:37
* @Description: kafka生产者
*/
public class SynKafkaProducer {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Properties properties = new Properties();
//kafka服务所在地址
properties.put("bootstrap.servers", "127.0.0.1:9092");
//key的序列化方式
properties.put("key.serializer", StringSerializer.class);
//value的序列化方式
properties.put("value.serializer", StringSerializer.class);
//构建一个kafka生成者
KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
//构建一个消息发送器
ProducerRecord<String, String> producerRecord = new ProducerRecord<>("topic1", "name", "gesiyu");
//发送消息并查看消息属性(同步阻塞)
Future<RecordMetadata> future = producer.send(producerRecord);
if (future.get() != null) {
RecordMetadata recordMetadata = future.get();
long offset = recordMetadata.offset();
String topic = recordMetadata.topic();
int partition = recordMetadata.partition();
System.out.println("消息发送成功:offset=" + offset + ",topic=" + topic + ",partition=" + partition);
}
//关闭连接
producer.close();
}
}
-
异步发送
在发送消息时,实现kafkaClient的Callback方法,可以以异步的方式接收到RecordMetadata,从而获得消息的各种属性。
package com.kafka.gesiyu.procucer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
/**
* @Author: gesiyu
* @Date: 2022-12-25 16:37
* @Description: kafka生产者
*/
public class BasicKafkaProducer {
public static void main(String[] args) {
Properties properties = new Properties();
//kafka服务所在地址
properties.put("bootstrap.servers", "127.0.0.1:9092");
//key的序列化方式
properties.put("key.serializer", StringSerializer.class);
//value的序列化方式
properties.put("value.serializer", StringSerializer.class);
//构建一个kafka生成者
KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
//构建一个消息发送器
ProducerRecord<String, String> producerRecord = new ProducerRecord<>("topic1", "name", "gesiyu");
//发送消息
producer.send(producerRecord);
//关闭连接
producer.close();
}
}
2.消费者
生产者在向Broker生产消息后,需要有消费者进行消费,在高并发情况下,生产者生产的消息速度远大于消费者消费的速度,这时就会造成消息的积压,此时必须对消费者进行横向伸缩,配置多个消费者进行消费。
2.1.必选属性
-
bootstrap.servers
该属性为指定kafka的服务端地址,地址的格式为 host:port 。支持集群的方式,当一个kafka服务发生宕机时,其他的kafka服务可以继续提供服务,格式为 host:port,host:port。
- key.deserializer
该属性为指定key的序列化方式,生产者在向服务(broker)发送消息时,broker希望收到的是字节数组,所以我们需要指定序列化方式。
key.serializer必须设置为实现org.apache.kafka.common.deserialization.Serializer的接口类,Kafka的客户端默认提供了ByteArrayDeSerializer,IntegerDeSerializer,StringDeSerializer,也可以实现自定义的序列化器。
- value.deserializer
同key.deserializer。
2.2.可选属性
- group.id
消费者群组并非完全需要,一个消费者群组订阅一个topic主题,也可以不属于任何一个消费者群组。
2.3.消费者群组
kafka里的消费者从属于消费者群组,一个消费者群组里的消费者订阅的都是同样的主题,且消费者群组里的消费者不能重复消费主题里的消息,每个消费者接收主题中的一部分分区的消息。
2.4.接收消息
package com.kafka.gesiyu.consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
/**
* @Author: gesiyu
* @Date: 2022-12-25 16:58
* @Description: kafka消费者
*/
public class KafkaConsumer {
public static void main(String[] args) {
Properties properties = new Properties();
//kafka服务器地址
properties.put("bootstrap.servers", "127.0.0.1:9092");
//key的反序列化方式
properties.put("key.deserializer", StringDeserializer.class);
//value的反序列化方式
properties.put("value.deserializer", StringDeserializer.class);
//所属于的消费者组
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
//构建一个kafka消费者
org.apache.kafka.clients.consumer.KafkaConsumer<String, String> kafkaConsumer = new org.apache.kafka.clients.consumer.KafkaConsumer<>(properties);
//订阅一个或多个主题
kafkaConsumer.subscribe(Collections.singletonList("topic1"));
while (true) {
//每过一秒拉取一次消息
ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofSeconds(1));
consumerRecords.forEach(consumerRecord -> {
String key = consumerRecord.key();
String value = consumerRecord.value();
System.out.println("收到消息:key=" + key + ",value=" + value);
});
}
}
}
二、自定义序列化
自定义序列化容易导致程序的脆弱性。举例,我们有多种类型的消费者,每个消费者对实体字段都有各自的需求,比如,有的将字段变更为long型,有的会增加字段,这样会出现新旧消息的兼容性问题。特别是在系统升级的时候,经常会出现一部分系统升级,其余系统被迫跟着升级的情况。
解决这个问题,可以考虑使用自带格式描述以及语言无关的序列化框架。比如Protobuf,Kafka官方推荐的Apache Avro。
三、分区(partition)
在kafka中,一个topic可以有多个分区,那么,在生产者生产消息时,这个消息应该放入哪个partition呢。
若在生产消息时,指定了消息应该存入哪个分区则直接放入这个分区,若没有指定则使用kafka的分区器来进行选择需要放入哪个分区。
3.1.默认分区器(DefaultPartitioner.class)
- 若指定了partition则直接使用这个分区。
- 若没有指定partition且没有指定分区器且指定了key则使用默认分区器,对序列化后的key进行murmur2哈希算法进行取模,计算应该放入哪个分区。
- 如果没有指定pattition也没有指定分区器且没有key,则使用粘性分区策略。
采用默认分区,key的用途主要分为两个:
- 用来计算消息该放入哪个分区,相同的key将放入相同的分区。
- 作为消息的附加消息。
3.2.循环分区器(RoundRobinPartitioner.class)
- 若指定了partition则直接使用这个分区。
- 若没有指定partition且key为null则使用循环分区器,循环分区器是将消息按照顺序依次放入每个分区中,可以保证分区的平均分配。
3.3.粘性分区策略(UniformStickyPartitioner.class)
粘性分区策略与默认分区器的唯一区别就是,默认分区器如果没有指定key则使用粘性分区,粘性分区策略则是不管有没有key统一使用粘性分区策略。
默认分区器与粘性分区策略已经废弃,可参考Kafka生产者3种分区分配策略-云社区-华为云。
3.4.自定义分区器
当然我们也可以进行自定义分区器,实现Partition接口。
package com.kafka.gesiyu.partition; import org.apache.kafka.clients.producer.Partitioner; import org.apache.kafka.common.Cluster; import org.apache.kafka.common.PartitionInfo; import org.apache.kafka.common.utils.Utils; import java.util.List; import java.util.Map; /** * 自定义分区器,以value值进行分区 * * @author gesiyu * @date 2023/5/11 17:18 */ public class MyPartition implements Partitioner { @Override public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) { List<PartitionInfo> partitionInfos = cluster.partitionsForTopic(topic); int num = partitionInfos.size(); //来自DefaultPartitioner的处理 return Utils.toPositive(Utils.murmur2(valueBytes)) % num; } @Override public void close() { } @Override public void configure(Map<String, ?> map) { } }
四、生产缓冲机制
kafka生产消息时,在进行分区器选择分区后,并不会立即将消息发送出去,会先将消息存入缓冲区中,多条消息组成一个Batch(批次),满足一定的条件才进行发送。
4.1.buffer.memory
设置生产者缓冲区的大小,生产者的数据都先将放入缓冲区,若生产者生产消息放入缓冲区的速度高于消息发送的速度,那么kafka将抛出异常,默认为32M。
4.2.batch.size
当多个消息被发送同一个分区时,生产者会把它们放在同一个批次里,这个参数设置缓冲区中一个批次数据的大小,当一个批次中的数据填满时,消息会进行发送。
4.3.linger.ms
指定了生产者在发送批次前等待更多消息加入批次的时间。它和batch.size以先到者为先。也就是说,一旦我们获得消息的数量够batch.size的数量了,他将会立即发送而不顾这项设置,然而如果我们获得消息字节数比batch.size设置要小的多,我们需要linger特定的时间以获取更多的消息。这个设置默认为0,即没有延迟。设定linger.ms=5,例如,将会减少请求数目,但是同时会增加5ms的延迟,但也会提升消息的吞吐量。
4.4.设置生产缓冲机制的原因
- 减少IO的开销,批次发送比单次发送的效率要好很多,但是kafka的默认配置linger.ms为0,基本上就是生产一条就发送一条,所以当linger.ms的配置大于0时才能减少IO的开销。
- 减少kafka客户端的GC,比如缓冲池大小是32MB。然后把32MB划分为N多个内存块,比如说一个内存块是16KB(batch.size),这样的话这个缓冲池里就会有很多的内存块。你需要创建一个新的Batch,就从缓冲池里取一个16KB的内存块就可以了,然后这个Batch就不断的写入消息。下次别人再要构建一个Batch的时候,再次使用缓冲池里的内存块就好了。这样就可以利用有限的内存,对他不停的反复重复的利用。因为如果你的Batch使用完了以后是把内存块还回到缓冲池中去,那么就不涉及到垃圾回收了。
五、消费者偏移量的提交
一般情况下,我们调用poll方法的时候,broker返回的是生产者写入Kafka同时kafka的消费者提交偏移量,这样可以确保消费者消息消费不丢失也不重复,所以一般情况下Kafka提供的原生的消费者是安全的,但是事情会这么完美吗?
5.1.自动提交
最简单的提交方式是让消费者自动提交偏移量。 如果enable.auto.commit被设为 true,消费者会自动把从poll()方法接收到的最大偏移量提交上去。提交时间间隔由auto.commit.interval.ms控制,默认值是5s。
自动提交是在轮询里进行的,消费者每次在进行轮询时会检査是否该提交偏移量了,如果是,那么就会提交从上一次轮询返回的偏移量。
不过,在使用这种简便的方式之前,需要知道它将会带来怎样的结果。
假设我们仍然使用默认的5s提交时间间隔, 在最近一次提交之后的3s发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了3s,所以在这3s内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量, 减小可能出现重复消息的时间窗, 不过这种情况是无法完全避免的。
在使用自动提交时,每次调用轮询方法都会把上一次调用返回的最大偏移量提交上去,它并不知道具体哪些消息已经被处理了,所以在再次调用之前最好确保所有当前调用返回的消息都已经处理完毕(enable.auto.comnit被设为 true时,在调用 close()方法之前也会进行自动提交)。一般情况下不会有什么问题,不过在处理异常或提前退出轮询时要格外小心。
5.2.消费者的配置参数
auto.offset.reset
earliest 当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费 latest 当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
只要group.Id不变,不管auto.offset.reset 设置成什么值,都从上一次的消费结束的地方开始消费。
六、群组协调
kafka的消费者可以有多个消费者群组,同一个消费者群组内的消费者消费同一个topic的不同的分区,可以避免消费者群组内的消息重复消费与丢失问题,那么消费者群组内的消费者是如何分配分区的呢,此时就用到kafka的群组协调器了。
当消费者要加入消费者组时,会向组协调器发送一个JoinGroup请求,第一个加入组协调器的消费者成为消费者群主,并为其他加入消费者群组的消费者进行分配分区,并将分配好的结果告知组协调器,组协调器再将分区结果告知各个消费者,每个消费者只能知道自己所要消费的分区,只有消费者群主和组协调器知道其他消费者要消费的分区。
组协调器的工作是在消费者发生变化(新加入或者掉线)或生产者的分区发生变化的时候进行的。
6.1.组协调器
kafka的组协调器是由kafka客户端自身维护的,有多少个_consumer_offset分区就有多少个组协调器,每个消费者群组都会对应一个组协调器,对应的逻辑为 hash(group.id
)%分区数。
- 选举leader消费者群主
- 处理申请加入组的客户端
- 再平衡后同步新的方案
- 维护与客户端的心跳检测
- 管理消费者群组已经消费的偏移量,并存储在_consumer_offset中
6.2.消费者协调器
每个客户端(消费者的客户端)都会有一个消费者协调器, 他的主要作用就是向组协调器发起请求做交互, 以及处理回调逻辑。
- 向组协调器发起入组请求
- 向组协调器发起同步组请求(如果是Leader客户端,则还会计算分配策略数据放到入参传入)
- 发起离组请求
- 保持跟组协调器的心跳线程
- 向组协调器发送提交已消费偏移量的请求
6.3.消费者加入分组的流程
- 客户端启动的时候, 或者重连的时候会发起JoinGroup的请求来申请加入的组中。
- 当前客户端都已经完成JoinGroup之后, 客户端会收到JoinGroup的回调, 然后客户端会再次向组协调器发起SyncGroup的请求来获取新的分配方案。
- 当消费者客户端关机/异常 时, 会触发离组LeaveGroup请求。
- 当然有主动的消费者协调器发起离组请求,也有组协调器一直会有针对每个客户端的心跳检测, 如果监测失败,则就会将这个客户端踢出Group。
- 客户端加入组内后, 会一直保持一个心跳线程,来保持跟组协调器的一个感知。
- 并且组协调器会针对每个加入组的客户端做一个心跳监测,如果监测到过期, 则会将其踢出组内并再平衡。
七、分区再均衡
当消费者群组里的消费者发生变化,或者主题里的分区发生了变化,都会导致再均衡现象的发生。从前面的知识中,我们知道,Kafka中,存在着消费者对分区所有权的关系,
这样无论是消费者变化,比如增加了消费者,新消费者会读取原本由其他消费者读取的分区,消费者减少,原本由它负责的分区要由其他消费者来读取,增加了分区,哪个消费者来读取这个新增的分区,这些行为,都会导致分区所有权的变化,这种变化就被称为 再均衡 。
再均衡对Kafka很重要,这是消费者群组带来高可用性和伸缩性的关键所在。不过一般情况下,尽量减少再均衡,因为再均衡期间,消费者是无法读取消息的,会造成整个群组一小段时间的不可用。
消费者通过向称为群组协调器的broker(不同的群组有不同的协调器)发送心跳来维持它和群组的从属关系以及对分区的所有权关系。如果消费者长时间不发送心跳,群组协调器认为它已经死亡,就会触发一次再均衡。
心跳由单独的线程负责,相关的控制参数为max.poll.interval.ms。
7.1.消费者提交偏移量导致的问题
当我们调用poll方法的时候,broker返回的是生产者写入Kafka但是还没有被消费者读取过的记录,消费者可以使用Kafka来追踪消息在分区里的位置,我们称之为 偏移量 。消费者更新自己读取到哪个消息的操作,我们称之为 提交 。
消费者是如何提交偏移量的呢?消费者会往一个叫做_consumer_offset的特殊主题发送一个消息,里面会包括每个分区的偏移量。发生了再均衡之后,消费者可能会被分配新的分区,为了能够继续工作,消费者者需要读取每个分区最后一次提交的偏移量,然后从指定的地方,继续做处理。
分区再均衡的例子:
某软件公司,有一个项目,有两块的工作,有两个码农,一个小王、一个小李,一个负责一块(分区消费),干得好好的。突然一天,小王桌子一拍不干了,老子中了5百万了,不跟你们玩了,立马收拾完电脑就走了。这个时候小李就必须承担两块工作,这个时候就是发生了分区再均衡。
过了几天,你入职,一个萝卜一个坑,你就入坑了,你承担了原来小王的工作。这个时候又会发生了分区再均衡。
- 如果提交的偏移量小于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息会被重复处理,
- 如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失