【Kafka系列】Kafka中Producer执行流程详解

190 阅读19分钟

1. Producer发送流程简述

​ 如上图所示,Kafka中Producer的消息发送过程主要涉及以下步骤,这里进行简单的阐述:

  • 生产者线程:消息经过拦截器、序列化器和分区器,确定了消息发送到哪个主题的哪个分区,然后被发送到对应的 RecordAccumulator 中,每个 RecordAccumulator 与主题分区一一映射。

  • RecordAccumulator:消息被累加和合并,直到达到配置的批次大小(batch.size)或等待超过配置的等待时间(linger.ms),触发 Sender IO 线程的消息发送。

  • Sender IO 线程:这个线程会等待来自 RecordAccumulator 的批量数据,并将它们异步发送到 Kafka 集群。每个请求池默认缓存五个请求(max.in.flight.requests.per.connection),并等待来自服务器的 ACK(确认)。如果未收到 ACK,它会进行重试,默认重试次数上限为整数最大值(retries)。

  • 如果 ACK 成功,相应的消息批次将被删除,并通知到生产者端。这一流程确保了消息的异步发送,减少了主线程的阻塞,提高了生产者的效率和性能。

2. 消息发送模式(send方法)

​在了解Kafka的具体消息发送流程之前,我们先了解一下Kafka的消息发送模式,Producer向Broker发送消息的第一步就是调用send方法,send方法返回的不是一个void,而是一个Future对象。你可以使用Future来跟踪消息发送的状态和获取相关元数据,主要的消息发送模式有以下三种:

发后即忘:

你可以使用以下方式来发送消息,但它并不关心消息的发送结果,因此可能导致消息发送失败:

try { 
    producer.send(record); 
} catch (ExecutionException I InterruptedException e) { 
    e.printStackTrace();
}

同步发送:

同步发送会等待消息发送的结果,通过调用get方法来实现。它具有较高的可靠性,因为要么消息发送成功,要么发送失败并抛出异常。你可以根据异常的类型来采取相应的处理措施,例如回滚或执行补偿操作。然而,需要注意的是,阻塞等待结果可能会影响性能,因为在发送一条消息后必须等待结果才能发送下一条。

try { 
    producer.send(record).get(); 
} catch (ExecutionException I InterruptedException e) { 
    e.printStackTrace();
}

异步发送:

使用异步发送,你可以提供一个回调函数(Callback),以便在消息发送完成时进行回调。这种方式提高了消息发送的吞吐量,也在一定程度上提高了消息的可靠性。你可以根据回调的结果来决定是否需要执行回滚逻辑。

kafkaProducer.send(new ProducerRecord<>("first", "atguigu" + i), new Callback() {
    @Override
    public void onCompletion(RecordMetadata metadata, Exception exception) {
        if (exception == null){
            System.out.println("主题:"+metadata.topic() + " 分区:"+ metadata.partition());
        }
    }
});

异步发送允许你在发送消息后继续执行其他操作,而无需等待发送结果。这提高了性能和并发性,尤其在高吞吐量的情况下表现出色。

3. Producer主线程和Sender IO 线程

在 Kafka 生产者端,消息的发送涉及两个关键线程,Producer的主线程和Sender IO线程,这两个线程分别执行不同的任务:

主线程(Producer 主线程): 这是用户在调用 send 方法时运行的线程。当消息发送请求进入主线程时,消息并不会立刻发送到 Kafka Broker。消息依次经过拦截器、序列化、分区器,然后被缓存在名为 RecordAccumulator 的内存缓冲区中,然后 send 方法立即返回,这意味着在这一刻,我们无法确保消息是否已真正发送到 Kafka Broker。

Sender IO 线程: 这是 Kafka Producer 内部的一个专用线程,负责异步地将消息从 RecordAccumulator 发送到 Kafka 集群。它采用非阻塞的 NIO(Non-Blocking I/O)方式执行网络 I/O 操作。这个线程会周期性轮询 RecordAccumulator,一旦满足特定条件,就会触发真正的网络 I/O 发送。此线程工作于异步非阻塞模式下,因此不会阻塞主线程的执行。

3.1 Producer主线程执行任务

主线程整体执行流程的逻辑在方法​org.apache.kafka.clients.producer.KafkaProducer#doSend中,核心源码如下:

private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
    
    //执行拦截器
    ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
    
    //序列化key和value
    serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
    serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
    
    //执行分区器,获取分区
    int partition = partition(record, serializedKey, serializedValue, cluster);
    
    //消息推送到RecordAccumulator中
    RecordAccumulator.RecordAppendResult result = accumulator.append(record.topic(), partition, timestamp, serializedKey,
                    serializedValue, headers, appendCallbacks, remainingWaitMs, abortOnNewBatch, nowMs, cluster);
}

3.1.1 消息封装(ProducerRecord)

首先,kafka主线程会将消息封装成为ProducerRecord对象,这个对象中主要包括了以下内容:

 public ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value, Iterable<Header> headers) {
        this.topic = topic; //主题
        this.partition = partition; //分区
        this.key = key; //key
        this.value = value; //消息内容
        this.timestamp = timestamp; //时间戳
        this.headers = new RecordHeaders(headers); //元数据
    }

上面代码中的RecordHeaders是Kafka的Producer和Consumer之间传递消息的元数据,主要用于传递一些与数据内容无关的附加信息,如:消息来源、类型、版本、生产时间、过期时间、分区数、用户id等。

3.1.2 拦截器(​ProducerInterceptors)

3.1.2.1 拦截器作用:

​Kafka中的Producer拦截器是一种高度可定制的机制,用于在消息被发送到Kafka服务器之前或之后,对消息进行一些处理或记录。这些拦截器提供了以下主要用途:

  1. 消息转换和增强:Producer拦截器允许你在消息发送之前对其内容进行修改或增强。例如,你可以为消息添加时间戳、序列号或其他自定义信息,以满足特定需求,如在消息中嵌入额外的元数据或执行格式转换。

  2. 消息过滤:你可以使用拦截器根据自定义条件过滤掉某些消息,确保只有符合特定条件的消息才会被发送到Kafka。这有助于控制哪些消息被记录或传递到Broker。

  3. 性能监控:Producer拦截器可用于监测Producer的性能指标,例如消息发送速度、错误率等。这些性能数据可以记录到外部系统,以便进行性能分析和故障排除。

  4. 消息日志记录:你可以使用拦截器记录消息发送的详细日志信息,以追踪消息的生命周期和进行故障排查。这对于跟踪消息传递过程中的问题非常有用。

  5. 消息验证:拦截器可用于执行消息验证,以确保消息的内容符合预期的格式和要求。这有助于防止无效或不合格的消息被发送到Kafka。

  6. 自定义扩展:你还可以编写自定义拦截器,以满足特定应用场景的需求。这为你提供了灵活性,以根据具体用例自定义Producer行为。

3.1.2.2 自定义拦截器

拦截器通常以链式调用的方式工作,一个Producer可以配置多个拦截器,它们按照配置的顺序依次处理消息。这为你提供了广泛的定制和扩展Producer行为的能力。在配置Producer时,你可以指定拦截器的类名,Kafka会自动加载并应用这些拦截器。​拦截器接口提供了三个方法,如下代码所示:

  @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        System.out.println("生产者拦截器 onSend()  run ."+record);
        return new ProducerRecord<>(
                record.topic(), record.partition(), record.key(), record.value().concat("_后缀"));    }

    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        System.out.println("生产者拦截器 onAcknowledgement run ."+metadata.toString() +" exception:"+exception);
    }
    @Override
    public void close() {
        System.out.println("生产者拦截器 close()  run .");
    }

    @Override
    public void configure(Map<String, ?> configs) {
        this.configs = configs;
        System.out.println("生产者拦截器 configure  run ."+configs);
    }
  • onSend: 该方法在消息发送到Kafka服务器之前调用,允许对消息进行修改或增强。例如,你可以修改消息内容或目标Topic。
  • onAcknowledgement: 该方法在消息成功发送或发送失败后进行回调,用于记录或处理消息确认情况。
  • close: 该方法在关闭拦截器时进行资源释放,可用于清理任何临时资源。

3.1.2.3 拦截过程源码解析:

​org.springframework.kafka.support.CompositeProducerInterceptor#onSend中为默认的拦截器逻辑,代码如下所示:

public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record) {
   ProducerRecord<K, V> interceptRecord = record;
   
   //拦截器可能有多个,依次遍历拦截器列表
   for (ProducerInterceptor<K, V> interceptor : this.delegates) {
       //依次调用拦截器得到最终过滤后的interceptRecord,并返回
       interceptRecord = interceptor.onSend(interceptRecord);
   }
   return interceptRecord;
}

拦截的逻辑也非常简单,kafka主线程会去遍历拦截器列表,依次执行每个拦截器的过滤操作,最终返回拦截过滤之后的ProducerRecord。

3.1.3 序列化器

3.1.3.1 序列化器作用

序列化器(Serializer)在Kafka中扮演着重要的角色,Producer需要将消息对象序列化为字节数组,而Consumer则需要将字节数组反序列化为可处理的对象。因此,你需要配置适当的序列化器来处理消息的序列化和反序列化。通常需要配置两种序列化器,一种用于消息的键(key),另一种用于消息的值(value)。这些序列化器指定了如何将对象转换为字节数组,以便通过网络发送给Kafka,以及如何从字节数组中还原出对象。

在Kafka中,常见的序列化器包括 StringSerializer、IntegerSerializer 等,它们用于处理常见的数据类型,你可以使用如下方式配置序列化器:

properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // 配置键序列化器
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // 配置值序列化器

在大多数情况下,你可以使用 StringSerializer,因为消息的键和值经常是字符串类型。然而,如果你需要处理自定义对象,你可以实现自己的序列化器,以满足特定的需求。为此,你可以创建一个自定义的序列化器类,实现 Kafka 提供的 org.apache.kafka.common.serialization.Serializer 接口,重写其中的 serialize 方法。

3.1.3.2 序列化器源码解析

在​org.apache.kafka.clients.producer.KafkaProducer#doSend中执行了消息的序列化逻辑,Kafka不会直接序列化ProducerRecord而是会对key和value进行序列化,这里只截取了序列化相关代码,源码如下:

private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
    //序列化key
    serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
    //序列化value
    serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
    //将序列化后的key和value放入RecordAccumulator中
    RecordAccumulator.RecordAppendResult result = accumulator.append(record.topic(), partition, timestamp, serializedKey,
                    serializedValue, headers, appendCallbacks, remainingWaitMs, abortOnNewBatch, nowMs, cluster);
}

具体的序列化器的方法以String序列化器为例,序列化源码如下:

public byte[] serialize(String topic, @Nullable Headers headers, @Nullable T data) {
   //消息为空,直接返回
   if (data == null) {
      return null;
   }

   if (this.addTypeInfo && headers != null) {
      headers.add(this.typeInfoHeader, data.getClass().getName().getBytes());
   }
   //直接讲String转成byte数组
   return data.toString().getBytes(this.charset);
}

3.1.4 分区器

3.1.4.1 分区器作用

​分区器(Partitioner)在Kafka中负责决定每条消息属于哪个分区,这有助于实现负载均衡和提高消息发送和接收的并行度。你可以根据消息的键、值或其他标准来选择合适的分区。Kafka的消息构造器提供了多个选项,允许你指定消息的分区,如下:

public ProducerRecord(String topic, Integer partition, K key, V value);
public ProducerRecord(String topic, K key, V value);
public ProducerRecord(String topic, V value);

3.1.4.2 分区器源码解析

Kafka默认使用的分区器是​BuiltInPartitioner,在​org.apache.kafka.clients.producer.KafkaProducer#partition可以看到调用默认分区器进行分区的相关代码逻辑:

private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
        if (record.partition() != null)
            return record.partition();
            
        //如果指定了自定义的分区器,则使用指定的分区器
        if (partitioner != null) {
            int customPartition = partitioner.partition(
                record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
            if (customPartition < 0) {
                throw new IllegalArgumentException(String.format(
                    "The partitioner generated an invalid partition number: %d. Partition number should always be non-negative.", customPartition));
            }
            return customPartition;
        }
        
        if (serializedKey != null && !partitionerIgnoreKeys) {
            //如果指定了key,则使用内置的分区器,根据key来进行分区
            return BuiltInPartitioner.partitionForKey(serializedKey, cluster.partitionsForTopic(record.topic()).size());
        } else {
            //如果未指定key,则返回UNKNOWN_PARTITION
            return RecordMetadata.UNKNOWN_PARTITION;
        }
    }
  • 发送消息指定了key:根据消息的key的hash值对分区数取余来确认分区,BuiltInPartitioner的partitionForKey代码如下:
    public static int partitionForKey(final byte[] serializedKey, final int numPartitions) {
        //先对serializedKey进行hash后转成int值,之后再对numPartitions取余来确定分区
        return Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
    }
  • 如果没有指定key:默认采用sticky的粘性分区策略,消息在相同的分区下填满一个批次就更换下一个分区,org.apache.kafka.clients.producer.internals.RecordAccumulator#append代码如下:
//默认即采用粘性分区策略
final BuiltInPartitioner.StickyPartitionInfo partitionInfo; 
final int effectivePartition;
if (partition == RecordMetadata.UNKNOWN_PARTITION) {
   partitionInfo = topicInfo.builtInPartitioner.peekCurrentPartitionInfo(cluster);
   effectivePartition = partitionInfo.partition();
} else {
   partitionInfo = null;
   effectivePartition = partition;
}
  • 自定义分区策略:当你的消息需要特定的分区策略时,可以自定义分区器。你可以创建一个分区器类,实现 Kafka 提供的 org.apache.kafka.clients.producer.Partitioner 接口,并重写其中的 partition 方法。这个方法负责根据键、值或其他消息特征来选择分区。

3.1.5 消息累加器(RecordAccumulator)

​Kafka中的累加器(RecordAccumulator)默认大小为32MB。如果生产者的发送速率超过sender线程的处理速率,消息将会积压在累加器中。这可能导致生产者阻塞或报错,具体取决于阻塞时间的配置。

累加器内部采用ConcurrentMap<TopicPartition, Deque>;ProducerBatch<>的存储结构,每个分区对应一个双端队列,队列中存储的是ProducerBatch。通常,ProducerBatch 的大小是由 batch.size 配置控制的,新的消息会被追加到 ProducerBatch 中,当达到指定大小(例如16KB)时,将触发sender线程进行消息的实际发送。

由于消息生成速率可能很高,而且产生了大量的 ProducerBatch,在发送后需要通过垃圾回收(GC)来回收这些 ProducerBatch,这可能对性能产生影响。因此,Kafka使用 BufferPool 作为内存池来管理 ProducerBatch 的创建和回收。当需要申请新的 ProducerBatch 空间时,会调用 free.allocate(size, maxTimeToBlock) 从内存池中申请空间。

如果单条消息的大小超过了16KB,就不再复用内存池,而是生成一个更大的 ProducerBatch 专门用于存放大消息。发送完成后,该内存空间将由GC回收。

为了降低网络中消息传输的带宽,还可以通过消息压缩的方式。在生产端,消息会在追加到 ProducerBatch 之前进行压缩,常用的压缩算法包括Gzip、Snappy、Lz4和Zstd。压缩虽然是以时间换取空间的方式,但可以有效减小网络传输的数据量。

3.1.5.1 核心参数

RecordAccumulater的核心参数就这一个topicInfoMap,记录了topic->topicInfo的map,重要的是TopicInfo会根据partition来创建消息的batchs。这个参数的目的就相当于将消息根据topic-partition进行管理了起来:

private final ConcurrentMap<String /*topic*/, TopicInfo> topicInfoMap = new CopyOnWriteMap<>();

3.1.5.2 TopicInfo

RecordAccumulater中定义了一个TopicInfo类,里面有一个batches成员变量是一个ConcurrentMap,存储partition和ProducerBatch的映射。TopicInfo实际上就是存储的Partion和其对应的消息信息的一个类

private static class TopicInfo {
    //一个map是parition和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);
    }
}

3.1.5.3 ProducerBatch

  • 核心参数:

private final List thunks = new ArrayList<>(); //producer讲消息封装到Thunk中

  • 核心方法:
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 {
        this.recordsBuilder.append(timestamp, key, value, headers);
        this.maxRecordSize = Math.max(this.maxRecordSize, AbstractRecords.estimateSizeInBytesUpperBound(magic(),
                recordsBuilder.compressionType(), key, value, headers));
        this.lastAppendTime = now;
        
        //将key,value,header和时间戳封装到FutureRecordMetadata
        FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount,
                                                               timestamp,
                                                               key == null ? -1 : key.length,
                                                               value == null ? -1 : value.length,
                                                               Time.SYSTEM);
        //进一步将封装好的消息添加到列表中
        thunks.add(new Thunk(callback, future));
        this.recordCount++;
        return future;
    }
}

3.1.5.4 append方法

RecordAccumulate的核心方法就是append方法,其主要目的是将Producer需要推送的消息添加到对应Partition所对应的Deque之中,以下是核心代码的展示和注释:

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 {
    TopicInfo topicInfo = topicInfoMap.computeIfAbsent(topic, k -> new TopicInfo(logContext, k, batchSize));
   
    appendsInProgress.incrementAndGet();
    ByteBuffer buffer = null;
    if (headers == null) headers = Record.EMPTY_HEADERS;
    try {
        while (true) {
            
            //先根据各种分区策略选择消息所需要推送到的分区
            final BuiltInPartitioner.StickyPartitionInfo partitionInfo;
            final int effectivePartition;
            if (partition == RecordMetadata.UNKNOWN_PARTITION) {
                partitionInfo = topicInfo.builtInPartitioner.peekCurrentPartitionInfo(cluster);
                effectivePartition = partitionInfo.partition();
            } else {
                partitionInfo = null;
                effectivePartition = partition;
            }

            // Now that we know the effective partition, let the caller know.
            setPartition(callbacks, effectivePartition);

            // 根据选定的分区判断其对应的Deque是否存在
            Deque<ProducerBatch> dq = topicInfo.batches.computeIfAbsent(effectivePartition, k -> new ArrayDeque<>());
            synchronized (dq) {
                //尝试将当前消息加入到Deque中
                RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
                if (appendResult != null) {
                        boolean enableSwitch = allBatchesFull(dq);
                        topicInfo.builtInPartitioner.updatePartitionInfo(partitionInfo, appendResult.appendedBytes, cluster, enableSwitch);
                        //加入成功则直接返回,否则执行后续的新建Deque并插入的功能
                        return appendResult;
                }
            }

            synchronized (dq) {
                //新建Deque并将消息入队,成功则直接返回
                RecordAppendResult appendResult = appendNewBatch(topic, effectivePartition, dq, timestamp, key, value, headers, callbacks, buffer, nowMs);
                return appendResult;
            }
        }
    } finally {
        free.deallocate(buffer);
        appendsInProgress.decrementAndGet();
    }
}

上述源码的主要流程如下:

  • 根据分区策略选择好当前消息需要发送的partition;

  • 尝试将消息推送到partition对应的Deque中,如果推送成功则直接返回,否责新建Deque并再次推送;

3.1.5.5 总结

RecordAccumulate的主要功能说起来其实也很简单,就是将消息按照Topic-Partition按批次缓存起来,需要关注的主要有​TopicInfo和​ProducerBatch两个对象,TopicInfo主要是将Partition和消息的Batch使用一个ConcurrentMap联系起来,同时要保证线程安全。​ProducerBatch主要是将消息封装成一个个的批次,在后续SenderIO线程发送消息的时候直接对每个批次的消息进行发送。

4. Sender IO线程

消息在被保存到RecordAccumulate中之后,Sender线程就会将符合条件的消息按照批次进行发送,除了发送消息之外,元数据的加载也是通过Sender线程来进行处理的。Sender线程发送以及接受消息都是基于Java NIO中的Selector来进行的。

4.1 Sender 总体流程图

4.2 Sender 源码解析

4.2.1 Sender 核心参数

public class Sender implements Runnable {
    private final KafkaClient client; //kafka的客户端信息,具体是kafka集群成员信息
    private final RecordAccumulator accumulator; //消息累加器,批量缓存了消息
    private final ProducerMetadata metadata; //生产者的元数据
    private final short acks; //应答个数
    private final int retries; //重试次数
    private final ProducerMetadata metadata;
    ...
}
  • KafkaClient client: Kafka 网络通信客户端,主要负责封装与 broker 的网络通信。

  • RecordAccumulator accumulator: 消息记录累积器,是消息追加的入口,通过 RecordAccumulator 的 append 方法实现。它负责将消息追加到生产者批次中。

  • ProducerMetadata metadata: 元数据管理器,用于管理 topic 的路由分区信息,确保生产者能够将消息发送到正确的分区。

  • int maxRequestSize: 通过调用 send 方法发送的最大请求大小,包括 key、消息体序列化后的消息总大小不能超过该值。通过参数 max.request.size 进行设置。

  • short acks: 用来定义消息“已提交”的条件,即 Broker 端向客户端承诺已经提交的条件。可选值包括 0、-1、1。

  • int retries: 重试次数,表示在发送失败时的重试次数。

  • Time time: 时间工具类,用于处理与时间相关的操作。

  • boolean running: 该线程状态,为 true 表示运行中。

  • SenderMetrics sensors: 消息发送相关的统计指标收集器,用于收集与消息发送性能相关的指标。

  • int requestTimeoutMs: 请求的超时时间,表示发送请求等待响应的最大时间。

  • long retryBackoffMs: 请求失败之后在重试之前等待的时间,用于控制重试的时间间隔。

  • TransactionManager transactionManager: 事务处理器,用于处理事务性消息。

  • Map<TopicPartition, List> inFlightBatches: 正在执行发送相关的消息批次的映射,记录了每个分区上正在发送的消息批次。

4.2.2 Sender 的执行位置

​ 在Kafka Produer的构造函数中,就会创建IO线程并且执行start方法来执行sender,sender本身是一个Runnable对象,能够直接被线程执行。

代码位置:org.apache.kafka.clients.producer.KafkaProducer#KafkaProducer

//创建IO线程,将sender放入线程中
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
//线程启动
this.ioThread.start();

4.2.3 Sender的核心方法

  • run方法:不断尝试执行runOnce方法,直到IO线程退出
public void run() {
        // 实际上核心代码就这一个部分,不断的尝试去执行runOnce方法,直到IO线程退出
        while (running) {
            try {
                runOnce();
            } catch (Exception e) {
                log.error("Uncaught error in kafka producer I/O thread: ", e);
            }
        }
    }
  • runOnce:由于这里我们不关注Kafka的事务,因此只关注核心方法,就是带上时间戳执行sendProducerData方法
void runOnce() {
        //删除了事务相关的代码
        long currentTimeMs = time.milliseconds();
        long pollTimeout = sendProducerData(currentTimeMs);
        client.poll(pollTimeout, currentTimeMs);
}
  • sendProducerData: 遍历找出已满的ProducerBatch并调用Java NIO方法推送消息到Kafka集群
private long sendProducerData(long now) {
    Cluster cluster = metadata.fetch();
    
    //遍历集群信息,找出已经准备好的partition
    RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(cluster, now);

    //调用drain方法,拿到已经准备好的ProducerBatchs
    Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(cluster, result.readyNodes, this.maxRequestSize, now);
  
    //将准备好的batches中的record推送到kafka的partition中
    sendProduceRequests(batches, now);
    return pollTimeout;
}
  1. 遍历cluster信息,找出已经准备好的partition

  2. 调用RecordAccumulator的drain方法,这个方法的功能是判断哪些ProducerBatch中的数据已满需要Sender发送

  3. 接着sendProduceRequests方法会使用Java NIO的Selector方法发送消息到Kafka的Partition

具体的像drain方法和​sendProduceRequests过于繁琐,这里不深入探讨,本文主要不是进行源码的详细解析,感兴趣的可以自己研究。

  • sendProduceRequest:这个方法就是具体调用KafkaClient即NetWorkClient,将数据从本地发送到Kafka集群中去
    private void sendProduceRequest(long now, int destination, short acks, int timeout, List<ProducerBatch> batches) {
        if (batches.isEmpty())
            return;

        //从这里开始经过一系列判断,将需要发送的数据封装到recordsByPartition中
        final Map<TopicPartition, ProducerBatch> recordsByPartition = new HashMap<>(batches.size());
        byte minUsedMagic = apiVersions.maxUsableProduceMagic();
        for (ProducerBatch batch : batches) {
            if (batch.magic() < minUsedMagic)
                minUsedMagic = batch.magic();
        }
        ProduceRequestData.TopicProduceDataCollection tpd = new ProduceRequestData.TopicProduceDataCollection();
        for (ProducerBatch batch : batches) {
            TopicPartition tp = batch.topicPartition;
            MemoryRecords records = batch.records();

            if (!records.hasMatchingMagic(minUsedMagic))
                records = batch.records().downConvert(minUsedMagic, 0, time).records();
            ProduceRequestData.TopicProduceData tpData = tpd.find(tp.topic());
            if (tpData == null) {
                tpData = new ProduceRequestData.TopicProduceData().setName(tp.topic());
                tpd.add(tpData);
            }
            tpData.partitionData().add(new ProduceRequestData.PartitionProduceData()
                    .setIndex(tp.partition())
                    .setRecords(records));
            recordsByPartition.put(tp, batch);
        }

        String transactionalId = null;
        if (transactionManager != null && transactionManager.isTransactional()) {
            transactionalId = transactionManager.transactionalId();
        }

         //以下就是具体的创建请求和发送消息到Kafka集群的逻辑
        ProduceRequest.Builder requestBuilder = ProduceRequest.forMagic(minUsedMagic,
                new ProduceRequestData()
                        .setAcks(acks)
                        .setTimeoutMs(timeout)
                        .setTransactionalId(transactionalId)
                        .setTopicData(tpd));
        RequestCompletionHandler callback = response -> handleProduceResponse(response, recordsByPartition, time.milliseconds());

        String nodeId = Integer.toString(destination);
        ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0,
                requestTimeoutMs, callback);
        client.send(clientRequest, now);
        log.trace("Sent produce request to {}: {}", nodeId, requestBuilder);
    }

5. 总结

本文较为完整的解析了Kafka的Producer的消息发送流程,针对其消息发送流程我们重点需要关注两个线程:Main线程和Sender IO线程即可。

Main线程的主要功能是将消息封装之后,依次进行过滤、序列化和分区选择的操作之后,放入RecordAccumulator中对消息进行批量缓存,直到消息批次装满或者时间超限之后会被Sender IO线程检测到并发送到Kafka集群。当消息被封装推送到RecordAccumulator之后,Main线程的工作就完成了,此时会直接返回。

Sender IO线程主要是一个死循环,不断的去检查Kafka Cluster集群的健康状态和RecordAccumulator中哪些分区已经被填满了,当发现分区数据就绪之后,就会发起网络请求通过Java NIO的方式异步的发送数据,并且等待ACKS的返回。

上面就是Kafka的Producer推送消息的主要过程,实际上在Kafka集群那边数据被推送到Leader Partition之后,还会通过ISR机制将消息分区复制到Follower Partition,并且最终返回ACKS。