Kafka学习(二):Producer

137 阅读5分钟

Producer

工作流程

Kafka Producer的生产流程涉及两个线程一个队列:Main线程负责处理数据并发送至队列,Sender线程负责从队列读取数据并发送到Kafka Broker,中间是消息收集队列RecordAccumulator。

下面是一张网上常见的图:

image.png 下面是Producer初始化部分源码。

KafkaProducer(ProducerConfig config,
                  Serializer<K> keySerializer,
                  Serializer<V> valueSerializer,
                  ProducerMetadata metadata,
                  KafkaClient kafkaClient,
                  ProducerInterceptors<K, V> interceptors) { // ...
        try {
            // ...
            this.partitioner = config.getConfiguredInstance(
                    ProducerConfig.PARTITIONER_CLASS_CONFIG,
                    Partitioner.class,
                    Collections.singletonMap(ProducerConfig.CLIENT_ID_CONFIG, clientId));
            // ...
            
            int batchSize = Math.max(1, config.getInt(ProducerConfig.BATCH_SIZE_CONFIG));
            this.accumulator = new RecordAccumulator(logContext,
                    batchSize,
                    compression); // ...
            // ...
            
            this.sender = newSender(logContext, kafkaClient, this.metadata);
            String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
            this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
            this.ioThread.start();
        } catch (Throwable t) { // ... }
    }

参数配置

参数描述
bootstrap.serversBroker集群地址
key.serializer/value.serializer指定发送消息的key和value的序列化类型
buffer.memoryRecordAccumulator缓冲区总大小,默认32m
batch.size上图中ProducerBatch大小。多个记录发往同一个分区时组成一批的大小,默认16K。发送请求可有多批次数据,一批次一分区
linger.ms数据达到batch.size大小,Sender线程会去拉取数据。如迟迟未达到batch.size,sender等待linger.time之后就会发送数据
compression.typeProducer 生成数据时可使用的压缩类型。默认值是none。 gzip、snappy、lz4
acksProducer 在确认一个请求发送完成之前需要收到的反馈信息的数量。[all, -1, 0, 1]
max.in.flight.requests.per.connection允许最多没有返回ack的次数,默认为5
retries默认0,若设置大于0的值,则客户端会将发送失败的记录重新发送

Main线程

拦截器InterceptorList应该是采用责任链模式,对生产者发送数据做一些前置处理,ProducerInterceptor是接口则应可以自定义拦截器。

序列化是将Java对象转换为字节数组,在网络中传输和在kafka broker中存储都是使用字节数据,不序列化kafka是无法处理的,一般也是用默认。

分区器

分区器将数据映射到一个分区下,每个Topic的每个分区在RecordAccumulator中都有一个对应的缓冲区。默认分区器逻辑如下。也可以自定义分区器,实现Partitioner接口。

  • 如果键存在,使用键的哈希值来决定分区。

  • 如果键不存在,使用轮询的方式将消息均匀地分配到各个可用的分区上。

Producer<String, String> producer = new Producer<>(props);
// key存在取hash分区
ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);
producer.send(record);

那么为什么要将Topic分区呢?

并行处理:分区允许 Kafka 实现水平扩展,多个消费者可以并行处理不同分区的数据,提高吞吐量。

负载均衡:分区可以将数据分布在不同的 Broker 上,均衡负载,避免单个 Broker 成为瓶颈。

数据局部性:分区可以提高数据的局部性,使得相关数据存储在一起,便于高效读取。

容错性:通过为每个分区创建多个副本,Kafka 可以提高数据的容错性和可用性。

有序性:每个分区内的消息是有序的,分区可以保证消息的顺序传递。从上图流程也可以看出,producer发送消息,在单分区内有序,分区之间无序。

Sender线程

sender发送数据的时机ready由参数中batch.size和linger.ms决定。Sender部分源码:

public void run() {
    // 主循环
    while (running) {
        // 发送准备好的 ProducerBatch
        sendProducerData(now());
        // 其他逻辑...
    }
}

private void sendProducerData(long now) {
    // 获取准备发送的 ProducerBatch
    Map<Integer, List<ProducerBatch>> batches = this.accumulator.ready(now);
    // 发送每个 ProducerBatch
    for (Map.Entry<Integer, List<ProducerBatch>> entry : batches.entrySet()) {
        Node node = this.metadata.fetch().nodeById(entry.getKey());
        if (node == null) {
            // 处理节点不存在的情况
        } else {
            // 发送 ProducerBatch
            sendProduceRequests(batches.get(entry.getKey()), node);
        }
    }
}

Sender会缓存一个请求队列,默认每个 Broker 最多可以缓存 5 个请求,可以通过配置 max.in.flight.requests.per.connection 值来改变。

private void sendProduceRequests(List<ProducerBatch> batches, Node node) {
    // 构建 ProduceRequest
    ProduceRequest.Builder requestBuilder = ProduceRequest.Builder.forCurrentMagic(
        (short) 1, acks, requestTimeoutMs, new HashMap<>());
    for (ProducerBatch batch : batches) {
        MemoryRecords records = batch.records();
        requestBuilder.setPartitionRecords(batch.topicPartition, records);
    }
    // 发送请求
    RequestFuture<ProduceResponse> future = client.send(node, requestBuilder);
    // 添加请求到 InFlightRequests
    InFlightRequest inFlightRequest = new InFlightRequest(requestBuilder.build(), time.milliseconds());
    client.inFlightRequests().add(node.id(), inFlightRequest);
    // 处理响应
    future.addListener(new RequestFutureListener<ProduceResponse>() {
        @Override
        public void onSuccess(ProduceResponse response) {
            handleResponses(response, batches);
        }
    });
}

发送吞吐量

提高吞吐量首先要做的就是压缩数据。以gzip压缩为例,压缩后数据大小是压缩前的1/12左右。

// MemoryRecordsBuilder.java
public void appendWithOffset(long offset, long timestamp, byte[] key, byte[] value, Header[] headers) {
    // 其他逻辑...
    if (compressionType != CompressionType.NONE) {
        // 应用压缩
        compressor.compress(key, value);
    }
    // 其他逻辑...
}

调整buffer.memroy / batch.size / linger.ms修改缓冲区大小或发送间隔,也可以调整吞吐量。

发送可靠性

在上一篇中已提到kafka producer的ack/ISR确认机制。

ack:0,生产者发送的数据不需要等落盘响应。这种情况下leader节点在数据落盘前任一环节挂掉都会导致数据丢失。

ack:1,生产者发送的数据leader分区落盘响应。follower还没同步新数据的时候leader挂了,同样数据丢失。

ack:-1,all,leader们和ISR队列(与leader们保持同步的部分follower集合)落盘响应。

public void update(Cluster cluster, long now) {
    // 更新集群元数据
    this.cluster = cluster;
    this.lastRefreshMs = now;
    this.needUpdate = false;
    // 更新主题、分区和副本信息
    for (TopicPartition tp : cluster.partitions()) {
        PartitionInfo partInfo = cluster.partition(tp);
        // 更新 ISR 列表
        Set<Node> isr = new HashSet<>(partInfo.inSyncReplicas());
        this.isr.put(tp, isr);
    }
}

发送不重复

上一篇中提到,数据交付语义一般有三种,结合kafka producer的实现。

AtLeastOnce:ack为-1,保证数据不丢失,但不能保证数据不重复。

AtMostOnce:ack为0,数据肯定不重复,但可能会丢失。

ExactlyOnce:ack为-1 + 幂等性。就一次,不丢也不重。

那么kafka的幂等性是指 每条消息在kafka中只出现一次且消息的顺序在重试时不变,无论出现多少次broker中只会持久化一条。幂等性通过以下机制实现:

唯一标识符:为每个生产者实例分配一个唯一的 Producer ID。

private void sendInitProducerIdRequest() {
    InitProducerIdRequest.Builder builder = 
        new InitProducerIdRequest.Builder(this.transactionalId, this.transactionTimeoutMs);
    this.client.send(builder, this.time.milliseconds());
}

序列号:为每个消息批次分配一个唯一的序列号(Sequence Number)。

public RecordAppendResult append(
        String topic, Integer partition, Long timestamp, byte[] key, byte[] value, 
        Header[] headers, Callback callback, long maxTimeToBlock, long nowMs) {
    // 累积消息并生成唯一序列号
    TopicPartition tp = new TopicPartition(topic, partition);
    long baseSequence = nextSequence(tp);
    // 其他逻辑
    return new RecordAppendResult(future, baseSequence, baseOffset, logAppendTime, nowMs);
}

private long nextSequence(TopicPartition tp) {
    long current = sequenceNumbers.get(tp);
    sequenceNumbers.put(tp, current + 1);
    return current;
}

事务日志:Kafka 使用事务日志来记录每个消息的 Producer ID 和 Sequence Number,以确保消息的唯一性和顺序性。