Kafka 是个什么玩意(2) : 生产者客户端详解

1,286 阅读10分钟

这是我参与8月更文挑战的第19天,活动详情查看:8月更文挑战

前言

在本文中,将通过生产者的介绍加使用来看看 producer 它是如何工作的吧。

一、必要参数配置

在创建生产者的时候,需要填入很多配置,其中有三个参数是必填的:

  • bootstrap.servers :指定连接 kafka 集群所需的 broker 地址清单,可以设置一个或多个
  • key.serializer 和 value.serializer :broker 中接收的消息必须以字节数组存在,因此,在发送消息之前需要把消息转成字节数组,即进行序列化

其他参数:

  • properties.put("client.id","id"); // 设定客户端的id,如果没填,就自动生成,例如producer1 、producer2...

  • properties.put("acks","all") :指定分区中必须要有多少副本收到这条消息

    • 生产者需要leader确认请求完成之前接收的应答数。此配置控制了发送消息的耐用性,支持以下配置:

      • acks=0 如果设置为0,那么生产者将不等待任何消息确认。消息将立刻添加到socket缓冲区并考虑发送。在这种情况下不能保障消息被服务器接收到。并且重试机制不会生效(因为客户端不知道故障了没有)。每个消息返回的offset始终设置为-1。
      • acks=1,这意味着leader写入消息到本地日志就立即响应,而不等待所有follower应答。在这种情况下,如果响应消息之后但follower还未复制之前leader立即故障,那么消息将会丢失。
      • acks=all 这意味着leader将等待所有副本同步后应答消息。此配置保障消息不会丢失(只要至少有一个同步的副本)。这是最强壮的可用性保障。等价于acks=-1。
  • properties.put("retries",1);// 重试次数
  • properties.put("batch.size",16384);// 批次大小
  • properties.put("linger.ms",1);// 等待时间
  • properties.put("buffer.memory", 33554432);// 缓冲区大小

生产者客户端参数配置建议

参数默认值推荐值说明
acks1高可靠:all高吞吐:1收到Server端确认信号个数,表示procuder需要收到多少个这样的确认信号,算消息发送成功。acks参数代表了数据备份的可用性。常用选项:acks=0:表示producer不需要等待任何确认收到的信息,副本将立即加到socket buffer并认为已经发送。没有任何保障可以保证此种情况下server已经成功接收数据,同时重试配置不会发生作用(因为客户端不知道是否失败)回馈的offset会总是设置为-1。acks=1:这意味着至少要等待leader已经成功将数据写入本地log,但是并没有等待所有follower是否成功写入。如果follower没有成功备份数据,而此时leader又无法提供服务,则消息会丢失。acks=all:这意味着leader需要等待所有备份都成功写入日志,只有任何一个备份存活,数据都不会丢失。
retries0结合实际业务调整客户端发送消息的重试次数。值大于0时,这些数据发送失败后,客户端会重新发送。注意,这些重试与客户端接收到发送错误时的重试没有什么不同。允许重试将潜在的改变数据的顺序,如果这两个消息记录都是发送到同一个partition,则第一个消息失败第二个发送成功,则第二条消息会比第一条消息出现要早。
request.timeout.ms30000结合实际业务调整设置一个请求最大等待时间,超过这个时间则会抛Timeout异常。超时时间如果设置大一些,如120000(120秒),高并发的场景中,能减少发送失败的情况。
block.on.buffer.fullTRUETRUETRUE表示当我们内存用尽时,停止接收新消息记录或者抛出错误。默认情况下,这个设置为TRUE。然而某些阻塞可能不值得期待,因此立即抛出错误更好。如果设置为false,则producer抛出一个异常错误:BufferExhaustedException
batch.size16384262144默认的批量处理消息字节数上限。producer将试图批处理消息记录,以减少请求次数。这将改善client与server之间的性能。不会试图处理大于这个字节数的消息字节数。发送到brokers的请求将包含多个批量处理,其中会包含对每个partition的一个请求。较小的批量处理数值比较少用,并且可能降低吞吐量(0则会仅用批量处理)。较大的批量处理数值将会浪费更多内存空间,这样就需要分配特定批量处理数值的内存大小。
buffer.memory3355443267108864producer可以用来缓存数据的内存大小。如果数据产生速度大于向broker发送的速度,producer会阻塞或者抛出异常,以“block.on.buffer.full”来表明。这项设置将和producer能够使用的总内存相关,但并不是一个硬性的限制,因为不是producer使用的所有内存都是用于缓存。一些额外的内存会用于压缩(如果引入压缩机制),同样还有一些用于维护请求。

二、消息发送流程

通常情况下,生产逻辑需要具备以下几个步骤:

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

发送消息它可以分为三种模式:

  • 1、发完即忘(fire-and-forget)
  • 2、同步(sync)
  • 3、异步(async) image-20210812175243149

2.1 发完即忘

public class KafkaProducerAnalysis {
    private static final String brokerList = "192.168.81.101:9092";
    private static final String topic = "xiaolei2";
​
    public static Properties initConfig(){
        Properties properties = new Properties();
​
        properties.put("bootstrap.servers",brokerList);
​
        // 指定key 的序列化器
        properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        // 指定 value 的序列化器
        properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        return properties;
    }
​
    public static void main(String[] args) {
​
        Properties properties = initConfig();
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
​
        for (int i = 0; i <= 5; i++) {
            producer.send(new ProducerRecord<>(topic,Integer.toString(i)));
        }
        producer.close();
    }
}

其中除了配置之外,我们能注意到一个重要的对象 ProducerRecord,它的实例属性如下,也是我们重点要掌握的。

public class ProducerRecord<K, V> {
    private final String topic; // 主题
    private final Integer partition;//分区
    private final Headers headers;// 消息头部,可以添加应用自定义消息
    private final K key;    // 键,可以用来计算分区号,不指定分区,同一个key的消息会被划分到同一个分区。
    private final V value;  // 值
    private final Long timestamp;   // 消息时间戳
 }

2.2 同步发送

发完即忘模式它是不在乎消息是否正确送达,这种可能会造成数据的丢失,这种方式的性能最高,可靠性也最差。

而同步发送的方式可以利用返回的 Futrune 对象实现。

    public static void main(String[] args) {
​
        Properties properties = initConfig();
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
​
        try{
            for (int i = 0; i <= 5; i++) {
                Future<RecordMetadata> future = producer.send(new ProducerRecord<>(topic, Integer.toString(i)));
                RecordMetadata recordMetadata = future.get();
                System.out.println(recordMetadata.topic()+"-"+recordMetadata.partition()+"-"+recordMetadata.offset());
            }
            producer.close();
        }catch (Exception e){
            e.printStackTrace();
        }
    }

打印效果:

xiaolei2-0-6
xiaolei2-0-7
xiaolei2-0-8
xiaolei2-0-9
xiaolei2-0-10
xiaolei2-0-11

通过 future.get() 获取一个 RecordMetadata 对象,在对象里面包含了消息的一些元数据信息,比如当前消息的主题、分区号、分区中的偏移量、时间戳等。

如果不需要这些信息,可以直接采用 producer.send(record).get() 的方式实现。

同步发送的可靠性很高,不过,性能会下降很多,需要阻塞等待一条消息发送完之后才可能发送下一条,要么消息发送成功,要么消息发送失败,发生异常。异常一般有两种类型:

  • 可重试的异常
  • 不可重试的异常。

对于可重复的异常,可以通过配置 retries 参数进行多次重试,如果消费失败就在外层逻辑处理。

对于不可重试的异常,例如消息太大,则直接抛出。

2.3 异步发送

异步发送 在 send()方法中调用了 Callback,Kafka有响应就回调

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

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

public class ProducerAck {
    public static void main(String[] args) {
        Properties properties = new Properties();
​
        properties.put("bootstrap.servers","hadoop101:9092");
        properties.put("acks","all");
​
        properties.put("retries",1);// 重试次数
        properties.put("batch.size",16384);// 批次大小
        properties.put("linger.ms",1);// 等待时间
        properties.put("buffer.memory", 33554432);// 缓冲区大小
        // 指定key 的序列化器
        properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        // 指定 value 的序列化器
        properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
​
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
​
        for (int i = 0; i <= 100; i++) {
            producer.send(new ProducerRecord<>("xiaolei", Integer.toString(i)), new Callback() {
                //回调函数,该方法会在Producer收到ack时调用,为异步调用
                @Override
                public void onCompletion(RecordMetadata recordMetadata, Exception e) {
                    if (e == null) {
                        System.out.println("success->" + recordMetadata.offset());
                    } else {
                        e.printStackTrace();
                    }
                }
            });
        }
        producer.close();
    }
}

三、 序列化

前面说过,生产者需要用序列化器 把对象转换成字节数组才能通过网络发送给Kafka,消息的 key 和 value 都使用了字符串,对应程序的序列化器也使用了客户端自带的 StringSerializer,此外,ByteArray、ByteBuffer、Bytes、Double、Integer、Long 这几种类型也实现了这个接口。

生产者和消费者的同一个 key value 的序列化应该一致,如果 消费者用 IntegerSerializer 那就无法解析 StringSerializer 的数据。

四、分区

4.1 分区策略

可以看到,除了 Topic 必须外,分区是可以不填写的,分区的原因就是方便集群中的水平扩展,可以调整 topic 的消息生产并发。

那分区的原则是怎样的呢?

我们发送消息都会用到 ProducerRecord 对象,它里面定义了分区的原则,如下:

  • 1、指明 partition 的情况下,直接将指明的值直接作为 partition 值。
  • 2、如果没有指明 partition,但有key,则将 key 的hash 值 与 topic 的 partition 数进行取余得到 partition 值
  • 3、既没有 partition 值 也没有 key 值的情况下,会随机一个分区,然后尽可能的一直使用该分区,待该分区的缓冲区(batch)满或者超过指定时间后,会重新随机一个分区来使用。

4.2 分区器

kafka 默认采用 DefaultPartitioner 实现 Partitioner 接口来实现分区器。

partition 方法用来计算分区号。我们也可以根据自己的业务来自定义分区的计算方式,比如大型电商有多个仓库,可以将仓库的名称作为 key 来存储商品信息的分区。

Kafka 除了提供的 默认分区其进行分区分配,还可以自定义分区,具体实现如下:

public class DemoPartitioner implements Partitioner {
    private final AtomicInteger counter = new AtomicInteger(0);
​
    @Override
    public int partition(String topic, Object key, byte[] bytes, Object o1, byte[] bytes1, Cluster cluster) {
        List<PartitionInfo> partitionInfos = cluster.partitionsForTopic(topic);
        int num = partitionInfos.size();
        if(null == bytes){
            return counter.getAndIncrement()%num;
        }else{
            return Utils.toPositive(Utils.murmur2(bytes1))% num;
        }
    }
​
    @Override
    public void close() {
    }
​
    @Override
    public void configure(Map<String, ?> map) {
    }
}

需要在配置类中添加自定义分区器:

properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, DemoPartitioner.class);

五、拦截器

生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息,修改消息的内容等,也可以做一些统计工作。

生产者拦截器通过自定义实现 ProducerInterceptor 接口实现。

下面,我们通过一个案例,将发送的消息加上前缀 “pre-”。

public class ProducerInterceptorPrefix implements ProducerInterceptor {
    
    private volatile long sendSuccess = 0;
    private volatile long sendFailure = 0;
​
    @Override
    public ProducerRecord onSend(ProducerRecord producerRecord) {
        String modify = "pre-"+producerRecord.value(); 
        return new ProducerRecord(producerRecord.topic(),producerRecord.partition(),producerRecord.timestamp(),producerRecord.key(),modify,producerRecord.headers());
    }
​
    @Override
    public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
        if(e == null){
            sendSuccess++;
        }else{
            sendFailure++;
        }
    }
​
    @Override
    public void close() {
        double result = (double)sendSuccess/(sendFailure+sendSuccess);
        System.out.println("成功了,百分比:"+result*100+"%");
    }
​
    @Override
    public void configure(Map<String, ?> map) {
​
    }
}

还需要在配置类中指定 拦截器:

properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName());

消费者消费的数据如下:

image-20210818155300304

同时,拦截器可以配置多个形成拦截链。用逗号分割如下:

properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName()+","+ProducerInterceptorPrefix.class.getName());

再执行,结果如下:

image-20210818155512246

六、整体架构

img

以上这图是生产者客户端的整体架构,主要包含核心类:KafkaProducer生产消息、消息累加器类 RecordAccumulator、处理发送请求器类 Sender 以及网络 IO 类 Selector。

  • 1、整个生产者客户端由两个线程协调运行,这两个线程分别为主线程和 Sender 线程(发送线程)。在主线程中由 KafkaProducer 创建消息,然后通过可能的拦截器、序列化器和分区器的作用之后缓存到 消息累加器中。
  • 2、消息累加器内部是一个双端队列,用来缓存消息,并压缩消息,然后提供给 Sender 线程批量发送。缓存大小默认为 32M,如果生产者发送消息的速度超过发送到服务器的速度,会导致生产者空间不足,这个时候 KafkaProducer 的send方法调用就会出现阻塞或异常。
  • 3、Sender 接收到消息之后,会将数据转换为 <NodeId,List> 的形式。其中 NodeId 代表 broker 节点id,List 代表发送的消息数据集合,但还不是最终的请求对象,Sender 还会进一步封装成<Node,Request>的形式,这样就可以将 Request 请求发送到各个 Node,这里的 Request指各种协议。最后才通过 Selector 进行网络 IO 层发送。
  • 4、Selector 是一个非阻塞支持多连接的网络 IO 类,包括 NetworkSend 和 NetworkReceive 两部分,分别负责向 Kafka 集群发送网络请求以及接收 kafka 集群的响应。

参考资料:

  • 《深入理解 Kakfa 核心设计与实现原理》