美文网首页
[Kafka] Producer发送消息机制解析

[Kafka] Producer发送消息机制解析

作者: LZhan | 来源:发表于2019-12-12 15:10 被阅读0次
    1. 前言
    • Sync Producer:低延迟,低吞吐率,无数据丢失
    • Async Producer:高延迟,高吞吐率,可能会有数据丢失
    2.Producer发送消息

    通过KafkaProducer的send方法发送消息,send方法有两种重载:

    Future<RecordMetadata> send(ProducerRecord<K, V> record)
    Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)
    

    消息是被封装成ProducerRecord,那么ProducerRecord是怎样的呢?


    核心属性:

    • String topic 消息所属的主题
    • Integer partition 消息所处的主题的分区数,可以人为指定,如果指定了 key 的话,会使用 key 的 hashCode 与队列总数进行取模来选择分区,如果前面两者都未指定,则会轮询主题下的所有分区
    • Headers headers 该消息的额外属性对,与消息体分开存储
    • K key 消息键,如果指定该值,则会使用该值的 hashcode 与 队列数进行取模来选择分区
    • V value 消息体
    • Long timestamp 消息时间戳,根据 topic 的配置信息 message.timestamp.type 的值来赋予不同的值
      CreateTime:发送客户端发送消息时的时间戳
      LogAppendTime:消息在broker追加时的时间戳
    3.源码分析消息追加流程

    KafkaProducer的send方法,并不会直接向broker发送消息,kafka将消息发送异步化,即分解成两个步骤,send 方法的职责是将消息追加到内存中(分区的缓存队列中),然后会由专门的 Send 线程异步将缓存中的消息批量发送到 Kafka Broker 中。

        public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
            // intercept the record, which can be potentially modified; this method does not throw exceptions
            ProducerRecord<K, V> interceptedRecord = this.interceptors == null ? record : this.interceptors.onSend(record);
            return doSend(interceptedRecord, callback);
        }
    
    3.1 首先执行消息发送的拦截器

    拦截器是通过属性interceptor.classes指定,

    转成List<String>类型,每一个元素就是拦截器的全类路径限定名,默认是空的(即没有默认拦截器存在)


    3.2 执行doSend方法

    step1:

    // first make sure the metadata for the topic is available
    ClusterAndWaitTime clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), maxBlockTimeMs);
    long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);
    

    获取元数据信息,包括topic可用的分区列表,如果本地没有该topic的分区信息,则需要向远端 broker 获取,该方法会返回拉取元数据所耗费的时间。在消息发送时的最大等待时间时会扣除该部分损耗的时间。

    step2:

                byte[] serializedKey;
                try {
                    serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
                } catch (ClassCastException cce) {
                    throw new SerializationException("Can't convert key of class " + record.key().getClass().getName() +
                            " to class " + producerConfig.getClass(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG).getName() +
                            " specified in key.serializer");
                }
                byte[] serializedValue;
                try {
                    serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
                } catch (ClassCastException cce) {
                    throw new SerializationException("Can't convert value of class " + record.value().getClass().getName() +
                            " to class " + producerConfig.getClass(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG).getName() +
                            " specified in value.serializer");
                }
    

    对key和value进行序列化,虽然序列化方法传入了topic、headers两个属性,但是参与序列化的只是key和value。

    step3:

    int partition = partition(record, serializedKey, serializedValue, cluster);
    tp = new TopicPartition(record.topic(), partition);
    

    根据分区负载算法计算本次消息发送该发往的分区。其默认实现类为 DefaultPartitioner,路由算法如下:

    • 如果消息指定了分区,则将消息发送到对应分区。
    • 如果指定了 key ,则使用 key 的 hashcode 与分区数取模。
    • 如果未指定 key,则轮询所有的分区。

    step4:

                setReadOnly(record.headers());
                Header[] headers = record.headers().toArray();
    
                int serializedSize = AbstractRecords.estimateSizeInBytesUpperBound(apiVersions.maxUsableProduceMagic(),
                        compressionType, serializedKey, serializedValue, headers);
                ensureValidRecordSize(serializedSize);
                long timestamp = record.timestamp() == null ? time.milliseconds() : record.timestamp();
                log.trace("Sending record {} with callback {} to topic {} partition {}", record, callback, record.topic(), partition);
                // producer callback will make sure to call both 'callback' and interceptor callback
                Callback interceptCallback = this.interceptors == null ? callback : new InterceptorCallback<>(callback, this.interceptors, tp);
    
                if (transactionManager != null && transactionManager.isTransactional())
                    transactionManager.maybeAddPartitionToTransaction(tp);
    

    将消息头信息设置为只读,根据使用的版本号,按照消息协议来计算消息的长度,并是否超过指定长度,如果超过则抛出异常,先初始化消息时间戳,并对传入的 Callable(回调函数) 加入到拦截器链中。如果事务处理器不为空,执行事务管理相关的。

    step5:重要

                RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
                        serializedValue, headers, interceptCallback, remainingWaitMs);
                if (result.batchIsFull || result.newBatchCreated) {
                    log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
                    this.sender.wakeup();
                }
                return result.future;
                // handling exceptions and record the errors;
                // for API exceptions return them in the future,
                // for other exceptions throw directly
    

    将消息追加到缓存区,如果当前缓存区写满或者创建了一个新的缓存区,则唤醒Sender(即消息发送线程),将缓存区中的消息发送到broker服务器,最终返回future。


    3.3 RecordAccumulator的append方法
    public RecordAppendResult append(TopicPartition tp,
                                         long timestamp,
                                         byte[] key,
                                         byte[] value,
                                         Header[] headers,
                                         Callback callback,
                                         long maxTimeToBlock) throws InterruptedException {
    
    • TopicPartition tp topic 与分区信息,即发送到哪个 topic 的那个分区。
    • long timestamp 客户端发送时的时间戳。
    • byte[] key 消息的 key。
    • byte[] value 消息体。
    • Header[] headers 消息头,可以理解为额外消息属性。
    • Callback callback 回调方法。
    • long maxTimeToBlock 消息追加超时时间。

    step1:

                // check if we have an in-progress batch
                Deque<ProducerBatch> dq = getOrCreateDeque(tp);
                synchronized (dq) {
                    if (closed)
                        throw new IllegalStateException("Cannot send after the producer is closed.");
                    RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);
                    if (appendResult != null)
                        return appendResult;
                }
    
    

    使用getOrCreateDeque尝试根据topic和分区获取一个双端队列,如果不存在,则创建一个,然后调用tryAppend方法将消息追加到缓存中(即消息队列中)。
    Kafka会为每一个topic的每一个分区创建一个消息缓存区消息先追加到缓存中,然后消息发送 API 立即返回,然后由单独的线程 Sender 将缓存区中的消息定时发送到 broker 。这里的缓存区的实现使用的是 ArrayQeque。然后调用 tryAppend 方法尝试将消息追加到其缓存区,如果追加成功,则返回结果。

    Kafka双端队列ArrayDeque:

    step2:

                byte maxUsableMagic = apiVersions.maxUsableProduceMagic();
                int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));
                log.trace("Allocating a new {} byte message buffer for topic {} partition {}", size, tp.topic(), tp.partition());
                buffer = free.allocate(size, maxTimeToBlock);
    

    如果第一步未追加成功,说明当前没有可用的 ProducerBatch,则需要创建一个 ProducerBatch,故先从 BufferPool 中申请 batch.size 的内存空间,为创建 ProducerBatch 做准备,如果由于 BufferPool 中未有剩余内存,则最多等待 maxTimeToBlock ,如果在指定时间内未申请到内存,则抛出异常。

    step3:

    synchronized (dq) {
        // Need to check if producer is closed again after grabbing the dequeue lock.
        if (closed)
            throw new KafkaException("Producer closed while send in progress");
        // 省略部分代码
        MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);
        ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, time.milliseconds());
        FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, headers, callback, time.milliseconds()));
        dq.addLast(batch);
        incomplete.add(batch);
        // Don't deallocate this buffer in the finally block as it's being used in the record batch
        buffer = null;
        return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true);
    }
    

    创建一个新的批次ProducerBatch,并且将消息写入该批次中,并返回追加结果,

    • 创建 ProducerBatch ,其内部持有一个 MemoryRecordsBuilder对象,该对象负责将消息写入到内存中,即写入到 ProducerBatch 内部持有的内存,大小等于 batch.size。
    • 将消息追加到 ProducerBatch 中。
    • 将新创建的 ProducerBatch 添加到双端队列的末尾。
    • 将该批次加入到 incomplete 容器中,该容器存放未完成发送到 broker 服务器中的消息批次,当 Sender 线程将消息发送到 broker 服务端后,会将其移除并释放所占内存。

    总结:整个append的过程,基本上就是从双端队列获取一个未填充完毕的ProducerBatch(消息批次),然后尝试将其写入到该批次中(缓存、内存中),如果追加失败,则创建一个新的ProducerBatch然后继续追加。

    3.4 ProducerBatch tryAppend方法

    上面RecordAccumulator的append方法,会去调用器tryAppend方法,然后会再去调用ProducerBatch的tryAppend方法。

        /**
         * Append the record to the current record set and return the relative offset within that record set
         *
         * @return The RecordSend corresponding to this record or null if there isn't sufficient room.
         */
        public FutureRecordMetadata tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, Callback callback, long now) {
            if (!recordsBuilder.hasRoomFor(timestamp, key, value, headers)) {
                return null;
            } else {
                Long checksum = this.recordsBuilder.append(timestamp, key, value, headers);
                this.maxRecordSize = Math.max(this.maxRecordSize, AbstractRecords.estimateSizeInBytesUpperBound(magic(),
                        recordsBuilder.compressionType(), key, value, headers));
                this.lastAppendTime = now;
                FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount,
                                                                       timestamp, checksum,
                                                                       key == null ? -1 : key.length,
                                                                       value == null ? -1 : value.length);
                // we have to keep every future returned to the users in case the batch needs to be
                // split to several new batches and resent.
                thunks.add(new Thunk(callback, future));
                this.recordCount++;
                return future;
            }
        }
    

    step1:
    首先去判断ProducerBatch是否还能够容纳消息,如果剩余内存不足,将直接返回 null。如果返回 null ,会尝试再创建一个新的ProducerBatch。
    step2:
    通过 MemoryRecordsBuilder 将消息写入按照 Kafka 消息格式写入到内存中,即写入到 在创建 ProducerBatch 时申请的 ByteBuffer 中。
    step3:
    更新 ProducerBatch 的 maxRecordSize、lastAppendTime 属性,分别表示该批次中最大的消息长度与最后一次追加消息的时间。
    step4:
    构建 FutureRecordMetadata 对象,这里是典型的 Future模式,里面主要包含了该条消息对应的批次的 produceFuture、消息在该批消息的下标,key 的长度、消息体的长度以及当前的系统时间。
    step5:
    将 callback 、本条消息的凭证(Future) 加入到该批次的 thunks 中,该集合存储了 一个批次中所有消息的发送回执。

    4 总结

    Kafka的send方法,并不会直接向broker发送消息,而是首先追加到生产者的内存缓存中,其内存存储结构如下:ConcurrentMap<TopicPartition,Deque<ProducerBatch>> batches,Kafka的生产者会为每一个topic的每一个分区单独维护一个队列,即ArrayDeque,内部存放的元素就是ProducerBatch,即代表一个批次,即Kafka消息发送时按批发送的。

    KafkaProducer的send方法,最终返回的就是Future的子类,Future模式 FutureRecordMetadata。所以kafka的消息发送如何实现异步,同步发送也就是这个返回值决定的。

    • 若需要同步发送,只要拿到send方法的返回结果后,调用get方法,此时如果消息未发送到Broker上,该方法就会被阻塞,等到 broker 返回消息发送结果后该方法会被唤醒并得到消息发送结果。
    • 若需要异步发送,则建议使用send(ProducerRecord< K, V > record, Callback callback),但是不能调用get方法,Callback 会在收到 broker 的响应结果后被调用,并且支持拦截器。

    相关文章

      网友评论

          本文标题:[Kafka] Producer发送消息机制解析

          本文链接:https://www.haomeiwen.com/subject/dorogctx.html