基于kafka 2.12-2.0.0版本
kafka-clients 2.0.0
本文是《深入理解Kafka核心设计与实践原理》的读书笔记
一、架构图
由此可以看到kafka体系架构的组成有如下几部分:
1.producer生产者,发送消息到kafka cluster
2.kafka cluster是由broker组成的集群
3.consumer消费者,从kafka cluster中pull拉取消息进行消费
4.zookeeper cluster,用于保存kafka集群的元数据
另外还有2个概念
1.主题topic。Kafka中的消息以主题为单位进行分类,生产者负责讲消息发送到特定的主题,消费者负责订阅主题并进行消费
2.分区 partition。同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量offset。offset是消息在分区中的唯一标识,kafka通过它来保证消息在分区内的顺序性。不过offset并不跨越分区,kafka保证的是分区有序而不是主题有序。
二、producer生产者客户端demo
引用依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.sid</groupId>
<artifactId>test-kafka</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
</project>
配置
package com.sid;
public class KafkaProperties {
public static final String ZK = "node1:2181";
public static final String TOPIC="sid_topic";
public static final String BROKER_LIST ="node1:9092";
public static final String GROUP_ID ="test_group1";
}
生产者发送demo
package com.sid;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;
public class ProducerTest {
public static void main(String[] args) {
Properties properties = new Properties();
//用来指定生产者客户端连接kafka集群所需的Broker地址清单,
//注意这里并非需要所有broker地址,因为生产者会从给定的broker里查找到其他broker的信息
properties.put("bootstrap.servers", KafkaProperties.BROKER_LIST);
properties.put("request.required.acks", "1");
properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
//KafkaProducer实现线程安全的,可以在多个线程中共享单个KafkaProducer实例,也可以将KafkaProducer实例进行池化来供其他线程调用
KafkaProducer<String, String> producer = new KafkaProducer<String, String>(properties);
ProducerRecord<String, String> record = new ProducerRecord<String, String>(KafkaProperties.TOPIC, "hello,kafka!");
try {
Future<RecordMetadata> result = producer.send(record);
//同步阻塞
RecordMetadata metadata = result.get();
System.out.println(metadata.topic() + "-" + metadata.partition() + ":" + metadata.offset());
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
producer.close();
}
}
其中,构建消息对象ProducerRecord,它并不只是单纯的消息,还包含了其他属性,消息"hello,kafka!"只是 ProducerRecord对象的value属性而已
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;
}
key是用来指定消息的键,它还有其他功能:
1.可以用来计算分区号进而让消息发往特性的分区。
同一个key的消息会被划分到同一个分区中。
2.有Key的消息还可以支持日志压缩的功能
bootstrap.servers用来指定生产者客户端连接kafka集群所需的Broker地址清单,注意这里并非需要所有broker地址,因为生产者会从给定的broker里查找到其他broker的信息。
kafkaProducer
KafkaProducer实现线程安全的,可以在多个线程中共享单个KafkaProducer实例,也可以将KafkaProducer实例进行池化来供其他线程调用 。
kafkaProducer中一般会发生两种类型的异常:
1.可重试异常
如NetworkException(网络异常)、LeaderNotAvailableException(分区的Leader副本不可用)、UnknownTopicOrPartitionException、NotEnoughReplicasException、NotCoordinatorException等。
对于可以重试的异常,如果配置了retries参数,那么只要在规定的重试次数内自行恢复了就不会抛出异常,retries默认为0。配置方式:props.put(ProducerConfig.RETRIES_CONFIG,10)
2.不可重试异常
RecordTooLargeException(发送的消息太大) 。
kafkaProducer对此不会进行任何重试,直接抛出异常
生产者发送信息后异步回调
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if(exception != null){
exception.printStackTrace();
}else {
System.out.println(metadata.topic() + "-" + metadata.partition() + ":" + metadata.offset());
}
}
});
其中onCompletion的两个入参是互斥的
消息发送成功metadata不为null,exception为null
消息发送异常,metadata为null,exception不为null
三、producer生产者客户端发送流程
由图可见:
整个生产者客户端由两个线程协调运行,这两个线程分别为主线程和 Sender 线程(发送线程)。
1.拦截器
作用:
生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
使用方式:
主要是自定义实现 org.apache.kafka.clients.producer. ProducerInterceptor 接口。
ProducerInterceptor 接口中包含3个方法:
public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record);
public void onAcknowledgement(RecordMetadata metadata, Exception exception);
public void close();
KafkaProducer 在将消息序列化和计算分区之前会调用生产者拦截器的 onSend() 方法来对消息进行相应的定制化操作。一般来说最好不要修改消息 ProducerRecord 的 topic、key 和 partition 等信息。
KafkaProducer 会在消息被应答(Acknowledgement)之前或消息发送失败时调用生产者拦截器的 onAcknowledgement() 方法,优先于用户设定的 Callback 之前执行。这个方法运行在 Producer 的I/O线程中,所以这个方法中实现的代码逻辑越简单越好,否则会影响消息的发送速度。
close() 方法主要用于在关闭拦截器时执行一些资源的清理工作。
实现了自定义的拦截器后(比如ProducerInterceptor1、ProducerInterceptor2),需要在KafkaProducer的配置参数interceptor.classes中指定这个拦截器。比如:
properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,ProducerInterceptor1.class.getName()+","+ProducerInterceptor2.class.getName());
拦截链会按照interceptor.classes参数配置的拦截器的顺序来一一执行,配置的时候,各拦截器之间使用逗号隔开。
2.序列化器
作用:
把java对象转成字节数组用于在网络中传输
使用方式:
自定义实现org.apache.kafka.common.serialization.Serializer 接口,此接口有3个方法:
public void configure(Map<String, ?> configs, boolean isKey)
public byte[] serialize(String topic, T data)
public void close()
configure() 方法用来配置当前类
serialize() 方法用来执行序列化操作
close() 方法用来关闭当前的序列化器
3.分区器
作用:
确定该消息发送到broker上的哪个分区。(分区的概念将会在讲broker的文章中专门讲到)
使用方式:
如果消息 ProducerRecord 中指定了 partition 字段,那么就不需要分区器的作用,因为 partition 字段代表的就是所要发往的分区号。
如果消息 ProducerRecord 中没有指定 partition 字段,那么就需要用分区器,根据 key 这个字段来计算 partition 的值。
Kafka 中提供的默认分区器是 org.apache.kafka.clients.producer.internals.DefaultPartitioner,它实现了 org.apache.kafka.clients.producer.Partitioner 接口,这个接口中定义了2个方法,具体如下所示。
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster);
public void close();
partition() 方法用来计算分区号,返回值为 int 类型。partition() 方法中的参数分别表示主题、键、序列化后的键、值、序列化后的值,以及集群的元数据信息,通过这些信息可以实现功能丰富的分区器。
close() 方法在关闭分区器的时候用来回收一些资源。
默认分区器 DefaultPartitioner 的实现:
close() 是空方法
partition() 方法中定义了主要的分区分配逻辑:
如果 key 不为 null,那么默认的分区器会对 key 进行哈希,最终根据得到的哈希值来计算分区号,拥有相同 key 的消息会被写入同一个分区。(这里其实就是Key-ordering 按消息键顺序策略)
如果 key 为 null,会调用nextValue(topic)方法获取一个自增的值,如果topic存在可用分区那么将nextValue转成正数之后对可用分区数进行取余,如果topic不存在可用分区那么就从所有不可用的分区中通过取余的方式返回一个不可用的分区。(这里其实就是Round-robin轮询策略)
自定义的分区器,只需同 DefaultPartitioner 一样实现 Partitioner 接口即可
4.消息累加器RecordAccumulator
也叫消息收集器。
作用
主要用来缓存消息以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能。
双端队列
主线程发送过来的消息都会被追加到RecordAccumulator的某个双端队列中。
在 RecordAccumulator 的内部为每个分区都维护了一个双端队列(Deque),队列中的内容就是ProducerBatch,既是Deque<ProducerBatch>。
消息写入缓存时是追加到双端队列的尾部,sender线程读取消息时,从双端队列的头部读取。
ProducerBatch
ProducerBatch时指一个消息的批次,ProducerRecord会被包含在ProducerBatch中。
ProducerRecord进入ProducerBatch的流程:
当一条消息ProducerRecord流入RecordAccumulator时,会先寻找与消息分区所对应的双端队列(如果没有则新建)
再从这个双端队列的尾部获取一个ProducerBatch(没有则新建)
查看ProducerBatch中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可用则需要创建一个新的ProducerBatch。
在新建ProducerBatch时评估这条消息的大小是否超过了batch.size参数的大小,如果不超过,那么就以batch.size参数的大小来创建ProducerBatch,这样在使用完这段内存区域后可以通过BufferPool管理来进行复用;如果超过,那么就以评估的大小来创建ProducerBatch,这段内存区域不会被复用。
BufferPool
消息在网络上都是以字节Byte的形式传输的,在发送之前需要创建一块内存区域来保存对应的消息。在Kafka生产者客户端中,通过java.io.ByteBuffer实现消息内存的创建释放。
不过频繁的创建和释放是比较耗费资源的,在RecordAccumulator的内部还有一个BufferPool,它主要用来实现ByteBuffer的复用,以实现缓存的高效利用。
BufferPool只针对特定大小的ByteBuffer进行管理,而其他大小的ByteBuffer不会缓存进BufferPool中,这个特定大小由batch.size参数来指定,默认为16384B,即16KB。
我们可以适当调大该参数以便多缓存一些消息。
5.sender
Sender 从 RecordAccumulator 中获取缓存的消息之后,会进一步将原本<分区, Deque< ProducerBatch>> 的保存形式转变成 <Node, List< ProducerBatch> 的形式,其中 Node 表示 Kafka 集群的 broker 节点。
KafkaProducer 要将此消息追加到指定主题的某个分区所对应的 leader 副本之前,首先需要知道主题的分区数量,然后经过计算得出(或者直接指定)目标分区,之后 KafkaProducer 需要知道目标分区的 leader 副本所在的 broker 节点的地址、端口等信息才能建立连接,最终才能将消息发送到 Kafka。
所以这里需要一个转换,对于网络连接来说,生产者客户端是与具体的 broker 节点建立的连接,也就是向具体的 broker 节点发送消息,而并不关心消息属于哪一个分区。
换句话说生产者也有一份broker集群的元素据,才知道某个分区的leader副本所在broker节点的地址、端口等信息,通过zk的watch机制实现broker元素据变更时通知生产者。
在转换成<Node,List<ProducerBatch>>的形式之后,Sender还会进行进一步封装成<Node,Request>的形式,这样就可以将Request请求发往各个Node了。
这里的Request的是指Kafka的各种协议请求。
6.InFlightRequests
请求在从 Sender 线程发往 Kafka 之前还会保存到 InFlightRequests 中,InFlightRequests 保存对象的具体形式为 Map<NodeId, Deque<Request>>,
它的主要作用是缓存了已经发出去但还没有收到响应的请求(NodeId 是一个 String 类型,表示节点的 id 编号)。
max.in.flight.requests.per.connection
InFlightRequests还提供许多管理类的方法,通过配置参数可以限制每个连接(客户端与Node之间的连接)最多缓存的请求数。这个配置参数为max.in.flight.requests.per.connection,默认值5,
即每个连接最多只能缓存5个未响应的请求,超过该数值之后就不能再向这个连接发送更多的请求了,除非有缓存的请求收到响应Response。
通过比较Deque<Request>的size与这个参数的大小来判断对应的Node中是否已经堆积了很多未响应的消息,如果真是如此,那么说明这个Node节点负载加大或网络连接有问题,再继续向其发送请求会增大请求超时的可能。
leastLoadedNode
InFlightRequests还可以获得leastLoadedNode,即所有Node中负载最小的那一个。
这里的负载最小是通过每个Node在InFlightRequests中还未确认的请求决定的,未确认的请求越多则认为负载越大。
四、kafka客户端元数据更新
元数据是指Kafka集群的元数据,这些元数据具体记录了集群中有哪些主题,这些主题有哪些分区,每个分区的leader副本分配在哪个节点上,follower副本分配在哪些节点上,哪些副本在AR、ISR等集合中,集群中有哪些节点,控制器节点又有哪一个等消息。
引起客户端元数据更新操作的原因:
1.客户端中没有需要使用的元数据
2.超过metadata.max.age.ms时间没有更新元数据(默认值为300000,即5分钟)
更新元数据的流程:
1.先挑选出leastLoadedNode
2.向这个Node发送MetadataRequest请求来获取具体的元数据信息。
3.这个更新操作是由sender线程发起的,在创建完MetadataRequest之后同样会存入InFlightRequests,之后的步骤就和发送消息时类似
这个点就是用来回答,生产者是怎么知道自己每次发送的消息应该发到哪个IP的
启动的时候会有个MetadataRequest请求到leastLoadedNode,得到元数据,之后每次发送的时候如果发现自己没有保存该分区leader的IP或者保存的是错的,就会再发送一次MetadataRequest请求到leastLoadedNode获得元数据。
每隔5分钟会发送MetadataRequest请求到leastLoadedNode更新一下自己缓存的元数据。
这里的leastLoadedNode是指的是未处理完的网络请求数最少的node
五、KafkaProducer管理TCP连接
Java producer端管理TCP连接的方式是:
1. KafkaProducer实例创建时启动Sender线程,从而创建与bootstrap.servers中所有broker的TCP连接
2. KafkaProducer实例拿到元数据信息之后还会再次创建与bootstrap.servers中所有broker的TCP连接
3. 步骤1中创建的TCP连接只用于首次获取元数据信息(实际上也只是会用到其中的一个连接,其他的N - 1个可能完全不会被用到)
4. 如果设置producer端connections.max.idle.ms参数大于0,则步骤1中创建的TCP连接会被自动关闭;如果设置该参数=-1,那么步骤1中创建的TCP连接将成为“僵尸”连接
六、生产者重要的参数
1.acks
这个参数用来指定分区中必须要有多少个副本收到这条消息之后生产者才认为这条消息时成功写入的。
它涉及消息的可靠性和吞吐量之间的权衡。
acks=0
只管发送,不论有不有分区收到了,不论是否已经保存到了副本中,发了就认为发送成功
acks=1(默认设置)
broker中对应的分区收到了,至少该分区有一个副本(leader副本)已收到落盘了,不过其他follower是否同步且落盘了这条消息,这样就认为发送成功。
该配置下可能会丢数据的场景:
生产者发送了,leader也落盘了,就认为发送成功了,但是follower还没来得及同步,leader就挂了,此时从follower选一个当leader,这个新的leader是没有这条数据的
acks=all或者-1(该场景可能出现消息重复)
broker中对应的分区收到了,该分区所有副本(leader 和 follower)都收到落盘了,这样才认为发送成功
由此可见ack机制会直接影响到Kafka集群的吞吐量和消息可靠性。而吞吐量和可靠性就像硬币的两面,两者不可兼得,只能平衡。
那么只要把ack位置为all就一定不会丢数据吗?
不是!
如果一个分区只有一个副本,那ack设置为all也会丢数据,所以建议一个分区至少有3个副本(1个leader,2个follower)
而且ISR中不能只有一个leader副本,想要获得更高的可靠性需要配置参数min.insync.replicas等参数的联动。
消息重复:
生产者发送数据,leader收到落盘,leader挂了,follower还没来得及同步,follower变成新leader,旧leader重启变成新follower(已有该条数据),生产者认为发送失败,生产者重发成功,新follower保存了两条该数据
注意
acks参数配置的值是一个字符串,不是整数类型。
2.max.request.size
这个参数用来限制生产者客户端发送消息的最大值,默认是1048576B,即是1MB.
这个参数还涉及一些跟其他参数(比如: broker端的message.max.bytes参数)的联动,不建议改。
3.retries和retry.backoff.ms
retries参数用来配置生产者重试次数,默认是0。如果该值大于1,生产者发生可重试的异常时会去重试,达到重试上线才会对上层应用程序跑异常。
retry.backoff.ms,默认值为100,它用来设置两次重试之间的时间间隔。
注意:
kafka可以保证同一个分区的消息是有序的。如果生产者按照一定的顺序发送消息,这些消息也会顺序的写入分区,进而消费者也可以按照同样的顺序消费他们。
如果将acks参数配置为非零的值,并且max.in.flight.requests.per.connection参数配置大于1的值,那么就会出现错序的现象:如果第一批次消息写入失败,而第二批次消息写入成功,那么生产者会重试发送第一批次的消息,如果此时第一次批次消息写入成功,那么这两个批次的消息就出现了错序。
一般,在需要保证消息顺序的场合建议把参数max.in.flight.requests.per.connection配置为1,而不是把ack配置为0,不过这样也会影响整体的吞吐。
4.compression.type
这个参数用来指定消息的压缩方式,默认是none,不压缩。
该参数还可以配置为gzip snappy lz4
对消息进行压缩可以极大的减少网络传输量、降低网络I/O,从而提高整体的性能。
消息压缩是一种使用时间换取空间的优化方案,如果对时间延迟由一定要求,则不推荐使用消息压缩。
5.connections.max.idle.ms
这个参数是用来指定在多久之后关闭闲置的连接,默认是540000ms,即使9分钟。
6.linger.ms
这个参数用来指定生产者发送ProducerBatch之前等待更多的消息(ProducerRecord)加入ProducerBatch的时间,默认值为0
生产者客户端会在ProducerBatch被填满或者等待时间超过linger.ms值时发送出去。
增大这个参数的值会增加消息的延迟,但是同时提升一定的吞吐量。
这个linger.ms参数与TCP协议中的Nagle算法有异曲同工之妙。
7.receive.buffer.bytes
这个参数用来设置socker接收消息缓冲区(SO_RECBUF)的大小,默认时32768B,即32KB。如果设置为-1,则使用操作系统默认值。如果producer与kafka处于不同的机房,则可适当调大这个参数值。
8.send.buffer.bytes
这个参数用来设置Socket发送消息缓冲区(SO_SNDBUF)的大小,默认值为131072(B),即128KB。与receive.buffer.bytes参数一样,如果设置为-1,则使用操作系统默认值。
9.request.timeout.ms
这个参数用来配置producer等待请求响应的最长时间,默认时30000ms。请求超时后可以选择进行重试。
注意这个值要比broker端的参数replica.lag.time.max.ms的值要大,这样可以减少因为客户端重试而引起的消息重复的概率。
10部分生产者客户端参数表
参数名称 | 默认值 | 参数意思 |
bootstrap.servers | " " | 指定连接kafka集群所需的broker地址清单 |
key.serializer | " " | 消息中key对应的序列化类,需要实现org.apache.kafka.common.serialization.Serializer接口 |
value.serializer | " " | 消息中value对应的序列化类,需要实现org.apache.kafka.common.serialization.Serializer接口 |
buffer.memory | 33554432 (32MB) | 生产者客户端中用于缓存消息的缓冲区大小 |
batch.size | 16384 (16KB) | 用于指定producerBatch可以复用内存区域的大小 |
client.id | " " | 用来设定kafkaproducer对应的客户端ID |
max.block.ms | 60000 | 用来控制kafkaProducer中send()方法和partitionsFor()方法的阻塞时间。当生产者的发送缓冲区已满,或者没有可用的元数据时,这些方法就是阻塞。 |
partitioner.class | org.apache.kafka.clients.producer.internals.DefaultPartitioner | 用来指定分区器,需要实现org.apache.kafka.clients.producer.Partitioner接口 |
enable.idempotence | false | 是否开启幂等性功能 |
interceptor.classes | " " | 用来设定生产者拦截器,需要实现org.apache.kafka.clients.producer.ProducerInterceptor接口 |
max.in.flight.requests.per.connection | 5 | 限制每个连接(也就是客户端与node之间的连接)最多缓存的请求数。(即没有收到响应的请求) |
metadata.max.age.ms | 300000(5分钟) | 如果在这个时间内元数据没有更新的话会被强制更新 |
transactional.id | null | 设置事务id,必须唯一 |