这是我参与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);// 缓冲区大小
生产者客户端参数配置建议
参数 | 默认值 | 推荐值 | 说明 |
---|---|---|---|
acks | 1 | 高可靠:all高吞吐:1 | 收到Server端确认信号个数,表示procuder需要收到多少个这样的确认信号,算消息发送成功。acks参数代表了数据备份的可用性。常用选项:acks=0:表示producer不需要等待任何确认收到的信息,副本将立即加到socket buffer并认为已经发送。没有任何保障可以保证此种情况下server已经成功接收数据,同时重试配置不会发生作用(因为客户端不知道是否失败)回馈的offset会总是设置为-1。acks=1:这意味着至少要等待leader已经成功将数据写入本地log,但是并没有等待所有follower是否成功写入。如果follower没有成功备份数据,而此时leader又无法提供服务,则消息会丢失。acks=all:这意味着leader需要等待所有备份都成功写入日志,只有任何一个备份存活,数据都不会丢失。 |
retries | 0 | 结合实际业务调整 | 客户端发送消息的重试次数。值大于0时,这些数据发送失败后,客户端会重新发送。注意,这些重试与客户端接收到发送错误时的重试没有什么不同。允许重试将潜在的改变数据的顺序,如果这两个消息记录都是发送到同一个partition,则第一个消息失败第二个发送成功,则第二条消息会比第一条消息出现要早。 |
request.timeout.ms | 30000 | 结合实际业务调整 | 设置一个请求最大等待时间,超过这个时间则会抛Timeout异常。超时时间如果设置大一些,如120000(120秒),高并发的场景中,能减少发送失败的情况。 |
block.on.buffer.full | TRUE | TRUE | TRUE表示当我们内存用尽时,停止接收新消息记录或者抛出错误。默认情况下,这个设置为TRUE。然而某些阻塞可能不值得期待,因此立即抛出错误更好。如果设置为false,则producer抛出一个异常错误:BufferExhaustedException |
batch.size | 16384 | 262144 | 默认的批量处理消息字节数上限。producer将试图批处理消息记录,以减少请求次数。这将改善client与server之间的性能。不会试图处理大于这个字节数的消息字节数。发送到brokers的请求将包含多个批量处理,其中会包含对每个partition的一个请求。较小的批量处理数值比较少用,并且可能降低吞吐量(0则会仅用批量处理)。较大的批量处理数值将会浪费更多内存空间,这样就需要分配特定批量处理数值的内存大小。 |
buffer.memory | 33554432 | 67108864 | producer可以用来缓存数据的内存大小。如果数据产生速度大于向broker发送的速度,producer会阻塞或者抛出异常,以“block.on.buffer.full”来表明。这项设置将和producer能够使用的总内存相关,但并不是一个硬性的限制,因为不是producer使用的所有内存都是用于缓存。一些额外的内存会用于压缩(如果引入压缩机制),同样还有一些用于维护请求。 |
二、消息发送流程
通常情况下,生产逻辑需要具备以下几个步骤:
- 1、配置生产者客户端参数及创建响应的生产者实例
- 2、构建待发送的消息
- 3、发送消息
- 4、关闭生产者实例
发送消息它可以分为三种模式:
- 1、发完即忘(fire-and-forget)
- 2、同步(sync)
- 3、异步(async)
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());
消费者消费的数据如下:
同时,拦截器可以配置多个形成拦截链。用逗号分割如下:
properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName()+","+ProducerInterceptorPrefix.class.getName());
再执行,结果如下:
六、整体架构
以上这图是生产者客户端的整体架构,主要包含核心类: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 核心设计与实现原理》