主要对Kafka生产者和消费者进行深入研究,具体的内容如下:
备注:文章中内部分图片来源自极客时间《Kafka核心技术与实战》专栏。
2.1 代码实例
public void send() {
Properties props = new Properties();
props.put("bootstrap.servers", "127.0.0.1:9093");
props.put("acks", "1");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("request.timeout.ms","500");
props.put("max.block.ms","2000");
Producer producer = new KafkaProducer(props);
for (int i=0;i<2000;i++) {
ProducerRecord producerRecord = new ProducerRecord<>("ajian-topic", i + "", i + "_" + "ajian");
Future future = producer.send(producerRecord);
try {
RecordMetadata recordMetadata = future.get(3, TimeUnit.SECONDS);
if (recordMetadata != null) {
System.out.println(recordMetadata.toString());
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
producer.close();
}
发送端的程序很简单,但是在实操过程中,发现消息一直发不出去,排查了很久,归根结底是配置错误,总结一下,消息发不出去的原因。
server.properties,文件内listeners=PLAINTEXT://127.0.0.1:9093,最好配置成一个ip或者域名的格式,如果配置成isteners=PLAINTEXT://:9093这样,前面的域名默认会使用:java.net.InetAddress.getCanonicalHostName()获取,大部分情况下获取到的是0.0.0.0,这样的话,可能在外网情况下是访问不到的。
server.properties,文件内advertised.listeners这里可以默认不配置,默认不配置,会使用listeners的值。
server.properties,文件内zookeeper.connect=localhost:2181/kafka1,如果在一台机器上启动多个broker,这里的path最好区分一下,因为我文件是复制的,这里用了localhsot:2181/kafka,因为这个对应的是第一台broker,这台broker,我已经下线了。我第二台broker也用了这个配置,在zk上看到提示node已经存在,zk上没有覆盖原来的节点的值。这里我猜测:发送消息时,消息已经到了broker服务端,这个时候服务端应该回去broker去检测下path下有无活着的broker,即/kafka/brokers/ids,这个时候发现是空,应该就认为是没有。所以消息一直发送不成功。解决方案:修改一个新的path即可,改为了/kafka1。
2.2 生产者发送整体流程
2.3 生产者相关的重要参数
acks
acks=1代表生产者发送消息后,只需要leader副本写入成功后,返回结果。可靠性和吞吐量折中的一个选择,大多数会选择这个。
acks=-1或者acks=all,代表生产者发送消息后,需要isr内的所有副本都写入成功后,才返回结果。倾向于可靠性。
acks=0代表不需要服务端的任何响应,存在丢消息的问题,倾向于吞吐量。
request.timeout.ms
这个参数用来配置Producer等待请求响应的最长时间,默认值为30000(ms)。请求超时之后可以选择进行重试。
max.request.size
这个参数用来限制生产者客户端能发送的消息的最大值,默认值为 1048576B,即 1MB。一般情况下,这个默认值就可以满足大多数的应用场景了。
retries
retries参数用来配置生产者重试的次数,默认值为0,即在发生异常的时候不进行任何重试动作。消息在从生产者发出到成功写入服务器之前可能发生一些临时性的异常,比如网络抖动、leader副本的选举等,这种异常往往是可以自行恢复的,生产者可以通过配置retries大于0的值,以此通过内部重试来恢复而不是一味地将异常抛给生产者的应用程序。
retry.backoff.ms
重试还和另一个参数retry.backoff.ms有关,这个参数的默认值为100,它用来设定两次重试之间的时间间隔,避免无效的频繁重试
3.1 代码实例
public void consume() {
Properties props = new Properties();
props.setProperty("bootstrap.servers", "127.0.0.1:9094");
props.setProperty("auto.offset.reset","earliest");
props.setProperty("group.id", "hipac-topic-1-1");
props.setProperty("enable.auto.commit", "false");
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("hipac-ajian-test"));
/*TopicPartition topicPartition = new TopicPartition("hipac-ajian-test",0);
consumer.assign(Collections.singletonList(topicPartition));*/
final int minBatchSize = 200;
List> buffer = new ArrayList<>();
while (true) {
ConsumerRecords records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord record : records) {
System.out.println("record : " + JSONObject.toJSONString(record));
buffer.add(record);
}
if (buffer.size() >= minBatchSize) {
System.out.println("consumer records: " + JSONObject.toJSONString(buffer));
consumer.commitSync();
buffer.clear();
}
}
}
这里一开始出现消费不到消息的缘故,查阅了非常多的资料。看到几个文章,都没有解决问题,但是思路不错,这里整理下:
https://www.dazhuanlan.com/2019/08/29/5d6792b15756a/?__cf_chl_jschl_tk__=9bb869cd440e88cf93675ccc3f726af8e1551afd-1590310702-0-AUKQM9teSKc1wm_vJAsZRNIjGnbBvLWHEnpZkmfABghjIjDQ0rTgJ1FqEWukiHRLmgcCjvnjCaqMIQsa8z-GIdMoKb3n25I2lOXw4_-d51YAiouRopdr0m-0sw79zeYV1kkkoIriW7UAqmncQ4TFIoE0bFYnrelslfW_dT9bJMDJRuCwOHra-UfQUCNlFwBAEXL9gMUhaAA0mJ-LQuyvzpqe_BTtwKyDNO017-edOYH_Fsugl7m8LwR6OnxioZWZ86nReTDlzskV-k3Hc74U_ccnUdBgTw5zjKl5B4xcCU30NYGHodygoEzabLR1rWJV9Q
后来排查,最主要的原因是,没有指定auto.offset.reset策略,如果没有指定默认会使用latest,表示从分区末尾开始消费消息。参考如下图:
按照默认的配置,消费者会从9开始进行消费(9是下一条要写入消息的位置),更加确切地说是从9开始拉取消息。如果将auto.offset.reset参数配置为“earliest”,那么消费者会从起始处,也就是0开始消费。
如果从latest开始,那么就意味着只要producer写入了一条消息,consumer马上就能消费到。而我做的测试都是,先执行main方法生成producer消息,然后再执行consumer程序,因为是用的latest,所以会一直拉取不到消息。
3.2 消费者组
Consumer Group 是 Kafka 提供的可扩展且具有容错性的消费者机制。既然是一个组,那么组内必然可以有多个消费者或消费者实例(Consumer Instance),它们共享一个公共的 ID,这个 ID 被称为 Group ID。
组内的所有消费者协调在一起来消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然,每个分区只能由同一个消费者组内的一个 Consumer 实例来消费。
1.Consumer Group 下可以有一个或多个 Consumer 实例。这里的实例可以是一个单独的进程,也可以是同一进程下的线程。在实际场景中,使用进程更为常见一些。
2.Group ID 是一个字符串,在一个 Kafka 集群中,它标识唯一的一个 Consumer Group。
3.Consumer Group 下所有实例订阅的主题的单个分区,只能分配给组内的某个 Consumer 实例消费。例如:主题A,分区有3个,一个消费者分组B,有三个消费者,那么每一个消费者都消费单独的一个分区。
3.3 分区分配策略
消费者端的参数,Kafka提供了消费者客户端参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。默认情况下,此参数的值为org.apache.kafka.clients.consumer.RangeAssignor,即采用RangeAssignor分配策略。除此之外,Kafka还提供了另外两种分配策略:RoundRobinAssignor 和 StickyAssignor。消费者客户端参数 partition.assignment.strategy可以配置多个分配策略,彼此之间以逗号分隔。
RangeAssignor分配策略
RangeAssignor 分配策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。
@Override
public Map> assign(Map partitionsPerTopic,
Map subscriptions) {
Map> consumersPerTopic = consumersPerTopic(subscriptions);
Map> assignment = new HashMap<>();
for (String memberId : subscriptions.keySet())
assignment.put(memberId, new ArrayList<>());
for (Map.Entry> topicEntry : consumersPerTopic.entrySet()) {
String topic = topicEntry.getKey();
List consumersForTopic = topicEntry.getValue();
Integer numPartitionsForTopic = partitionsPerTopic.get(topic);
if (numPartitionsForTopic == null)
continue;
Collections.sort(consumersForTopic);
int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();
List partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
for (int i = 0, n = consumersForTopic.size(); i < n; i++) {
int start = numPartitionsPerConsumer * i + Math.min(i, consumersWithExtraPartition);
int length = numPartitionsPerConsumer + (i + 1 > consumersWithExtraPartition ? 0 : 1);
assignment.get(consumersForTopic.get(i)).addAll(partitions.subList(start, start + length));
}
}
return assignment;
}
举个例子:假如现在有,1个topic,bmw-topic, 3个分区,分区分别是,0,1,2 1个分组,1个分组内有两个消费者。
获取到每一个topic对应的consumerId list, key : topic ,value : consumerList,记做:consumersPerTopic
获取到每一个topic对应的分区梳理,key: topic,value: 分区数量,记做:partitionsPerTopic
对所有的consumerId进行自然排序
每个topic的分区总数 除以 cosumerIdList的数量,即:3 / 2 = 1,即:numPartitionsPerConsumer
每个topic分区数量 取余 consumerIdList的数量,即:3%2 = 1,即:consumersWithExtraPartition
循环每个consumerId,计算start, numPartitionsPerConsumer*1 + 最小值(i,consumersWithExtraPartition),计算length, numPartitionsPerConsumer +(i+1 >consumersWithExtraPartition?0:1 ), 最后截取list。
计算完毕后,第一个消费者区间为:(0,1),第二个消费者区间为:(2,4)
RoundRobinAssignor分配策略
RoundRobinAssignor分配策略的原理是将消费组内所有消费者及消费者订阅的所有主题的分区按照字典序排序,然后通过轮询方式逐个将分区依次分配给每个消费者
举个例子,假设消费组中有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:
* C0: [t0p0, t0p2, t1p1]
* C1: [t0p1, t1p0, t1p2]
假设消费组内有3个消费者(C0、C1和C2),它们共订阅了3个主题(t0、t1、t2),这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。具体而言,消费者C0订阅的是主题t0,消费者C1订阅的是主题t0和t1,消费者C2订阅的是主题t0、t1和t2,
* C0: [t0p0]
* C1: [t1p0]
* C2: [t1p1, t2p0, t2p1, t2p2]
可以看到RoundRobinAssignor策略也不是十分完美,这样分配其实并不是最优解,因为完全可以将分区t1p1分配给消费者C1。
StickyAssignor分配策略
我们再来看一下StickyAssignor分配策略,“sticky”这个单词可以翻译为“黏性的”,Kafka从0.11.x版本开始引入这种分配策略,它主要有两个目的:(1)分区的分配要尽可能均匀。(2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标。
设消费组内有3个消费者(C0、C1和C2),它们都订阅了4个主题(t0、t1、t2、t3),并且每个主题有2个分区。也就是说,整个消费组订阅了t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1这8个分区。最终的分配结果如下:
C0: [t0p0, t1p1, t3p0]
C1: [t0p1, t2p0, t3p1]
C2: [t1p0, t2p1]
C1从消费者组中下线后:
C0: [t0p0, t1p0, t2p0, t3p0]
C2: [t0p1, t1p1, t2p1, t3p1]
此分区分配算法,保证了最少移动。其实之前也写过类似的分配策略,当时用的算法是一致性哈希。
消费者协调器和组协调器 (Reblance)
Reblance触发的条件:
1.一个消费者组内的实例数增加或者减少。
2.主题对应分区数的增加或减少。
3.消费者订阅主题数的增加或减少。
有几个常见问题如下?
1.如果有多个消费者,彼此所配置的分配策略并不完全相同,那么以哪个为准?
答案:根据投票选择,票数最多的分区策略为最终的分区策略。
2.多个消费者之间的分区分配是需要协同的,那么这个协同的过程又是怎样的呢?
答案:具体协调分为消费者端和组协调器,具体见下方流程。
这一切都是交由消费者协调器(ConsumerCoordinator)和组协调器(GroupCoordinator)来完成的,它们之间使用一套组协调协议进行交互。
首先要了解下消费者状态机,状态如下:
重平衡的整体流程比较复杂,需要消费者和协调者共同完成。
消费者端,主要是两个请求:JoinGroup(加入消费者组), SyncGroup(同步消费者组)
JoinGroup请求:
1.第一个发送joinGroup请求的消费者,会被认为是整个消费者组内的leader,来与协调者共同完成分区方案的分配。
2.joinGroup的本质目的,是收集每个消费者订阅的主题信息和分区策略这种元数据,同时协调者负责将每个组成员订阅的信息返回给消费者组内的leader。
SyncGroup请求:
1.协调者负责把消费者组内的leader指定的方案下发给具体的每个成员。
2.消费者分区leader是如何确定选用何种分区分配策略的呢?这里看了一部分资料说是通过投票,但是这里仍有一个疑问,如果两种方案票数相同,该确定哪一种呢?
3.当所有成员都收到syncGroup的响应后,即进入stable状态,可以继续进行消费了。
协调者端重平衡,分别是新成员加入组、组成员主动离组、组成员崩溃离组、组成员提交位移。
场景一:新成员入组
消费者组稳定了之后有新成员加入的情形,即stable状态。
当协调者收到新的 JoinGroup 请求后,它会通过心跳请求响应的方式通知组内现有的所有成员,强制它们开启新一轮的重平衡。
场景二:组成员离组
场景三:组成员崩溃离组。
崩溃离组,需要通过超时时间才能感知到,即session.timeout.ms。
场景四:重平衡时协调者对组内成员提交位移的处理。
正常情况下,每个组内成员都会定期汇报位移给协调者。当重平衡开启时,协调者会给予成员一段缓冲时间,要求每个成员必须在这段时间内快速地上报自己的位移信息,然后再开启正常的 JoinGroup/SyncGroup 请求发送。
1.生产者的整体发送流程,通过主线程,累加器,IO线程,三个组件协作进行,生产者端的一些配置参数,例如:acks非常重要,绝大数场景可以使用acks=1。
2.消费者往往通过消费者组功能来配合使用,消费者有多种分配策略,建议可以使用分区移动最少的策略,同时对于一个消费者组内,消费者实例入组、离组、分区增加等操作,都会触发重平衡,要理解整体重平衡的流程。最后,在整个重平衡过程中,每一个消费者实例是无法消费的,需要等到重平衡完成后,切换至stable状态,才可以继续消费。