中间件系列之Kafka-3-生产者

259 阅读11分钟

1.Kafka搭建

1.1 Kafka搭建

环境的搭建不再赘述,个人练习的话,基于Docker已经很方便了,比如:在Docker环境下部署Kafka,或者你还是喜欢传统的方式,在Windows上搭建Kafka在Linux安装Kafka。当然,直接看官网说明那是最靠谱的。

1.2 配置参数说明

Kafka中有很多参数可以调优,但大部分都可以使用默认参数配置

broker 端配置

  • broker.id

每个 kafka broker 都有一个唯一的标识来表示,默认为0,但可以是任意值,这个唯一的标识符即是 broker.id。

  • port

Kaka的监听端口,默认9092。但如果使用 1024 以下的端口,需要使用 root 权限启动 kakfa。

  • zookeeper.connect

Zookeeper连接的地。比如指定 localhost:2181 表示这个 Zookeeper 是运行在本地 2181 端口上的。我们也可以通过 比如我们可以通过 zk1:2181,zk2:2181,zk3:2181 来指定 zookeeper.connect 的多个参数值。该配置参数是用冒号分割的一组 hostname:port/path 列表,其含义如下

  • hostname:Zookeeper的域名或IP地址;
  • port:Zookeeper的连接端口;
  • path:Kafka的工作根目录,可选参数。

如果你有两套 Kafka 集群,假设分别叫它们 kafka1 和 kafka2,那么两套集群的zookeeper.connect参数可以这样指定:zk1:2181,zk2:2181,zk3:2181/kafka1zk1:2181,zk2:2181,zk3:2181/kafka2

  • log.dirs

Kafka将消息持久化到磁盘,存放在这个参数指定的路径下。它是用一组逗号来分割的本地系统路径,log.dirs 是没有默认值的,你必须手动指定他的默认值。如果指定超过1个路径,那么Kafka会基于最少使用的原则来实现分区日志均衡,但前提是同一个分区的日志放在相同的路径下。

  • num.recovery.threads.per.data.dir

Kafka使用一个线程池来处理目录的日志,这个线程池会用在:

  • 当成功启动时,用来打开每个分区的日志;
  • 当从故障恢复时,检查和恢复分区日志;
  • 当关闭时,优雅关闭日志。

默认情况下,每个日志目录只使用一个线程。因为这些线程只是在服务器启动和关闭时会用到,所以完全可以设置大量的线程来达到并行操作的目的。但需要注意,所配置的数字对应的是 log.dirs 指定的单个日志目录。也就是说,如果 num.recovery.threads.per.data.dir 被设为 8,并且 log.dirs 指定了 3 个路径,那么总共需要 24 个线程。

  • auto.create.topics.enable

这个参数默认为true,指明broker在如下场景会自动创建主题:

  • 生产者开始写消息到这个主题时;
  • 消费者开始从这个主题读消息时;
  • 当任何客户端查询这个主题的元信息时。

主题默认配置

Kafka 为新创建的主题提供了很多默认配置参数,下面来看看这些默认参数

  • num.partitions

指定主题创建时的分区数,默认为1个分区。如果启用了主题自动创建功能(该功能是默认启用的),主题分区的个数就是该参数指定的值。但要注意,我们只可以增加主题分区的个数,但不能减少分区的个数。

  • default.replication.factor

表示 kafka保存消息的副本数,如果一个副本失效了,另一个还可以继续提供服务,默认值为1。

  • log.retention.hours

Kafka 通常根据时间来决定数据可以保留多久。该参数就是来配置时间的,默认是 168 个小时,即一周。除此之外,还有两个参数 log.retention.minuteslog.retentiion.ms 。这三个参数作用是一样的,都是决定消息多久以后被删除,推荐使用 log.retention.ms

  • log.retention.bytes

另一种保留消息的方式是判断消息是否超出存储阈值。它的值通过参数 log.retention.bytes 来指定,作用在每一个分区上。也就是说,如果有一个包含 8 个分区的主题,并且 log.retention.bytes 被设置为 1GB,那么这个主题最多可以保留 8GB 数据。

  • log.segment.bytes

当写入消息时,消息会被追加到日志段文件中。如果日志段超过log.segment.bytes指定大小(默认为1G),那么会打开日志段进行追加。旧日志段关闭后,超过了一定时间会被过期删除。

  • log.segment.ms

上面提到日志片段经关闭后需等待过期,那么 log.segment.ms 这个参数就是指定日志多长时间被关闭的参数和,log.segment.mslog.retention.bytes 也不存在互斥问题。日志片段会在大小或时间到达上限时被关闭,就看哪个条件先得到满足。

当使用log.segment.ms参数时,有一个场景需要注意,那就是如果有大量的分区而这些分区的日志段都没有到达指定大小,那么达到log.segment.ms时间时,这些分区的日志段会同时被关闭,可能会影响磁盘性能。

  • message.max.bytes

broker 通过设置 message.max.bytes 参数来限制单个消息的大小,默认是 1000 000, 也就是 1MB,当生产者发送的消息超过这个大小时,发送消息会失败。注意这个参数限制的是发送者发送到broker的消息大小,如果发送前消息超过此阈值,但是压缩后消息小于此阈值,那么发送仍然会成功。

这个值对性能有显著的影响。值越大,那么负责处理网络连接和请求的线程就需要花越多的时间来处理这些请求。另外,Kafka中有另外一个参数fetch.message.max.bytes来限制消费者获取的消息大小,fetch.message.max.bytes小于message.max.bytes,那么可能会导致消费者无法消费消息而被卡住。

JVM 参数配置

目前,Java 8及以上版本是大家使用的主流版本,对于其默认的G1垃圾收集器,一般只需要调整这两个参数即可:

  • MaxGCPauseMillis:指定垃圾回收的最大停顿时间,默认为200ms。G1会尽可能在保证垃圾回收时不超过这个阈值,但是在需要的情况下停顿时间会超过这个时间。。
  • InitiatingHeapOccupancyPercent:指定多大的堆使用比例会触发垃圾收集,默认为45%。这个百分比包括新生代和老年代。

Kafka本身使用内存非常高效,因此我们可以将这两个参数设置得更小。在64G机器内存,5G的Kafka内存情况下,我们可以设置MaxGCPauseMills为20ms,InitiatingHeapOccupancyPercent为35。

2.Kafka生产者

2.1 创建 Kafka 生产者

要向 Kafka 写入消息,首先需要创建一个生产者对象,并设置一些属性。有三个基本属性:

  • bootstrap.servers

该属性指定 broker 的地址清单,地址的格式为 host:port。清单里不需要包含所有的 broker 地址,生产者会从给定的 broker 里查找到其他的 broker 信息。但建议指定至少包含两个broker,这样一个broker宕机后生产者可以连接到另一个broker。

  • key.serializer

属性值是类的名称。这个属性指定了用来序列化键值(key)的类。Kafka broker只接受字节数组,但生产者的发送消息接口允许发送任何的Java对象,因此需要将这些对象序列化成字节数组。key.serializer指定的类需要实现org.apache.kafka.common.serialization.Serializer接口,它表示类将会采用何种方式序列化,它的作用是把对象转换为字节,实现了 Serializer 接口的类主要有 ByteArraySerializerStringSerializerIntegerSerializer ,其中 ByteArraySerialize 是 Kafka 默认使用的序列化器,要注意的一点:key.serializer 是必须要设置的,即使你打算只发送值的内容

  • value.serializer

与 key.serializer 一样,value.serializer 指定的类会将值序列化。

示例如下:

Properties kafkaProps = new Properties();
kafkaProps.put("bootstrap.servers", "broker1:9092,broker2:9092");
kafkaProps.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
kafkaProps.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
KafkaProducer producer = new KafkaProducer<String, String>(kafkaProps);

2.2 发送消息到Kafka

创建完生产者后,我们可以发送消息。Kafka中有三种发送消息的方式:

  • 只发不管结果(fire-and-forget)

    最简单的发送模式,只调用接口发送消息到Kafka服务器,但不管成功写入与否。由于Kafka是高可用的,因此大部分情况下消息都会写入,但在异常情况下会丢消息。

    ProducerRecord<String, String> record = new ProducerRecord<>("UserInfo", "Name", "Li Si");
    try {
      producer.send(record);
    } catch (Exception e) {
      e.printStackTrace();
    }
    

    逻辑很简单:

    • 创建一个ProducerRecord,并且指定了主题以及消息的key/value。其中,此处调用的ProducerRecord构造方法为

      public ProducerRecord(String topic, K key, V value) {}
      
    • 使用send()方法来发送消息,该方法会返回一个RecordMetadataFuture对象,但由于我们没有跟踪Future对象,因此并不知道发送结果。

    • 虽然我们忽略了发送消息到broker的异常,但是我们调用send()方法时仍然可能会遇到一些异常,例如序列化异常、发送缓冲区溢出异常等等。故用try..catch..处理

  • 同步发送(Synchronous send)

    调用send()方法返回一个Future对象,我们可以使用它的get()方法来判断消息发送成功与否。

    ProducerRecord<String, String> record = new ProducerRecord<>("UserInfo", "Name", "Li Si");
    try {
        producer.send(record).get();
    } catch (Exception e) {
        e.printStackTrace();
    }
    

    发送成功,就会得到一个RecordMetadata对象,否则抛出异常。其中,发送过程中一般有两种错误:一是broker返回不可恢复异常,比如消息内容过大,生产者直接抛出该异常;二是可恢复异常,例如连接异常,生产者会进行重试,如果重试超过一定次数仍不成功则抛出异常。

  • 异步发送(Asynchronous send)

    调用send()时提供一个回调方法,当接收到broker结果后回调此方法。

    class ProducerCallBack implements Callback {
        @Override
        public void onCompletion(RecordMetadata metadata, Exception exception) {
            if (exception != null) {
                exception.printStackTrace();
            }
        }
    }
    
    ProducerRecord<String, String> record = new ProducerRecord<>("UserInfo", "Name", "Li Si");
    try {
        producer.send(record, new ProducerCallBack());
    } catch (Exception e) {
        e.printStackTrace();
    }
    

    如上所示,回调需要定义一个实现了org.apache.kafka.clients.producer.Callback的类,这个接口只有一个 onCompletion方法。如果 kafka 返回一个错误,onCompletion 方法会抛出一个异常,我们可以对其进行自定义的处理。

2.3 分区

在上面的例子中,我们创建消息的时候,声明了主题和消息的内容,而消息的key是可选的,当不指定key时默认为null。

消息的key有两个重要的作用:1)提供描述消息的额外信息;2)用来决定消息写入到哪个分区,所有具有相同key的消息会分配到同一个分区中。

如果key为null,Kafka会使用默认的轮询分配器将消息均衡到多有分区,否则,将key进行哈希并根据结果将消息分配到对应分区。需要注意,在计算消息与分区的映射关系时,使用的是全部的分区数。如果某个分区不可用,而消息刚好被分配到该分区,那么将会写入失败。此外,如果需要增加额外的分区,那么消息与分区的映射关系也会发生改变。

Kafka支持自定义的分区策略,显示配置生产者端的参数 Partitioner.class即可,类结构如下

public interface Partitioner extends Configurable, Closeable {
  
  public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

  public void close();
  
  default public void onNewBatch(String topic, Cluster cluster, int prevPartition) {}
}
  • partition(): 这个类有几个参数: topic,表示需要传递的主题;key 表示消息中的键值;keyBytes表示分区中序列化过后的key,byte数组的形式传递;value 表示消息的 value 值;valueBytes 表示分区中序列化后的值数组;cluster表示当前集群的原数据。Kafka 给你这么多信息,就是希望让你能够充分地利用这些信息对消息进行分区,计算出它要被发送到哪个分区中。
  • close() : 继承了 Closeable 接口能够实现 close() 方法,在分区关闭时调用。
  • onNewBatch(): 表示通知分区程序用来创建新的批次

以下为一个例子,将key为Name的消息单独放在一个分区

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

    @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 ((keyBytes == null) || (!(key instanceof String))){
            throw new InvalidRecordException("We expect all messages to have customer name as key");
        }
        if (((String) key).equals("Name")){
            return numPartitions; 
        }
        return (Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1));
    }

    @Override
    public void close() {}

}

2.4 生产者的配置

同样,一个良好的生产者也离不开因地制宜的配置项,以下将会介绍部分影响比较大的参数进行说明:

acks

acks控制多少个副本必须写入消息后生产者才能认为写入成功,这个参数对消息丢失可能性有很大影响。有以下三种取值:

  • acks=0:生产者将完全不等待服务器的任何确认,把消息发送到broker即认为成功。这种方式的吞吐最高,但也是最容易丢失消息的。
  • acks=1:生产者会在该分区的leader写入消息并返回成功后,认为消息发送成功,但不会等待所有follower的完全确认这种方式能够一定程度避免消息丢失,但如果群首宕机时该消息没有复制到其他副本,那么该消息还是会丢失。
  • acks=all:生产者会等待所有副本成功写入该消息,这种方式是最安全的,保证了只要至少一个同步副本仍处于活动状态,记录就不会丢失,等效于acks = -1设置。 但是延迟也是最大的。

buffer.memory

这个参数设置生产者缓冲发送的消息的内存大小,如果发送记录的速度比将记录发送到服务器的速度快,则生产者将阻塞max.block.ms,此后它将引发异常。

compression.type

默认情况下消息是不压缩的,这个参数可以指定使用消息压缩,参数可以取值为none,gzip,snappy,lz4或zstd。通过使用压缩,我们可以节省网络带宽和Kafka存储成本。

retries

当生产者发送消息收到一个可恢复异常时,会进行重试,这个参数指定了重试的次数。默认情况下,生产者在每次重试之间等待 100ms,这个等待参数可以通过 retry.backoff.ms 进行修改。建议总的重试时间比集群重新选举leader的时间长,这样可以避免生产者过早结束重试导致失败。

batch.size

当多条消息发送到一个分区时,生产者会进行批量发送,这个参数指定了批量消息的大小上限(以字节为单位)。当批量消息达到这个大小时,生产者会一起发送到broker。

linger.ms

这个参数指定生产者在发送批量消息前等待的时间,当设置此参数后,即便没有达到批量消息的指定大小,到达时间后生产者也会发送批量消息到broker。默认情况下,生产者的发送消息线程只要空闲了就会发送消息,即便只有一条消息。设置这个参数后,发送线程会等待一定的时间,这样可以批量发送消息增加吞吐量,但同时也会增加延迟。

client.id

这个参数可以是任意字符串,它是broker用来识别消息是来自哪个客户端的。

max.in.flight.requests.per.connection

这个参数指定生产者可以发送多少消息到broker并且等待响应,设置此参数较高的值可以提高吞吐量,但同时也会增加内存消耗。另外,如果设置过高反而会降低吞吐量,因为批量消息效率降低。设置为1,可以保证发送到broker的顺序和调用send方法顺序一致,即便出现失败重试的情况也是如此。

timeout.ms, request.timeout.ms, metadata.fetch.timeout.ms

这些参数控制生产者等待broker的响应时间。request.timeout.ms指定发送数据的等待响应时间,metadata.fetch.timeout.ms指定获取元数据(例如获取分区的群首信息)的等待响应时间。timeout.ms则指定broker在返回结果前等待其他副本(与acks参数相关)响应的时间,如果时间到了但其他副本没有响应结果,则返回消息写入失败。

max.block.ms

这个参数指定应用调用send方法或者获取元数据方法(例如partitionFor)时的阻塞时间,超过此时间则抛出timeout异常。

max.request.size

这个参数限制生产者发送数据包的大小,数据包的大小与消息的大小、消息数相关。如果我们指定了最大数据包大小为1M,那么最大的消息大小为1M,或者能够最多批量发送1000条消息大小为1K的消息。另外,broker也有message.max.bytes参数来控制接收的数据包大小。在实际中,建议这些参数值是匹配的,避免生产者发送了超过broker限定的数据大小。

receive.buffer.bytes, send.buffer.bytes

Kafka 是基于 TCP 实现的,为了保证可靠的消息传输,这两个参数分别指定了 TCP Socket 接收和发送数据包的缓冲区的大小。如果它们被设置为 -1,就使用操作系统的默认值。