Kafka是一个 分布式 的基于 发布/订阅模式 的 消息队列 (MQ,Message Queue),也是一个开源的分布式事件流平台,主要应用于大数据缓冲和实时处理,也可以做一些比如高性能数据管道、流分析、数据集成、关键任务等应用。
一、基础概念
Producer
生产者,向Broker(Kafka进程/服务)发送数据。
Broker
可以看作是Kafka进程/服务,一个服务器上只有一个Broker,多个服务器上的Broker协同工作就构成了Kafka集群(Kafka Cluster)。
Topic
主题,如果之前没接触过消息队列的话,可以理解为不同的数据如果拥有相同的主题,那么它们在消息队列中就属于同一个队列的,属于“同一组”或者“同一类”,会进入同一个“管道”。
Partition
分区,一个Kafka节点(服务器)上可以拥有多个分区,在Kafka的处理机制上,通常为了保证处理的效率和性能,同一个Topic的数据会发往不同节点上的分区,在不同的节点服务器上进行处理。需要注意的是,发往不同节点的数据是不会重复的,也就是说,第一批数据发往Broker1的0分区,第二批数据则发往其他节点的分区(如发往Broker2的0分区)。数据进行分区处理是Kafka的一大特色,其优点如下:
- 合理利用服务器或存储资源,实现多节点下的负载均衡。
- 提高并行度,使得无论是生产者还是消费者,处理消息时都不容易卡在发送和接收消息这一步。
Leader/Follower
为了保证数据的安全,Kafka会将分区的数据进行拷贝,并放在其他节点上作为备份(副本),一旦正在使用的分区故障之后,则立即启用备份的分区数据。正在使用的分区称为Leader分区,而拷贝到其他节点的备份分区则称为Follower分区。图中的“B2-P0(Follower)”表示Broker2的Partition0的Follower分区副本。
Consumer/Consumer Group
消费者和消费者组,Kafka中其实不存在单独的一个消费者,在消费数据时,如果你没有为消费者指定消费者组,Kafka也会默认给你指定一个消费组,也就是说,消费者怎么都是在一个消费者组中的。消费者组的特点是,同一个消费者组中的不同消费者,不能对同一个“Topic+Partition”进行消费,也就是说,一个消费者组在逻辑上就是一个消费者,它被看作一个“独立的个体”来进行数据的消费。Kafka这样做,其实也是为了提高消费者消费数据的效率,如果一个Topic中的数据量过大,就可以通过同一个消费者组中的不同消费者来一起消费。
Zookeeper
用于管理和存放Broker和Topic的Leader等相关信息,比如当前有哪些Broker和Leader正在运行。
二、Producer生产者
1. 发送数据基础流程
生产者发送数据时并不是直接就发送到Broker中,其发送的大致过程如下图:
send()方法发送数据
这里的send过程画的比较简单,中间省略了拦截器、序列化器、分区器等组件的操作,这些组件有默认的处理逻辑,也可以允许被自定义并替换掉默认的处理逻辑,感兴趣的也可以自己下去了解下。这里想表达的是数据使用send方法发送之后并不是直接就发送到节点服务器Broker中了,而是会发送到本地的消息队列(MQ)中了,而将数据从消息队列中发送到Broker中的实际是sender线程。另外一点值得注意的是,这个send方法有同步和异步两种方式,即是否阻塞当前线程,或者说是否等待发送的结果响应,具体使用方法见后面第2小节的示例。
消息队列(MQ)
由图中可以看到消息队列不止一个,具体的消息队列数量由分区数来决定,这里的消息队列和Broker中的分区数(Leader)是对应的,或者说是数据进入到哪一个消息队列(本地分区)其实由分区器计算好的。这些消息队列占用内存的最大值( **RecordAccumulator ** )默认是32M,当需要处理的数据量比较大时,可以适当调整这个值(推荐调整为2的倍数),比如64M。
sender线程
sender线程在这里也省略一些内容,比如NetworkClient、Selector等,如果想要深入研究其原理(比如请求的缓存机制、失败重试机制等),也可以自己下去阅读下源码。sender线程主要在后台负责从消息队列中拉取数据并通过请求的方式发送到Broker中(注意,在这一步之前,生产者发送的数据都还存在本地中),而sender线程从消息队列中拉取数据的时机由两个参数来决定:
- batch.size: 当消息队列中的数据量大小累积到该值后,sender才会从消息队列中拉取数据并发送到Broker中,这个值默认是16K,如果要增大这个值的话,建议调整为2的倍数,比如32K。
- linger.ms: 当消息队列中的数据等待指定时间后(单位ms),即使数据量没有达到
batch.size
指定的大小,也会拉取消息队列中的数据发送到Broker中,但是这个参数的默认值是0ms,也就是说数据到达消息队列后就会马上被发送到Broker中。实际生产中会根据使用场景灵活调整这个值,毕竟默认值0ms,batch.size
参数其实就没起作用了。
应答acks
Broker接收到生产者发送来的数据后,需要给生产者返回应答(acks),该应答有三个值:
- 0: Broker收到数据后立即返回应答成功,不需要等待数据持久化到硬盘中(即写入到log文件中)。
- 1: Broker收到数据后,且Leader分区确认收到数据并将数据持久化到硬盘中(即写入到log文件中)后才会返回应答成功。
- -1: Broker收到数据后,且Leader分区和所有Follower分区都收到数据并将数据持久化硬盘中(即写入到log文件中)后才会返回应答成功。
注: acks这个参数主要用于保证数据的可靠性,比如配置为-1,则哪怕中途出现了一些意外,也能保证发送成功的数据被丢失的概率很小。
2. 同步和异步发送数据
异步发送数据:
指的是将消息发送到队列之后就直接返回结果了,不需要等待队列中的消息发送到集群节点上再返回结果,异步发送在指定参数的时候也有两种方式:带回调函数和不带回调函数。示例如下:
package com.yun.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class CustomProducerAsync {
public static void main(String[] args) {
// 通过Properties对象设置Kafka客户端的属性
Properties properties = new Properties();
// 设置bootstrap.servers的值为对应的Kafka服务端信息,多个节点信息之间可以用逗号连接
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "host001:9092,host002:9092");
// 设置对应的序列化器
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 创建一个Kafka生产者客户端
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
// 异步发送方式一:发送5条数据,无回调操作
for (int i = 0; i < 5; i++) {
kafkaProducer.send(new ProducerRecord<>("topic_test001", "hello"));
}
// 异步发送方式二:发送5条数据,并执行回调方法
for (int i = 0; i < 5; i++) {
// 如果指定了callback参数,每次发送消息之后,都会执行一个回调方法onCompletion,
// 方法的参数recordMetadata中存放了此次发送消息的结果信息,包括消息发送到了哪个topic和partition,
// 如果发送过程中发生了异常,异常信息则保存在参数e中
kafkaProducer.send(new ProducerRecord<>("topic_test001", "hello"), new Callback() {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e == null) {
System.out.println("主题:" + recordMetadata.topic() + "分区:" + recordMetadata.partition());
}
}
});
}
// 关闭客户端
kafkaProducer.close();
}
}
同步发送数据:
指不仅要将消息发送到队列中,还需要将队列中的消息发送到节点服务器之后,此次send操作才算完成,不然会一直阻塞当前的send操作。示例如下:
package com.yun.kafka.producer;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
public class CustomProducerAsync {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 通过Properties对象设置Kafka客户端的属性
Properties properties = new Properties();
// 设置bootstrap.servers的值为对应的Kafka服务端信息,多个节点信息之间可以用逗号连接
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "host001:9092,host002:9092");
// 设置对应的序列化器
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 创建一个Kafka生产者客户端
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
// 同步发送:发送5条数据
for (int i = 0; i < 5; i++) {
// 相比于异步发送就多了个get()方法,正是由于这个get()方法,才会阻塞当前操作,
// 直到确认消息已经到节点服务器中才会继续发送下一条消息(下一次循环)
kafkaProducer.send(new ProducerRecord<>("topic_test001", "hello")).get();
}
// 关闭客户端
kafkaProducer.close();
}
}
自定义分区器
在send方法发送数据之后,会经过分区器将数据划分到不同的分区,再存储到对应的消息队列中,在某些特殊的场景下可能需要按照特定的要求将数据发送到不同的分区,这时候就可以使用自定义分区器,将数据发送到指定的分区。自定义的分区器,只要实现Partitioner接口即可。示例如下:
package com.yun.kafka.partitioners;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import java.util.Map;
public class MyPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes1, Cluster cluster) {
// 获取数据,并按照自定义的规则将数据发送到指定的分区
String msg = value.toString();
int partition;
if (msg.contains("yun")) {
partition = 0;
} else {
partition = 1;
}
return partition;
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> map) {
}
}
给生产者指定分区器:
// 通过Properties对象设置Kafka客户端的属性
Properties properties = new Properties();
// 设置bootstrap.servers的值为对应的Kafka服务端信息,多个节点信息之间可以用逗号连接
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "host001:9092,host002:9092");
// ...
// 指定自定义分区器
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, "com.yun.kafka.partitioners.MyPartitioner");
// 创建一个Kafka生产者客户端
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
三、Consumer消费者
1. Consumer消费数据
对于不同的Consumer,它们都是独立的,互不干扰,每个Consumer都可以消费一个或多个分区,但是需要注意,同一个消费者组中的消费者需要当作一个消费者来看待,或者说一个消费者组的消费逻辑对于Broker来说就是一个Consumer。Kafka使用offset来标识每个Consumer对于Topic的消费进度,offset值保存在Broker中,并持久化到磁盘中。Consumer在消费数据时一些常用参数如下:
- session.timeout.ms: 与Broker保持通信的超时时间,Broker会和消费者进行心跳检查,如果消费者与Broker之前通信异常了,每次心跳检查都失败,持续时间达到该值后,Broker就会将此消费者从“消费者名单”中移除,并触发再平衡(让其他消费者继续消费这些数据)。
- fetch.min.bytes: 每批次拉取的最小数据量,默认是1Byte,如果Consumer去拉取数据时发现将要拉取到的内容小于了此参数,就不会再去拉取了。
- fetch.max.wait.ms: 拉取数据的超时时间,默认是500ms,如果上次成功拉取数据到此次尝试拉取数据的间隔时间超过了这个值,无论将要拉取到数据大小是否满足要求,都会进行拉取。比如
fetch.min.bytes
指定最小拉取数据大小是10KB,但是分区中实际只有3KB,拉取的时候就不满足条件了,就不会去拉取数据,但是等待了fetch.max.wait.ms
指定的时间之后,数据量还是没有达到要求,还是只有3KB,那就无论如何都会去拉取数据了。 - fetch.max.bytes: 每次拉取的数据量最大值,默认是50M。
- max.poll.records: Consumer拉取到数据之后,每次消费的最大消息条数,即调用poll()方法之后返回的数据条数,默认是500条。
- max.poll.interval.ms: 消费者处理一次poll()方法数据的超时时间。如果两次poll()方法的调用时间间隔超过了这个时间,Broker会认为当前消费者的消费能力较弱,也会触发Broker对于消费者的再平衡(重新选择另一个消费者来消费数据)。
示例如下:
package com.yun.kafka.consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Properties;
public class CustomConsumer {
public static void main(String[] args) {
// 1. 配置参数
Properties properties = new Properties();
// 连接Kafka服务 bootstrap server
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "host01:9092,host02:9092");
// 配置反序列化
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
// 指定Consumer所属消费者组的groupId
properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
// 2. 创建Consumer对象
KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties);
// 3. 1)订阅Topic,同一个Consumer可以订阅多个Topic,使用subscribe方法订阅即可
ArrayList<String> topics = new ArrayList<>();
topics.add("topic01");
kafkaConsumer.subscribe(topics);
// 2)订阅某个主题特定分区的数据,需要使用到TopicPartition对象和assign方法
ArrayList<TopicPartition> topicPartitions = new ArrayList<>();
topicPartitions.add(new TopicPartition("topic02", 0));
kafkaConsumer.assign(topicPartitions);
// 4. 消费数据
while (true) {
// poll传入的参数表示拉取数据超时时间(这里设置为1秒),默认拉取500条数据,如果没有拉取到500条,
// poll方法内部会继续轮询拉取,直到拉取到500条或者拉取时间达到了1秒,然后直接返回当前已拉取到的数据
ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
System.out.println(consumerRecord);
}
}
}
}
2. 消费者组
每个Consumer在创建时都需要给它指定一个groupId,如果没有指定,Kafka也会默认给你指定一个GroupId,也就是说一个消费者怎么都会属于一个消费者组,或者可以从另一个方面来说,一个消费者组中可以只有一个Consumer,也可以有多个。但是需要特别注意的一点是,同一个消费者组中的Consumer,一个分区只能由一个Consumer进行消费,哪怕消费者组中的Consumer数量超过了分区数,同一个分区也不能由同一个消费者组中的多个Consumer来消费,当然,此时就会产生闲置的Consumer了,如果相反的情况,消费者组中的Consumer数量少于分区数,那么一个Consumer可能就需要负责消费多个分区了。
消费者组中的Consumer都会和Broker中的coordinator组件保持心跳(默认3秒),一旦发生超时(参数为 session.timeout.ms
,默认45秒),或者Consumer处理消息的时间过长(参数是 max.poll.interval.ms
,默认5分钟),该Consumer会被移除,并触发再平衡,即该Consumer对应的分区会被分配给消费者组中其他的Consumer进行消费。
3. offset管理
每个Topic对应分区的offset都保存在对应的Broker节点服务器中,我们在消费数据之后需要更新对应offset,通常有两种方式:自动提交和手动提交。
自动提交涉及以下两个参数:
- enable.auto.commit: 是否开启启动提交offset,默认是true(Kafka希望我们能专注于业务的开发)。
- auto.commit.interval.ms: 自动提交offset的间隔,默认是5秒。
手动提交有两种方式:
- commitSync: 同步提交,即执行提交方法时会阻塞当前线程,直到提交成功(如果失败,则会进行自动重试)。
- commitAsync: 异步提交,即执行提交方法后不会管是否提交成功,直接执行后面的步骤(处理数据或者继续消费数据)。
消费数时也可以使用 auto.offset.reset 指定offset消费策略:
- earliest: 从分区的最早offset开始消费。
- latest: 默认值,从上次消费的最新offset开始消费。
四、Kafka服务Broker
1. 核心配置文件
Kafka的核心配置文件在安装目录的config/server.properties,常用配置项有:
# 这个Broker的唯一标识,如果部署了多个Broker,一定要保证多个Broker的broker.id不能重复
broker.id=0
# 这是Kafka存放数据的目录,默认是在/tmp/kafka-logs目录下,因为/tmp目录会被定期清理,所以一定要修改这个参数
log.dirs=/opt/kafka/data
# 配置Broker连接信息,默认是localhost:2181,格式为“host:port,host:port,...”,最后的/kafka表示在zookeeper集群管理的根节点下创建一个kafka目录用于存放zookeeper信息
zookeeper.connect=host01:2181,host02:2181,host03:2181/kafka
# 日志文件(也是数据存储文件)保留时长,过期就会自动删除,默认7天
log.retention.hours=168
# Topic对应的分区数,默认创建1个分区
num.partitions=1
# Leader对应的Follower(副本数),默认创建1个副本
default.replication.factor=1
# 是否允许删除主题,默认false
delete.topic.enable=false
2. 数据存储
Producer发送数据到Broker之后,Broker会将同一个Topic的数据分别存入不同的分区,那如果发送的数据量还是太大了,比如一个分区都有几十G呢?其实Kafka对于每一个分区的数据还会继续“分段”存储,会将分区中的数据分为不同的Segment进行存储,一个Segment中存储的数据大小为1G,每个Segment由如下文件组成:
- 000000000.index: 偏移量索引文件,文件名以对应000000000.log文件中第一条数据在该分区中的偏移量offset来命名,文件中存储的则是对应000000000.log文件中对应数据的offset,这里需要注意两点:一是存储的offset是相对偏移量,相对该000000000.log文件中第一条数据的偏移量(即文件名表示的offset),二是存储offset是按照稀疏索引的方式存储的,即000000000.log文件中每存入4KB的数据(“稀疏”的大小由参数
log.index.interval.bytes
指定),000000000.index文件中才会存入一条索引记录。 - 000000000.log: 日志文件,其实也就是存储数据的文件,文件名也是按照第一条数据相对于该分区的offset进行命名的,对于同一个000000000.log文件,新数据来了之后会以追加的方式存入该文件,这种存入方式大大提升了数据写入和未来进行搜索的效率。
- 000000000.timeindex: 用于记录该Segment已存在的时间,Kafka在进行数据清理时,根据检查策略(有默认值,也可以根据相关参数进行自定义设置),数据过期之后Kafka会根据清理策略(也可以自定义配置)自动清理对应的Segment。
- 其他文件
- 注: 同一个分区的数据都存放在“Topic+分区号”方式命名的目录下,不同的Segment又以不同的offset进行命名。