关于 Kafka 的那些事儿
1、概述
一个典型的 Kafka 体系架构包括若干 Producer(生产者)、若干 个Broker(服务代理节点) 、若干 Consumer(消费者) ,以及一个 ZooKeeper 集群。其中 Kafka 用 ZooKeeper 来管理集群元数据、控制器的选举等操作。Producer(生产者) 将消息发送到 Broker, Broker 负责将收到的消息存储到磁盘中,Consumer(消费者)负责从 Broker 订阅并消 费消息。
2、生产者 Producer
2.1、生产者整体架构
简单的东西就不说了,比如发送消息的示例😅
2.2、自定义生产者拦截器
生产者拦截器,用于在消息发送之前对消息进行预处理,以及在消息发送之后执行一些后处理操作。可以在消息发送前做一些准备工作(比如按照某个规则过滤不符合要的消息、自定义的日志记录、修改消息的内容等),也可以用来在发送回调逻辑前做一些制化的需求(比如统计类工作等)。
自定义生产者分为两步:
- ① 实现 ProducerInterceptor 接口。
- ② 在KafkaProducer的配置参数interceptor.classes中指定自定义的拦截器,此参数的默认值为""。
实现 ProducerInterceptor 接口
package com.demo.config.kafka;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import java.util.Map;
/**
* @ClassName MyProducerInterceptor
* @description: 自定义生产者拦截器
*/
@Slf4j
public class MyProducerInterceptor implements ProducerInterceptor<String, String> {
private volatile long sendSuccess = 0;
private volatile long sendFailure = 0;
/**
* 在消息发送之前调用,可以在这里对消息进行预处理
* 最好不要在此方法修改消息ProducerRecord的topic、key、partition 等信息,
* 如果要修改,则需确保对其有准确的判断,否则会与预想的效果出现偏差。
* 比如修改 key 不仅会影响分区的计算,同样会影响 broker 端日志压缩( Log Compaction) 的功能
* @param producerRecord
* @return
*/
@Override
public ProducerRecord<String, String> onSend(ProducerRecord<String, String> producerRecord) {
// 例如,可以在这里修改消息的键或值
// record.key() = "new-key";
// record.value() = "new-value";
String modifiedValue = "prefixl-" + producerRecord.value();
return new ProducerRecord<>(producerRecord.topic(),
producerRecord.partition(), producerRecord.timestamp() ,
producerRecord.key() , modifiedValue , producerRecord.headers()) ;
}
/**
* KafkaProducer 会在消息被应答( Acknowledgement )之前或消息发送失败时调用该方法,优先于用户设定的 Callback 之前执行。
* 方法运行在 Producer I/O 线程中,所以这个方法中实现的代码逻辑越简单越好, 否则会影响消息的发送速度。
*
* @param recordMetadata
* @param e
*/
@Override
public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
// todo 在消息发送之后调用,可以在这里执行一些后处理操作,比如可以在这里记录日志或处理异常情况
// e 为空表示发送没有异常
if (e == null) {
sendSuccess++;
} else {
sendFailure ++;
}
}
/**
* 用于在关闭拦截器时执行一些资源的清理工作
*/
@Override
public void close() {
double successRatio = (double)sendSuccess / (sendFailure + sendSuccess);
log.info("发送成功率 = {}%" , successRatio*100);
}
/**
* 配置拦截器,
* @param map
*/
@Override
public void configure(Map<String, ?> map) {
// todo 可以在这里接收一些自定义的配置参数
}
}
配置参数 interceptor.classes
package com.demo.config.kafka;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName KafkaProducerConfig
*/
@Configuration
public class KafkaProducerConfig {
@Value("${kafka.bootstrap.servers}")
private String bootstrapServers;
@Bean
public Map<String, Object> producerConfigs() {
Map<String, Object> props = new HashMap<>();
// 该参数用来指定生产者客户端连接 Kafka 集群所需的 broker 地址清单,具体的内
// 容格式为 host1:portl,host2:port2 ,可以设置一个或多个地址,中间以逗号隔开,此参数的默认值为""
// 注意这里并非需要所有的 broker地址,因为生产者会从给定的broker中查找到其他broker的信息,最好是配置多个,防止一个宕机
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
// broker 端接收的消息必须以字节数组(byte[])的形式存在,指定key序列化操作的序列化器,无默认值
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 指定value序列化操作的序列化器,无默认值
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 这里配置自定义的 生产者拦截器
props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,
com.demo.config.kafka.MyProducerInterceptor.class);
return props;
}
@Bean
public ProducerFactory<String, String> producerFactory() {
return new DefaultKafkaProducerFactory<>(producerConfigs());
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
2.3、分区器
消息在通过send()方法发往broker的过程中, 有可能要经过生产者拦截器、序列化器和分区器等一系列作用之后才能被真正地发往broker。拦截器一般不是必需的,而序列化器是必须的,消息经过序列 之后就需要确定它发往 分区。
分区器的作用就是为消息分配分区,默认分区器(DefaultPartitioner)默认的分区策略:
- 如果消息ProducerRecord中指定了partition字段,就不需要分区器作用,因为partition代表的就是所要发往的分区号。
- 如果消息ProducerRecord中没有指定了partition字段,就依赖分区器,根据key字段来计算partition的值。
如果未指定分区但存在key
,则根据序列化key使用murmur2哈希算法对分区数取模,最终根据得到的哈希值来计算分区号,拥有相同 key 的消息会被写入同一个分区
。如果 key 为null,消息会已轮询的方式发往 topic 内的各个可用分区
。
自定义分区器分为两步
- ① 实现 Partitioner 接口。
- ② 将自定义的分区器,设置为生产者的配置参数。
实现 Partitioner 接口:
package com.demo.config.kafka;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import java.util.List;
import java.util.Map;
/**
* @ClassName MyPartitioner
* @description: 自定义分区器
*/
public class MyPartitioner implements Partitioner {
/**
* 该方法主要 来获取配置信息及初始化数据。
* @param configs
*/
@Override
public void configure(Map<String, ?> configs) {
// 配置参数,可以在这里接收一些自定义的配置参数
}
/**
* 该方法用来计算分区号,返回值为 int 类型。
* @param topic 主题
* @param key 键
* @param keyBytes 序列化后的键
* @param value 值
* @param valueBytes 序列化后的值
* @param cluster 集群的元数据信息
* @return
*/
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
// 自定义分区逻辑,这里简单示例为根据key的哈希值分配分区
int partition = Math.abs(key.hashCode()) % partitions.size();
return partition;
}
/**
* 该方法在关闭分区器的时候用来回收一些资源
*/
@Override
public void close() {
// 分区器关闭时执行的操作,通常用于清理资源
}
}
将自定义的分区器,设置为生产者的配置参数:
package com.demo.config.kafka;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName KafkaProducerConfig
*/
@Configuration
public class KafkaProducerConfig {
@Value("${kafka.bootstrap.servers}")
private String bootstrapServers;
@Bean
public Map<String, Object> producerConfigs() {
Map<String, Object> props = new HashMap<>();
// 该参数用来指定生产者客户端连接 Kafka 集群所需的 broker 地址清单,具体的内
// 容格式为 host1:portl,host2:port2 ,可以设置一个或多个地址,中间以逗号隔开,此参数的默认值为""
// 注意这里并非需要所有的 broker地址,因为生产者会从给定的broker中查找到其他broker的信息,最好是配置多个,防止一个宕机
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
// broker 端接收的消息必须以字节数组(byte[])的形式存在,指定key序列化操作的序列化器,无默认值
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 指定value序列化操作的序列化器,无默认值
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 这里配置自定义的 生产者拦截器
props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,
com.demo.config.kafka.MyProducerInterceptor.class);
// 设置自定的分区器
props.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,
com.demo.config.kafka.MyPartitioner.class);
return props;
}
@Bean
public ProducerFactory<String, String> producerFactory() {
return new DefaultKafkaProducerFactory<>(producerConfigs());
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
2.4、集群元数据同步
Kafka生产客户端使用集群元数据,来获取和更新有关主题、分区、领导者副本和偏移量提交的信息
,以确保其能够正确地发送消息,并处理集群中的更改。客户端使用Kafka的集群元数据API,来获取这些信息,并在需要时更新它。
为了确保元数据的一致性,Kafka生产者还实现了 幂等性
(确保了在相同的会话中,相同的消息不会被重复发送)和 事务性
(确保了在发送消息的过程中,如果发生错误或失败,可以回滚事务并恢复到之前的状态)。
元数据更新主要涉及以下方面:
- ①. 主题和分区信息:当客户端需要发送消息时,它使用元数据API获取目标主题和分区的信息。如果主题或分区发生更改(例如,新的分区被添加或分区被删除),客户端会收到一个更新事件,以便它可以更新其本地缓存。
- ②. 领导者副本信息:Kafka的每个分区都有一个领导者副本,负责处理写入请求。如果领导者副本发生更改(例如,领导者副本出现故障或被重新选举),客户端会收到一个更新事件,以便它可以重新定向其写入请求。
- ③. 偏移量提交:Kafka生产客户端可以定期提交其分区偏移量,以便在发生故障时可以从上次提交的偏移量处继续发送消息。客户端使用元数据API找到适当的分区和副本,以便将偏移量提交到正确的位置。
- ④. 负载均衡:如果Kafka集群中的某个节点出现故障或变得不可用,客户端可以使用元数据API来查找新的领导者副本,并将写入请求重定向到新的领导者副本。
Kafka集群的元数据同步机制更侧重于CP原则
。
对于Kafka来说,其设计原则是CP,即保证数据一致性优先
。这主要是因为在分布式系统中,如果主节点出现故障,Kafka可以通过选举机制重新选择主节点,以保证数据的一致性。虽然这可能会导致一段时间内数据的不一致,但在大多数情况下,Kafka通过CP原则保证了数据的一致性。
AP和CP是分布式系统的两种不同设计原则,AP主要是提高可用性,可能会有数据不一致的情况,但只要最终一致就可以;而CP主要是保证数据一致性,即使有节点故障也要保证一致性。
2.5、生产者常用配置
参数 | 说明 |
---|---|
bootstrap.servers | 指定连接 Kafk 集群所需的 brok 地址清单。 |
acks | Producer需要Leader确认的Producer请求的应答数. acks = 0 : 表示Producer请求立即返回,不需要等待Leader的任何确认。acks = -1 : 表示分区Leader必须等待消息被成功写入到所有的ISR副本(同步副本)中才认为Producer请求成功。acks = 1 : 表示Leader副本必须应答此Producer请求并写入消息到本地日志,之后Producer请求被认为成功。如果此时Leader副本应答请求之后挂掉了,消息会丢失。 示例:properties put(“acks”,“0”); |
buffer.memory | 该参数用于指定Producer端用于缓存消息的缓冲区大小,单位为字节,默认值为:33554432即32MB。 |
compression.type | 压缩器,目前支持none(不压缩),gzip,snappy和lz4。 |
retries | Producer发送消息失败重试的次数。重试时Producer会重新发送之前由于瞬时原因(元数据信息失效、副本数量不足、超时、位移越界或未知分区等)出现失败的消息。倘若设置了retries > 0,那么这些情况下Producer会尝试重新发送。 |
retry backoff.ms | 用来设定两次重试之间的时间间隔,避免无效的频繁重试,默认值为 100. |
batch.size | 默认值为16KB,Producer按照batch进行发送,当batch满了后,Producer会把消息发送出去。 |
linger.ms | Producer是按照batch进行发送的,但是还要看linger.ms的值,默认是0,表示不做停留。为了减少了网络IO,提升整体的性能。建议设置5-100ms。 |
max equest.size | 用来限制生产者客户端能发送的消息的最大值,默认值为 1048576B ,即 1MB. |
connections.max.idle. ms | 用来指定在多久之后关闭限制的连接,默认值是 540000 ms ,即9分钟。 |
request.timeout.ms | 用来配置 Producer 等待请求响应的最长时间,默认值为 30000 ms.请求超时之后可以选择进行重试。 |
metadata.max.age.ms | 如果在这个时间内元数据没有更新的话会被强制更新,默认300000 (5分钟) |
max.in.flight.requests. per.connection | 限制每个连接(也就是客户端与 Node 之间的连接)最多缓存的请求数,默认5 |
3、消费者
同理简单的东西就不说了,直接讲重点😅
3.1、消费组、主题、分区
3.2、再均衡
再均衡是指,分区的所属权从一个消费者转移到另一消费者的行为。
Kafka再均衡时需要注意以下几个点:
-
1、再均衡条件:Kafka再平衡是在消费者组内成员,发生变化或者主题分区发生改变时触发的。
- 当消费者组内增加或减少消费者;
- 或者主题的分区数增加时;
- 或者订阅的主题发生变化(增加匹配的主题)。
-
2、再均衡过程:
当再均衡条件满足时,Kafka消费者组会重新分配所有分区并同步偏移量
,使每个消费者持有的分区数量相等。 -
3、再均衡的影响:
再均衡过程可能会导致一段时间内,消费者无法从Kafka消费消息
,会对Kafka的TPS产生极大影响。特别**是在Kafka集群节点较多的情况下,再平衡可能耗时极多(数分钟到数小时),导致Kafka在这段时间内处于不可用状态
**。因此,在实际应用中,应该尽量避免触发再均衡。- 再均衡过程可能会导致一段时间内(时间可长可短),消费者无法从Kafka消费消息。
- 再均衡过程中可能导致消息重复消费。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移,就发生了再均衡操作,就会导致重复消费。
再均衡监听器
,用来设定发生再均衡动作前后的一些准备或收尾的动作。通过实现 ConsumerAwareRebalanceListener
或 ConsumerRebalanceListener
接口来达到监听目的。ConsumerAwareRebalanceListener 继承于 ConsumerRebalanceListener 接口。
/** ConsumerAwareRebalanceListener接口主要包含以下方法: **/
/**
* 当消费者被分配到新的分区时触发。该方法可以用来执行一些再均衡后的操作,例如提交消费位移。
* 以避免一些不必要的重复消费现象的发生。
* @param consumer 表示当前消费者。
* @param partitions 表示再均衡后所分配到的分区
*/
onPartitionsAssigned(Consumer<?, ?> consumer, Collection<TopicPartition> partitions);
/**
* 该方法会在再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,
* 以避免一些不必要的重复消费现象的发生。
* @param partitions 表示再均衡后所分配到的分区。
*/
onPartitionsRevoked(Collection<TopicPartition> partitions);
这里以@KafkaListener 注解(的 rebalanceListener 参数)使用 再均衡监听器为例,同时也可使用 Consumer的subscribe(String topic, ConsumerRebalanceListener listener);方法用于订阅一个指定的主题,并指定一个消费者再均衡监听器来实现。
@Component
public class MyKafkaListener {
@KafkaListener(topics = "my-topic", groupId = "my-group", rebalanceListener = "myRebalanceListener")
public void listen(String message) {
// 处理消息
}
// 自定义在均衡监听器
@Bean
public ConsumerAwareRebalanceListener myRebalanceListener() {
return new ConsumerAwareRebalanceListener() {
@Override
public void onPartitionsAssigned(Consumer<?, ?> consumer, Collection<TopicPartition> partitions) {
// 分区分配完成后的操作
}
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// 分区被撤销后的操作
}
};
}
}
3.3、位移提交
在Kafka 的分区维度中,一个分区中的每条消息都有唯一的 offset(偏移量或位移),用来表示消息在分区中对应的位置。新版的kafka 消费位移存储在 Kafka 部的主题 _consumer_offsets 中。位移提交就是消费者消费消息后,将 offset 提交给 kafka 持久化,达到标记当前分区消费到了什么位置的目的。
因为消费者可能同时消费多个分区,为了跟踪每个分区的消费进度,消费者需要向Kafka提交自己在每个分区中的位移数据。以便在消费者发生故障重启后,或者在进行消费者组再均衡后,消费者可以从之前提交的位移处继续消费,从而保证了消息的可靠性和不丢失。
位移提交具有以下特点:
- Consumer需要向Kafka记录自己的位移数据,这个汇报过程称为提交位移(Committing Offsets)。
- Consumer需要为分配给它的每个分区提交各自的位移数据,位移提交是按照分区的粒度进行的。
- 位移提交分为自动提交和手动提交,手工位移提交又分为同步提交和异步提交。
总之,Kafka的位移提交是为了确保消息的可靠性和不丢失,以及跟踪每个分区的消费进度
。
3.3.1 自动提交
Kafka 默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 enable.auto.commit
配置,默认值为 true 。默认的自动提交不是每消费一条消息就提交一次,而是定期提交
,这个定期的周期时间由客户端参数 auto.commit.interval.ms
配置,默认值为5秒
,此参数生效的前提是 enable.auto.commit 参数为 true 。
在默认的方式下,消费者每隔5秒会将拉取到的每个分区中最大的消息位移进行提交。自动位移提交的动作是在 poll()方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。
3.3.2 手动提交示例
package com.demo.config.kafka;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName KafkaConsumerConfig
* @description:
*/
@Configuration
public class KafkaConsumerConfig {
@Bean
@Qualifier("myConsumerConfigs")
public Map<String, Object> myConsumerConfigs() {
Map<String, Object> props = new HashMap<>();
// 该参数用来指定消费者客户端连接 Kafka 集群所需的 broker 地址清单
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
// 指定消费组为 test-group
props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
// key序列化配置
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
// value序列化配置
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
// 是否开启 自动提交位移 true 代表自动, false 代表手动
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// 表示开启自动提交消费位移功能时自动提交消费位移的时间间隔
// props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
return props;
}
@Bean
@Qualifier("myConsumerFactory")
public ConsumerFactory<?, ?> myConsumerFactory() {
Map<String, Object> configs = myConsumerConfigs();
DefaultKafkaConsumerFactory factory = new DefaultKafkaConsumerFactory<>(configs);
return factory;
}
@Bean
@Qualifier("myContainerFactory")
public ConcurrentKafkaListenerContainerFactory<?, ?> myContainerFactory() {
ConcurrentKafkaListenerContainerFactory containerFactory = new ConcurrentKafkaListenerContainerFactory<>();
containerFactory.setConsumerFactory(myConsumerFactory());
containerFactory.setConcurrency(1);
//单个消费
containerFactory.setBatchListener(false);
// 手动提交
containerFactory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
return containerFactory;
}
}
package com.demo.config.kafka;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* @ClassName MyConsumer
*/
@Component
public class MyConsumer {
@KafkaListener(topics = "test-topic-name" + "-${kafka.env}",
groupId = "test-group-name",
containerFactory = "myContainerFactory",
properties = {JsonDeserializer.KEY_DEFAULT_TYPE + "=java.lang.String", JsonDeserializer.VALUE_DEFAULT_TYPE + "=java.lang.Long"
})
public void consumer(ConsumerRecord<String, Long> message, Acknowledgment ack) throws Exception {
try {
// 处理消息的逻辑
// todo...
// 提交位移 ack.acknowledge()是同步的。
// ack.acknowledgeAsync()为异步提交。异步提交位移可能会导致一些延迟,因为位移的提交和确认是在后台进行的。
ack.acknowledge();
} catch (Exception e) {
// 回滚位移
ack.nack(Duration.ZERO);
// 处理异常逻辑
}
// 确认消息的处理
ack.acknowledge();
}
}
3.4、自定义消费者拦截器
消费者的自定义拦截器,可以处理消费者幂等的问题
自定义拦截器:
package com.demo.config.kafka;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerInterceptor;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @ClassName MyConsumerInterceptor
*/
@Slf4j
public class MyConsumerInterceptor implements ConsumerInterceptor<String, String> {
private static final long EXPIRE_INTERVAL = 10 * 1000;
/**
* 在处理每条消息之前进行自定义处理
* 例如,修改消息的内容或添加自定义的元数据等
* @param records
* @return
*/
@Override
public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
//举例: 如果某条消息在既定的时间窗口无法到达,那么就会被视为无效,就不需要再被继续处理了,就是 TTL 功能
long now = System.currentTimeMillis();
Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords = new HashMap<>();
for (TopicPartition tp : records.partitions()) {
List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
for (ConsumerRecord<String, String> record : tpRecords) {
// 消息的时间戳与当前的时间戳相差超过 10 秒则判定为过期,过期既过滤,不再投递给消费者
if (now - record.timestamp() < EXPIRE_INTERVAL) {
newTpRecords.add(record);
}
}
if (!newTpRecords.isEmpty()) {
newRecords.put(tp, newTpRecords);
}
}
return new ConsumerRecords<>(newRecords);
}
/**
* 在提交完消费位移之后调用
* @param offsets
*/
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
offsets.forEach((tp, offset) ->
log.info("提交位移 -> {}:{}", tp, offset.offset())
);
}
/**
* 在关闭消费者时进行处理
*/
@Override
public void close() {}
/**
* 处理配置
* @param map
*/
@Override
public void configure(Map<String, ?> map) {}
}
配置自定义的拦截器:
props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, MyConsumerInterceptor.class.getName());
package com.demo.config.kafka;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName KafkaConsumerConfig
* @description:
*/
@Configuration
public class KafkaConsumerConfig {
@Bean
@Qualifier("myConsumerConfigs")
public Map<String, Object> myConsumerConfigs() {
Map<String, Object> props = new HashMap<>();
// 该参数用来指定消费者客户端连接 Kafka 集群所需的 broker 地址清单
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
// 指定消费组为 test-group
props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
// key序列化配置
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
// value序列化配置
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
// 是否开启 自动提交位移 true 代表自动, false 代表手动
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// 表示开启自动提交消费位移功能时自动提交消费位移的时间间隔
// props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
// 自定义消费者拦截器
props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, MyConsumerInterceptor.class.getName());
return props;
}
@Bean
@Qualifier("myConsumerFactory")
public ConsumerFactory<?, ?> myConsumerFactory() {
Map<String, Object> configs = myConsumerConfigs();
DefaultKafkaConsumerFactory factory = new DefaultKafkaConsumerFactory<>(configs);
return factory;
}
@Bean
@Qualifier("myContainerFactory")
public ConcurrentKafkaListenerContainerFactory<?, ?> myContainerFactory() {
ConcurrentKafkaListenerContainerFactory containerFactory = new ConcurrentKafkaListenerContainerFactory<>();
containerFactory.setConsumerFactory(myConsumerFactory());
containerFactory.setConcurrency(1);
//单个消费
containerFactory.setBatchListener(false);
// 手动提交
containerFactory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
return containerFactory;
}
}
4、分区 Partition 和副本 Replica
4.1、关于分区 Partition
-
Topic 是一个逻辑上的概念,它还可以细分为多个 Partition(分区),一个 Partition 只属于单个Topic,很多时候也会把分区称为主题分区( Topic-Partition )。
-
同一 Topic 下的不同 Partition(分区) 包含的消息是不同的,Partition(分区) 在存储层面可以看作一个可追加的日志( Log )文件,消息在被追加到分区日志(Log)文件的时候,都会分配一个特定的偏移量( offset )。
-
offset(偏移量) 是消息在分区中的唯一标识, Kafka 通过它来保证消息在分区内的顺序性,不过 offset 并不跨越分区。Kafka 保证的是 Partition 有序而不是 Topic 有序。
如下图所示,假设某个 Topic 中有 4个 Partition(分区),消息被顺序追加到每个分区日志文件的尾部。 Kafka 中的分区可以分布在不同的服务器 Broker 上。也就是说,一个 Topic 可以横跨多个 Broker ,以此来提供比单个 Broker 更强大的性能。
4.2、关于副本 Replica
- 副本(Replica)本质就是一个只能追加写消息的提交日志。Kafka 为分区(Partition)引入了多副本 Replica 机制, 通过增加副本数量可以提升容灾能力。kafka 的副本是基于 分区的维度上讨论的,即副本是特定分区的副本。
- 同一Partition 的不同副本中保存的是相同的消息(在同一时刻,副本之间并非完全一样),副本之间是“一主多从”的关系。
- leader 副本负责处理读写请求,follower 副本只负责与 leader 副本的消息同步。
- 副本处于不同的 broker 中,当 leader 副本出现故障时,从 follower 副本中重新选举新的 leader 本对外提供服务。
-
分区中的所有副本统称为
AR
,ISR
是指与 leader 副本保持同步状态的副本集合,leader 副本本身也是 ISR 集合中的一员。 -
LEO
标识每个分区中最后一条消息的下一个位置,分区的每个副本都有自己的 LEO,ISR 中最小的 LEO 即为HW
(俗称高水位),消费者只能拉取到HW之前的消息。
一般来讲,生产者发出的一条消息,首先会被写入分区的 leader 副本,然后等待 ISR 集合中的所有 follower 副本都同步完之后,才能被认为已经提交。之后才会更新分区的 HW ,进而消费者可以消费到这条消息。实际上还是要看 生产者的 ack 配置
。 示例:properties put(“acks”,“0”);
ack配置值 | ack配置说明 |
---|---|
acks = 0 | Producer请求立即返回,不需要等待Leader的任何确认。 |
acks = -1 | 分区Leader必须等待消息被成功写入到所有的ISR副本(同步副本)中才认为Producer请求成功。 |
acks = 1 | Leader副本必须应答此Producer请求,并写入消息到本地日志之后Producer请求被认为成功。如果此时Leader副本应答请求之后挂掉了,消息会丢失。 |
4.2.1、关于失效副本
正常来讲分区的所有副本都应处于 ISR 集合中,当副本同步失效
或功能失效
时( 比如副本处于非存活状态)就被称为失效副本。失效副本会被剥离出 ISR 集合。失效副本对应的分区也就称为同步失效分区 ,即 under-replicated 分区。
关于同步失效:
Kafka 0.9.x 版本开始通过 broker 端参数 replica.lag.time.max.ms
来设置同步失效时间。当 ISR 集合中follower副本滞后 leader 副本的时间,超过此参数指定的值时,则判定为同步失败,会将此 follower 副本剔除出 ISR 集合。replica.lag.time.max.ms
参数的默认值为 10000.
Kafka 源码注释中说明了,一般有两种情况会导致副本失效:
-
follower 副本进程卡住,在一段时间内根本没有向 leader 副本发起同步请求,比如频繁 Full GC。
-
follower 副本进程同步过慢,在一段时间内都无法追赶上 leader 副本,比如 I/O 开销过大。
如果通过工具增加了副本因子,那新增的副本在赶上 leader 副本之前都处于失效状态 。如果一个 follower 副本由于某些原因(比如若机)而下线,之后再上线 ,在追赶上 leader 副本之前也处于失效状态。
4.2.2、关于 ISR 的伸缩
kafka 在启动时,会开启两个定时任务:isr-expiration
、isr-change-propagation
-
isr-expiration
任务会周期性地检测每个分区是否需要缩减其 ISR 集合。当检测到 ISR 集合中有失效副本时,就会收缩 ISR 集合。-
周期时间是
replica.lag.time.max.ms
配置的一半,默认为 5000ms. -
如果某个分区的 ISR 集合发生变更,则会将变更后的数据记录到 ZooKeep 对应的/brokers/topics//partition/ /state 节点中。
-
-
当 ISR 集合发生变更时还会将变更后的记录缓存到 isrChangeSet 中,
isr-change-propagation
任务会周期性(固定值为 2500ms )地检查 isrChangeSet。- 如果发现isrChangeSet 中有ISR 集合的变更记录,它会在 zooKeer的 isr_change_notification路径下创建一个以 isr_change_开头的持久顺序节点,并将isrChangeSet 中的信息保存到这个节点中。
- /isr_change_notification 有一个相关 Watcher,当该节点中有子节点发生变化时会触发 Watcher,来通知控制器更新相关元数据信息,并向它管理的 broker 节点发送更新元数据的请求,最后删除/isr_change_notification 路径下已经处理过的节点。
- 频繁地触 Watcher 会影响Kafka 控制器、 ZooKeeper 甚至其 broker 点的性能。为了避免这种情况 Kafka 添加了限定条件,当检测到分区的 ISR 集合发生变化时,还需要检查以下两个条件之一,才可以将 ISR 集合的变化写入目标节点。
- ① 上一次 IS 集合发生变化距离现在己经超过。
- ② 上一次写入 ZooKeeper 的时间距离现在已经超过 60s。
5、多副本架构
如下图,假设 Kafka 集群中有4个 Broker ,某个Topic 中有 3 个Pritition(分区),且副本因子(即副本个数)也为 3,如此每个分区便有1个 leader 副本和 2个 follower 副本。生产者和消费者只与 leader 副本进行交互,而 follow 副本只负责消息的同步,很多时候 follower 副本中的消息相对 leader 副本而言会有一定的滞后。
5.1、多副本术语
-
Partition(分区)中的所有副本统称为
AR
( Assigned Replicas)。 -
所有与 leader 副本保持一定程度同步的副本(包括 leader 副本在内)组成
ISR
(On-Sync Replicas) ,ISR 集合是 AR 集合中的一个子集
。 -
与 leader 副本同步滞后过多的副本(不包 leader 副本)组成
OSR
(Out-of-Sync Replicas )。
leader 副本负责维护和跟踪 ISR 集合中所有 follower 副本的滞后状态, follower 副本落后过多或失效时, leader 副本会把它从 ISR 集合中剔除,当 OSR 集合中有 follower 副本 “追上”了 leader 副本时,leader 副本 它从 OSR 集合转移至 ISR 集合
5.2、多副本模式下,写入消息
如下图所示,它代表 1 个日志(Log)文件,这个日志(Log)文件中有9条消息,第一条消息的 offset( LogStartOffset )为0 ,最后 1 条消息的 offset 8。offset 为9 的消息用虚线框表示,代表下一条待写入的消息。日志(Log)文件的 HW 为6,表示消费者(Consumer)只能拉取到 offset 在0 到 5 之间的消息,而offset 等于6 的消息对消费者而言是不可见的。
6、重复消费和消息丢失
kafka 什么情况下会发生 重复消费和消息丢失的情况?
1、再均衡发生期间,比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移,就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息又被重新消费一遍,也就是发生了重复消费。
2、对于位移提交的具体时机的把握也很有讲究,有可能会造成重复消费和消息丢失的现象。当前 poll()操作所拉取的消息集为 [x+2, x+7], x+2 是上一次提交的消费位移,说明己经完成了 x+1 之前(包括 x+1在内)的所有消息的消费, x+5 表示当前正在处理的位置。如果拉取到消息之后就进行了位移提交 ,即提交了 x+8,那么当前消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+8 开始的。也就是说, x+5 到x+7 之间的消息并未能被消费,发生了消息丢失的现象。
3、当前 poll()操作所拉取的消息集为 [x+2, x+7],位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+2 开始的,x+2到 x+4 之间的消息又重新消费了一遍,故而又发生了重复消费的现象。
4、消费位移自动提交时(默认为每5秒提交一次),假设刚刚提交完一次消费位移,然后再拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象。
7、关于kafka的消息有序性
Kafka主要通过以下策略来保证消息的顺序消费:
- 分区策略:Kafka的主题可以分为多个分区,每个分区内的消息是有序的。生产者将相关的消息发送到同一个分区,通过分区策略来实现。默认情况下,Kafka会使用基于消息键(key)的哈希分区策略,保证具有相同键的消息将被发送到相同的分区,从而保证了消息的顺序性。
- 分区数和消费者数的关系:在分布式消费者的情况下,要确保每个分区只由一个消费者消费,这可以通过控制分区数和消费者数的关系来实现。每个分区只能被一个消费者消费,这确保了该分区内消息的严格顺序。如果有多个消费者,可以将分区数设置为消费者的数量,或者通过手动分配分区给每个消费者来确保分区和消费者的一一对应关系。
- 偏移量(Offset):每个分区中的消息都有一个唯一标识符,称为偏移量(Offset)。偏移量表示消息在分区内的顺序。消费者可以通过指定偏移量来读取分区中的特定消息。Kafka使用偏移量来记录消费者的消费进度,并在消费者恢复或重启时从上一次的偏移量处继续消费,保证了消息的有序性。
综上,Kafka通过分区策略、分区数和消费者数的关系以及偏移量等策略保证了消息的顺序消费。
8、Kafka的磁盘存储策略
Kafka的磁盘存储策略主要包括以下几个方面:
- ① 分区存储:Kafka将消息分成一个个的分区(Partition),每个分区对应一个日志文件(Log File),并将日志文件存储在磁盘上。这种分区存储的方式可以并行处理消息,提高吞吐量。
- ② 日志文件分段:每个日志文件由多个日志段(Log Segment)组成,每个日志段的大小可以配置。当一个日志段写满后,Kafka会创建一个新的日志段,并将新的消息写入其中。旧的日志段会被异步地清理掉,以释放磁盘空间。
- ③ 文件系统缓存:Kafka利用操作系统的文件系统缓存来加速数据的读取和写入。当消息被写入磁盘时,操作系统会将其缓存在内存中,并在需要时进行读取,避免了频繁的磁盘IO操作。这种利用文件系统缓存(
pagecache
) 的方式可以显著提高Kafka的读写性能。 - ④ 零拷贝技术:Kafka使用了零拷贝技术,即在消息传输过程中避免了数据的复制操作。传统的方式是将数据从一个缓冲区复制到另一个缓冲区,而零拷贝技术通过操作系统的DMA(Direct Memory Access)功能,将数据直接从磁盘读取到内存中,或者从内存中直接写入磁盘,避免了数据的多次复制,提高了数据传输的效率。
总的来说,Kafka的磁盘存储策略通过分区存储、日志文件分段、文件系统缓存和零拷贝技术等方式,提高了消息的存储和传输效率,从而满足了高吞吐量的需求。
.