Kafka生产者客户端详解

文章目录

    • 引言
    • 生产者 vs 消费者
        • 线程安全
    • 1. 生产者Java客户端
        • 生产者客户端代码:
        • 必备参数配置
    • 2. 消息发送模式
      • 2.1 发后即忘
      • 2.2 同步发送
      • 2.3 异步发送
    • 3. 消息发送流程
      • 3.1 生产者拦截器
        • 自定义拦截器与拦截器链
      • 3.2 序列化器
      • StringSerialize序列化器的内部实现
        • 自定义序列化器
      • 3.3 分区器
        • 默认分区器
        • 自定义分区器
    • 4. 消息确认与异常重试
        • `acks`参数
        • 可重试异常和不可重试异常
        • 消息重试与消息的顺序性
    • 5. 生产者客户端整体架构
      • 5.1 两个线程
      • 5.2 消息累加器 RecordAccumulator
      • 5.3 消息封装
      • 5.4 已发送未响应缓存 InFlightRequests
    • 参考文献

引言

  • 生产者(Producer)就是负责向Kafka发送消息的应用程序;
  • 消费者(Consumer)就是可以从Kafka订阅主题,并从订阅的主题中拉取消息的应用程序;
  • 对于Kafka来说,生产者和消费者都属于客户端

生产者 vs 消费者

线程安全

  • 生产者KakfaProducer是线程安全的,可以在多个线程中共享KafkaProducer实例,也可以将Kafka实例进行池化来供其他线程调用;
  • 消费者KafkaConsumer是非线程安全的;

1. 生产者Java客户端

使用Java开发生产者/消费者客户端需要先引入Pom依赖:



    org.apache.kafka
    kafka-clients
    2.0.0

  • 注意:kafka从2.0.0版本开始,不再支持JDK7及以下的版本!

生产者客户端代码:

/**
 * Java生产者客户端
 */
public class ProducerFastStart {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-learn";

    /**
     * 构建生产者客户端配置参数
     */
    public static Properties initConfig() {
        Properties proper = new Properties();
        proper.put("bootstrap.servers", brokerList);
        proper.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        proper.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        proper.put("client.id", "producer.client.id.demo");

        return proper;
    }

    public static void main(String[] args) {
        Properties proper = initConfig();
        // 创建一个生产者客户端实例
        KafkaProducer producer = new KafkaProducer<>(proper);
        // 构建所需要发送的消息
        ProducerRecord record = new ProducerRecord<>(topic, "Hello,Kafka");
        
        // 发送消息
        producer.send(record);

        // 关闭生产者客户端实例
        producer.close();
    }
}
  • bootstrap.serverskey.serializervalue.serializer是生产者客户端连接kafak必备的参数;
  • 为了防止参数名记错,可以使用 ProductConfig的常量配置,每一个生产者客户端需要的参数都有一个常量名;
  • 序列化器要使用全类别(带包名),为了防止字符串写错,可以使用StringSerializer.class.getName(),其中getName()方法是获取全限定类名,getShortName()方法是只获取类名;

必备参数配置

  • bootstrap.servers:连接Kafka集群所需要的broker地址清单。可以设置一个或多个,中间以逗号隔开,默认值是"";
    • 注意:这里并非需要所有的broker地址,因为生产者会从给定的broker里查找其他broker的信息。不过建议至少要配置两个以上的broker地址信息,当其中任意一台宕机时,生产者仍然可以连接到kafka集群上;
  • key.serializervalue.serializer:broker端接收的消息必须以字节数组byte[]的形式存在,所以在将消息发往broker之前需要将消息中对应的keyvalue做相应的序列化来转换成字节数组(在消费者客户端再使用对应的反序列化类将字节数组反序列化回来);
    • 这两个参数都无默认值;
  • client.id:用来设定KafkaProducer对应客户端的id,默认值为"",如果客户端不设置,则KafkaProducer会自动生成一个非空字符串;
    • 注意:这个参数不是必备参数;
  • 其他参数见000-参数配置

2. 消息发送模式

生产者实例是KafkaProducer,发送的消息是ProducerRecord

/**
 *  消息 ProducerRecord 的成员属性
 */
public class ProducerRecord {

    private final String topic;
    private final Integer partition;
    private final Headers headers;
    private final K key;
    private final V value;
    private final Long timestamp;
    
    // ... 省略成员方法
  • ProducerRecord接收的泛型分别是消息的keyvalue的类型;
  • topic属性指定消息要发送到哪个主题;
  • value是消息内容;
  • topicvalue属性是必填项;
  • parition指定消息要发送的分区,如果不指定partition字段,则由分区器根据key字段计算partition的值;

消息发送方法send()有两个重载方法:

/**
 * KafkaProducer的send方法签名
 */
public Future send(ProducerRecord record);
public Future send(ProducerRecord record, Callback callback);
  • 方法的返回类型是 Future,代表方法本身就是异步的,返回的Future对象可以使调用方稍后获得发送的结果;
  • 第二个重载的方法接收一个Callback的回调函数,Kafka在返回响应时调用该函数来实现异步的发送确认;

所以,生产者发送消息可以有三种模式:发后即忘(fire-and-forget)、同步(sync)和异步(async);

2.1 发后即忘

这种方式只调用send方法发送消息,调用完后既不接收Future等待发送结果,也不传递callback回调方法;

// 构建所需要发送的消息
ProducerRecord record = new ProducerRecord<>(topic, "Hello,Kafka");

/**
 * 发送方式1:发后即忘
 */
producer.send(record);
  • 这种发送方式性能最高,但是可靠性也最差;

2.2 同步发送

执行完send方法后,调用返回的Futureget方法(这是一个阻塞方法,会一直等待返回结果,也可以使用带超时时间的get方法),返回的是一个RecordMeta对象,对象中包含了消息的一些元数据信息,比如当前消息的主题、分区号、分区中的偏移量、时间戳等;

// 构建所需要发送的消息
ProducerRecord record = new ProducerRecord<>(topic, "Hello,Kafka");
        
/**
 * 发送方式2:同步发送,使用返回的future调用get方法,阻塞等待返回结果
 */
Future future = producer.send(record);
try {
    RecordMetadata metadata = future.get();
    System.out.println("===metadata:"+metadata.topic() + "-" + metadata.partition() + "-" + metadata.offset());
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (ExecutionException e) {
    e.printStackTrace();
}
  • 对于返回的Future对象,也可以用带可超时的get(long tikeout, TimeUnit unit)方法;
  • 这种属于同步发送消息,因为调用get方法时会阻塞等待发送结果;
  • 同步发送可靠性高,但是性能会差很多;需要阻塞等待一条消息发送完后才能发送下一条;

2.3 异步发送

异步发送是在send()方法里指定一个Callback的回调函数,Kafka在返回响应时调用该函数来实现异步的发送确认;

// 构建所需要发送的消息
ProducerRecord record = new ProducerRecord<>(topic, "Hello,Kafka");

/**
 * 发送方式3:异步发送,使用带Callback参数的send方法,发送完成后调用
 */
producer.send(record, new Callback() {
    @Override
    public void onCompletion(RecordMetadata recordMetadata, Exception e) {
        if(e!=null){
            e.printStackTrace();
        }else {
            System.out.println("===发送成功:"+recordMetadata.topic() + "-" + recordMetadata.partition() + "-" + recordMetadata.offset());
        }
    }
});
  • Callback接口只有一个方法onCompletion();
  • onCompletion()方法的两个参数是互斥的:
    • 消息发送成功时,RecordMetadata不为null,Exception为null;
    • 消息发送失败时,RecordMetadata为null,而Exception不为null;
  • 发送到同一个分区的消息的回调函数调用是有序的,即对于同一个分区而言,如果消息record1record2之前发送,那么KafkaProducer就可以保证对应的callback1callback2之前调用;
  • KafkaProducer发送的消息保证的是分区有序,而不是主题有序,发送的消息可以根据key发送到同一分区,也可以指定分区;

3. 消息发送流程

KafkaProducer发送的消息会依次经过:拦截器序列化器分区器,然后到达消息累加器;

  • 其中,序列化器是必需的;拦截器和分区器不是必需的;
  • 如果消息的ProducerRecord中指定了partition字段(即,指定了消息发往的分区),那么就不需要分区器;

3.1 生产者拦截器

Kafka中一共有两种拦截器:生产者拦截器和消费者拦截器。

生产者拦截器既可以用来在消息发送前做一些准备工作,也可以用来在发送回调逻辑前做一些定制化需求;

/**
 * 生产者拦截器接口 ProducerInterceptor
 */

public interface ProducerInterceptor extends Configurable {
    public ProducerRecord onSend(ProducerRecord record);
    public void onAcknowledgement(RecordMetadata metadata, Exception exception);
    public void close();
}
  • KafkaProducer在将消息序列化和计算分区之前,会调用生产者拦截器的onSend()方法来对消息进行相应的定制化操作;
  • KafkaProducer在消息被应答之前或消息发送失败时调用生产者拦截器的onAcknowledgement()方法,优先于用户设定的Callback之前执行;
  • close()方法用于在关闭拦截器时执行一些资源清理工作;
  • ProducerInterceptor接口还有一个父接口org.apache.kafka.common.Configurable,这个接口只有一个方法,主要用来获取配置信息及初始化数据:
/**
 * Configurable接口
 */
public interface Configurable {

    void configure(Map configs);
}

自定义拦截器与拦截器链

  • 自定义拦截器时,需要自定义一个实现ProducerInterceptor接口的类;
  • 在配置参数interceptor.classes里指定使用自定义的拦截器;
  • interceptor.classes参数的默认值是"",代表没有默认拦截器;
  • Kafka中可以指定多个拦截器以形成拦截器链,配置的时候各个拦截器之间使用逗号隔开,拦截器链会按照interceptor.classes配置的顺序一一执行;
  • 如果拦截器链中的某个拦截器执行失败,那么下一个拦截器会接着上一个执行成功的拦截器继续执行;

3.2 序列化器

生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka;

消费者需要用反序列化器(Deserializer)把从Kafka中接收的字节数组转换成相应的对象;

  • 生产者使用的序列化器和消费者使用的反序列化器需要一一对应;

序列化器需要实现接口org.apache.kafka.common.serialization.Serializer接口:

/**
 * 序列化器接口 Serializer
 */
public interface Serializer<T> extends Closeable {
    void configure(Map<String, ?> configs, boolean isKey);
    byte[] serialize(String topic, T data);
    @Override
    void close();
}
  • configure方法用来配置当前类;
  • serialize方法用来执行序列化操作;
  • close方法用来关闭当前的序列化器;
    • 一般情况下,close是一个空方法,如果实现了此方法,则必须确保此方法的幂等性,因为这个方法很可能会被KafkaProducer调用多次;
    • 幂等性:对接口的多次调用所产生的结果和调用一次是一致的;

StringSerialize序列化器的内部实现

/**
 * 内置的String序列化器StringSerializer
 */
public class StringSerializer implements Serializer {
    private String encoding = "UTF8";

    @Override
    public void configure(Map configs, boolean isKey) {
        String propertyName = isKey ? "key.serializer.encoding" : "value.serializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null)
            encodingValue = configs.get("serializer.encoding");
        if (encodingValue instanceof String)
            encoding = (String) encodingValue;
    }

    @Override
    public byte[] serialize(String topic, String data) {
        try {
            if (data == null)
                return null;
            else
                return data.getBytes(encoding);
        } catch (UnsupportedEncodingException e) {
            throw new SerializationException("Error when serializing string to byte[] due to unsupported encoding " + encoding);
        }
    }

    @Override
    public void close() {
        // nothing to do
    }
}

除了用于String类型的序列化器,Kafka还提供了用于ByteArrayByteBufferBytesDoubleIntegerLong这几种类型的序列化器,如果还无法满足应用需求,可以选择JsonProtoBufProtostuff等通用序列化工具实现自定义的序列化器

自定义序列化器

/**
 * 使用Protostuff实现自定义序列化器
 */
public class ProtoStuffSerializer implements Serializer {

    @Override
    public void configure(Map configs, boolean isKey) {

    }

    @Override
    public byte[] serialize(String topic, Object data) {
        RuntimeSchema schema = null;
        LinkedBuffer buffer = null;
        byte[] result;
        try {
            schema = RuntimeSchema.createFrom(data.getClass());
            buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
            result = ProtostuffIOUtil.toByteArray(data, schema, buffer);
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }

        return result;
    }

    @Override
    public void close() {

    }
}
  • 要使用自定义序列化器,则需要在key.serializervalue.serializer中指定使用自定义序列化器,其中value.serializer控制发送的消息内容使用的序列化器;

3.3 分区器

分区器的作用是为消息分配分区

  • 消息ProducerRecordpartition字段代表消息要发送的分区号;
  • 如果消息的partition字段指定了值,那么就不需要使用分区器;
  • partition字段没有指定值时,依赖分区器根据key字段计算partition的值;

分区器需要实现接口 org.apache.kafka.clients.producer.Partitioner

/**
 * 分区器接口 Partitioner
 */
public interface Partitioner extends Configurable, Closeable {

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

    public void close();
}
  • Partition接口还有一个父接口org.apache.kafka.common.Configurable,这个接口只有一个方法configure,主要用来获取配置信息及初始化;
  • partition方法定义分区分配逻辑;

默认分区器

  • Kafka中提供的默认分区器是org.apache.kafka.clients.producer.internals.DefaultPartitioner
  • 默认分区器DefaultPartitioner会根据key计算哈希,最终根据得到的哈希值来计算分区号,拥有相同key的消息会被写入同一个分区。
  • 如果keynull,那么消息会以轮询的方式发往主题内的各个可用分区
  • 在不改变分区数量的情况下,key与分区之间的映射可以保持不变;不过,一旦主题中增加了分区,那么就难以保证key与分区之间的映射关系了;

注意:

  • 如果key不为null,那么计算得到的分区号会是所有分区中的任意一个;
  • 如果key为null,那么计算得到的分区号仅为可用分区中的任意一个;
  • 即,轮询时只会将消息发送给可用分区

自定义分区器

  • 除了使用Kafka提供的默认分区器进行分区分配,还可以使用自定义的分区器,只需要同DefaultPartitioner一样实现Partition接口即可;
  • 实现自定义的分区器后,需要通过partitioner.class来显式指定使用这个分区器(如果不指定就会使用默认分区器);

4. 消息确认与异常重试

acks参数

消息发送的分区一般有多个副本(一个leader副本,0到多个follower副本),那么有多少个副本接收到消息后,服务器可以向生产者回复消息已经成功写入呢?答案是:由参数acks控制,该参数的设置是消息的可靠性吞吐量之间的权衡。

acks参数用来指定分区中必须有多少个副本接收到这条消息后,生产者才会认为这条消息成功写入。acks参数又三种类型:

  • acks=1 默认值,生产者发送消息后,只要分区的leader副本成功写入消息,那么它就会接收到来着服务端的成功响应。
  • acks=0 生产者发送消息之后不需要等待任何服务器的响应。在其他配置环境相同的情况下,acks设置为0可以达到最大的吞吐量;
  • acks=-1或acks=all 生产者在消息发送后,需要等待ISR中的所有副本都成功写入消息之后,才能够收到服务端的成功响应。在其他配置环境相同的情况下,acks=-1(或all)可以达到最强的可靠性;
    • 但是并不意外着消息一定可靠,因为ISR中可能只有leader副本,这时就和acks=1的配置是一样的。

注意:

  • acks参数配置的值是一个字符串类型,而不是整数类型;
properties.put("acks","0");

可重试异常和不可重试异常

KafkaProducer中一般会发生两种类型的异常:可重试异常和不可重试异常;

可重试异常:比如由于网络故障导致当前消息未发送成功,可以在一定时间后重试发送消息来解决;

不可重试异常:比如发送的消息太大,不管重试多少次都不能解决的异常,称为不可重试异常;

retries参数

  • 对于可重试异常,如果配置了retries参数,那么只要在规定的重试次数内自行恢复了,就不会抛异常(KafkaProducer会自动重试,超过重试次数还没有发送成功时,才会抛异常);
  • retries参数的默认值为0;即代表不重试,配置的重试次数不把第一次发送消息算在内;

消息重试与消息的顺序性

  • Kafka可以保证同一个分区中的消息是有序的,即如果生产者按照一定的顺序发送消息到一个分区,那么这些消息也会顺序的写入分区,进而消费者也可以按照同样的顺序消费它们;
  • 但是,如果在消息重试时,就会出现错序:如果第一批消息写入失败,而第二批消息写入成功,那么生产者会重试第一批消息,此时如果第一批消息重试成功,那么这两个批次的消息就会出现了错序。
  • 在需要保证消息顺序的场合,建议把参数max.in.flight.requests.per.connection配置为1(限制每个连接,也就是客户端与Node之间的连接,最多缓存的请求数),该参数默认值是5;

5. 生产者客户端整体架构

5.1 两个线程

整个生产者客户端由两个线程协调运行:主线程和Sender线程(发送线程);

主线程:主线程中KafkaProducer创建消息,然后通过可能的拦截器序列化器分区器作用之后,将消息缓存到消息累加器(RecordAccumulator)(也称为消息收集器);

Sender线程:负责从消息累加器中获取消息并将其发送到kafka中;

5.2 消息累加器 RecordAccumulator

RecordAccumulator主要用来缓存消息,以便Sender线程可以批量发送,进而减少网络传输的资源消耗以提升性能;

RecordAccumulator内部为每个分区都维护了一个双端队列,队列中的内容是ProducerBatch,即Deque;

  • 生产者创建的消息是ProducerRecordProducerBatch是指一个消息批次,可以包含一至多个ProducerRecord;
  • 将较小的ProducerRecord拼凑成较大的ProducerBatch,可以减少网络请求的次数,以提升整体的吞吐量;
  • 当一条消息(ProducerRecord)流入消息累加器(RecordAccumulator)时,会先寻找消息分区所对应的双端队列,如果没有则新建,再从这个双端队列的尾部获取一个ProducerBatch,如果没有也新建,查看ProcuderBatch中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可以则需要创建一个新的ProducerBatch;

消息写入时,追加到双端队列的队尾,Sender线程读取消息时,从双端队列的头部读取;

参数:

  • RecordAccumulator缓存的大小可以通过生产者客户端的参数buffer.memory配置,单位是B默认值是3355443232MB
  • batch.size参数用于指定ProducerBath可以复用内存区域的大小;单位是B默认值是1638416KB

5.3 消息封装

  1. ProducerRecord => <分区,Deque>
  • KafkaProducer创建的消息ProducerRecord到达消息累加器RecordAccumulator时,RecordAccumulator将消息封装成ProducerBatch,将ProducerBatch放入双端队列中,一个分区对应一个双端队列,消息结构是<分区,Deque>
  1. <分区,Deque> => >
  • 主题和分区是逻辑上的概念,生产者客户端是与具体的borker节点建立的连接,也就是向具体的borker节点发送消息,而不关心消息属于哪个分区。所以Sender线程从消息累加器读取到消息后,会封装成发送到每个borker节点的消息,结构是>;
  1. > =>
  • 客户端与服务器之间发送消息是需要协议支持的,所以Sender线程将消息封装成节点对应的list结构后,还会进一步将消息封装成的形式,然后将Request发送给kafka;

5.4 已发送未响应缓存 InFlightRequests

请求在从Sender线程发送给kafka之前还会保存到InFlightRequests中;它的主要作用是缓存已经发出去但还没有收到响应的请求。保存对象的具体形式为Map>;

max.in.flight.requests.per.connection设置每个连接(即到每个NodeId)最多缓存的请求数,默认值是5;超过该数值之后就不能再向这个连接发送更多的请求了,除非缓存的请求收到了响应。

通过比较Node节点未响应的请求消息Deque的size与配置的最大参数的大小可以来判断对应的Node中是否已经积压了很多未响应的请求,如果真是如此,那么说明这个Node节点负载较大或网络连接有问题,再继续向其发送请求会增加请求超时的可能;

参考文献

  • 《深入理解Kafka核心设计与实践原理》 朱忠华 著,电子工业出版社.

你可能感兴趣的:(Kafka,kafka)