Kafka Producer 发消息流程 与机制
消息发送流程
我们可以使用Kafka提供的Producer类,来快速进行消息发送,发送demo代码如下:
public class MyProducer {
// 服务器连接地址
private static final String BOOTSTRAP_SERVERS ="worker1:9092,worker2:9092,worker3:9092";
// 基础TOPIC
private static final String TOPIC = "disTopic";
public static void main(String[] args) throws ExecutionException,InterruptedException {
//PART1:设置发送者相关属性
Properties props = new Properties();
// 此处配置的是kafka的端口
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS);
// 配置key的序列化类
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");
// 配置value的序列化类
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer");
// 创建Producer
Producer<String,String> producer = new KafkaProducer<>(props);
CountDownLatch latch = new CountDownLatch(5);
for(int i = 0; i < 5; i++) {
// 构建消息
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC,
Integer.toString(i), "MyProducer" + i);
// 发送消息
// 方式1. 单向发送:不关心服务端的应答。类似于UDP协议,只发不管是否送达
producer.send(record);
System.out.println("message "+i+" sended");
// 方式2. 同步发送:获取服务端应答消息前,会阻塞当前线程。效率不如单向发送,但是消息会被接收
RecordMetadata recordMetadata = producer.send(record).get();
// recordMetadata 包含信息
String topic = recordMetadata.topic();
int partition = recordMetadata.partition();
long offset = recordMetadata.offset();
String message = recordMetadata.toString();
System.out.println("message:["+ message+"] sended with topic:"+topic+";partition:"+partition+ ";offset:"+offset);
//异步发送:消息发送后不阻塞,服务端有应答后会触发回调函数
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e){
if(null != e){
System.out.println("消息发送失败,"+e.getMessage());
e.printStackTrace();
}else{
String topic = recordMetadata.topic();
long offset = recordMetadata.offset();
String message = recordMetadata.toString();
System.out.println("message:["+ message+"] sended with topic:"+topic+";offset:"+offset);
}
latch.countDown();
}
});
}
//消息处理完才停止发送者。
latch.await();
producer.close();
}
}
整体来说,构建Producer分为三个步骤:
- 设置Producer核心属性 :Producer可选的属性都可以由ProducerConfig类管理。比如ProducerConfig.BOOTSTRAP_SERVERS_CONFIG属性,显然就是指发送者要将消息发到哪个Kafka集群上。这是每个Producer必选的属性。在ProducerConfig中,对于大部分比较重要的属性,都配置了对应的DOC属性进行描述。
- 构建消息:Kafka的消息是一个Key-Value结构的消息。其中,key和value都可以是任意对象类型。其中,key主要是用来进行Partition分区的,业务上更关心的是value。
- 使用Producer发送消息:通常用到的就是单向发送、同步发送和异步发送者三种发送方式。
Producer 机制
Producer 拦截器机制
生产者拦截机制允许客户端在生产者在消息发送到Kafka集群之前,对消息进行拦截,甚至可以修改消息内容。这涉及到Producer中指定的一个参数:INTERCEPTOR_CLASSES_CONFIG。
我们可以自定义Producer 拦截器,进行消息拦截,来统一完成我们对指定消息的修改。拦截器demo如下:
public class MyInterceptor implements ProducerInterceptor {
//发送消息时触发,可以根据业务统一调整消息
@Override
public ProducerRecord onSend(ProducerRecord producerRecord) {
System.out.println("prudocerRecord : " + producerRecord.toString());
return producerRecord;
}
//收到服务端响应时触发
@Override
public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
System.out.println("acknowledgement recordMetadata:"+recordMetadata.toString());
}
//连接关闭时触发
@Override
public void close() {
System.out.println("producer closed");
}
//整理配置项
@Override
public void configure(Map<String, ?> map) {
System.out.println("=====config start======");
for (Map.Entry<String, ?> entry : map.entrySet()) {
System.out.println("entry.key:"+entry.getKey()+" === entry.value:"+entry.getValue());
}
System.out.println("=====config end======");
}
}
Producer 拦截器配置:
// 在生产者中指定拦截器类(多个拦截器类,用逗号隔开)
props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,"com.roy.kfk.basic.MyInterceptor");
消息序列化机制
Kafka本身提供了序列化方式,例如StringSerializer,IntegerSerializer,UUIDSerializer,ByteBufferSerializer,ByteArraySerializer等。 我们也可以自定义序列化机制,参考StringSerializer的实现方式即可。 Producer的设置方式为:
// 配置key的序列化类
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
消息分区路由机制
我们在创建Producer的时候,基本用如下的构造方法:
public ProducerRecord(String topic, Integer partition, K key, V value) {
this(topic, partition, (Long)null, key, value, (Iterable)null);
}
public ProducerRecord(String topic, K key, V value) {
this(topic, (Integer)null, (Long)null, key, value, (Iterable)null);
}
在上述的构造方式中,如果我们不填入partition,那么Kafka是如何帮我们把数据放入到partition中去的呢?消息是如何路由的?
-
Producer会根据消息的key选择Partition,会根据选择的Partitioner 来获取相应的partition。Kafka提供的Partitioner有:
- DefaultPartitioner
- RoundRobinPartitioner
- UniformStickyPartitioner
我们也可以根据Partitioner接口实现自己的partition策略。Partitioner的配置方式如下:
// 配置 Partitioner
props.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, "org.apache.kafka.clients.producer.RoundRobinPartitioner");
生产者消息缓存机制
Kafka生产者为了避免高并发请求对服务端造成过大压力,每次发消息时并不是一条一条发往服务端,而
是增加了一个高速缓存,将消息集中到缓存后,批量进行发送。这种缓存机制也是高并发处理时非常常用的
一种机制。
Kafka的消息缓存机制涉及到KafkaProducer中的两个关键组件: accumulator 和 sender。其中RecordAccumulator,就是Kafka生产者的消息累加器。KafkaProducer要发送的消息都会在ReocrdAccumulator中缓存起来,然后再分批发送给kafka broker。在RecordAccumulator中,会针对每一个Partition,维护一个Deque双端队列,这些Dequeue队列基本上是和Kafka服务端的Topic下的Partition对应的。每个Dequeue里会放入若干个ProducerBatch数据。KafkaProducer每次发送的消息,都会根据key分配到对应的Deque队列中。然后每个消息都会保存在这些队列中的某一个ProducerBatch中。而消息分发的规则,就是由上面Partitioner组件完成的。
sender就是KafkaProducer中用来发送消息的一个单独的线程。从这里可以看到,每个KafkaProducer对象都对应一个sender线程。他会负责将RecordAccumulator中的消息发送给Kafka。Sender也并不是一次就把RecordAccumulator中缓存的所有消息都发送出去,而是每次只拿一部分消息。他只获取RecordAccumulator中缓存内容达到BATCH_SIZE_CONFIG大小的ProducerBatch消息。当然,如果消息比较少,ProducerBatch中的消息大小长期达不到BATCH_SIZE_CONFIG的话,Sender也不会一直等待。最多等待LINGER_MS_CONFIG时长。然后就会将ProducerBatch中的消息读取出来。LINGER_MS_CONFIG默认值是0。然后,Sender对读取出来的消息,会以Broker为key,缓存到一个对应的队列当中。这些队列当中的消息就称为InflightRequest。接下来这些Inflight就会一一发往Kafka对应的Broker中,直到收到Broker的响应,才会从队列中移除。这些队列也并不会无限缓存,最多缓存MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION(默认值为5)个请求。最后,Sender会通过其中的一个Selector组件完成与Kafka的IO请求,并接收Kafka的响应。
优化思路
Kafka的生产者缓存机制是Kafka面对海量消息时非常重要的优化机制。合理优化这些参数,对于Kafka集群性能提升是非常重要的。比如如果你的消息体比较大,那么应该考虑加大batch.size,尽量提升batch的缓存效率。而如果Producer要发送的消息确实非常多,那么就需要考虑加大total.memory参数,尽量避免缓存不够造成的阻塞。如果发现生产者发送消息比较慢,那么可以考虑提升max.in.flight.requests.per.connection参数,这样能加大消息发送的吞吐量。
缓存机制涉及两条属性配置,具体为:
// batch_size 默认值为 16K
props.put(ProducerConfig.BATCH_SIZE_CONFIG,16384);
// buffer.memory 默认值为 32M
props.put(ProducerConfig.BUFFER_MEMORY_CONFIG,32 * 1024 * 1024L);
// linger.ms 默认值为 0
props.put(ProducerConfig.LINGER_MS_CONFIG,0);
// max.in.flight.requests.per.connection 默认值 5
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION,5);
发送应答机制
在Producer将消息发送到Broker后,要怎么确定消息是不是成功发到Broker上了呢?这是在开发过程中比较重要的一个机制,也就是在Producer端一个不太起眼的属性ACKS_CONFIG。
官方给出的这段解释,同样比任何外部的资料都要准确详细了。如果你理解了Topic的分区模型,这个属性就非常容易理解了。这个属性更大的作用在于保证消息的安全性,尤其在replica-factor备份因子比较大的Topic中,尤为重要。
- acks=0,生产者不关心Broker端有没有将消息写入到Partition,只发送消息就不管了。吞吐量是最高
的,但是数据安全性是最低的。 - acks=all or -1,生产者需要等Broker端的所有Partiton(Leader Partition以及其对应的Follower
Partition都写完了才能得到返回结果,这样数据是最安全的,但是每次发消息需要等待更长的时间,吞
吐量是最低的。 - acks设置成1,则是一种相对中和的策略。Leader Partition在完成自己的消息写入后,就向生产者返回
结果。
参数设置方式如下:
// 配置 ack, 0 不需要客户端返回, 1 客户端对应partition leader 返回即可 , -1 / all 参与复制的partition都需要返回
props.put(ProducerConfig.ACKS_CONFIG,"all");
生产者消息幂等性
我们再看acks属性的说明时,会看到另外一个单词,idempotence。这个单词的意思就是幂等性。这个幂等性是什么意思呢?
当Producer的acks设置成1或-1时,Producer每次发送消息都是需要获取Broker端返回的RecordMetadata的。这个过程中就需要两次跨网络请求。如果要保证消息安全,那么对于每个消息,这两次网络请求就必须要求是幂等的。但是,网络是不靠谱的,在高并发场景下,往往没办法保证这两个请求是幂等的。Producer发送消息的过程中,如果第一步请求成功了, 但是第二步却没有返回。这时,Producer就会认为消息发送失败了。那么Producer必然会发起重试。重试次数由参数ProducerConfig.RETRIES_CONFIG,默认值是Integer.MAX。这时问题就来了。Producer会重复发送多条消息到Broker中。Kafka如何保证无论Producer向Broker发送多少次重复的数据,Broker端都只保留一条消息,而不会重复保存多条消息呢?这就是Kafka消息生产者的幂等性问题。
这里首先需要理解分布式数据传递过程中的三个数据语义:
- at-least-once:至少一次
at-least-once 语义只能保证消息不丢失,但不能保证消息不重复.例如我们acks 属性 设置为 1 或 -1的时候,保证有应答,但是不能避免消息重复的问题. - at-most-once:最多一次
at-most-once 语义只能保证消息不重复,不能保证消息不丢失.例如我们acks 属性 设置为 0,不关心应答,成功则就记录一次,失败了也不会进行重试. - exactly-once:精确一次
exactly-once 语义通过acks 这个属性已无法进行保证,需要Producer的消息幂等性来保证.
Kafka为了保证消息发送的Exactly-once语义,增加了几个概念:
- PID:每个新的Producer在初始化的过程中就会被分配一个唯一的PID。这个PID对用户是不可见的。
- Sequence Numer: 对于每个PID,这个Producer针对Partition会维护一个sequenceNumber。这是一个从0开始单调递增的数字。当Producer要往同一个Partition发送消息时,这个Sequence Number就会加1。然后会随着消息一起发往Broker。
- Broker端则会针对每个<PID,Partition>维护一个序列号(SN),只有当对应的SequenceNumber =SN+1时,Broker才会接收消息,同时将SN更新为SN+1。否则,SequenceNumber过小就认为消息已经写入了,不需要再重复写入。而如SequenceNumber过大,就会认为中间可能有数据丢失了。对生产者就会抛出一个OutOfOrderSequenceException。
这样,Kafka在打开idempotence幂等性控制后,在Broker端就会保证每条消息在一次发送过程中,
Broker端最多只会刚刚好持久化一条。这样就能保证at-most-once语义。再加上之前分析的将生产者的acks
参数设置成1或-1,保证at-least-once语义,这样就整体上保证了Exactaly-once语义。
涉及相关的参数为:
// enable.idempotence 默认值为 true
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG ,true);
生产者消息事务
通过生产者消息幂等性问题,能够解决单生产者消息写入单分区的的幂等性问题。但是,如果是要写入多个分区呢?比如像我们的示例中,就发送了五条消息,他们的key都是不同的。这批消息就有可能写入多个Partition,而这些Partition是分布在不同Broker上的。这意味着,Producer需要对多个Broker同时保证消息的幂等性。
这时候,通过上面的生产者消息幂等性机制就无法保证所有消息的幂等了。这时候就需要有一个事务机制,保证这一批消息最好同时成功的保持幂等性。或者这一批消息同时失败,这样生产者就可以开始进行整体重试,消息不至于重复。而针对这个问题, Kafka就引入了消息事务机制。这涉及到Producer中的几个API:
// 1 初始化事务
void initTransactions();
// 2 开启事务
void beginTransaction() throws ProducerFencedException;
// 3 提交事务
void commitTransaction() throws ProducerFencedException;
// 4 放弃事务(类似于回滚事务的操作)
void abortTransaction() throws ProducerFencedException;
Kafka的事务消息还会做两件事情:
- 一个TransactionId只会对应一个PID
如果当前一个Producer的事务没有提交,而另一个新的Producer保持相同的TransactionId,这时旧的生产者会立即失效,无法继续发送消息。 - 跨会话事务对齐
如果某个Producer实例异常宕机了,事务没有被正常提交。那么新的TransactionId相同的Producer实例会对旧的事务进行补齐。保证旧事务要么提交,要么终止。这样新的Producer实例就可以以一个正常的状态开始工作。
其中对于事务ID这个参数,可以任意起名,但是建议包含一定的业务唯一性。生产者的事务消息机制保证了Producer发送消息的安全性。