kafka 生产者如何发送消息到Cluster

44 阅读15分钟

kafka实战

Kafka生产者

生产者就是负责向Kafka发送消息的应用程序。

1. 客户端开发

客户端开发 一个正常的生产逻辑需要具备以下几个步骤:

  1. 配置生产者客户端参数及创建相应的生产者实例。
  2. 构建待发送的消息。
  3. 发送消息。
  4. 关闭生产者实例。
1.1 参数设置与消息的发送

生产者客户端示例代码:

package com.linfang.learning.kafka.chapter2;

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;
import java.util.concurrent.TimeUnit;

/**
 * KafkaProducerAnalysis
 */
public class KafkaProducerAnalysis3 {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";

    public static void main(String[] args) throws InterruptedException {
        Properties props = initConfig();
        // 创建生产者实例
        // <String, String> 泛型对应的是消息中key和value的类型
        KafkaProducer<String, String> producer = new KafkaProducer<>(props);
        // 构建消息
        // 这里有多种构造方法,但是,使用这一种是最简单的。
        ProducerRecord<String, String> record = new ProducerRecord<>(topic, "hello, Kafka!");
        try {
            // 发送消息主要有三种模式:发后即忘(fire-and-forget)、同步(sync)及异步(async)。
            // 发后即忘(fire-and-forget)
            producer.send(record);

            // 同步(sync),本身send方法是异步的,这里使用get方法阻塞等待kafka响应,直到消息发送成功或者发生异常。
            //producer.send(record).get();

            // 异常情况:可重试的异常和不可重试的异常
            // 1. 可重试的异常 NetworkException、LeaderNotAvailableException、UnknownTopicOrPartitionException、NotEnoughReplicasException、NotCoordinatorException 等
            // 2. 不可重试的异常 比如 RecordTooLargeException异常,暗示了所发送的消息太大,KafkaProducer对此不会进行任何重试,直接抛出异常。

            //  同步发送的方式可靠性高,要么消息被发送成功,要么发生异常。如果发生异常,则可以捕获并进行相应的处理

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

            //            producer.send(record, new Callback() {
            //                @Override
            //                public void onCompletion(RecordMetadata metadata, Exception exception) {
            //                    if (exception == null) {
            //                        System.out.println(metadata.partition() + ":" + metadata.offset());
            //                    }
            //                }
            //            });

            //对于同一个分区而言,如果消息record于record2之前先发送,那么KafkaProducer就可以保证对应的callback在callback2之前调用,也就是说,回调函数的调用也可以保证分区有序。
            // producer.send(record1, callback1);
            // producer.send(record2, callback2);
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 一个KafkaProducer不会只负责发送单条消息,更多的是发送多条消息,在发送完这些消息之后,需要调用KafkaProducer的close()方法来回收资源。
        // 在实际应用中,一般使用的都是无参的close()方法。
        producer.close();
    }

    /**
     * kafka Properties 配置信息。我们可以直接使用客户端中的 ProducerConfig
     *
     * @return
     * @see org.apache.kafka.clients.producer.ProducerConfig
     * 类替代默认值,预防写错。
     */
    public static Properties initConfig() {
        Properties props = new Properties();
        // 指定生产者客户端连接Kafka集群所需的broker地址清单,具体的内容格式为host1:port1,host2:port2,
        // 可以设置一个或多个地址,中间以逗号隔开,此参数的默认值为""。注意这里并非需要所有的broker地址,因为生产者会从给定的broker里查找到其他broker的信息。
        // 不过建议至少要设置两个以上的broker 地址信息,当其中任意一个宕机时,生产者仍然可以连接到 Kafka集群上
        props.put("bootstrap.servers", brokerList);
        //props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);

        // broker 端接收的消息必须以字节数组(byte[])的形式存在
        // 在发往broker之前需要将消息中对应的key和value做相应的序列化操作来转换成字节数组。
        // key.serializer和value.serializer这两个参数分别用来指定key和value序列化操作的序列化器,这两个参数无默认值。
        props.put("key.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
        //props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
        //                StringSerializer.class.getName());
        props.put("value.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
        //props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
        //                StringSerializer.class.getName());

        // 用来设定KafkaProducer对应的客户端id,默认值为""。
        // 如果客户端不设置,则KafkaProducer会自动生成一个非空字符串,内容形式如“producer-1”“producer-2”,即字符串“producer-”与数字的拼接
        props.put("client.id", "producer.client.id.demo");

        return props;
    }
}
1.2 序列化

生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka。消费者也需要用反序列化器(Deserializer)从 Kafka 中把收到的字节数组转换成相应的对象。上面例子中为了方便,消息的key和value都使用了字符串,对应程序中的序列化器也使用了客户端自带的org.apache.kafka.common.serialization.StringSerializer,除了用于String类型的序列化器,还有ByteArray、ByteBuffer、Bytes、Double、Integer、Long这几种类型,它们都实现了org.apache.kafka.common.serialization.Serializer接口。

该接口有三个方法,分别为:

  • 配置方法
configure(Map<String, ?> configs, boolean isKey)

这个方法是在创建KafkaProducer实例的时候调用的,主要用来确定编码类型,不过一般客户端对于 key.serializer.encoding、value.serializer.encoding和serializer.encoding这几个参数都不会配置,在KafkaProducer的参数集合(ProducerConfig)里也没有这几个参数(它们可以看作用户自定义的参数),所以一般情况下encoding的值就为默认的“UTF-8”。

  • 序列化方法
byte[] serialize(String topic, T data)

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

  • 关闭当前序列化器的方法
default void close()

如果实现了此方法,则必须确保此方法的幂等性,因为这个方法很可能会被KafkaProducer调用多次。

1.3 分区器

消息在通过send()方法发往broker的过程中,有可能需要经过拦截器(Interceptor)序列化器(Serializer)分区器(Partitioner)的一系列作用之后才能被真正地发往 broker。消息经过序列化之后就需要确定它发往的分区,如果消息ProducerRecord中指定了partition字段,那么就不需要分区器的作用,因为partition代表的就是所要发往的分区号。

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

Kafka中提供的默认分区器是org.apache.kafka.clients.producer.internals.DefaultPartitioner,它实现了org.apache.kafka.clients.producer.Partitioner接口。

/**
 * Compute the partition for the given record.
 *
 * @param topic 主题名字
 * @param key 分区上的key (如果没有则为null)
 * @param keyBytes 分区上序列化的key( or null if no key)
 * @param value 分区上的值
 * @param valueBytes 在分区上序列化的值
 * @param cluster 当前集群的元信息
 */
int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

/**
 * 在关闭分区器的时候用来回收一些资源。
 */
void close();

在默认分区器 DefaultPartitioner 的实现中,close()是空方法,而在 partition()方法中定义了主要的分区分配逻辑。如果 key 不为 null,那么默认的分区器会对 key 进行哈希(采用MurmurHash2算法,具备高运算性能及低碰撞率),最终根据得到的哈希值来计算分区号,拥有相同key的消息会被写入同一个分区。如果key为null,那么消息将会以轮询的方式发往主题内的各个可用分区。

注意:如果 key 不为 null,那么计算得到的分区号会是所有分区中的任意一个;如果 key为null,那么计算得到的分区号仅为可用分区中的任意一个,注意两者之间的差别。

在不改变主题分区数量的情况下,key与分区之间的映射可以保持不变。不过,一旦主题中增加了分区,那么就难以保证key与分区之间的映射关系了。

自定义分区:

public class DemoPartitioner implements Partitioner {
    private final AtomicInteger counter = new AtomicInteger(0);

    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (null == keyBytes) {
            return counter.getAndIncrement() % numPartitions;
        } else
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }

    @Override
    public void close() {
    }

    @Override
    public void configure(Map<String, ?> configs) {
    }
}

可以根据自身业务的需求来灵活实现分配分区的计算方式,比如一般大型电商都有多个仓库,可以将仓库的名称或ID作为key来灵活地记录商品信息。

1.4 生产者拦截器

生产者拦截器既可以用来在消息发送前做一些准备工作。自定义实现org.apache.kafka.clients.producer.ProducerInterceptor接口即可实现生产者拦截器。

调用时机:

  1. KafkaProducer在将消息序列化和计算分区之前会调用生产者拦截器的onSend()方法来对消息进行相应的定制化操作。
  2. KafkaProducer 会在消息被应答(Acknowledgement)之前消息发送失败时调用生产者拦截器的 onAcknowledgement()方法,优先于用户设定的 Callback之前执行。这个方法运行在Producer 的 I/O 线程中,所以这个方法中实现的代码逻辑越简单越好,否则会影响消息的发送速度。
  3. close()方法主要用于在关闭拦截器时执行一些资源的清理工作。

生产者拦截器示例:

public class ProducerInterceptorPrefix implements
        ProducerInterceptor<String, String> {
    private volatile long sendSuccess = 0;
    private volatile long sendFailure = 0;

    @Override
    public ProducerRecord<String, String> onSend(
            ProducerRecord<String, String> record) {
        String modifiedValue = "prefix1-" + record.value();
        return new ProducerRecord<>(record.topic(),
                record.partition(), record.timestamp(),
                record.key(), modifiedValue, record.headers());
//        if (record.value().length() < 5) {
//            throw new RuntimeException();
//        }
//        return record;
    }

    @Override
    public void onAcknowledgement(
            RecordMetadata recordMetadata,
            Exception e) {
        if (e == null) {
            sendSuccess++;
        } else {
            sendFailure++;
        }
    }

    @Override
    public void close() {
        double successRatio = (double) sendSuccess / (sendFailure + sendSuccess);
        System.out.println("[INFO] 发送成功率="
                + String.format("%f", successRatio * 100) + "%");
    }

    @Override
    public void configure(Map<String, ?> map) {
    }
}

实现自定义的 ProducerInterceptorPrefix 之后,需要在 KafkaProducer 的配置参数interceptor.classes中指定这个拦截器,此参数的默认值为“”。props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,ProducerInterceptorPrefix.class.getName()); KafkaProducer中不仅可以指定一个拦截器,还可以指定多个拦截器以形成拦截链。拦截链会按照 interceptor.classes 参数配置的拦截器的顺序来一一执行(配置的时候,各个拦截器之间使用逗号隔开)。**.getName()+","+**.getName()。 在拦截链中,如果某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。

2. 原理分析

消息在真正发往Kafka之前,有可能需要经历拦截器(Interceptor)、序列化器(Serializer)和分区器(Partitioner)等一系列的作用。

生产者客户端的整体架构.png

整个生产者客户端由两个线程协调运行,这两个线程分别为主线程Sender线程(发送线程)。在主线程中由KafkaProducer创建消息,然后通过可能的拦截器、序列化器和分区器的作用之后缓存到消息累加器(RecordAccumulator,也称为消息收集器)中。Sender 线程负责从RecordAccumulator中获取消息并将其发送到Kafka中。

public class Sender implements Runnable {
@Override
public void run() {
    log.debug("Starting Kafka producer I/O thread.");

    if (transactionManager != null)
        transactionManager.setPoisonStateOnInvalidTransition(true);

    // main loop, runs until close is called
    while (running) {
        try {
            runOnce();
        } catch (Exception e) {
            log.error("Uncaught error in kafka producer I/O thread: ", e);
        }
    }
}

RecordAccumulator 主要用来缓存消息以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能。RecordAccumulator 缓存的大小可以通过生产者客户端参数buffer.memory 配置,默认值为 33554432B,即 32MB。(这里的缓存的大小,是在KafkaProducer创建的时候,就指定了的,如下图:)

对比.png

KafkaProducer构造器.png

配置类.png

如果生产者发送消息的速度超过发送到服务器的速度,则会导致生产者空间不足,这个时候KafkaProducer的send()方法调用要么被阻塞,要么抛出异常,这个取决于参数max.block.ms的配置,此参数的默认值为60000,即60秒。

//max.block.ms的配置和上面的一样,在配置类中可以查看的到
public static final String MAX_BLOCK_MS_CONFIG = "max.block.ms";

主线程中发送过来的消息 使用doSend方法向RecordAccumulator中添加信息:

public RecordAppendResult append(String topic,
                                 int partition,
                                 long timestamp,
                                 byte[] key,
                                 byte[] value,
                                 Header[] headers,
                                 AppendCallbacks callbacks,
                                 long maxTimeToBlock,
                                 boolean abortOnNewBatch,
                                 long nowMs,
                                 Cluster cluster) throws InterruptedException {
    //ConcurrentMap的方法 topicInfoMap.computeIfAbsent 的意思是,如果指定的键尚未与值关联(或映射到null),
    // 则尝试使用给定的映射函数计算其值,并将其输入到该映射中,除非为null。  
    TopicInfo topicInfo = topicInfoMap.computeIfAbsent(topic, k -> new TopicInfo(logContext, k, batchSize));
    // 获取有效分区,省略...
    // 现在我们知道了有效分区,让调用者知道。
    setPartition(callbacks, effectivePartition);

    // 检查我们是否有正在进行的批次
    Deque<ProducerBatch> dq = topicInfo.batches.computeIfAbsent(effectivePartition, k -> new ArrayDeque<>());
    //...
    RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
    //...
  }

主线程中发送过来的消息都会被追加到RecordAccumulator的某个双端队列(Deque)中,在 RecordAccumulator 的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即 Deque<ProducerBatch>。

初始化TopicInfo:

/**
 * 每个主题信息。
 * RecordAccumulator 的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即 Deque<ProducerBatch>。
 */
private static class TopicInfo {
    //Integer 指定的是分区号,为每个分区都维护了一个双端队列Deque,队列的内容是ProducerBatch。
    public final ConcurrentMap<Integer /*partition*/, Deque<ProducerBatch>> batches = new CopyOnWriteMap<>();
    public final BuiltInPartitioner builtInPartitioner;

    public TopicInfo(LogContext logContext, String topic, int stickyBatchSize) {
        builtInPartitioner = new BuiltInPartitioner(logContext, topic, stickyBatchSize);
    }
}

消息写入缓存时,追加到双端队列的尾部;Sender读取消息时,从双端队列的头部读取。注意ProducerBatch不是ProducerRecord,ProducerBatch中可以包含一至多个 ProducerRecord。通俗地说,ProducerRecord 是生产者中创建的消息,而ProducerBatch是指一个消息批次,ProducerRecord会被包含在ProducerBatch中,这样可以使字节的使用更加紧凑。与此同时,将较小的ProducerRecord拼凑成一个较大的ProducerBatch,也可以减少网络请求的次数以提升整体的吞吐量。如果生产者客户端需要向很多分区发送消息,则可以将buffer.memory参数适当调大以增加整体的吞吐量。

消息写入缓存时,追加到双端队列的尾部:

private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers,
                                     Callback callback, Deque<ProducerBatch> deque, long nowMs) {
    if (closed)
        throw new KafkaException("Producer closed while send in progress");
    ProducerBatch last = deque.peekLast();
    if (last != null) {
        int initialBytes = last.estimatedSizeInBytes();
        FutureRecordMetadata future = last.tryAppend(timestamp, key, value, headers, callback, nowMs);
        if (future == null) {
            last.closeForRecordAppends();
        } else {
            int appendedBytes = last.estimatedSizeInBytes() - initialBytes;
            return new RecordAppendResult(future, deque.size() > 1 || last.isFull(), false, false, appendedBytes);
        }
    }
    return null;
}

消息在网络上都是以字节(Byte)的形式传输的,在发送之前需要创建一块内存区域来保存对应的消息。在Kafka生产者客户端中,通过java.io.ByteBuffer实现消息内存的创建和释放。不过频繁的创建和释放是比较耗费资源的,在RecordAccumulator的内部还有一个BufferPool,它主要用来实现ByteBuffer的复用,以实现缓存的高效利用。不过BufferPool只针对特定大小的ByteBuffer进行管理,而其他大小的ByteBuffer不会缓存进BufferPool中,这个特定的大小由batch.size参数来指定,默认值为16384B,即16KB。我们可以适当地调大batch.size参数以便多缓存一些消息。

问题:为什么需要这个BufferPool?

熟悉NIO的同学,一定知道ByteBuffer这个组件,是NIO核心3大组件之一。它是一块内存,这里通过一个内存池来维护多块ByteBuffer。这样的好处就是避免创建的内存空间,频繁的被GC,而且可以达到很好的重用性。这一点是不错的思考。而且由于 Kafka底层使用NIO进行通信,使用ByteBuffer存放的数据,可以更好、更简单的被发送出去。

RecordAccumulator是由KafkaProducer创建的,在创建的时候同时创建了BufferPool,指定了最大的生产者客户端参数buffer.memory为32MB。这里,该客户端下的所有的主题和主题下的所有分区都共享这32MB。同时也指定了 batchSize 也就是说消息可以打包的batch默认一批是16KB。这里要注意如果消息比较大,这个两个参数需要适当调整。

image.png

ProducerBatch的大小和batch.size参数也有着密切的关系。当一条消息(ProducerRecord)流入RecordAccumulator时,会先寻找与消息分区所对应的双端队列(如果没有则新建),再从这个双端队列的尾部获取一个 ProducerBatch(如果没有则新建),查看 ProducerBatch 中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可以则需要创建一个新的ProducerBatch。在新建ProducerBatch时评估这条消息的大小是否超过batch.size参数的大小,如果不超过,那么就以 batch.size 参数的大小来创建ProducerBatch,这样在使用完这段内存区域之后,可以通过BufferPool 的管理来进行复用;如果超过,那么就以评估的大小来创建ProducerBatch,这段内存区域不会被复用。

 /**
  *  尝试附加到ProducerBatch。
  *  如果它已满,则返回null,并创建一个新的批。我们还关闭了记录附加的批处理,以释放压缩缓冲区等资源。
  *  在以下情况之一(以先到者为准)下,批处理将完全关闭(即写入记录批头并构建内存记录):
  *  在发送之前,如果它已过期,或者当生产者关闭时。
 */
private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers,
                                     Callback callback, Deque<ProducerBatch> deque, long nowMs) {
    if (closed)
        throw new KafkaException("Producer closed while send in progress");
    // 从这个双端队列的尾部获取一个 ProducerBatch(如果没有则新建),查看 ProducerBatch 中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可以则需要创建一个新的ProducerBatch
    ProducerBatch last = deque.peekLast();
    if (last != null) {
        // 获取写入基础缓冲区的字节数的估计值。
        int initialBytes = last.estimatedSizeInBytes();
        // 查看 ProducerBatch 中是否还可以写入这个ProducerRecord
        FutureRecordMetadata future = last.tryAppend(timestamp, key, value, headers, callback, nowMs);
        if (future == null) {
            last.closeForRecordAppends();
        } else {
            int appendedBytes = last.estimatedSizeInBytes() - initialBytes;
            return new RecordAppendResult(future, deque.size() > 1 || last.isFull(), false, false, appendedBytes);
        }
    }
    return null;
}

Sender 从 RecordAccumulator 中获取缓存的消息之后,会进一步将原本<分区,Deque<ProducerBatch>>的保存形式转变成<Node,List<ProducerBatch>的形式,其中Node表示Kafka集群的broker节点。对于网络连接来说,生产者客户端是与具体的broker节点建立的连接,也就是向具体的 broker 节点发送消息,而并不关心消息属于哪一个分区;而对于 KafkaProducer的应用逻辑而言,我们只关注向哪个分区中发送哪些消息,所以在这里需要做一个应用逻辑层面到网络I/O层面的转换。

public Map<Integer, List<ProducerBatch>> drain(Metadata metadata, Set<Node> nodes, int maxSize, long now) {
    if (nodes.isEmpty())
        return Collections.emptyMap();

    Map<Integer, List<ProducerBatch>> batches = new HashMap<>();
    for (Node node : nodes) {
        List<ProducerBatch> ready = drainBatchesForOneNode(metadata, node, maxSize, now);
        batches.put(node.id(), ready);
    }
    return batches;
}

在转换成<Node,List<ProducerBatch>>的形式之后,Sender 还会进一步封装成<Node,Request>的形式,这样就可以将Request请求发往各个Node了,这里的Request是指Kafka的各种协议请求,对于消息发送而言就是指具体的ProduceRequest。