探究Kafka原理-3.生产者消费者API原理解析

  • 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
  • 📕系列专栏:Spring源码、JUC源码、Kafka原理
  • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
  • 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
  • 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

文章目录

  • [API 开发:producer 生产者](#API 开发:producer 生产者)
    • [生产者 api 示例](#生产者 api 示例)
    • 必要的参数配置
    • 发送消息
      • [发后即忘( fire-and-forget)](#发后即忘( fire-and-forget))
      • [同步发送(sync )](#同步发送(sync ))
      • [异步发送(async )](#异步发送(async ))
  • [API 开发:consumer 消费](#API 开发:consumer 消费)

API 开发:producer 生产者

生产者 api 示例

一个正常的生产逻辑需要具备以下几个步骤

(1)配置生产者参数及创建相应的生产者实例

(2)构建待发送的消息

(3)发送消息

(4)关闭生产者实例

首先,引入 maven 依赖

xml 复制代码
<dependency>
	<groupId>org.apache.kafka</groupId>
	<artifactId>kafka-clients</artifactId>
	<version>2.3.1</version>
</dependency>

采用默认分区方式将消息散列的发送到各个分区当中

java 复制代码
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;

import java.util.Properties;
/*

kafka生产者api代码示例

*/
public class MyProducer {
	public static void main(String[] args) throws InterruptedException {
		Properties props = new Properties();
        
		//设置 kafka 集群的地址 必选
        props.put("bootstrap.servers", "doitedu01:9092,doitedu02:9092,doitedu03:9092");
        
        
        //ack 模式,取值有 0,1,-1(all) , all 是最慢但最安全的 消息发送,应答级别
        props.put("acks", "all");
        
        
        
        //序列化器 因为业务数据有各种类型的,但是kafka底层存储里面不可能有各种类型的,只能是序列化的字节,所以不管你要发什么东西给它,都要提供一个序列化器,帮你能够把key value序列化成二进制的字节
        // 因为kafka底层的存储是没有类型维护机制的,用户所发的所有数据类型,都必须 序列化成byte[],所以kafka的producer需要一个针对用户所发送的数据类型的序列化工具类,且这个序列化工具类,需要实现kafka所提供的序列工具接口。
        props.put("key.serializer",
        "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer",
        "org.apache.kafka.common.serialization.StringSerializer");
        
        
        
        /*
        需要额外的指定泛型,key value
        */
        Producer<String, String> producer = new KafkaProducer<>(props);
        for (int i = 0; i < 100; i++)
            // 其调用是异步的,数据的发送动作在producer的底层是异步线程的
        	producer.send(new ProducerRecord<String, String>("test",
        Integer.toString(i), "dd:"+i));
        // 在这里面可以通过逻辑判断去指定发送到那个topic中
        //Thread.sleep(100);
        producer.close();
	}
}

消息对象 ProducerRecord,除了包含业务数据外,还包含了多个属性:

java 复制代码
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;

其发送方法中,根据参数的不同,有不同的构造方法

其实这样也就意味着我们可以把消息发送到不同的topic。

必要的参数配置

Kafka 生产者客户端 KakaProducer 中有 3 个参数是必填的。

复制代码
bootstrap.servers / key.serializer / value.serializer

为了防止参数名字符串书写错误,可以使用如下方式进行设置:

java 复制代码
pro.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
pro.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,StringSerializer.class.getName());

发送消息

创建生产者实例和构建消息之后 就可以开始发送消息了。发送消息主要有 3 种模式:

发后即忘( fire-and-forget)

发后即忘,它只管往 Kafka 发送,并不关心消息是否正确到达。

在大多数情况下,这种发送方式没有问题;

不过在某些时候(比如发生不可重试异常时)会造成消息的丢失。

这种发送方式的性能最高,可靠性最差。

java 复制代码
Future<RecordMetadata> send = producer.send(rcd);

同步发送(sync )

java 复制代码
try {
	producer.send(rcd).get();
} catch (Exception e) {
	e.printStackTrace();
}

因为Future的get方法是同步阻塞的。

异步发送(async )

回调函数会在 producer 收到 ack 时调用,为异步调用,该方法有两个参数,分别是 RecordMetadata 和Exception,如果 Exception 为 null,说明消息发送成功,如果 Exception 不为 null,说明消息发送失败。

注意:消息发送失败会自动重试,不需要我们在回调函数中手动重试。

java 复制代码
import org.apache.kafka.clients.producer.*;
import java.util.Properties;
public class MyProducer {
    public static void main(String[] args) throws InterruptedException {
        Properties props = new Properties();
		// Kafka 服务端的主机名和端口号
        props.put("bootstrap.servers", "doitedu01:9092,doitedu02:9092,doitedu03:9092");
		// 等待所有副本节点的应答
        props.put("acks", "all");
		// 消息发送最大尝试次数
        props.put("retries", 0);
		// 一批消息处理大小
        props.put("batch.size", 16384);
		// 增加服务端请求延时
        props.put("linger.ms", 1);
		// 发送缓存区内存大小
        props.put("buffer.memory", 33554432);
		// key 序列化
        props.put("key.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
		// value 序列化
        props.put("value.serializer",
                "org.apache.kafka.common.serialization.StringSerializer");
        KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(props);
        for (int i = 0; i < 50; i++) {
            kafkaProducer.send(new ProducerRecord<String, String>("test", "hello" + i),
                    new Callback() {
                        @Override
                        public void onCompletion(RecordMetadata metadata, Exception exception) {
                            if (metadata != null) {
                                System.out.println(metadata.partition()+ "-"+ metadata.offset());
                            }
                        }
                    });
        }
        kafkaProducer.close();
    }
}

API 开发:consumer 消费

java 复制代码
import org.apache.kafka.clients.consumer.*;
import java.util.Arrays;
import java.util.Properties;
public class MyConsumer {
    public static void main(String[] args) {
        Properties props = new Properties();
		// 定义 kakfa 服务的地址,不需要将所有 broker 指定上
        // 客户端只要知道一台服务器,就能通过这一台服务器来获知整个集群的信息(所有的服务器、主机名等)
        // 如果你只填写一台,万一,你得客户端启动的时候,宕机了不在线,那就无法连接到集群了
        // 如果你填写了堕胎,有一个好处就是,万一连不上其中一个,可以去连接其它的
        props.put("bootstrap.servers", "doitedu01:9092");
        
		// 制定 consumer group
        props.put("group.id", "g1");
        
        
        // 按照一个时间间隔自动去提交偏移量
		// 是否自动提交 offset
        props.put("enable.auto.commit", "true");
		// 自动提交 offset 的时间间隔
        props.put("auto.commit.interval.ms", "1000");
        
        
		// key 的反序列化类
        props.put("key.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
		// value 的反序列化类
        props.put("value.deserializer",
                "org.apache.kafka.common.serialization.StringDeserializer");
        
        // kafka的消费者,默认是从属组之前所记录的偏移量开始消费,如果找不到之前记录的偏移量,则从如下参数配置的策略确定消费起始偏移量
		// 如果没有消费偏移量记录,则自动重设为起始 offset:latest, earliest, none
        /*
        earliest 自动重置到每个分区的最前一条消息
        latest   自动重置到每个分区的最新一条消息
        none	 没有重置策略
        */
        props.put("auto.offset.reset","earliest");
        
        
		// 定义 consumer
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        
        
        
		// 消费者订阅的 topic, 可同时订阅多个
        // subscribe订阅,是需要参与消费者组的再均衡机制才能真正获得自己要消费的topic及其分区
        // 只要消费者组里的消费者 变化了 就要发生再均衡
        consumer.subscribe(Arrays.asList("first", "test","test1"));
        
        
        
        
        // 显式指定消费起始偏移量(如果同时设置了消费者 偏移策略的话,以手动指定的为准)
        // 在设置消费分区起始偏移量这里,存在一个点,如果此时到这里了然后消费者组再均衡机制还没有做完,那么就会报错,因为可能这个消费者还没有被分配到这个分区  针对这个问题,其实动态再分配是有一个过程 和 时间的,谁也不知道要等多久,所以最好想的sleep就不容易实现了。
        想要解决这个问题有两种办法
            1.在这个过程中 拉一次数据,能拉到就代表再均衡机制完成了 consumer.poll(Long.MAX_VALVE);这里是无意义的拉一次数据,主要是为了确保分区分配已完成,然后就能够去定位偏移量了。但是这种方式不符合最初的设计初衷,如果是使用subscribe来订阅主题,那就意味着是应该参与这个组的均衡的,参与了,那就不要去指定组的偏移量了,应该听从组的分配。
            2.既然要自己指定一个确定的起始消费位置,那通常隐含之意就是不需要去参与消费者组的自动再均衡机制那么就不要使用subscribe来订阅主题
            consumer.assign(Arrays.asList(new TopicPartition("ddd",0))) 使用这个是不参与消费者的自动再均衡的。
        
        
        
        //TopicPartition first0 = new TopicPartition("first",0);
        //TopicPartition first1 = new TopicPartition("first",1);
        //consumer.seek(first0,10);
        //consumer.seek(first1,15);
        
        
        /*
        kafka消费者的起始消费位置有两种决定机制
        1.手动指定了起始位置,它肯定从你指定的位置开始
        2.如果没有手动指定位置,它会在找消费组之前所记录的偏移量开始
        3.如果之前的位置也获取不到,就看参数 : auto.offset.reset 所指定的重置策略
        */
        
        
        while (true) {
		// 读取数据,读取超时时间为 100ms
            ConsumerRecords<String, String> records = consumer.poll(100);
            for (ConsumerRecord<String, String> record : records)
                
                // ConsumerRecord中,不光有用户的业务数据,还有kafka塞入的元数据
                String key = record.key();
            	String value = record.value();
                
                // 本条数据所属的topic
            	String topic = record.topic();
            	// 本条数据所属的分区
            	int partition = record.partition
                
                // 本条数据的offset
                long offset = record.offset();
                
                // 当前这条数据所在分区的leader的朝代纪年
            	Optional<Integer> leaderEpoch = record.leaderEpoch();
            
            	// 在kafka的数据底层存储中,不光有用户的业务数据,还有大量元数据,timestamp就是其中之一:记录本条数据的时间戳,但是时间戳有两种类型,本条数据的创建时间(生产者)、本条数据的追加时间(broker写入log文件的时间)
                TimestampType timestampType = record.timestampType();
            	long timestamp = record.timestamp();
                
                // 数据头,是生产者在写入数据时附加进去的(相当于用户自己的元数据)
            	// 在生产者发送数据的时候,有一个构造方法可以允许你自己携带自己的 headers
            	Headers headers = record.headers();
                
                
                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(),
                        record.key(), record.value());
        }
    }
}

如果消息还没生产到指定的位置呢?这是一个很有趣的问题,到底是等,还是报错

复制代码
kafka-console-consumer.sh --bootstrap-server doit01:9092 --topic test --offset 100000 --partition 0  

假设分区0 中并没有offset >= 100000 的消息,执行之后,并不会报错,但是如果超标了,就会自动重置到最新的(lastest)。

如果如果指定的offset大于最大可用的offset,那么就会定义到最后一条消息。


subscribe 订阅主题

subscribe 有如下重载方法:

java 复制代码
public void subscribe(Collection<String> topics,ConsumerRebalanceListener listener)
public void subscribe(Collection<String> topics)
public void subscribe(Pattern pattern, ConsumerRebalanceListener listener)
public void subscribe(Pattern pattern)

通过这几个构造函数来看,其中有ConsumerRebalanceListener listener 其实就是 再均衡 的监听器,再均衡的过程中,会调用这个方法。

java 复制代码
Properties props = new Properties();
// 从配置文件中加载写好的参数
props.load(Consumer.class.getClassLoader.getResourceAsStream("consumer.properties"));
// 手动set一些参数进去
props.setProperty();
......
    
KafkaConsumer<String,String> consumer = new KafkaConsumer<>(props);


// reb-1 主题 3个分区
// reb-2 主题 2个分区
consumer.subscribe(Arrays.asList("reb-1","reb-2"),new ConsumerRebalanceListener(){
    // 再均衡分配过程中,消费者会取消先前所分配的主题、分区
    // 取消了之后,consumer会调用下面的方法
    public void onPartitionsRevoked(Collection<TopicPartition> partitions){
        
    }
    
    // 再均衡过程中,消费者会重新分配到新的主题、分区
    // 分配了新的主题 和 分区之后,consumer底层会调用下面的方法
    public void onPartitionAssigned(Collection<TopicPartition> partitions){
        
    }
});
但是以上的过程 懒加载,只有消费者真正 开始 poll的时候,才会实现再均衡分配的过程。

现有的再均衡原则就是每次有消费者增减 都会重新分配,其实就是先全部取消,然后又重新分配了呢,这过程中肯定存在消耗,得先把工作暂停,把偏移量记好,另外一个人接手的时候,还需要另外去读偏移量,重新从对应的位置开始。

而在kafka2.4.1中解决了这个重分配的问题。但是大多数使用的框架没有到这个版本,或者所使用的如spark flink等底层所依赖的kafka没有2.4.1这个版本。

消费者组再均衡分区分配策略

消费者组的意义何在?为了提高数据处理的并行度!

会触发 rebalance 的事件可能是如下任意一种:

  • 有新的消费者加入消费组。
  • 有消费者宕机下线,消费者并不一定需要真正下线,例如遇到长时间的 GC 、网络延迟导致消费者长时间未向 GroupCoordinator 发送心跳等情况时,GroupCoordinator 会认为消费者己下线。
  • 有消费者主动退出消费组(发送 LeaveGroupRequest 请求):比如客户端调用了 unsubscrible()方法取消对某些主题的订阅。
  • 消费组所对应的 GroupCoorinator 节点发生了变更。
  • 消费组内所订阅的任一主题或者主题的分区数量发生变化。

将分区的消费权从一个消费者移到另一个消费者称为再均衡(rebalance),如何 rebalance 也涉及到分区分配策略。

kafka 有两种的分区分配策略:range (默认) 和 round robin(新版本中又新增了另外 2 种)

我们可以通过 partition.assignment.strategy 参数选择 range 或 roundrobin。
partition.assignment.strategy 参数默认的值是 range。

partition.assignment.strategy=org.apache.kafka.clients.consumer.RoundRobinAssignor

partition.assignment.strategy=org.apache.kafka.clients.consumer.RangeAssignor

这个参数属于"消费者"参数!

Range Strategy
  • 先将消费者按照 client.id 字典排序,然后按 topic 逐个处理;
  • 针对一个 topic,将其 partition 总数/消费者数得到 商 n 和 余数 m,则每个 consumer 至少分到 n个分区,且前 m 个 consumer 每人多分一个分区;

举例说明 2:假设有 TOPIC_A 有 5 个分区,由 3 个 consumer(C1,C2,C3)来消费;

5/3 得到商 1,余 2,则每个消费者至少分 1 个分区,前两个消费者各多 1 个分区 C1: 2 个分区,C2:2 个分区, C3:1 个分区

接下来,就按照"区间"进行分配:

复制代码
C1: TOPIC_A-0 TOPIC_A-1
C2: TOPIC_A-2 TOPIC_A_3
C3: TOPIC_A-4

举例说明 2:假设 TOPIC_A 有 5 个分区,TOPIC_B 有 3 个分区,由 2 个 consumer(C1,C2)来消费

先分配 TOPIC_A:

5/2 得到商 2,余 1,则 C1 有 3 个分区,C2 有 2 个分区,得到结果

复制代码
C1: TOPIC_A-0 TOPIC_A-1 TOPIC_A-2
C2: TOPIC_A-3 TOPIC_A-4

再分配 TOPIC_B:

3/2 得到商 1,余 1,则 C1 有 2 个分区,C2 有 1 个分区,得到结果

复制代码
C1: TOPIC_B-0 TOPIC_B-1
C2: TOPIC_B-2

最终分配结果:

复制代码
C1: TOPIC_A-0 TOPIC_A-1 TOPIC_A-2 TOPIC_B-0 TOPIC_B-1
C2: TOPIC_A-3 TOPIC_A-4 TOPIC_B-2

如果共同订阅的主题很多,那也就意味着,排在前面的消费者拿到的分区会明显多余排在后面的。

而消费者本身有一个id,是根据id号去排序

以上就是该种模式的弊端,其实就是一个topic一个topic去分的。这个问题尤其是在订阅多个topic的时候最明显,分配单个topic的情况,也就多一个分区。

Round-Robin Strateg

将所有主题分区组成 TopicAndPartition 列表,并对 TopicAndPartition 列表按照其 hashCode 排序,然后,以轮询的方式分配给各消费者。

以上述问题来举例:

先对 TopicPartition 的 hashCode 排序,假如排序结果如下:

TOPIC_A-0 TOPIC_B-0 TOPIC_A-1 TOPIC_A-2 TOPIC_B-1 TOPIC_A-3 TOPIC_A-4 TOPIC_B-

然后按轮询方式分配

C1: TOPIC_A-0 TOPIC_A-1 TOPIC_B-1 TOPIC_A-4

C2: TOPIC_B-0 TOPIC_A-2 TOPIC_A-3 TOPIC_B-2

Sticky Strategy

对应的类叫做: org.apache.kafka.clients.consumer.StickyAssignor

sticky 策略的特点:

  • 要去打成最大化的均衡
  • 尽可能保留各消费者原来分配的分区

再均衡的过程中,还是会让各消费者先取消自身的分区,然后再重新分配(只不过是分配过程中会尽量让原来属于谁的分区依然分配给谁)

以一个例子来看

复制代码
---开始
C1:A-P0		B-P1	B-P2
C2:B-P0		A-P1

---加入C3后再分配
Range Strategy
C1:A-P0		A-P1
C2:B-P0		B-P2
C3:B-P1


Sticky Strategy
C1:A-P0		B-P1
C2:B-P0		A-P1
C3:B-P2
Cooperative Sticky Strategy

对应的类叫做: org.apache.kafka.clients.consumer.ConsumerPartitionAssignor(最新的一种 2.4.1)

sticky 策略的特点:

  • 逻辑与 sticky 策略一致
  • 支持 cooperative 再均衡机制(再均衡的过程中,不会让所有消费者取消掉所有分区然后再进行重分配,影响到谁,就针对那个消费者进行即可)

消费者组再均衡流程

消费组在消费数据的时候,有两个角色进行组内的各事务的协调;

  • 角色 1: Group Coordinator (组协调器) 位于服务端(就是某个 broker)
  • 角色 2: Group Leader (组长) 位于消费端(就是消费组中的某个消费者)
GroupCoordinator 介绍

每个消费组在服务端对应一个 GroupCoordinator 其进行管理,GroupCoordinator 是 Kafka 服务端中用于管理消费组的组件。

消费者客户端中由 ConsumerCoordinator 组件负责与 GroupCoordinator 行交互;

ConsumerCoordinator 和 GroupCoordinator 最重要的职责就是负责执行消费者 rebalance 操作

eager 协议再均衡步骤细节

定位 Group Coordinator

coordinator 在我们组记偏移量的__consumer_offsets 分区的 leader 所在 broker 上

查找 Group Coordinator 的方式:

先根据消费组 groupid 的 hashcode 值计算它应该所在_consumer_offsets 中的分区编号:

复制代码
Utils.abc(groupId.hashCode) % groupMetadataTopicPartitionCount
groupMetadataTopicPartitionCount 为 __consumer_offsets 的 分 区 总 数 , 这 个 可 以 通 过 broker 端 参 数
offset.topic.num.partitions 来配置,默认值是 50;

找到对应的分区号后,再寻找此分区 leader 副本所在 broker 节点,则此节点即为自己的 Grouping

Coordinator;

加入组 Join The Group

此阶段的重要操作之 1:选举消费组的 leader

java 复制代码
private val members = new mutable.HashMap[String, MemberMetadata]

var leaderid = members.keys.head
set集合本身无序的,取头部的一个,自然也是无序的

消费组 leader 的选举,策略就是:随机!

此阶段的重要操作之 2:选择分区分配策略

最终选举的分配策略基本上可以看作被各个消费者支持的最多的策略,具体的选举过程如下:

(1)收集各个消费者支持的所有分配策略,组成候选集 candidates。

(2)每个消费者从候选集 candidates 找出第一个自身支持的策略,为这个策略投上一票。

(3)计算候选集中各个策略的选票数,选票数最多的策略即为当前消费组的分配策略(如果得票一样,那就以组长的为主)。

其实,此逻辑并不需要 consumer 来执行,而是由 Group Coordinator 来执行。

组信息同步 SYNC Group

此阶段,主要是由消费组 leader 将分区分配方案,通过 Group Coordinator 来转发给组中各消费者

心跳联系 HEART BEAT

进入这个阶段之后,消费组中的所有消费者就会处于正常工作状态。

各消费者在消费数据的同时,保持与 Group Coordinator的心跳通信。

消费者的心跳间隔时间由参数 heartbeat.interval.ms 指定,默认值为 3000 ,即这个参数必须比

session.timeout.ms 参 数 设 定 的 值 要 小 ; 一 般 情 况 下 heartbeat.interval.ms 的 配 置 值 不 能 超 过

session.timeout.ms 配置值的 1/3 。这个参数可以调整得更低,以控制正常重新平衡的预期时间;

如果一个消费者发生崩溃,并停止读取消息,那么 GroupCoordinator 会等待一小段时间确认这个消费者死亡之后才会触发再均衡。在这一小段时间内,死掉的消费者并不会读取分区里的消息。

这 个 一 小 段 时 间 由 session.timeout. ms 参 数 控 制 , 该 参 数 的 配 置 值 必 须 在 broker 端 参 数

group.min.session.timeout. ms (默认值为 6000 ,即 6 秒)和 group.max.session. timeout. ms (默认值为 300000 ,即 5 分钟)允许的范围内

再均衡流程

eager 协议的再均衡过程整体流程如下图:

特点:再均衡发生时,所有消费者都会停止工作,等待新方案的同步

Cooperative 协议的再均衡过程整体流程如下图:

特点:cooperative 把原来 eager 协议的一次性全局再均衡,化解成了多次的小均衡,并最终达到全局均衡的收敛状态

指定集合方式订阅主题

java 复制代码
consumer.subscribe(Arrays.asList(topicl));

consumer.subscribe(Arrays.asList(topic2))

正则方式订阅主题

如果消费者采用的是正则表达式的方式(subscribe(Pattern))订阅, 在之后的过程中,如果有人又创建了新的主题,并且主题名字与正表达式相匹配,那么这个消费者就可以消费到新添加的主题中的消息。如果应用程序需要消费多个主题,并且可以处理不同的类型,那么这种订阅方式就很有效。

正则表达式的方式订阅的示例如下

java 复制代码
consumer.subscribe(Pattern.compile ("topic.*" ));

利用正则表达式订阅主题,可实现动态订阅;

assign 订阅主题

消费者不仅可以通过 KafkaConsumer.subscribe() 方法订阅主题,还可直接订阅某些主题的指定分区;

在 KafkaConsumer 中提供了 assign() 方法来实现这些功能,此方法的具体定义如下:

java 复制代码
public void assign(Collection<TopicPartition> partitions)

这个方法只接受参数 partitions,用来指定需要订阅的分区集合。示例如下:

java 复制代码
consumer.assign(Arrays.asList(new TopicPartition ("tpc_1" , 0),new TopicPartition("tpc_2",1))) ;

subscribe 与 assign 的区别

通过 subscribe()方法订阅主题具有消费者自动再均衡功能 ;

在多个消费者的情况下可以根据分区分配策略来自动分配各个消费者与分区的关系。当消费组的消费者增加或减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移。

assign() 方法订阅分区时,是不具备消费者自动均衡的功能的;

其实这一点从 assign()方法参数可以看出端倪,两种类型 subscribe()都有 ConsumerRebalanceListener类型参数的方法,而 assign()方法却没有。

取消订阅

既然有订阅,那么就有取消订阅;

可以使用 KafkaConsumer 中的 unsubscribe()方法采取消主题的订阅,这个方法既可以取消通过subscribe( Collection)方式实现的订阅;也可以取消通过 subscribe(Pattem)方式实现的订阅,还可以取消通过 assign( Collection)方式实现的订阅。示例码如下

java 复制代码
consumer.unsubscribe();

如果将 subscribe(Collection )或 assign(Collection)集合参数设置为空集合,作用与 unsubscribe()方法相同,如下示例中三行代码的效果相同:

java 复制代码
consumer.unsubscribe();

consumer.subscribe(new ArrayList<String>()) ;

consumer.assign(new ArrayList<TopicPartition>());

消息的消费模式

Kafka 中的消费是基于拉取模式的。

消息的消费一般有两种模式:推送模式和拉取模式。推模式是服务端主动将消息推送给消费者,而拉模式是消费者主动向服务端发起请求来拉取消息。

Kafka 中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用 poll( ) 方法, poll( )方法返回的是所订阅的主题(分区)上的一组消息。

对于 poll ( ) 方法而言,如果某些分区中没有可供消费的消息,那么此分区对应的消息拉取的结果就为空,如果订阅的所有分区中都没有可供消费的消息,那么 poll( )方法返回为空的消息集;

poll ( ) 方法具体定义如下:

java 复制代码
public ConsumerRecords<K, V> poll(final Duration timeout)

超时时间参数 timeout ,用来控制 poll( ) 方法的阻塞时间,在消费者的缓冲区里没有可用数据时会发生阻塞。如果消费者程序只用来单纯拉取并消费数据,则为了提高吞吐率,可以把 timeout 设置为Long.MAX_VALUE;

消费者消费到的每条消息的类型为 ConsumerRecord

java 复制代码
public class ConsumerRecord<K, V> {
    public static final long NO_TIMESTAMP = RecordBatch.NO_TIMESTAMP;
    public static final int NULL_SIZE = -1;
    public static final int NULL_CHECKSUM = -1;
    
    private final String topic;
    private final int partition;
    private final long offset;
    private final long timestamp;
    private final TimestampType timestampType;
    private final int serializedKeySize;
    private final int serializedValueSize;
    private final Headers headers;
    private final K key;
    private final V value;
    
    private volatile Long checksum;
    
    
topic partition 这两个字段分别代表消息所属主题的名称和所在分区的编号。
offset 表示消息在所属分区的偏移量。
timestamp 表示时间戳,与此对应的 timestampType 表示时间戳的类型。
timestampType 有两种类型 CreateTime 和 LogAppendTime ,分别代表消息创建的时间戳和消息追加
到日志的时间戳。
headers 表示消息的头部内容。
key value 分别表示消息的键和消息的值,一般业务应用要读取的就是 value ;
serializedKeySize、serializedValueSize 分别表示 key、value 经过序列化之后的大小,如果 key 为空,
则 serializedKeySize 值为 -1,同样,如果 value 为空,则 serializedValueSize 的值也会为 -1;
checksum 是 CRC32 的校验值。

示例代码片段

java 复制代码
/**
* 订阅与消费方式 2
*/
TopicPartition tp1 = new TopicPartition("x", 0);
TopicPartition tp2 = new TopicPartition("y", 0);
TopicPartition tp3 = new TopicPartition("z", 0);
List<TopicPartition> tps = Arrays.asList(tp1, tp2, tp3);
consumer.assign(tps);
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
	for (TopicPartition tp : tps) {
		List<ConsumerRecord<String, String>> rList = records.records(tp);
		for (ConsumerRecord<String, String> r : rList) {
            r.topic();
            r.partition();
            r.offset();
            r.value();
            //do something to process record.
		}
	}
}

指定位移消费

有些时候,我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer 中的 seek() 方法正好提供了这个功能,让我们可以追前消费或回溯消费。

seek()方法的具体定义如下:

复制代码
public void seek(TopicPartiton partition,long offset)

代码示例:

java 复制代码
public class ConsumerDemo3 指定偏移量消费 {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG,"g002");
        props.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"doit01:9092");
        props.setProperty(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.setProperty(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,StringDeserializer.clas
                s.getName());
        props.setProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"latest");
		// 是否自动提交消费位移
        props.setProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,"true");
		// 限制一次 poll 拉取到的数据量的最大值
        props.setProperty(ConsumerConfig.FETCH_MAX_BYTES_CONFIG,"10240000");
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
		// assign 方式订阅 doit27-1 的两个分区
        TopicPartition tp0 = new TopicPartition("doit27-1", 0);
        TopicPartition tp1 = new TopicPartition("doit27-1", 1);
        consumer.assign(Arrays.asList(tp0,tp1));
		// 指定分区 0,从 offset:800 开始消费 ; 分区 1,从 offset:650 开始消费
        consumer.seek(tp0,200);
        consumer.seek(tp1,250);
		// 开始拉取消息
        while(true){
            ConsumerRecords<String, String> poll = consumer.poll(Duration.ofMillis(3000));
            for (ConsumerRecord<String, String> rec : poll) {
                System.out.println(rec.partition()+","+rec.key()+","+rec.value()+","+rec.offset()
                );
            }
        }
    }
}

自动提交消费者偏移量

Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 enable.auto.commit 配置,默认值为 true 。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数 auto.commit.interval.ms 配置,默认值为 5 秒,此参数生效的前提是 enable. auto.commit 参数为 true。

在默认的方式下,消费者每隔 5 秒会将拉取到的每个分区中最大的消息位移进行提交。自动位移提交的动作是在 poll() 方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。

Kafka 消费的编程逻辑中位移提交是一大难点,自动提交消费位移的方式非常简便,它免去了复杂的位移提交逻辑,让编码更简洁。但随之而来的是重复消费消息丢失的问题。

  • 重复消费

假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象(对于再均衡的情况同样适用)。我们可以通过减小位移提交的时间间隔来减小重复消息的窗口大小,但这样并不能避免重复消费的发送,而且也会使位移提交更加频繁。

  • 丢失消息

按照一般思维逻辑而言,自动提交是延时提交,重复消费可以理解,那么消息丢失又是在什么情形下会发生的呢?我们来看下图中的情形:

拉取线程不断地拉取消息并存入本地缓存,比如在 BlockingQueue 中,另一个处理线程从缓存中读取消息并进行相应的逻辑处理。设目前进行到了第 y+l 次拉取,以及第 m 次位移提交的时候,也就是x+6 之前的位移己经确认提交了,处理线程却还正在处理 x+3 的消息;此时如果处理线程发生了异常,待其恢复之后会从第 m 次位移提交处,也就是 x+6 的位置开始拉取消息,那么 x+3 至 x+6 之间的消息就没有得到相应的处理,这样便发生消息丢失的现象。

手动提交消费者偏移量(调用 kafka api)

自动位移提交的方式在正常情况下不会发生消息丢失或重复消费的现象,但是在编程的世界里异常无可避免;同时,自动位移提交也无法做到精确的位移管理。在 Kafka 中还提供了手动位移提交的方式,这样可以使得开发人员对消费位移的管理控制更加灵活。

很多时候并不是说拉取到消息就算消费完成,而是需要将消息写入数据库、写入本地缓存,或者是更加复杂的业务处理。在这些场景下,所有的业务处理完成才能认为消息被成功消费;

手动的提交方式可以让开发人员根据程序的逻辑在合适的时机进行位移提交。开启手动提交功能的前提是消费者客户端参数 enable.auto.commit 配置为 false ,示例如下:

java 复制代码
props.put(ConsumerConf.ENABLE_AUTO_COMMIT_CONFIG, false);

手动提交可以细分为同步提交和异步提交,对应于 KafkaConsumer 中的 commitSync()和commitAsync()两种类型的方法。

  • 同步提交的方式

commitSync()方法的定义如下:

java 复制代码
/**
* 手动提交 offset
*/
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
	for (ConsumerRecord<String, String> r : records) {
		//do something to process record.
	}
	consumer.commitSync();
}

对于采用 commitSync()的无参方法,它提交消费位移的频率和拉取批次消息、处理批次消息的频率是一样的,如果想寻求更细粒度的、更精准的提交,那么就需要使用 commitSync()的另一个有参方法,具体定义如下:

java 复制代码
public void commitSync(final Map<TopicPartition,OffsetAndMetadata> offsets

示例代码如下:

java 复制代码
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
	for (ConsumerRecord<String, String> r : records) {
		long offset = r.offset();
		//do something to process record.
		
		TopicPartition topicPartition = new TopicPartition(r.topic(), r.partition());
		consumer.commitSync(Collections.singletonMap(topicPartition,new
OffsetAndMetadata(offset+1)));
	}
}

提交的偏移量 = 消费完的 record 的偏移量 + 1

因为,__consumer_offsets 中记录的消费偏移量,代表的是,消费者下一次要读取的位置!!!

  • 异步提交方式

异步提交的方式( commitAsync())在执行的时候消费者线程不会被阻塞;可能在提交消费位移的结果还未返回之前就开始了新一次的拉取。异步提交可以让消费者的性能得到一定的增强。commitAsync 方法有一个不同的重载方法,具体定义如下:

示例代码

java 复制代码
/**
* 异步提交 offset
*/
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
	for (ConsumerRecord<String, String> r : records) {
		long offset = r.offset();
		
		//do something to process record.
		TopicPartition topicPartition = new TopicPartition(r.topic(), r.partition());
		consumer.commitSync(Collections.singletonMap(topicPartition,new
OffsetAndMetadata(offset+1)));
		consumer.commitAsync(Collections.singletonMap(topicPartition, new
OffsetAndMetadata(offset + 1)), new OffsetCommitCallback() {
	@Override
	public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
				if(e == null ){
					System.out.println(map);
				}else{
					System.out.println("error commit offset");
				}
			}
		});
	}
}

手动提交位移(时机的选择)

  • 数据处理完成之前先提交偏移量

可能会发生漏处理的现象(数据丢失)

反过来说,这种方式实现了: at most once 的数据处理(传递)语义

  • 数据处理完成之后再提交偏移量

可能会发生重复处理的现象(数据重复)

反过来说,这种方式实现了: at least once 的数据处理(传递)语义

当然,数据处理(传递)的理想语义是: exactly once(精确一次)

Kafka 也能做到 exactly once(基于 kafka 的事务机制)

消费者提交偏移量方式的总结

consumer 的消费位移提交方式:

全自动

  • auto.offset.commit = true
  • 定时提交到 consumer_offsets

半自动

  • auto.offset.commit = false;
  • 然后手动触发提交 consumer.commitSync()
  • 提交到 consumer_offsets

全手动

  • auto.offset.commit = false;
  • 写自己的代码去把消费位移保存到你自己的地方 mysql/zk/redis
  • 提交到自己所涉及的存储;初始化时也需要自己去从自定义存储中查询到消费位移
相关推荐
面朝大海,春不暖,花不开9 分钟前
自定义Spring Boot Starter的全面指南
java·spring boot·后端
得过且过的勇者y9 分钟前
Java安全点safepoint
java
夜晚回家44 分钟前
「Java基本语法」代码格式与注释规范
java·开发语言
小鸡脚来咯1 小时前
RabbitMQ入门
分布式·rabbitmq
斯普信云原生组1 小时前
Docker构建自定义的镜像
java·spring cloud·docker
wangjinjin1801 小时前
使用 IntelliJ IDEA 安装通义灵码(TONGYI Lingma)插件,进行后端 Java Spring Boot 项目的用户用例生成及常见问题处理
java·spring boot·intellij-idea
wtg44521 小时前
使用 Rest-Assured 和 TestNG 进行购物车功能的 API 自动化测试
java
白宇横流学长1 小时前
基于SpringBoot实现的大创管理系统设计与实现【源码+文档】
java·spring boot·后端
fat house cat_2 小时前
【redis】线程IO模型
java·redis
qq_463944862 小时前
【Spark征服之路-2.2-安装部署Spark(二)】
大数据·分布式·spark