Kafka作为消息组件使用,并不是单纯的消息组件,Kafka被定位成开源的分布式事件流平台。
相关消费kafka消息博客如下:
Kafka还依赖于Zookeeper,因此安装Kafka之前需要先安装、运行ZooKeeper。
ZooKeeper和Kafka相关博客:
Kafak的主题只是存储消息的逻辑容器,主题之下会分为若干个分区,分区才是存储消息的物理容器。
简而言之,消息存在于分区中,一个或多个分区组成主题。因此,Kafka的消息组织方式是三级结构:主题->分区->消息。
主题只是消息的逻辑分类,它是发布消息的类别或消息源的名称。分区才是真正存储消息的地方,分区在物理存储层面就是一个个日志文件。kafka配置的log.dirs属性用于指定Kafka日志的存储目录,由于Kafka消息实际存储在分区(日志文件)中,因此该属性是指定Kafka消息的存储目录。
分区文件是一个有序的、不可变的记录序列,序列的数据项可通过下标访问,下标从0开始。分区文件的结构有点类似于不可变的List集合,只不过List集合存储在内存中,而分区文件则持久化地存储在磁盘上。
Kafka默认有一个名为"__consumer_offsets"的主题,该主题是Kafka自动创建的内部主题:位移主题,用于保存Kafka内部的位移信息。
ISR副本就是Kafka认为与领导者副本的数据同步的副本。根据该定义可以看出,领导者副本天然就是ISR副本,某些情况下,ISR中只要领导者一个副本。
追随者副本怎样的条件下才算ISR副本呢?
两个副本都有可能符合ISR标准,也都有可能不符合ISR标准,设置有可能副本A符合ISR标准,副本B不符合ISR标准。
判断一个副本是否符合ISR标准,取决于server.properties文件中的replica.lag.time.max.ms配置参数,该参数的默认值为30000(即30秒),Kafka建议将该参数配置为10~30秒。
Kafka将所有不符合ISR标准的副本称为非同步副本。通常而言,非同步副本滞后于领导者副本太多,当领导者副本挂掉时,非同步副本不适合被选举为领导者副本,否则会造成数据丢失,这也是Kafka的默认设置。
但是,如果剥夺了非同步副本被选举为领导者副本的资格,则势必会造成可用性降低。比如将复制因子设为4,这意味这一个分区有1个领导者副本和3个追随者副本。当领导者副本挂掉时,有可能这3个追随者副本都不符合ISR标准,那么就没法选出新的领导者副本,这个分区也就不可用了。
因此,在允许一定数据丢失的场景中,也可开启Unclean领导者选举,也就是允许选举非同步副本作为领导者副本——只要将server.properties文件中的unclean.leader.election.enable参数设为true即可。
开启Unclean领导者选举可以提高Kafka的可用性,但可能会造成数据丢失。
消息就是Kafka所记录的数据节点,消息在Kafka中又被称为记录(record)或事件(event),用消息来代指Kafka的数据节点。
从存储上看,消息就是存储在分区文件(有点类似于List集合)中的一个数据项,消息具有key、value、时间戳和可选的元数据头。
消息示例:
key:“java”
value: “a new book”
timestamp: “2:06 p.m.”
消息生产者向消息主题发送消息,这些消息将会被分发到该主题下的分区中保存,主题下的每条消息只会被保存在一个领导者分区中,而不会在多个领导者分区中保存多份。
分区的主要目的就是实现负载均衡,可以将同一个主题的不同分区房子不同节点上,因此对消息的读写操作也都是针对分区这个粒度进行的。所以,每个节点都能独立地处理各自分区的读、写请求,通过添加新节点即可很方便地提高Kafka的吞吐量。
当消息生产者发送一条消息时,会按如下方式来决定该消息被分发到哪个分区:
round-robin策略就是指按顺序来分发消息,比如一个主题有P0、P1、P2三个分区,那么第一条消息被分发到P0分区,第二条消息被分发到P1分区,第三条消息被分发到P2分区…
消费者用于从消息主题读取消息。
Kafka的消息主题与JMS、AMQP的消息队列是不同的:
从某种角度来看,Kafka主题中的消息会在一段时间内被持久化保存,客户端(消费者)可根据需要反复地读取它们。Kafka主题中的消息默认保存时间为7天,这个默认保存时间可通过server.properties文件中的如下配置进行修改:
#设置主题中消息的默认保存时间
log.retention.hours=168
当消息过期之后,Kafka可以对消息进行两种处理:delete或compact。
通过server.properties文件中的如下配置来设置对过期消息的处理策略:
#设置删除过期消息
log.cleanup.policy=delete
如果想修改某个主题下的保存时间,可专门配置该主题的retention.ms属性。修改指定主题的额外属性,推荐使用kafka-config.bat命令。
该命令指定如下常用选项:
以下命令将test1主题的retention.ms属性设为10个小时:
kafka-config.sh --alter --bootstrap-server localhost:9092
--entity-type topics
--entity-name test1
--add-config retention.ms=3600000
kafka采用轮询机制来检测消息是否过期,意味着即使某些消息已经过期,但只要轮询机制还没有处理到这些过期消息,就会依然保留在该主题下。
Kafka轮询检查的时间间隔也在server.properties文件中设置,该文件中包含如下配置:
#设置对过期消息进行轮询检查的间隔时间为5分钟
log.retention.check.interval.ms=300000
上面配置指定了对过期消息进行轮询检查的间隔时间为5分钟,意味着每5分钟就会检查一次消息是否过期。
一个消费者组包含多个消费者实例。同一个消费者组内的所有消费者共享一个公告的ID,这个ID被称为组ID。
在同一个消费者组内,每个分区只能由一个消费者实例来负责消费,这意味着同一个消费者组内的多个消费者实例不可能消费相同的消息,这就是典型的P2P消息模型。
由于消费者组之间彼此独立,互不影响,能订阅相同的主题而互不干涉。如果消费者实例属于不同的消费者组,这就是典型的Pub消息模型。
Kafka仅仅使用消费者组这种机制,就实现了传统消息引擎P2P和Pub-Sub两种消息模型。
在理想情况下,消费者组中的消费者实例数恰好等于该组所订阅主题的分区总数,这样每个消费者实例就恰好负责消费一个分区,否则,可能出现如下两种情况:
Kafka为消费者实例提供了3种分配分区的策略:
1)range策略:
range策略是基于每个主题单独分配分区的,大致步骤如下:
2)round-robin策略:
round-robin策略会把所有订阅主题的所有分区按顺序排列,然后采用轮询方式依次分给各消费者实例。
一般来说,如果消费者组内所有消费者实例所订阅的主题是相同的,那么使用round-robin策略能带来更公平的分配方案,否则使用range策略的效果更好。
3)sticky策略:
sticky策略主要用于处理重平衡需求,重平衡就是指重新为消费者实例分配分区的过程。比如以下3种情况就会触发重平衡:
当触发重新平衡处理时,使用range策略或round-robin策略,Kafka会彻底抛弃原有的分配方案,对变化后的消费者实例、分区进行彻底的重新分配。
sticky策略则有效地避免了上述两种策略的缺点:sticky策略会尽力维持之前的分配方案,只对改动部分进行最小的再分配,因此通常认为sticky策略在处理重平衡时具有最佳的性能。
Kafka包含如下5个核心API:
生产者API的核心类是KafkaProducer,提供了一个send()方法来发送消息,该send()方法需要传入一个ProducerRecord
使用生产者API发送消息很简单,基本只要两步:
Kafka Clients依赖包括生产者API、消费者API、管理API
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<groupId>org.crazyitgroupId>
<artifactId>kafka_testartifactId>
<version>1.0-SNAPSHOTversion>
<name>kafka_testname>
<properties>
<maven.compiler.source>11maven.compiler.source>
<maven.compiler.target>11maven.compiler.target>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
properties>
<dependencies>
<dependency>
<groupId>org.apache.kafkagroupId>
<artifactId>kafka-clientsartifactId>
<version>2.7.0version>
dependency>
<dependency>
<groupId>org.apache.kafkagroupId>
<artifactId>kafka-streamsartifactId>
<version>2.7.0version>
dependency>
dependencies>
project>
程序先发送了50条key为"fkjava"的消息,意味着50条消息都进入一个分区,程序后发送的50条不带key消息,意味着50条消息会被轮询进入该主题的3个分区。
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class Producer
{
public final static String TOPIC = "test1";
public static void main(String[] args)
{
var props = new Properties();
// 指定Kafka的节点地址
props.put("bootstrap.servers",
"localhost:9092,localhost:9093,localhost:9094");
// 指定确认机制,默认值是0。
props.put("acks", "all"); // ①
// 指定发送失败后的重试次数
props.put("retries", 0);
// 当多条消息要发送到同一分区时,生产者将尝试对多条消息进行批处理,
// 从而减少网络请求数,这有助于提高客户机和服务器的性能。
// 该参数控制默认的批处理的数据大小
props.put("batch.size", 16384);
// 指定消息key的序列化器
props.put("key.serializer", StringSerializer.class.getName());
// 指定消息value的序列化器
props.put("value.serializer", StringSerializer.class.getName());
try (
// 创建消息生产者
var producer = new KafkaProducer<String, String>(props))
{
for (var messageNo = 1; messageNo < 101; messageNo++)
{
var msg = "你好,这是第" + messageNo + "条消息";
if (messageNo < 51)
{
// 发送带消息
producer.send(new ProducerRecord<>(TOPIC, "fkjava", msg));
} else
{
// 发送不带key的消息
producer.send(new ProducerRecord<>(TOPIC, msg));
}
// 每生产了20条消息输出一次
if (messageNo % 20 == 0)
{
System.out.println("发送的信息:" + msg);
}
}
}
}
}
消费者API的核心类是KafkaConsumer,提供了如下常用方法:
如果开启了自动提交offset,无须调用commitAsync()或commitSync()方法进行手动提交。自动提交offset比较方便,但手动提交offset则更准确,消费者程序真正被处理后再手动提交offset。
KafkaConsumer的poll()方法用于拉取消息,该方法返回一个ConsumerRecords
使用消费者API拉取消息基本只要3步:
ConsumerA:
import java.time.Duration;
import java.util.Arrays;
import java.util.Properties;
import java.util.Scanner;
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;
public class ConsumerA
{
// 定义消费的主题
public final static String TOPIC = "test1";
// 定义该消费者实例所属的组ID
private static final String GROUPID = "groupA";
private static KafkaConsumer<String, String> consumer;
public static void main(String[] args) throws InterruptedException
{
// 启动一条新线程来处理程序退出
new Thread(() ->
{
var scanner = new Scanner(System.in);
if (scanner.nextLine().equals(":exit"))
{
if (consumer != null)
{
// 取消订阅
consumer.unsubscribe();
// 关闭消费者
consumer.close();
}
System.exit(0);
}
}).start();
var props = new Properties();
// 指定Kafka的节点地址
props.put("bootstrap.servers",
"localhost:9092,localhost:9093,localhost:9094");
// 指定消费者组ID
props.put("group.id", GROUPID);
// 设置是否自动提交offset
props.put("enable.auto.commit", "true");
// 设置自动提交offset的时间间隔
props.put("auto.commit.interval.ms", "1000");
// session超时时长
props.put("session.timeout.ms", "30000");
// 程序读取消息的初始offset
props.put("auto.offset.reset", "latest");
// 指定消息key的反序列化器
props.put("key.deserializer", StringDeserializer.class.getName());
// 指定消息value的反序列化器
props.put("value.deserializer", StringDeserializer.class.getName());
consumer = new KafkaConsumer<>(props);
// 订阅主题
consumer.subscribe(Arrays.asList(TOPIC));
System.out.println("---------开始消费---------");
while (true)
{
// 拉取消息
ConsumerRecords<String, String> msgList = consumer.poll(Duration.ofMillis(100));
if (null != msgList && msgList.count() > 0)
{
// 遍历取得的消息
for (ConsumerRecord<String, String> record : msgList)
{
System.out.println("收到消息: key = " + record.key() + ", value = "
+ record.value() + " offset = " + record.offset());
}
} else
{
Thread.sleep(1000);
}
}
}
}
ConsumerB:
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.Arrays;
import java.util.Properties;
import java.util.Scanner;
public class ConsumerB
{
// 定义消费的主题
public final static String TOPIC = "test1";
// 定义该消费者实例所属的组ID
private static final String GROUPID = "groupA";
private static KafkaConsumer<String, String> consumer;
public static void main(String[] args) throws InterruptedException
{
// 启动一条新线程来处理程序退出
new Thread(() ->
{
var scanner = new Scanner(System.in);
if (scanner.nextLine().equals(":exit"))
{
if (consumer != null)
{
// 取消订阅
consumer.unsubscribe();
// 关闭消费者
consumer.close();
}
System.exit(0);
}
}).start();
var props = new Properties();
// 指定Kafka的节点地址
props.put("bootstrap.servers",
"localhost:9092,localhost:9093,localhost:9094");
// 指定消费者组ID
props.put("group.id", GROUPID);
// 设置是否自动提交offset
props.put("enable.auto.commit", "true");
// 设置自动提交offset的时间间隔
props.put("auto.commit.interval.ms", "1000");
// session超时时长
props.put("session.timeout.ms", "30000");
// 程序读取消息的初始offset
props.put("auto.offset.reset", "latest");
// 指定消息key的反序列化器
props.put("key.deserializer", StringDeserializer.class.getName());
// 指定消息value的反序列化器
props.put("value.deserializer", StringDeserializer.class.getName());
consumer = new KafkaConsumer<>(props);
// 订阅主题
consumer.subscribe(Arrays.asList(TOPIC));
System.out.println("---------开始消费---------");
while (true)
{
// 拉取消息
ConsumerRecords<String, String> msgList = consumer.poll(Duration.ofMillis(100));
if (null != msgList && msgList.count() > 0)
{
// 遍历取得的消息
for (ConsumerRecord<String, String> record : msgList)
{
System.out.println("收到消息: key = " + record.key() + ", value = "
+ record.value() + " offset = " + record.offset());
}
} else
{
Thread.sleep(1000);
}
}
}
}
ConsumerA和ConsumerB中的GROUPID使用相同的字符串,此时ConsumerA和ConsumerB模拟的是P2P消息模型:
ConsumerA和ConsumerB中的GroupID使用不同的字符串,此时ConsumerA和ConsumerB模拟的是Pub-Sub消息模型:
# 配置Kafka默认的节点地址
spring.kafka.bootstrap-servers=\
localhost:9092,localhost:9093,localhost:9094
# 指定生产者的确认机制
spring.kafka.producer.acks=all
# 指定生产者发送失败后的重试次数
spring.kafka.producer.retries=0
# 指定生产者批处理的数据大小
spring.kafka.producer.batch-size=16384
# 指定生产者的消息key的序列化器
spring.kafka.producer.key-serializer=\
org.apache.kafka.common.serialization.StringSerializer
# 指定生产者的消息value的序列化器
spring.kafka.producer.value-serializer=\
org.apache.kafka.common.serialization.StringSerializer
# 指定默认的消费者组ID
spring.kafka.consumer.group-id=defaultGroup
# 设置消费者是否自动提交offset
spring.kafka.consumer.enable-auto-commit=true
# 设置消费者自动提交offset的时间间隔
spring.kafka.consumer.auto-commit-interval=1000
# 程序读取消息的初始offset
spring.kafka.consumer.auto-offset-reset=latest
# 指定消息key的反序列化器
spring.kafka.consumer.key-deserializer=\
org.apache.kafka.common.serialization.StringDeserializer
# 指定消息value的反序列化器
spring.kafka.consumer.value-deserializer=\
org.apache.kafka.common.serialization.StringDeserializer
# session超时时长
spring.kafka.consumer.properties[session.timeout.ms]=30000
server.port=8081
# 设置监听器的确认模式
spring.kafka.listener.ack-mode=batch
# 指定Streams API的应用ID
spring.kafka.streams.application-id=spring-pipe
# 指定应用启动时自动创建流
spring.kafka.streams.auto-startup=true
# 指定消息key默认的序列化和反序列化器
spring.kafka.streams.properties[default.key.serde]=\
org.apache.kafka.common.serialization.Serdes$StringSerde
# 指定消息value默认的序列化和反序列化器
spring.kafka.streams.properties[default.value.serde]=\
org.apache.kafka.common.serialization.Serdes$StringSerde
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.4.2version>
<relativePath/>
parent>
<groupId>org.crazyitgroupId>
<artifactId>kafka_bootartifactId>
<version>1.0-SNAPSHOTversion>
<name>kafka_bootname>
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<java.version>11java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.kafkagroupId>
<artifactId>spring-kafkaartifactId>
dependency>
<dependency>
<groupId>org.apache.kafkagroupId>
<artifactId>kafka-streamsartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
SpringBoot可以将自动配置的KafkaTemplate注入任意组件,接下来该组件调用Kafka Template的send()方法即可发送消息。
以下Service组件调用了KafkaTemplate来发送消息。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
public class MessageService
{
public static final String TOPIC = "test1";
private final KafkaTemplate<String, String> kafkaTemplate;
@Autowired
public MessageService(KafkaTemplate<String, String> kafkaTemplate)
{
this.kafkaTemplate = kafkaTemplate;
}
public void produce(String key, String message)
{
if (Objects.nonNull(key))
{
// 发送消息
this.kafkaTemplate.send(TOPIC, key, message);
} else
{
// 发送不带key的消息
this.kafkaTemplate.send(TOPIC, message);
}
}
}
为了让程序能调用上面Service组件的方法,本例提供了一个控制器类来调用Service组件的方法。
\app\controller\HelloController.java
import org.crazyit.app.service.MessageService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController
{
private final MessageService messService;
public HelloController(MessageService messService)
{
this.messService = messService;
}
@GetMapping("/produce/{key}/{message}")
public String produce(@PathVariable String message,
@PathVariable(required = false) String key)
{
messService.produce(key, message);
return "发送消息";
}
@GetMapping("/produce/{message}")
public String produce(@PathVariable String message)
{
messService.produce(null, message);
return "发送消息";
}
}
该控制器类定义了两个处理方法,分别用于发送带key的消息和不带key的消息。
SpringBoot会自动将@KafkaListener注解修饰的方法注册为消息监听器。没有显示地通过containerFactory属性指定监听器容器工厂(KafkaListenerContainerFactory),SpringBoot会在容器中自动配置一个ConcurrentKafkaListenerContainerFactory Bean作为监听器容器工厂。
定义一个监听消息队列的监听器TopicListener1
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class TopicListener1
{
@KafkaListener(topics = "test1", groupId="groupA")
public void processMessage(ConsumerRecord<String, String> message)
{
System.out.println("从test1收到消息,其key为:" + message.key()
+ ",其value为:" + message.value());
}
}
定义一个监听消息队列的监听器TopicListener2
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class TopicListener2
{
@KafkaListener(topics = "test1", groupId="groupB")
public void processMessage(ConsumerRecord<String, String> message)
{
System.out.println("从test1收到消息,其key为:" + message.key()
+ ",其value为:" + message.value());
}
}
如果要定义更多的监听器容器工厂或者覆盖默认的监听器工厂,则可通过SpringBoot提供的ConcurrentKafkaListenerContainerFactory来实现,可对ConcurrentKafkaListenerContainerFactory进行与自动配置相同的设置。
例如以下配置片段:
@Configuration(proxyBeanMethods = false)
static class KafkaConfiguration
{
@Bean
public ConcurrentKafkaListenerContainerFactory myFactory(
ConcurrentKafkaListenerContainerFactoryConfigure configurer,
ConsumerFactory consumerFactory)
{
//创建ConcurrentKafkaListenerContainerFactory实例
ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory();
//使用与自动配置相同的属性来配置监听器容器工厂
configurer.configure(factory,consumerFactory);
//下面可以对ConcurrentKafkaListenerContainerFactory进行额外的设置
....
return factory;
}
}
有了自定义的监听器容器工厂后,可通过@KafkaListener注解的containerFactory属性来指定使用自定义的监听器容器工厂。例如如下代码:
@KafkaListener(topics = "test1", containerFactory="myFactory")
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App
{
public static void main(String[] args)
{
SpringApplication.run(App.class, args);
}
}