Kafka Producer 发消息流程 与机制

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分为三个步骤:

  1. 设置Producer核心属性 :Producer可选的属性都可以由ProducerConfig类管理。比如ProducerConfig.BOOTSTRAP_SERVERS_CONFIG属性,显然就是指发送者要将消息发到哪个Kafka集群上。这是每个Producer必选的属性。在ProducerConfig中,对于大部分比较重要的属性,都配置了对应的DOC属性进行描述。
  2. 构建消息:Kafka的消息是一个Key-Value结构的消息。其中,key和value都可以是任意对象类型。其中,key主要是用来进行Partition分区的,业务上更关心的是value。
  3. 使用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本身提供了序列化方式,例如StringSerializerIntegerSerializerUUIDSerializerByteBufferSerializerByteArraySerializer等。 我们也可以自定义序列化机制,参考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中去的呢?消息是如何路由的?

  1. 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发送消息的安全性。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值