Loading

[18] Kafka Producer

1. 发送消息流程

1.1 整体架构

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

1.2 消息收集器

RecordAccumulator 主要用来缓存消息以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能。RecordAccumulator 缓存的大小可以通过生产者客户端参数 buffer.memory 配置,默认 33554432B,即 32MB。

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

主线程中发送过来的消息都会被追加到 RecordAccumulator 的某个双端队列(Deque)中,在 RecordAccumulator 的内部为每个分区都维护了一个双端队列,队列中的内容就是 ProducerBatch,即 Deque<ProducerBatch>。消息写入缓存时,追加到 Deque 的尾部;Sender 线程读取消息时,从 Deque 的头部读取。

注意!ProducerBatch 不是 ProducerRecord,ProducerBatch 中可以包含一至多个 ProducerRecord。通俗地说,ProducerRecord 是生产者中创建的消息,而 ProducerBatch 是指一个消息批次,ProducerRecord 会被包含在 ProducerBatch 中,这样可以使字节的使用更加紧凑。与此同时,将较小的 ProducerRecord 拼凑成一个较大的 ProducerBatch,也可以较少网络请求的次数以提升整体的吞吐量。

消息在网络上都是以字节(byte)的形式传输的,在发送之前需要创建一块内存区域来保存对应的消息。在 Kafka 生产者客户端中,通过 java.io.ByteBuffer 实现消息内存的创建和释放,不过频繁的创建和释放是比较耗费资源的,在 RecordAccumulator 的内部还有一个 BufferPool,它主要用来实现 ByteBuffer 的复用,以实现缓存的高效利用。

不过 BufferPool 只针对特定大小的 ByteBuffer 进行管理,而其他大小的 ByteBuffer 不会缓存在 BufferPool 中,这个特定的大小由 batch.size 参数来指定,默认值为 16384B,即 16KB。我们可以适当地调大 batch.size 参数以便多缓存一些消息。

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

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

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

1.3 元数据更新

请求在从 Sender 线程发往 Kafka 之前还会保存到 InFlightRequests 中,InFlightRequest 保存对象的具体形式为 Map<NodeId, Deque<Request>>,它的主要作用是缓存已经发出去但还没有受到响应的请求(NodeId 是一个 String 类型,表示节点的 id 编号)。

与此同时,InFlightRequests 还提供了许多管理类的方法,并且通过配置参数还可以限制每个连接(也就是客户端与 Node 之间的连接)最多缓存的请求数。

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

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

InFlightRequests 还可以获得 leastLoadedNode,即所有 Node 中负载最小的那一个。这里的负载最小是通过每个 Node 在 InFlightRequests 中还未确认的请求决定的,未确认的请求越多则认为负载越大。

元数据是指 Kafka 集群的元数据,这些元数据具体记录了集群中有哪些主题,这些主题有哪些分区,每个分区的 Leader 副本分配在哪个节点上,Follower 副本分配在哪些节点上,那些副本在 AR、ISR 等集合中,集群中有哪些节点,控制器节点又是哪一个等信息。

当客户端中没有需要使用的元数据时,比如没有指定的主题信息,或者超过 metadata.max.age.ms 时间没有更新元数据都会引起元数据的更新操作。客户端参数 meadata.max.age.ms 的默认值为 300000,即 5min。元数据的更新操作是在客户端内部进行的,对客户端的外部使用者不可见。

当需要更新元数据时,会先挑出 leastLoadedNode,然后向这个 Node 发送 MetadataRequest 请求来获取具体的元数据信息。这个更新操作是由 Sender 线程发起的,在创建完 MetadataRequest 之后同样会存入 InFlightRequests,之后的步骤就和发送消息时类似。元数据虽然由 Sender 线程负责更新,但是主线程也需要读取这些信息,这里的数据同步通过 synchronized 和 final 关键字来保障。

2. 客户端开发

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

2.1 示例代码

a. 普通异步发送

public class CustomProducer {

  final static Integer COUNT = 5;

  public static void main(String[] args) {
    // 0. 配置
    Properties config = new Properties();
    // a. 连接集群
    config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "hadoop102:9092,hadoop103:9092");
    // b. 指定对应的 key/value 的序列化类型
    config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 1. 创建 Kafka 生产者对象
    try (KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(config)) {
      for (int i = 0; i < COUNT; i++) {
        // 2. 发送数据
        kafkaProducer.send(new ProducerRecord("firstTopic", "message" + i));
        // send() 后链式调用 get() 即为同步发送
        // kafkaProducer.send(new ProducerRecord("firstTopic", "message" + i)).get();
      }
    }
    // 3. 关闭资源(extends Closeable)
    // close() 方法会阻塞等待之前所有的发送请求完成后再关闭 KafkaProducer
  }
}

KafkaProducer 的 send() 方法并非是 void 类型,而是 Future<RecordMetadata> 类型,send() 方法有 2 个重载方法。

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

要实现同步的发送方式,可以利用返回的 Future 对象实现,即在执行 send() 方法之后链式调用 get() 来阻塞等待 Kafka 的响应,直到消息发送成功,或者发生异常。如果发生异常,那么就需要捕获异常并交由外层逻辑处理。

b. 带回调的异步

有读者或许会有疑问,send() 方法的返回值类型就是 Future,而 Future 本身就可以用作异步的逻辑处理。这样做不是不行,只不过 Future 里的 get() 方法在何时调用给,以及怎么调用都是需要面对的问题,消息不停地发送,那么诸多消息对应的 Future 对象的处理难免会引起代码处理逻辑的混乱。使用 Callback 的方式非常简洁明了,Kafka 有响应时就会回调,要么发送成功,要么抛出异常。

回调函数会在 Producer 收到 ack 时调用,为异步调用,该方法有两个参数,分别是元数据信息(RecordMetadata)和异常信息(Exception),如果 Exception 为 null,说明消息发送成功,如果 Exception 不为 null,说明消息发送失败。

public class CustomProducerCallback {

  final static Integer COUNT = 5;

  public static void main(String[] args) {
    // 0. 配置
    Properties config = new Properties();
    // a. 连接集群
    config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "hadoop102:9092,hadoop103:9092");
    // b. 指定对应的 key/value 的序列化类型
    config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 1. 创建 Kafka 生产者对象
    try (KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(config)) {
      for (int i = 0; i < COUNT; i++) {
        // 2. 发送数据
        kafkaProducer.send(new ProducerRecord("firstTopic", "message" + i), (recordMetadata, e) -> {
          if (e == null) {
            System.out.println("TopicInfo: " + recordMetadata.topic() + "partitionInfo: " + recordMetadata.partition());
          } else {
            System.out.println("send failed! recordMetadata: " + recordMetadata);
          }
        });
      }
    }
    // 3. 关闭资源(extends Closeable)
  }
}

注意:消息发送失败会自动重试,不需要我们在回调函数中手动重试。

c. 消息对象

public class ProducerRecord<K, V> {
    private final String topic;
    private final Integer partition;
    private final Headers headers;
    private final K key;
    private final V value;
    private final Long timestamp;

    // ...
}

2.2 参数配置

3. 生产者分区

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

3.1 分区优势

  1. 便于合理使用存储资源,每个 Partition 在一个 Broker 上存储,可以把海量的数据按照分区切割成一块一块数据存储在多台 Broker 上。合理控制分区的任务,可以实现负载均衡的效果;
  2. 提高并行度,生产者可以以分区为单位发送数据;消费者可以以分区为单位进行消费数据。

3.2 分区策略

3.3 自定义分区器

例如我们实现一个分区器实现,发送过来的数据中如果包含 6x7 就发往 0 号分区,不包含 6x7就发往 1 号分区。

public class MyPartitioner implements Partitioner {
  /**
   * 返回信息对应的分区
   *
   * @param topic      主题
   * @param key        消息的 key
   * @param keyBytes   消息的 key 序列化后的字节数组
   * @param value      消息的 value
   * @param valueBytes 消息的 value 序列化后的字节数组
   * @param cluster    集群元数据可以查看分区信息
   * @return
   */
  @Override
  public int partition(String topic, Object key, byte[] keyBytes,
                  Object value, byte[] valueBytes, Cluster cluster) {
    // 1. 获取消息
    String msgValue = value.toString();
    // 2. 创建 partition
    int partition;
    // 3. 判断消息是否包含 6x7
    if (msgValue.contains("6x7")) {
      partition = 0;
    } else {
      partition = 1;
    }
    // 4. 返回分区号
    return partition;
  }

  @Override
  public void close() {}

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

在测试主方法中添加自定义分区器:

properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,"io.tree6x7.kafka.MyPartitioner");
posted @ 2023-02-12 13:05  tree6x7  阅读(24)  评论(0编辑  收藏  举报