建议先看上一篇(二)Kafka之集群架构原理,可以让我们更清楚的认识到Kafka的面貌,避免管中窥豹。
今天这篇我们分析Kafka的核心模块——生产者。
Kafka生产者的流程概览
1、Kafka生产者会将消息封装成一个 ProducerRecord 向 kafka集群中的某个 topic 发送消息;
2、发送的消息首先会经过序列化器进行序列化,以便在网络中传输;
3、发送的消息需要经过分区器来决定该消息会分发到 topic 对应的 partition,当然如果指定了分区,那么就不需要分区器了;
4、这个时候消息离开生产者开始往kafka集群指定的 topic 和 partition 发送;
5、如果写入成功,kafka集群会回应 生产者一个 RecordMetaData 的消息,如果失败会根据配置的允许失败次数进行重试,如果还是失败,那么消息写入失败,并告诉生产者。
Kafka生产者的详细流程
步骤一:一条消息过来首先会被封装成为一个ProducerRecord 对象。
步骤二:接下来要对这个对象进行序列化,因为 Kafka 的消息需要从客户端传到服务端,涉及到网络传输,所以需要实现序列。Kafka 提供了默认的序列化机制,也支持自定义序列化(这种设计也值得我们积累,提高项目的扩展性)。
步骤三:消息序列化完了以后,对消息要进行分区,分区的时候需要获取集群的元数据。分区的这个过程很关键,因为这个时候就决定了,我们的这条消息会被发送到 Kafka 服务端到哪个主题的哪个分区了。
步骤四:分好区的消息不是直接被发送到服务端,而是放入了生产者的一个缓存里面。在这个缓存里面,多条消息会被封装成为一个批次(batch),默认一个批次的大小是 16K。
步骤五:Sender 线程启动以后会从缓存里面去获取可以发送的批次。
步骤六:Sender 线程把一个一个批次发送到服务端。大家要注意这个设计,在 Kafka0.8 版本以前,Kafka 生产者的设计是来一条数据,就往服务端发送一条数据,频繁的发生网络请求,结果性能很差。后面的版本再次架构演进的时候把这儿改成了批处理的方式,性能指数级的提升,这个设计值得我们积累。 生产者细节深度剖析。
Kafka生产者中重难点分析
ProducerRecord
生产者需要往集群发送消息前,要先把每一条消息封装成ProducerRecord对象,这是生产者内部完成的。
序列化器
在创建ProducerRecord时,必须指定序列化器,除了默认提供的序列化器之外推荐使用序列化框架Avro、Thrift、ProtoBuf等,不推荐自己创建序列化器。因为如果一旦需要修改,那么在维护新旧消息代码的兼容性时会遇到不同程度的问题。
Apache Avro
Apache Avro是一个数据序列化系统,它支持丰富的数据结构,提供了紧凑的,快速的,二进制的数据格式。
在使用 Avro 之前,需要先定义模式(schema),模式通常使用 JSON 来编写。
(1)创建一个类代表客户,作为消息的value
class Custom {
private int customID;
privat String customerName;
public Custom(int customID, String customerName) {
super();
this.customID = customID;
this.customerName = customerName;
}
public int getCustomID() {
return customID;
}
public String getCustomerName() {
return customerName;
}
}
(2)定义schema
{
"namespace": "customerManagement.avro",
"type": "record",
"name": "Customer",
"fields":[
{
"name": "id", "type": "string"
},
{
"name": "name", "type": "string"
},
]
}
(3)生成Avro对象发送到Kafka。当使用Avro读取消息时,需要先读取整个的schema。为了实现这一点,可以使用了一个名为Schema Registry的架构。
Properties props = new Properties();
props.put("bootstrap", "loacalhost:9092");
props.put("key.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("schema.registry.url", schemaUrl);//schema.registry.url指向射麻的存储位置
String topic = "CustomerContacts";
Producer produer = new KafkaProducer(props);
//不断生成消息并发送
while (true) {
Customer customer = CustomerGenerator.getNext();
ProducerRecord record = new ProducerRecord<>(topic, customer.getId(), customer);
producer.send(record);//将customer作为消息的值发送出去,KafkaAvroSerializer会处理剩下的事情
}
分区
当key为空且使用默认的分区器时,消息会被随机发送到指定topic的其中一个可用分区,会使用round-robin算法均衡分区间的消息。
当key不为空且使用默认的分区器时,Kafka会计算该key的hash值,并使用得到的hash值把消息映射到特定的分区,把一个key始终映射到同一分区是非常重要的。
只要一个topic的分区数量不变,key与分区的映射关系就能保证一致。但是如果你添加一个新的分区到一个topic时,虽然存在的数据仍然会保存在原来的分区里,但具有相同key的新消息不能保证还会写入到原来的分区。所以在创建topic时最好预先定义好需要的分区数量,避免后期添加新的分区造成映射关系的不一致。
缓冲区
一个消息被分区以后,消息首先会被放到一个缓存里面,我们看一下里面具体的细节。
默认缓存块的大小是 32M,这个缓存块里面有一个重要的数据结构:batches,这个数据结构是 key-value 的数据结构。key 就是消息主题的分区,value 是一个队列,里面存的是发送到对应分区的批次。
生产者高级设计之自定义数据结构
生产者把批次信息用 batches 这个对象进行存储。如果是大家,大家会考虑用什么数据结构去存储批次信息?
Kafka 这儿采取的方式是自定义了一个数据结构:CopyOnWriteMap。熟悉 Java 的同学都知道,JUC 下面是有一个 CopyOnWriteArrayList 的数据结构的,但是没有 CopyOnWriteMap,我这儿给大家解释一下 Kafka 为什么要设计这样的一个数据结构。
1.他们存储的信息的是 key-value 的结构,key 是分区,value 是要存到这个分区的对应批次(批次可能有多个,所以用的是队列),故因为是 key-value 的数据结构,所以锁定用 Map 数据结构。
2.这个 Kafka 生产者面临的是一个高并发的场景,大量的消息会涌入这个这个数据结构,所以这个数据结构需要保证线程安全,这样我们就不能使用 HashMap 这样的数据结构了。
3.这个数据结构需要支持的是读多写少的场景。读多是因为每条消息过来都会根据 key 读取 value 的信息,假如有 1000 万条消息,那么就会读取 batches 对象 1000 万次。写少是因为,比如我们生产者发送数据需要往一个主题里面去发送数据,假设这个主题有 50 个分区,那么这个 batches 里面就需要写 50 个 key-value 数据就可以了(大家要搞清楚我们虽然要写 1000 万条数据,但是这 1000 万条是写入 queue 队列的 batch 里的,并不是直接写入 batches,所以就我们刚刚说的这个场景,batches 里只需要最多写 50 条数据就可以了)。
根据第二和第三个场景我们总结出来,Kafka 这儿需要一个能保证线程安全的,支持读多写少的 Map 数据结构。但是 Java 里面并没有提供出来的这样的一个数据,唯一跟这个需求比较接近的是 CopyOnWriteArrayList,但是偏偏它又不是 Map 结构,所以 Kafka 这儿模仿 CopyOnWriteArrayList 设计了 CopyOnWriteMap。采用了读写分离的思想解决了线程安全且支持读多写少等问题。
高效的数据结构保证了生产者的性能。(CopyOnWriteArrayList 不熟悉的同学,可以尝试百度学习)。这儿笔者建议大家可以去看看 Kafka 生产者往 batches 里插入数据的源码,生产者为了保证插入数据的高性能,采用了多线程,又为了线程安全,使用了分段加锁等多种手段,源码非常精彩。
生产者高级设计之内存池设计
刚刚我们看到 batches 里面存储的是批次,批次默认的大小是 16K,整个缓存的大小是 32M,生产者每封装一个批次都需要去申请内存,正常情况下如果一个批次发送出去了以后,那么这 16K 的内存就等着 GC 来回收了。但是如果是这样的话,就可能会频繁的引发 FullGC,故而影响生产者的性能,所以在缓存里面设计了一个内存池(类似于我们平时用的数据库的连接池),一个 16K 的内存用完了以后,把数据清空,放入到内存池里,下个批次用的时候直接从里面获取就可以。这样大大的减少了 GC 的频率,保证了生产者的稳定和高效(Java 的 GC 问题是一个头疼的问题,所以这种设计也非常值得我们去积累)。
Sender
把消息放进缓冲区之后,与此同时会有一个独立线程Sender去把一个个Batch发送给对应的主机。
生产者代码
//1. 设置参数
Properties properties = new Properties();
properties.put("bootstrap.servers", "120.27.233.226:9092");
properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
properties.put("acks", "-1");
properties.put("retries", 3);
properties.put("batch.size", 16384);
properties.put("linger.ms", 10);
properties.put("buffer.memory", 33554432);
properties.put("max.block.ms", 3000);
properties.put("max.request.size", 1048576);
properties.put("request.timeout.ms", 30000);
//2.创建Producer实例,跟broker建立socket
Producer producer = null;
try {
producer = new KafkaProducer(properties);
for (int i = 0; i < 100; i++) {
String msg = "This is Message " + i;
//3. 创建消息
ProducerRecord record = new ProducerRecord("test_topic", "test", msg);
//4. 发送消息
producer.send(record);
System.out.println("Sent:" + msg);
Thread.sleep(1000);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//5. 关闭连接
producer.close();
}
关于消息发送
3.1 Fire-and-forget
发送消息后不需要关心是否发送成功。因为Kafka是高可用的,而且生产者会自动重新发送,所以大多数情况都会成功,但是有时也会失败。
ProducerRecord record = new ProducerRecord("CustomerCountry",
"Precision Products", "France");
try {
producer.send(record);
} catch (Exception e) {
e.printStackTrace();
}
在发送消息之前有可能会发生异常,例如是,序列化消息失败的SerializationException,缓冲区满的BufferExhaustedException,发送超时的TimeoutException或者发送的线程被中断的InterruptException。
3.2 Synchronous send
同步发送,调用send()方法后返回一个Future对象,再调用get()方法会等待直到结果返回,根据返回的结果可以判断是否发送成功。
简单的使用下面的代码替换上面try里面的一行代码:
producer.send(record).get();
在调用send()方法后再调用get()方法等待结果返回。如果发送失败会抛出异常,如果发送成功会返回一个RecordMetadata对象,然后可以调用offset()方法获取该消息在当前分区的偏移量。
KafkaProducer有两种类型的异常,第一种是可以重试的Retriable,该类异常可以通过重新发送消息解决。例如是连接异常后重新连接、“no leader”异常后重新选取新的leader。KafkaProducer可以配置为遇到该类异常后自动重新发送消息直到超过重试次数。第二类是不可重试的,例如是“message size too large”(消息太大),该类异常会马上返回错误。
3.3 Asynchronous send
异步发送,在调用send()方法的时候指定一个callback函数,当broker接收到返回的时候,该callback函数会被触发执行。
class DemoProducerCallback implements Callback {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e != null) {
e.printStackTrace();
}
}
}
producer.send(record, new DemoProducerCallback());
要使用callback函数,先要实现org.apache.kafka.clients.producer.Callback接口,该接口只有一个onCompletion方法。如果发送异常,onCompletion的参数Exception e会为非空。
Kafka中的异常
1)LeaderNotAvailableException:这个就是如果某台机器挂了,此时leader副本不可用,会导致你写入失败。需要等待其他follower副本切换为leader副本之后,才能继续写入,此时可以重试发送即可。如果说你平时重启kafka的broker进程,肯定会导致leader切换,会报LeaderNotAvailableException异常。
2)NotControllerException:这个也是同理,如果说Controller所在Broker挂了,那么此时会有问题,需要等待Controller重新选举,此时也是一样就是重试即可
3)NetworkException:网络异常,重试即可 我们之前配置了一个参数,retries,他会自动重试的,但是如果重试几次之后还是不行,就会提供Exception给我们来处理了。
代码参数调优
① acks 消息验证
acks | 消息发送成功判断 |
---|---|
-1 | leader & all follower接收 |
1 | leader接收 |
0 | 消息发送即可 |
② retries 重试次数(重要)
props.put("retries", 3);
在kafka中可能会遇到各种各样的异常,特别是网络突然出现问题,但是集群不可能每次出现异常都抛出,因为可能下一秒网络就恢复了,所以我们要设置重试机制。
③ batch.size 批次大小
props.put("batch.size", 32384);
批次的大小默认是16K,这里设置了32K,设置大一点可以稍微提高一下吞吐量,设置这个批次的大小还和消息的大小有关,假设一条消息的大小为16K,一个批次也是16K,这样的话批次就失去意义了。
④ linger.ms 发送时间限制
props.put("linger.ms", 100);
比如我现在设置了批次大小为32K,而一条消息是2K,此时已经有了3条消息发送过来,总大小为6K,而生产者这边就没有消息过来了,那在没够32K的情况下就不发送过去集群了吗?显然不是,linger.ms就是设置了固定多长时间,就算没塞满Batch,也会发送,上面我设置了100毫秒,所以就算我的Batch迟迟没有满32K,100毫秒过后都会向集群发送Batch。
⑤ buffer.memory 缓冲区大小
props.put("buffer.memory", 33554432);
当我们的Sender线程处理非常缓慢,而生产数据的速度很快时,我们中间的缓冲区如果容量不够,生产者就无法再继续生产数据了,所以我们有必要把缓冲区的内存调大一点,缓冲区默认大小为32M,其实基本也是合理的。
⑥ max.request.size 最大消息大小
props.put("max.request.size", 1048576);
max.request.size:这个参数用来控制发送出去的消息的大小,默认是1048576字节,也就1M,这个一般太小了,很多消息可能都会超过1mb的大小,所以需要自己优化调整,把它设置更大一些(企业一般设置成10M),不然程序跑的好好的突然来了一条2M的消息,系统就报错了,那就得不偿失
⑦ request.timeout.ms 请求超时
props.put("request.timeout.ms", 30000);
request.timeout.ms:这个就是说发送一个请求出去之后,他有一个超时的时间限制,默认是30秒,如果30秒都收不到响应(也就是上面的回调函数没有返回),那么就会认为异常,会抛出一个TimeoutException来让我们进行处理。如果公司网络不好,要适当调整此参数。