消费者:
__consumer_offsets
的主题中Zookeeper
,需要设置offset.storage=zookeeper
消费组:
configs.put("group.id", "xxx");
group.id=xxxxx
⼀个拥有四个分区的主题,包含⼀个消费者的消费组
此时,消费组中的消费者消费主题中的所有分区。并且没有重复的可能。
如果在消费组中添加⼀个消费者2,则每个消费者分别从两个分区接收消息
如果消费组有四个消费者,则每个消费者可以分配到⼀个分区
如果向消费组中添加更多的消费者,超过主题分区数量,则有⼀部分消费者就会闲置,不会接收任何消息
向消费组添加消费者是横向扩展消费能⼒的主要⽅式。
必要时,需要为主题创建⼤量分区,在负载增⻓时可以加⼊更多的消费者。但是不要让消费者的数量超过主题分区的数量。
除了通过增加消费者来横向扩展单个应⽤的消费能⼒之外,经常出现多个应⽤程序从同⼀个主题消费的情况。
此时,每个应⽤都可以获取到所有的消息。只要保证每个应⽤都有⾃⼰的消费组,就可以让它们获取到主题所有的消息。
横向扩展消费者和消费组不会对性能造成负⾯影响。
为每个需要获取⼀个或多个主题全部消息的应⽤创建⼀个消费组,然后向消费组添加消费者来横向扩展消费能⼒和应⽤的处理能⼒,则每个消费者只处理⼀部分消息。
初始的消费者消费分区:
消费者宕机,退出消费组,触发再平衡,重新给消费组中的消费者分配分区
由于broker宕机,主题X的分区3宕机,此时分区3没有Leader副本,触发再平衡,消费者4没有对应的主题分区,则消费者4闲置
Kafka 的⼼跳是 Kafka Consumer 和 Broker 之间的健康检查,只有当 Broker Coordinator 正常时,Consumer 才会发送⼼跳。
Consumer 和 Rebalance 相关的 2 个配置参数:
broker 端,sessionTimeoutMs 参数
broker 处理⼼跳的逻辑在 GroupCoordinator
类中。如果⼼跳超期, broker coordinator 会把消费者从 group 中移除,并触发 rebalance。
可以看看源码的kafka.coordinator.group.GroupCoordinator#completeAndScheduleNextHeartbeatExpiration
方法。
如果客户端发现⼼跳超期,客户端会标记 coordinator 为不可⽤,并阻塞⼼跳线程;如果超过了 poll 消息的间隔超过了 rebalanceTimeoutMs,则 consumer 告知 broker 主动离开消费组,也会触发 rebalance
可以看看源码的org.apache.kafka.clients.consumer.internals.AbstractCoordinator.HeartbeatThread
内部类
Topic:Kafka⽤于分类管理消息的逻辑单元,类似与MySQL的数据库。
Partition*:是Kafka下数据存储的基本单元,这个是物理上的概念。同⼀个topic的数据,会被分散的存储到多个partition中,这些partition可以在同⼀台机器上,也可以是在多台机器上。优势在于:有利于⽔平扩展,避免单台机器在磁盘空间和性能上的限制,同时可以通过复制来增加数据冗余性,提⾼容灾能⼒。为了做到均匀分布,通常partition的数量通常是Broker Server数量的整数倍。**
Consumer Group:同样是逻辑上的概念,是Kafka实现单播和⼴播两种消息模型的⼿段**。保证⼀个消费组获取到特定主题的全部的消息。在消费组内部,若⼲个消费者消费主题分区的消息,消费组可以保证⼀个主题的每个分区只被消费组中的⼀个消费者消费。
consumer 采⽤ pull 模式从 broker 中读取数据。
采⽤ pull 模式,consumer 可⾃主控制消费消息的速率, 可以⾃⼰控制消费⽅式(批量消费/逐条消费),还可以选择不同的提交⽅式从⽽实现不同的传输语义。
订阅主题:consumer.subscribe("tp_demo_01,tp_demo_02")
Kafka的broker中所有的消息都是字节数组,消费者获取到消息之后,需要先对消息进⾏反序列化处理,然后才能交给⽤户程序消费处理。
常用的Kafka提供的,反序列化器包括key的和value的反序列化器:
消费者从订阅的主题拉取消息:consumer.poll(3_000);
在Fetcher类中,对拉取到的消息⾸先进⾏反序列化处理:
private ConsumerRecord<K, V> parseRecord(TopicPartition partition, RecordBatch batch, Record record) {
try {
long offset = record.offset();
long timestamp = record.timestamp();
Optional<Integer> leaderEpoch = this.maybeLeaderEpoch(batch.partitionLeaderEpoch());
TimestampType timestampType = batch.timestampType();
Headers headers = new RecordHeaders(record.headers());
ByteBuffer keyBytes = record.key();
byte[] keyByteArray = keyBytes == null ? null : Utils.toArray(keyBytes);
K key = keyBytes == null ? null : this.keyDeserializer.deserialize(partition.topic(), headers, keyByteArray);
ByteBuffer valueBytes = record.value();
byte[] valueByteArray = valueBytes == null ? null : Utils.toArray(valueBytes);
V value = valueBytes == null ? null : this.valueDeserializer.deserialize(partition.topic(), headers, valueByteArray);
return new ConsumerRecord(partition.topic(), partition.partition(), offset, timestamp, timestampType, record.checksumOrNull(), keyByteArray == null ? -1 : keyByteArray.length, valueByteArray == null ? -1 : valueByteArray.length, key, value, headers, leaderEpoch);
} catch (RuntimeException var17) {
throw new SerializationException("Error deserializing key/value for partition " + partition + " at offset " + record.offset() + ". If needed, please seek past the record to continue consumption.", var17);
}
}
Kafka默认提供了⼏个反序列化的实现:
org.apache.kafka.common.serialization.ByteArrayDeserializer
org.apache.kafka.common.serialization.ByteBufferDeserializer
org.apache.kafka.common.serialization.BytesDeserializer
org.apache.kafka.common.serialization.DoubleDeserializer
org.apache.kafka.common.serialization.FloatDeserializer
org.apache.kafka.common.serialization.IntegerDeserializer
org.apache.kafka.common.serialization.LongDeserializer
org.apache.kafka.common.serialization.ShortDeserializer
org.apache.kafka.common.serialization.StringDeserializer
反序列化器都需要实现org.apache.kafka.common.serialization.Deserializer
接⼝:
这里根据前面自定义的序列化器,再自定义一个反序列化器。
先回顾一下前面的序列化器,添加了一个 User 对象,需要序列化User对象:
public class User {
private Integer userId;
private String username;
// set、get、toString、全参构造函数、无参构造函数 方法省略,
}
/**
* User对象的序列化器
*/
public class UserSerializer implements Serializer<User> {
@Override
public void configure(Map<String, ?> map, boolean b) {
// do Nothing
}
@Override
public byte[] serialize(String topic, User user) {
try {
// 如果数据是null,则返回null
if (user == null) return null;
Integer userId = user.getUserId();
String username = user.getUsername();
int length = 0;
byte[] bytes = null;
if (null != username) {
bytes = username.getBytes("utf-8");
length = bytes.length;
}
ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + length);
buffer.putInt(userId);
buffer.putInt(length);
buffer.put(bytes);
return buffer.array();
} catch (UnsupportedEncodingException e) {
throw new SerializationException("序列化数据异常");
}
}
@Override
public void close() {
// do Nothing
}
}
这里再自定义一个反序列化器:
public class UserDeserializer implements Deserializer<User> {
@Override
public void configure(Map<String, ?> configs, boolean isKey) {
// do Nothing
}
@Override
public User deserialize(String topic, byte[] data) {
ByteBuffer allocate = ByteBuffer.allocate(data.length);
allocate.put(data);
allocate.flip();
int userId = allocate.getInt();
int length = allocate.getInt();
String userName = new String(data, 8, length);
return new User(userId, userName);
}
@Override
public void close() {
// do Nothing
}
}
消费者使用自定义反序列化器:
消费者在拉取了分区消息之后,要⾸先经过反序列化器对key和value进⾏反序列化处理。
处理完之后,如果消费端设置了拦截器,则需要经过拦截器的处理之后,才能返回给消费者应⽤程序进⾏处理。
消费端定义消息拦截器,需要实现org.apache.kafka.clients.consumer.ConsumerInterceptor
接⼝。
configre
⽅法获取消费者配置的属性,如果消费者配置中没有指定clientID
,还可以获取KafkaConsumer
⽣成的clientId
。获取的这个配置是跟其他拦截器共享的,需要保证不会在各个拦截器之间产⽣冲突。ConsumerInterceptor
⽅法抛出的异常会被捕获、记录,但是不会向下传播。如果⽤户配置了错误的key或value类型参数,消费者不会抛出异常,⽽仅仅是记录下来。ConsumerInterceptor
回调发⽣在org.apache.kafka.clients.consumer.KafkaConsumer#poll(long)
⽅法同⼀个线程该接⼝中有如下⽅法:
public interface ConsumerInterceptor<K, V> extends Configurable {
/**
* 该⽅法在poll⽅法返回之前调⽤。调⽤结束后poll⽅法就返回消息了。
*
* 该⽅法可以修改消费者消息,返回新的消息。拦截器可以过滤收到的消息或⽣成新的消息。
* 如果有多个拦截器,则该⽅法按照KafkaConsumer的configs中配置的顺序调⽤。
*
* @param records 由上个拦截器返回的由客户端消费的消息。
*/
public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);
/**
* 当消费者提交偏移量时,调⽤该⽅法
* 该⽅法抛出的任何异常调⽤者都会忽略。
*/
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);
/**
* This is called when interceptor is closed
*/
public void close();
}
代码实现
自定义一个消费者拦截器:
public class OneInterceptor implements ConsumerInterceptor<String, String> {
@Override
public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
// poll⽅法返回结果之前最后要调⽤的⽅法
System.out.println("One -- 开始");
// 消息不做处理,直接返回
return records;
}
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
// 消费者提交偏移量的时候,经过该⽅法
System.out.println("One -- 结束");
}
@Override
public void close() {
// ⽤于关闭该拦截器⽤到的资源,如打开的⽂件,连接的数据库等
}
@Override
public void configure(Map<String, ?> configs) {
// ⽤于获取消费者的设置参数
configs.forEach((k, v) -> {
System.out.println(k + "\t" + v);
});
}
}
按照 OneInterceptor
拦截器复制两个拦截器,更名为 TwoInterceptor
、ThreeInterceptor
消费者使用自定义拦截器:
位移提交介绍:
Committing Offsets
)__consumer_offsets
Kafka Consumer 后台提交
enable.auto.commit=true
auto.commit.interval.ms
,默认 5s在消费者中设置自动提交和自动提交间隔:
Map<String, Object> configs = new HashMap<>();
configs.put("bootstrap.servers", "192.168.0.102:9092");
configs.put("group.id", "mygrp");
// 设置偏移量⾃动提交。⾃动提交是默认值。这⾥做示例。
configs.put("enable.auto.commit", "true");
// 偏移量⾃动提交的时间间隔
configs.put("auto.commit.interval.ms", "3000");
configs.put("key.deserializer", StringDeserializer.class);
configs.put("value.deserializer", StringDeserializer.class);
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(configs);
⾃动提交位移的顺序:
enable.auto.commit = true
重复消费举例:
KafkaConsumer#commitSync()
:会提交 KafkaConsumer#poll()
返回的最新 offsetwhile (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
process(records); // 处理消息
try {
consumer.commitSync();
} catch (CommitFailedException e) {
handle(e); // 处理提交失败异常
}
}
⼿动同步提交会:
KafkaConsumer#commitAsync()
while (true) {
ConsumerRecords<String, String> records = consumer.poll(3_000);
process(records);
// 处理消息
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
handle(exception);
}
});
}
手动异步提交不会自动重试的解决方案:
try {
while(true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
process(records); // 处理消息
commitAysnc(); // 使⽤异步提交规避阻塞
}
} catch(Exception e) {
handle(e); // 处理异常
} finally {
try {
consumer.commitSync(); // 最后⼀次提交使⽤同步阻塞式提交
} finally {
consumer.close();
}
}
Kafka中,消费者根据消息的位移顺序消费消息。
消费者的位移由消费者管理,可以存储于zookeeper中,也可以存储于Kafka主题__consumer_offsets
中。
Kafka提供了消费者API,让消费者可以管理⾃⼰的位移。
KafkaConsumer
的 API如下:
public void assign(Collection partitions)
subscribe(Collection,ConsumerRebalanceListener)
⼀起使⽤。public Set assignment()
public Map> listTopics()
public List partitionsFor(String topic)
public Map beginningOffsets(Collection partitions)
public void seekToEnd(Collection partitions)
isolation.level=read_committed
,则会将分区的消费偏移量移动到最后⼀个稳定的偏移量,即下⼀个要消费的消息现在还是未提交状态的事务消息。public void seek(TopicPartition partition, long offset)
public long position(TopicPartition partition)
public void seekToBeginning(Collection partitions)
准备数据
# ⽣成消息⽂件
[root@node1 ~]# for i in `seq 60`; do echo "hello $i" >> nm.txt; done
# 创建主题,三个分区,每个分区⼀个副本
[root@node1 ~]# kafka-topics.sh --zookeeper localhost:2181/myKafka --create --topic tp_demo_01 --partitions 3 --replication-factor 1
# 将消息⽣产到主题中
[root@node1 ~]# kafka-console-producer.sh --broker-list localhost:9092 --topic tp_demo_01 < nm.txt
API 实战:
/**
* 消费者位移管理
*/
public class MyConsumer2 {
public static void main(String[] args) {
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.0.102:9092");
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
config.put(ConsumerConfig.GROUP_ID_CONFIG, "myGroup");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(config);
// 给当前消费者⼿动分配⼀系列主题分区
consumer.assign(Arrays.asList(new TopicPartition("tp_demo_01", 1)));
// 获取给当前消费者分配的分区集合
Set<TopicPartition> assignment = consumer.assignment();
assignment.forEach(topicPartition -> System.out.println(topicPartition));
// 获取对⽤户授权的所有主题分区元数据。该⽅法会对服务器发起远程调⽤
Map<String, List<PartitionInfo>> stringListMap = consumer.listTopics();
stringListMap.forEach((k, v) -> {
System.out.println("主题:" + k);
v.forEach(info -> System.out.println(info));
});
Set<String> strings = consumer.listTopics().keySet();
strings.forEach(topicName -> System.out.println(topicName));
// 获取指定主题的分区元数据
List<PartitionInfo> partitionInfos = consumer.partitionsFor("tp_demo_01");
for (PartitionInfo partitionInfo : partitionInfos) {
Node leader = partitionInfo.leader();
System.out.println(leader);
System.out.println(partitionInfo);
// 当前分区在线副本
Node[] nodes = partitionInfo.inSyncReplicas();
// 当前分区下线副本
Node[] nodes1 = partitionInfo.offlineReplicas();
}
// 对于给定的主题分区,列出它们第⼀个消息的偏移量。
// 注意,如果指定的分区不存在,该⽅法可能会永远阻塞。
// 该⽅法不改变分区的当前消费者偏移量。
Map<TopicPartition, Long> topicPartitionLongMap = consumer.beginningOffsets(consumer.assignment());
topicPartitionLongMap.forEach((k, v) -> {
System.out.println("主题:" + k.topic() + "\t分区:" + k.partition() + "偏移量\t" + v);
});
// 将偏移量移动到每个给定分区的最后⼀个。
consumer.seekToEnd(consumer.assignment());
//将给定主题分区的消费偏移量移动到指定的偏移量,即当前消费者下⼀条要消费的消息偏移量。
consumer.seek(new TopicPartition("tp_demo_01", 1), 10);
// 检查指定主题分区的消费偏移量
long position = consumer.position(new TopicPartition("tp_demo_01", 1));
System.out.println(position);
// 将偏移量移动到每个给定分区的最后⼀个。
consumer.seekToEnd(Arrays.asList(new TopicPartition("tp_demo_01", 1)));
// 关闭⽣产者
consumer.close();
}
}
重平衡可以说是kafka为⼈诟病最多的⼀个点了。
重平衡其实就是⼀个协议,它规定了如何让消费者组下的所有消费者来分配topic中的每⼀个分区。⽐如⼀个topic有100个分区,⼀个消费者组内有20个消费者,在协调者的控制下让组内每⼀个消费者分配到5个分区,这个分配的过程就是重平衡。
重平衡的触发条件主要有三个:
消费者宕机,退出消费组,触发再平衡,重新给消费组中的消费者分配分区。
由于broker宕机,主题X的分区3宕机,此时分区3没有Leader副本,触发再平衡,消费者4没有对应的主题分区,则消费者4闲置。
主题增加分区,需要主题分区和消费组进⾏再均衡。
由于使⽤正则表达式订阅主题,当增加的主题匹配正则表达式的时候,也要进⾏再均衡。
为什么说重平衡为⼈诟病呢?因为重平衡过程中,消费者⽆法从kafka消费消息,这对kafka的TPS影响极⼤,⽽如果kafka集内节点较多,⽐如数百个,那重平衡可能会耗时极多。数分钟到数⼩时都有可能,⽽这段时间kafka基本处于不可⽤状态。所以在实际环境中,应该尽量避免重平衡发⽣。
不可能完全避免重平衡,因为你⽆法完全保证消费者不会故障。⽽消费者故障其实也是最常⻅的引发重平衡的地⽅,所以我们需要保证尽⼒避免消费者故障。
⽽其他⼏种触发重平衡的⽅式,增加分区,或是增加订阅的主题,抑或是增加消费者,更多的是主动控制。
如果消费者真正挂掉了,就没办法了,但实际中,会有⼀些情况,kafka错误地认为⼀个正常的消费者已经挂掉了,我们要的就是避免这样的情况出现。
⾸先要知道哪些情况会出现错误判断挂掉的情况。
在分布式系统中,通常是通过⼼跳来维持分布式系统的,kafka也不例外。
在分布式系统中,由于⽹络问题你不清楚没接收到⼼跳,是因为对⽅真正挂了还是只是因为负载过重没来得及发⽣⼼跳或是⽹络堵塞。所以⼀般会约定⼀个时间,超时即判定对⽅挂了。⽽在kafka消费者场景中,session.timout.ms
参数就是规定这个超时时间是多少。
还有⼀个参数,heartbeat.interval.ms
,这个参数控制发送⼼跳的频率,频率越⾼越不容易被误判,但也会消耗更多资源。
此外,还有最后⼀个参数,max.poll.interval.ms
,消费者poll数据后,需要⼀些处理,再进⾏拉取。如果两次拉取时间间隔超过这个参数设置的值,那么消费者就会被踢出消费者组。也就是说,拉取,然后处理,这个处理的时间不能超过max.poll.interval.ms
这个参数的值。这个参数的默认值是5分钟,⽽如果消费者接收到数据后会执⾏耗时的操作,则应该将其设置得⼤⼀些。
总结:
session.timout.ms
控制⼼跳超时时间。heartbeat.interval.ms
控制⼼跳发送频率。max.poll.interval.ms
控制poll的间隔。这⾥给出⼀个相对较为合理的配置,如下:
session.timout.ms
:设置为6sheartbeat.interval.ms
:设置2smax.poll.interval.ms
:推荐为消费者处理消息最⻓耗时再加1分钟consumer group是kafka提供的可扩展且具有容错性的消费者机制。
三个特性:
group.id
是⼀个字符串,唯⼀标识⼀个消费组消费者在消费的过程中记录已消费的数据,即消费位移(offset)信息。
每个消费组保存⾃⼰的位移信息,那么只需要简单的⼀个整数表示位置就够了;同时可以引⼊checkpoint机制定期持久化。
⾃动VS⼿动
Kafka默认定期⾃动提交位移(enable.auto.commit
= true),也⼿动提交位移。另外kafka会定期把group消费情况保存起来,做成⼀个offset map,如下图所示:
位移提交
位移是提交到Kafka中的__consumer_offsets
主题。__consumer_offsets
中的消息保存了每个消费组某⼀时刻提交的offset信息
[root@localhost kafka_2.12-1.0.2]# kafka-console-consumer.sh --topic __consumer_offsets --bootstrap-server localhost:9092 --formatter "kafka.coordinator.group.GroupMetadataManager\$OffsetsMessageFormatter" --consumer.config /usr/src/kafka_2.12-1.0.2/config/consumer.properties --from-beginning | head
上图中,标出来的,表示消费组为console-consumer-46068
,消费的主题为topic_1
,消费的分区是0,偏移量为5。
__consumers_offsets
主题配置了compact策略,使得它总是能够保存最新的位移信息,既控制了该topic总体的⽇志容量,也能实现保存最新offset的⽬的。
什么是再平衡:
再均衡(Rebalance)本质上是⼀种协议,规定了⼀个消费组中所有消费者如何达成⼀致来分配订阅主题的每个分区。
⽐如某个消费组有20个消费组,订阅了⼀个具有100个分区的主题。正常情况下,Kafka平均会为每个消费者分配5个分区。这个分配的过程就叫再均衡。
什么时候再平衡:
再均衡的触发条件:
如何进行组内分区分配:
三种分配策略:RangeAssignor
和RoundRobinAssignor
以及StickyAssignor
谁来执⾏再均衡和消费组管理:
Kafka提供了⼀个⻆⾊:Group Coordinator来执⾏对于消费组的管理。
Group Coordinator——每个消费组分配⼀个消费组协调器⽤于组管理和位移管理。当消费组的第⼀个消费者启动的时候,它会去和Kafka Broker确定谁是它们组的组协调器。之后该消费组内所有消费者和该组协调器协调通信。
如何确定coordinator:
__consumers_offsets
的哪个分区。具体计算公式:_consumers_offsets partition# = Math.abs(groupId.hashCode() % groupMetadataTopicPartitionCount)
注意:groupMetadataTopicPartitionCount
由offsets.topic.num.partitions
指定,默认是50个分区。Rebalance Generation:
它表示Rebalance之后主题分区到消费组中消费者映射关系的⼀个版本,主要是⽤于保护消费组,隔离⽆效偏移量提交的。如上⼀个版本的消费者⽆法提交位移到新版本的消费组中,因为映射关系变了,你消费的或许已经不是原来的那个分区了。每次group进⾏Rebalance之后,Generation号都会加1,表示消费组和分区的映射关系到了⼀个新版本,如下图所示: Generation 1时group有3个成员,随后成员2退出组,消费组协调器触发Rebalance,消费组进⼊Generation 2,之后成员4加⼊,再次触发Rebalance,消费组进⼊Generation 3.
协议(protocol)
kafka提供了5个协议来处理与消费组协调相关的问题:
组协调器在再均衡的时候主要⽤到了前⾯4种请求。
liveness
消费者如何向消费组协调器证明⾃⼰还活着?
通过定时向消费组协调器发送Heartbeat请求。如果超过了设定的超时时间,那么协调器认为该消费者已经挂了。⼀旦协调器认为某个消费者挂了,那么它就会开启新⼀轮再均衡,并且在当前其他消费者的⼼跳响应中添加“REBALANCE_IN_PROGRESS”
,告诉其他消费者:重新分配分区。
再均衡过程
再均衡分为2步:Join和Sync
注意:在协调器收集到所有成员请求前,它会把已收到请求放⼊⼀个叫purgatory(炼狱)的地⽅。然后是分发分配⽅案的过程,即SyncGroup请求:
消费组状态机
消费组组协调器根据状态机对消费组做不同的处理:
说明: