不同的使用场景对生产者API的使用和配置会有直接的影响。
一、生产者发送消息流程
1、发送原理
在消息发送的过程中,涉及到了两个线程——main和Sender线程,在main线程中创建了一个双端队列RecordAccumulator。main线程将消息封装好发送给RecordAccumulator,Sender线程不断从RecordAccumulator中拉取消息发送到Kafka Broker。
batch.size:数据累积知道batch.size之后,sender才会发送数据,默认16k。
linger.ms:如果数据迟迟未达到batch.size,sender等待linger.ms设置的时间到了之后就会发送数据。单位是ms,默认值是0ms,表示没有延迟。
acks参数讲解:
- 0:生产者在成功写入消息之前不会等待任何来自服务器的响应,也就是不需要等待数据落盘应答。
- 1:只要Kafka集群的首领节点收到消息,生产者就会收到一个来自服务器的成功响应。
- -1或者all:只有当所有同步副本全部收到消息时,生产者才会收到一个来自服务器的成功响应。
2、生产者的重要参数
参数名称 | 描述 | 必选/可选 |
---|---|---|
bootstrap.servers
| 指定Broker的地址清单。地址的格式为host:port.清单里不需要包含所有的broker地址,生产者会从给定的broker里找到其他Broker的信息。不过建议至少提供两个Broker的信息,一旦其中一个宕机,生产者依然能够连接到集群上年。 | 必选 |
key.serializer
| Broker希望接收到的消息的键和值都是字节数组。生产者允许使用参数化类型,因此可以把Java对象作为键和值发送给Broker。key.serializer必须被设置为一个实现了org.apache.kafka.common.serialization.Serializer接口的类,生产者会使用这个类把键对象序列化成字节数组。Kafka客户端默认提供了ByteArraySerializer、StringSerializer和IntegerSerializer。 | 必选 |
value.serializer | 与key.serializer一样 | 必选 |
acks | 0:生产者在成功写入消息之前不会等待任何来自服务器的响应。吞吐量最高,但如果有服务器出问题,就会导致消息丢失。 1:只要集群的Leader节点收到消息,生产者就会收到一个来自服务器的成功响应。如果消息无法到达首领节点(比如首领节点崩溃,新的首领为选举出来),生产者会收到一个错误响应,为了避免数据丢失,生产者会重发消息。不过,如果一个没有收到消息的节点成为新首领,消息还是会丢失。 -1或者all:只有当所有同步副本全部收到消息时,生产者才会收到一个来自服务器的成功响应。这种模式是最安全的,它就算有服务器发生崩溃,整个集群仍能运行。它的延迟比acks=1高,因为我们要等待的不止一个服务器节点接收消息。 | 可选 |
buffer.memory
| 该参数用来设置生产者内存缓冲区的大小,生产者用它缓冲要发送到服务器的消息。如果应用程序发送消息的速度超过发送到服务器的速度,就会导致生产者空间不足。这个时候,send()方法调用要么被阻塞,要么抛出异常,取决于如何设置block.on.buffer.full参数。 | 可选 |
batch.size
| 缓冲区一批数据最大值,默认16k。适当增加该值,可以提高吞吐量,但是如果该值设置太大,会导致数据延迟增加。 | 可选 |
linger.ms
| 如果数据迟迟未达到batch.size,sender等到linger.time之后就会发送数据。单位:ms,默认是0ms,表示没有延迟。生产环境建议设置该值大小在5-100ms之间。 | 可选 |
compression.type | 默认情况下,消息发送时不会被压缩。该参数可以设置为snappy、gzip或lz4算法。 | 可选 |
retries | 在消息发送出现错误的时候,生产者可以重发消息。retries表示重发次数,达到这个次数生产者会放弃重试并返回错误。默认是int最大值:2147483647. 如果设置了重试,还想保证消息的有序性,需要设置
MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION=1,否则在重试此失败消息的时候,其他的消息可能发送成功了。
| 可选 |
retry.backoff.ms | 两次重试之间的时间间隔,默认是100ms。 | 可选 |
enable.idempotence |
是否开启幂等性,
默认
true
,开启幂等性。
| 可选 |
client.id | 该参数可以是任意的字符串,服务器会用它来识别消息的来源,还可以用在日志和配额指标里。 | 可选 |
max.in.flight.requests.per.connection | 该参数指定了生产者在收到服务器相应之前可以发送多少个消息。它的值越高,就会占用越多的内存,不过也会提升吞吐量。 把它设为1可以保证消息是按照发送的顺序写入服务器的,即使发生了重试。 | 可选 |
timeout.ms | 指定了Broker等待同步副本返回消息确认的时间,与acks的配置相匹配。如果在指定时间内没有收到同步副本的确认,那么Broker就会返回一个错误。 | 可选 |
request.timeout.ms | 指定了生产者在发送数据时等待服务器返回响应的时间。 | 可选 |
metadata.fetch.timeout.ms | 指定了生产者在获取元数据(比如目标分区的Leader是谁)时等待服务器返回响应的时间。如果等待超时,那么生产者要么重试发送数据,要么返回一个错误。 | 可选 |
max.block.ms | 该参数指定了在调用send()方法或使用partitionsFor()方法获取元数据时生产者的阻塞时间。当生产者的发送缓冲区已满,或者没有可用的元数据时,这些方法就会阻塞。在阻塞时间达到max.block.ms时,生产者会抛出异常。 | 可选 |
max.request.size | 用于控制生产者发送的请求大小。它可以指定能发送的单个消息的最大值,也可以指定单个请求里所有消息总的大小。 | 可选 |
receive.buffer.bytes | 指定了TCP socket接收数据包的缓冲区大小。 | 可选 |
send.buffer.bytes | 指定了TCP socket发送数据包的缓冲区大小。 | 可选 |
二、Kafka生产者代码
1、创建Kafka生产者
下面的代码片段演示如何创建一个新的生产者,只包含必选的属性,其他使用默认配置:
package com.itcast.producer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class KafkaProducerTest {
public static void main(String[] args) {
// 0 配置
Properties properties = new Properties();
// 连接集群 bootstrap.servers
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"hadoop101:9092,hadoop102:9092");
// 指定对应的key和value的序列化类型 key.serializer
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,StringSerializer.class.getName());
// 1 创建kafka生产者对象
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
// 2 发送数据
// 3 关闭资源
kafkaProducer.close();
}
}
我们在这里创建了一个Kafka生产者,总共有三步:
- 新建一个Properties对象
- 指定配置参数
- 创建一个新的生产者对象,并将Properties对象传给它。
这个接口很简单,实例化生产者对象后,我们就可以发送消息了。发送消息主要有以下3种方式:
1、发送并忘记(fire and forget)
我们把消息发送给服务器,但并不关心它是否到达。大多数情况下,消息会正常到达,因为Kafka是高可用的,而且生产者会自动尝试重发。不过,使用这种方式有时也会丢失一些消息。
2、同步发送
我们使用send()发送消息,它会返回一个Future对象,调用get()方法进行等待,就可以知道消息是否发送成功。
3、异步发送
我们调用send()发送消息,并指定一个回调函数,服务器在返回响应时调用该函数。
2、发送消息到kafka
// 2 发送数据
try{
for (int i = 0; i < 5; i++) {
kafkaProducer.send(new ProducerRecord<>("myTopic","learn Kafka","producer"+i));
}
}catch (Exception e){
e.printStackTrace();
}
(1)生产者的send()方法将ProducerRecord对象作为参数,所以我们要先创建一个ProducerRecord对象。
(2)我们使用生产者的send()方法发送ProducerRecord对象。从生产者的架构图中可以看到,消息先是被放进缓冲区,然后使用单独的线程发送到服务器端。send()方法会返回一个包含RecordMetadata的Future对象,不过因为我们会忽略返回值,所以无法知道消息是否发送成功。如果不关心发送结果,那么可以使用这种方式。
比如,记录消息日志,或记录不太重要的应用程序日志。
(3)我们可以忽略发送消息时可能发生的错误或服务器端可能发生的错误,但在发送消息之前,生产者还是有可能发生其他的异常。这些异常有可能是SerializationException(序列化失败)、BufferExhaustedException或TimeOutException(缓冲区满),或者是InterruptException(线程被中断)。
3、同步发送消息
// 2 同步发送数据
try{
for (int i = 0; i < 5; i++) {
kafkaProducer.send(new ProducerRecord<>("myTopic","learn Kafka","producer"+i)).get();
}
}catch (Exception e){
e.printStackTrace();
}
(1)kafkaProducer.send()方法先返回一个Future对象,然后调用Future对象的get()方法等待Kafka相应。如果服务器返回错误,get()方法会抛出异常。如果没有异常,我们会得到一个RecordMetadata对象,可以用它获取消息的偏移量。.
(2)如果在发送数据之前或者在发送过程中发生了任何错误,比如broker返回了一个不允许重发消息的异常或者已经超过了重发的次数,那么就会抛出异常。
KafkaProducer一般会发生两类错误。
第一种是可重试错误,这类错误可以通过重发消息来解决。比如对于连接错误,可以通过再次建立连接来解决,“无主(no leader)”错误则可以通过重新为分区选举首领来解决。KafkaProducer可以被配置成自动重试,如果在多次重试后仍无法解决问题,应用程序会收到一个重试异常。
另一种错误无法冲过重试来解决,比如“消息太大”异常。对于这类错误,KafkaProducer不会进行任何尝试,直接抛出异常。
4、异步发送消息
假设消息在应用程序和Kafka集群之间一个来回需要10ms。如果在发送完每个消息后都等待回应,那么发送100个消息需要1秒。但如果只发送消息而不等待响应,那么发送100个消息所需的时间会少很多。大多数时候,我们并不需要等待响应——尽管Kafka会把目标主题、分区信息和消息的偏移量发送回来,但对于发送端的应用程序来说不是必须得。
不过在遇到消息发送失败时,我们需要抛出异常、记录错误日志,或者把消息写入“错误消息”文件以便日后分析。
为了在异步发送消息的同时能够对异常情况进行处理,生产者提供了回调支持。下面是一个例子。
// 4. 调用 send 方法,发送消息
for (int i = 0; i < 5; i++) {
// 添加回调
kafkaProducer.send(new ProducerRecord<>("myTopic", "learn Kafka","Producer " + i), (metadata, exception) -> {
if (exception == null) {
// 没有异常,输出信息到控制台
System.out.println(" 主题: " + metadata.topic() + "->" + "分区:" + metadata.partition());
} else {
// 出现异常打印
exception.printStackTrace();
}
});
// 睡一会看数据发往不同分区
Thread.sleep(2);
}
(1)为了使用回调,需要一个实现了org.apache.kafka.clients.producer.Callback接口的类,这个接口只有一个onCompletion()方法;
(2)如果Kafka返回一个错误,onCompletion()方法会抛出一个非空(not null)异常。这里我们只是简单的打印,在生产环境中可以根据业务自定义处理。
(3)记录与之前的一样
(4)在发送消息时传入一个回调对象。
5、生产者分区
5.1、分区好处
(1)便于合理使用存储资源,实现负载均衡。每个Partition在一个Broker上存储,可以把海量数据按照分区切割成一块块数据存储在多态Broker上。合理控制分区数量,可以实现负载均衡。
(2)提高数据并行处理能力。生产者可以以分区为单位发送数据,消费者可以以分区为单位进行消费。
5.2、分区策略
(1)默认的分区器DefaultPartitioner
在IDEA中CTRL+N,全局查找DefaultPartitioner。
如果键为null,并且使用了默认的默认的分区器,那么记录将被随机的发送到主题内各个可用的分区上。分区器使用轮询(Round Robin)算法将消息均衡的分布到各个分区上。
(2)将数据发往指定Partition的情况,比如将所有数据局发到分区1中。
// 4. 调用 send 方法,发送消息
for (int i = 0; i < 5; i++) {
// 添加回调
kafkaProducer.send(new ProducerRecord<>("myTopic", 1,"learn Kafka","Producer " + i), (metadata, exception) -> {
if (exception == null) {
// 没有异常,输出信息到控制台
System.out.println(" 主题: " + metadata.topic() + "->" + "分区:" + metadata.partition());
} else {
// 出现异常打印
exception.printStackTrace();
}
});
// 睡一会看数据发往不同分区
Thread.sleep(2);
}
(3)没有指定Partition值但有key的情况下
如果键不为空,并且使用了默认的分区器,那么Kafka会对键进行散列(使用Kafka自己的散列算法,即使升级Java版本,散列值也不会发生变化),然后根据散列值把消息映射到特定的分区上。这里的关键之处在于,同一个键总是被映射到同一个分区上,所以在进行映射时,我们会使用主题所有的分区,而不仅仅是可用的分区。
只有在不改变主题分区数量的情况下,键和分区之间的映射才能保持不变。
// 4. 调用 send 方法,发送消息
for (int i = 0; i < 5; i++) {
// 添加回调
// 将key值依次改为a,b,c,d,e
kafkaProducer.send(new ProducerRecord<>("myTopic","a","Producer " + i), (metadata, exception) -> {
if (exception == null) {
// 没有异常,输出信息到控制台
System.out.println(" 主题: " + metadata.topic() + "->" + "分区:" + metadata.partition());
} else {
// 出现异常打印
exception.printStackTrace();
}
});
// 睡一会看数据发往不同分区
Thread.sleep(2);
}
(4)自定义分区器
研发人员可以根据企业需求,自定义实现分区器。
假如你是一个B2B供应商,你有一个大客户京东,占据了你整体业务30%的份额。如果使用默认的散列分区算法,京东的账号记录将和其他账号记录一起被散列到相同的分区,导致京东所占的分区比其他的分区要大一些,服务器可能因此出现存储空间不足、处理效率低下等问题。我们需要给京东分配单独的分区,然后使用散列算法处理其他账号。
下面是一个自定义分区器的例子:
需求: 实现一个分区器,如果发送过来的数据中包含jingdong,就发往0号分区,不包含jingdong,就发往1号分区。
实现步骤:
*1.定义类实现Partitioner接口
*2.重写Partition()方法
package com.itcast.producer;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import java.util.Map;
/**
* 1. 实现接口 Partitioner
* 2. 实现 3 个方法:partition,close,configure
* 3. 编写 partition 方法,返回分区号
*/
public class MyPartitioner implements Partitioner {
/**
* 返回信息对应的分区
* @param topic 主题
* @param key 消息的 key
* @param keyBytes 消息的 key 序列化后的字节数组
* @param value 消息的 value
* @param valueBytes 消息的 value 序列化后的字节数组
* @param cluster 集群元数据可以查看分区信息
* @return
*/
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
// 获取消息
String msgValue = value.toString();
// 创建 partition
int partition;
// 判断消息是否包含 jingdong
if (msgValue.contains("jingdong")){
partition = 0;
}else {
partition = 1;
}
// 返回分区号
return partition;
}
// 关闭资源
@Override
public void close() {
}
// 配置方法
@Override
public void configure(Map<String, ?> configs) {
}
}
*3.使用分区器的方法,在生产者的配置中添加分区器参数
package com.itcast.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class CustomerPartitioner {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"hadoop101:9092,hadoop2:9092");
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,StringSerializer.class.getName());
// 添加自定义分区器
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,"com.itcast.producer.MyPartitioner");
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
for (int i = 0; i < 5; i++) {
kafkaProducer.send(new ProducerRecord<>("myTopic", "learn " + i), (metadata, ex) -> {
if (ex == null){
System.out.println(" 主题: " + metadata.topic() + "->" + "分区:" + metadata.partition());
}else {
ex.printStackTrace();
}
});
}
kafkaProducer.close();
}
}
6、序列化器
我们在之前的生产者参数列表中看到,创建一个生产者对象必须指定序列化器。我们已经知道如何使用默认的字符串序列化器,Kafka还提供了整型和字节数组的序列化器。不过它们还不足以满足大部分场景的需求。到最后,我们需要序列化的记录类型会越来越多。
6.1、自定义序列化器
如果发送到Kafka的对象不是简单的字符串或整型,那么可以使用序列化框架来创建消息记录,比如Avro、Thrift或Protobuf,或者使用自定义序列化器。我们强烈建立使用通用的序列化框架。
创建一个对象
package com.itcast.producer;
public class Customer {
private int cusId;
private String cusName;
public Customer(int cusId, String cusName) {
this.cusId = cusId;
this.cusName = cusName;
}
public int getCusId() {
return cusId;
}
public void setCusId(int cusId) {
this.cusId = cusId;
}
public String getCusName() {
return cusName;
}
public void setCusName(String cusName) {
this.cusName = cusName;
}
}
创建一个序列化器。
package com.itcast.producer;
import org.apache.kafka.common.errors.SerializationException;
import org.apache.kafka.common.serialization.Serializer;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class CustomerSerializer implements Serializer<Customer> {
/**
* Customer对象被序列化成:
* 表示cusID的4字节整数
* 表示cusName长度的4字节整数(如果cusName为空,则长度为0)
* 表示cusName的N个字节
* @param topic
* @param customer
* @return
*/
@Override
public byte[] serialize(String topic, Customer customer) {
try{
byte[] serializedName;
int stringSize;
if(customer == null){
return null;
}else{
if(customer.getCusName() != null){
serializedName = customer.getCusName().getBytes(StandardCharsets.UTF_8);
stringSize = serializedName.length;
}else{
serializedName = new byte[0];
stringSize = 0;
}
}
ByteBuffer buffer = ByteBuffer.allocate(4+4+stringSize);
buffer.putInt(customer.getCusId());
buffer.putInt(stringSize);
buffer.put(serializedName);
return buffer.array();
}catch(Exception e){
throw new SerializationException("Error when serializing Customer to byte[] "+e);
}
}
}
只要使用这个CustomerSerializer,就可以把消息记录定义成ProducerRecord<String,Customer>,并且可以直接把Customer对象传给生产者。
这个例子很简单,不过代码看起来太脆弱了——如果我们有多种类型的消费者,可能需要把CustomerID字段变成长整型,或者为Customer添加startDate字段,这样就会出现新旧消息的兼容性问题。在不同版本的序列化器和反序列化器之间调试兼容性问题着实是个挑战——你需要比较原始的字节数组。
更糟糕的是,如果同一个公司的不同团队都需要往Kafka写入Customer数据,那么他们就需要使用相同的序列化器,如果序列化器发生改动,他们几乎要在同一时间修改代码。
基于以上原因,我们不建议使用自定义序列化器。而是使用已有的序列化器和反序列化器,比如JSON、Avro、Thrift或Protobuf。
6.2、使用Avro序列化
Apache Avro是一种与编程语言无关的序列化格式。Avro通过与语言无关的schema来定义。schema通过JSON来描述,数据被序列化成二进制或JSON文件,不过一般会使用二进制文件。Avro在读写文件时需要用到schema,schema一般会被内嵌在数据文件里。
Avro有一个很有意思的特性,当负责写消息的应用程序使用了新的schema,负责读消息的应用程序可以继续处理消息而无需做任何改动。
6.3、在Kafka里使用Avro
Avro的数据文件里包含了整个Schema,不过这样的开销是可接受的。但是如果在每条Kafka记录里都嵌入schema,会让记录的大小成倍的增加。
下图是Avro记录的序列化和反序列化流程图
下面例子演示如何把生成的Avro对象发送到Kafka。
package com.itcast.producer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class KafkaAvroProducer {
public static void main(String[] args) {
// 0 配置
Properties properties = new Properties();
String schemaUrl = "com.itcast.producer.Customer";
// 连接集群 bootstrap.servers
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"hadoop101:9092,hadoop102:9092");
// 指定对应的key和value的序列化类型 key.serializer
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "io.confluent.kafka.serializers.KafkaAvroSerializer");
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"io.confluent.kafka.serializers.KafkaAvroSerializer");
properties.put("schema.registry.url",schemaUrl);
// 1 创建kafka生产者对象
KafkaProducer<String, Customer> kafkaProducer = new KafkaProducer<>(properties);
// 2 发送数据
// 不断生成事件,知道有人按下Ctrl+C组合键
while(true){
Customer customer = new Customer(1,"hello");
ProducerRecord<String,Customer> producerRecord= new ProducerRecord<String, Customer>("myTopic",String.valueOf(customer.getCusId()),customer);
kafkaProducer.send(producerRecord);
}
}
}
(1)使用Avro的KafkaAvroSerializer来序列化对象。
(2)schema.registry.url是一个新的参数,指向schema的存储位置。
(3)Customer是生成的对象。我们会告诉生产者Customer对象就是记录的值。
(4)实例化一个ProducerRecord对象,并指定为Customer为值的类型,然后再传给它一个Customer对象。
(5)把Customer对象作为记录发送出去,KafkaAvroSerializer会处理剩下的事情。
最后,感谢大家的阅读,欢迎大家随时在下方评论,需要看什么内容,并且提出改进。