消息队列的流派
MQ 是什么
Message Queue(MQ)是一种消息队列中间件。MQ 的主要作用是通过分离消息的发送和接收来实现应用程序的异步和解耦。然而,MQ 的核心目的是通信:它屏蔽了底层复杂的通信协议,并定义了一套更简单的应用层通信协议。
在分布式系统中,模块间通信通常使用 HTTP 或自定义的 RPC TCP。这两种协议都较为原始,HTTP 难以实现双向通信,而 TCP 更加基础。MQ 在这些协议之上构建了一个简单的"协议"——生产者/消费者模型。MQ 提供的不是具体的通信协议,而是更高层次的通信模型。它定义了两个角色——生产者(发送数据)和消费者(接收数据),并提供 SDK 让我们能定义自己的生产者和消费者,实现消息通信而无需关注底层通信协议。
有 Broker 的 MQ
这类 MQ 通常有一台服务器作为 Broker,所有消息都通过它中转。生产者将消息发送给 Broker 后就完成了自己的任务,Broker 则负责将消息主动推送给消费者(或由消费者主动轮询)。
带 Topic 的 MQ
Kafka 和 JMS(如 ActiveMQ)属于这一类。生产者发送 key 和数据到 Broker,Broker 根据 key 决定将消息发送给哪个消费者。这是最常见的模式,也是我们对 MQ 最普遍的印象。在这种模式下,topic 通常是一个较大的概念,有时一个系统中可能只有一个 topic。topic 在某种程度上等同于 queue,生产者发送 key 就相当于说:“嘿,把数据放到这个 key 对应的队列中”。
如上所述,Broker 定义了三个队列:key1、key2、key3。生产者发送数据时会附带 key(如 key1)和实际数据,Broker 在推送数据时可能只推送数据本身,也可能连同 key 一起推送。尽管架构相似,Kafka 的性能远超 JMS。因此,这类 MQ 中 Kafka 几乎是唯一的选择。如果你需要一个高性能的数据流(注重性能而非灵活性),Kafka 是最佳选择。
轻量级 Topic
RabbitMQ(或更广泛地说,AMQP 协议)是这类 MQ 的代表。生产者发送 key 和数据,消费者定义订阅的队列,Broker 接收数据后通过特定逻辑将 key 映射到对应队列,然后将数据传递给队列。这种模式解耦了 key 和 queue。在这种架构中,queue 非常轻量(在 RabbitMQ 中,其上限取决于可用内存)。消费者只关心自己的 queue,生产者无需关心数据最终去向,只需指定 key。中间的映射层在 AMQP 中称为 exchange(交换机)。AMQP 定义了四种 exchange:
- 直接交换机:key 直接对应 queue。
- 扇形交换机:忽略 key,向所有 queue 发送消息副本。
- 主题交换机:key 可用通配符模糊匹配 queue。
- 头部交换机:忽略 key,根据消息的头部元数据决定发送到哪个 queue(AMQP 的头部元数据丰富且可自定义)。
这种架构为通信带来了极大的灵活性,几乎所有可以想到的通信方式都能通过这四种 exchange 实现。如果你需要一个灵活的企业数据总线,RabbitMQ 绝对值得考虑。
无 Broker 的 MQ
ZeroMQ 是无 Broker MQ 的代表。其作者敏锐地意识到 MQ 本质上是更高级的 Socket,旨在解决通信问题。因此,ZeroMQ 被设计成一个"库"而非中间件,实现了无 Broker 的目标。节点间的消息直接发送到彼此的队列中,每个节点既是生产者又是消费者。ZeroMQ 封装了一套类似 Socket 的 API,用于发送和接收数据。实际上,ZeroMQ 是一个跨语言的、功能强大的 Actor 模型邮箱库。你可以将自己的程序视为一个 Actor,ZeroMQ 则提供邮箱功能。它既可实现同一机器上的 RPC 通信,也能实现不同机器间的 TCP、UDP 通信。如果你需要强大、灵活、高效的通信能力,ZeroMQ 是不二之选。
一、Kafka介绍
Kafka是一个分布式、支持分区、多副本的消息系统,其最大特点是能实时处理大量数据以满足各种需求场景。它可用于日志收集、消息系统、用户活动跟踪和运营指标等。Kafka由Scala语言编写,于2010年贡献给Apache基金会并成为顶级开源项目。
1. Kafka的使用场景
- 日志收集
- 消息系统
- 用户活动跟踪
- 运营指标
2. Kafka基本概念
Kafka是一个分布式、分区的消息服务,提供了消息系统应有的功能。它借鉴了JMS规范的思想,但并未完全遵循JMS规范。JMS是类似于JDBC之于数据库的、针对Java调用消息队列的接口规范。
让我们先来了解一下基础的消息(Message)相关术语:
名称 | 解释 |
---|---|
Broker | 消息中间件处理节点,一个Kafka节点就是一个broker,一个或多个Broker可以组成一个Kafka集群 |
Topic | Kafka根据topic对消息进行归类,发布到Kafka集群的每条消息都需要指定一个topic |
Producer | 消息生产者,向Broker发送消息的客户端 |
Consumer | 消息消费者,从Broker读取消息的客户端 |
ConsumerGroup | 每个Consumer属于一个特定的Consumer Group,一条消息可以被多个不同的Consumer Group消费,但一个Consumer Group中只能有一个Consumer能够消费该消息 |
Partition | 物理上的概念,一个topic可以分为多个partition,每个partition内部消息是有序的 |
从较高层面来看,producer通过网络发送消息到Kafka集群,然后consumer进行消费,如下图所示:
服务端(brokers)和客户端(producer、consumer)之间通过TCP协议进行通信。
二、Kafka基本使用
官方文档
https://kafka.apache.org/documentation/
1. 安装
- 安装JDK
- 安装ZooKeeper
ZooKeeper主要服务于分布式系统,可用于:统一配置管理、统一命名服务、分布式锁和集群管理。分布式系统不可避免地面临节点管理问题(如实时感知节点状态、统一管理节点等),这些问题处理起来较为复杂。ZooKeeper作为一个通用中间件,能够有效解决这些问题。
安装ZooKeeper
docker run --name some-zookeeper -dit -p 2181:2181 zookeeper
将云服务器中的2181端口放开,等待约15秒,确保ZooKeeper完全启动
<b>将云服务器中的9092端口放开</b>
安装Kafka
docker run -d --name=kafka2 \
-p 9092:9092 \
-e ALLOW_PLAINTEXT_LISTENER=yes \
-e KAFKA_CFG_ZOOKEEPER_CONNECT=101.200.219.220:2181 \
-e KAFKA_BROKER_ID=2 \
-e KAFKA_NODE_ID=2 \
-e KAFKA_ENABLE_KRAFT=false \
-e KAFKA_HEAP_OPTS="-Xmx180m -Xms180m" \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://101.200.219.220:9092 \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
-e BITNAMI_DEBUG=true \
bitnami/kafka
<b>将云服务器中的9093端口放开</b>
安装Kafka(另一个实例)
docker run -d --name=kafka2 \
-p 9093:9092 \
-e ALLOW_PLAINTEXT_LISTENER=yes \
-e KAFKA_CFG_ZOOKEEPER_CONNECT=81.70.199.213:2181 \
-e KAFKA_BROKER_ID=2 \
-e KAFKA_NODE_ID=2 \
-e KAFKA_ENABLE_KRAFT=false \
-e KAFKA_HEAP_OPTS="-Xmx180m -Xms180m" \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://81.70.199.213:9093 \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
-e BITNAMI_DEBUG=true \
bitnami/kafka
注意:命令末尾不要添加 /bin/bash,否则会导致Kafka容器无法启动。
参数释义:
- -e KAFKA_BROKER_ID=2:在Kafka集群中,每个Kafka实例都有一个唯一的BROKER_ID
- -e KAFKA_CFG_ZOOKEEPER_CONNECT=81.70.199.213:2181:配置ZooKeeper的连接地址
- -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://81.70.199.213:9092:向ZooKeeper注册Kafka的地址和端口
- -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092:配置Kafka的监听端口(容器内部)
- -e KAFKA_HEAP_OPTS=“-Xmx180m -Xms180m”:设置Kafka占用的内存
2. 创建主题(Topic)
Topic是什么概念?Topic用于实现消息的分类,不同消费者可以订阅不同的Topic。
输入图片说明
执行以下命令创建名为"test"的Topic,该Topic只有一个分区(partition),且备份因子设置为1:
首先进入Kafka容器系统:
docker exec -it kafka2 /bin/bash
Kafka的安装目录位于/opt/bitnami/kafka
cd /opt/bitnami/kafka
cd bin
./kafka-topics.sh --create --topic test --bootstrap-server 101.200.219.220:9092 --replication-factor 1 --partitions 1
#######
Created topic test.
查看当前Kafka中的所有Topic:
./kafka-topics.sh --bootstrap-server 101.200.219.220:9092 --list
3. 发送消息
Kafka自带了一个生产者(producer)命令行客户端,可以从本地文件读取内容,或直接在命令行输入内容,并将这些内容作为消息发送到Kafka集群。默认情况下,每一行被视为一条独立的消息。使用Kafka的消息发送客户端,指定Kafka服务器地址和Topic来发送消息。
./kafka-console-producer.sh --broker-list 101.200.219.220:9092 --topic test
4. 消费消息
对于消费者(consumer),Kafka同样提供了一个命令行客户端,它会将获取到的内容在命令行中输出。默认情况下,它会消费最新的消息。使用Kafka的消费者客户端,从指定Kafka服务器的指定Topic中消费消息。
方式一:从最后一条消息的偏移量+1开始消费(消费最新的消息,建议先测试方式二)
./kafka-console-consumer.sh --bootstrap-server 81.70.199.213:9092 --topic test
方式二:从头开始消费
./kafka-console-consumer.sh --bootstrap-server 101.200.219.220:9092 --from-beginning --topic test
几个注意点:
- 消息会被存储:存储在磁盘中。即使系统关机重启,让消费者重新从头消费数据,仍可以获取到关机前生产的数据。
- 消息是顺序存储的:Kafka通常从队列尾部添加数据,从队列头部取出数据,位置是固定的。
- 消息有偏移量:例如,Topic “test” 中:队列头部 1(hello) 2(xizhi) 3(chaofan) 4(yuxiang) 5(he ye) 队列尾部。消费者(如小张)可以从偏移量5开始消费数据。
- 消费时可以指定偏移量进行消费。
三、Kafka中的关键细节
1.消息的顺序存储
消息的发送方会把消息发送到broker中,broker会存储消息,消息是按照发送的顺序进行存储。因此消费者在消费消息时可以指明主题中消息的偏移量。默认情况下,是从最后一个消息的下一个偏移量开始消费。
一个broker相当于是一个节点 -> 一个kafka容器就是一个broker
读取和写入的顺序都是先进先出
2. 单播消息的实现
单播消息:一个消费组里 只会有一个消费者能消费到某一个topic中的消息。于是可以创建多个消费者,这些消费者在同一个消费组中。
./kafka-console-producer.sh --broker-list 101.200.219.220:9092 --topic test
./kafka-console-consumer.sh --bootstrap-server 101.200.219.220:9092 --consumer-property group.id=testGroup --topic test
3.多播消息的实现
在一些业务场景中需要让一条消息被多个消费者消费,那么就可以使用多播模式(广播)。
kafka实现多播,只需要让不同的消费者处于不同的消费组即可。
./kafka-console-consumer.sh --bootstrap-server 101.200.219.220:9092 --consumer-property group.id=testGroup1 --topic test
./kafka-console-consumer.sh --bootstrap-server 101.200.219.220:9092 --consumer-property group.id=testGroup2 --topic test
4.查看消费组及信息
# 查看当broker下有哪些消费组
./kafka-consumer-groups.sh --bootstrap-server 101.200.219.220:9092 --list
# 查看当前topic 中的 消费组中的具体信息:比如当前偏移量、最后一条消息的偏移量、堆积的消息数量
./kafka-consumer-groups.sh --bootstrap-server 101.200.219.220:9092 --describe --group testGroup1
- Currennt-offset: 当前消费组的已消费偏移量 * Log-end-offset: 主题对应分区消息的结束偏移量(HW) * Lag: 当前消费组未消费的消息数
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID
testGroup1 test 0 12 12 0 console-consumer-e8490316-aa97-40ff-8e7b-36e8c68e9a6e /81.70.199.213 console-consumer
四、主题、分区的概念
面试知识点:初次接触Kafka只需了解这些基本概念即可
1. 主题(Topic)
主题(Topic)可以理解为一个类别的名称。
2. 分区(Partition)
输入图片说明
由于一个主题中的消息量可能非常大,因此可以通过设置分区来实现分布式(集群)存储这些消息。例如,如果一个topic创建了3个分区,那么该topic中的消息就会分别存放在这三个分区中。
为一个主题创建多个分区
./kafka-topics.sh --create --topic test1 --bootstrap-server 101.200.219.220:9092 --replication-factor 1 --partitions 2
可以通过以下命令查看topic的分区信息:
./kafka-topics.sh --bootstrap-server 101.200.219.220:9092 --topic test1 --describe
test的结果
##结果
Topic: test1 TopicId: UBg9xwGhSKyaeWV-RdiNcQ PartitionCount: 2 ReplicationFactor: 1 Configs:
Topic: test1 Partition: 0 Leader: 2 Replicas: 2 Isr: 2
Topic: test1 Partition: 1 Leader: 2 Replicas: 2 Isr: 2
通过查看topic信息,其中的关键数据:
- replicas:当前副本所在的broker节点
- leader:副本中的一个重要概念
- 每个partition都有一个broker作为leader。
- 消息发送方需要将消息发送给哪个broker?这取决于副本的leader所在的broker。副本中的leader专门用于接收消息。
- 接收到消息后,其他follower通过poll的方式来同步数据。
isr:可以同步的broker节点和已同步的broker节点,存放在isr(In-Sync Replicas)集合中。
分区的作用:
- 实现分布式存储
- 支持并行写入
五、Kafka集群及副本的概念
1. 搭建Kafka集群:2个broker
使用以下命令启动2台broker
注意:此Kafka安装依赖于ZooKeeper。如果之前已安装过ZooKeeper,请勿重复使用,而是创建新的ZooKeeper实例。
请删除之前安装的ZooKeeper和Kafka容器,重新创建ZooKeeper。
安装ZooKeeper
docker run --name some-zookeeper -dit -p 2181:2181 zookeeper
ZooKeeper安装完成后,等待30秒,让其完全启动。
确保安装完集群后,至少还有800MB的剩余空间。
注意:两个Kafka分别占用9092和9093端口,请确保云服务防火墙中这两个端口都已开放。
# 节点1
docker run -d --name=kafka2 \
-p 9092:9092 \
-e ALLOW_PLAINTEXT_LISTENER=yes \
-e KAFKA_CFG_ZOOKEEPER_CONNECT=101.200.219.220:2181 \
-e KAFKA_BROKER_ID=2 \
-e KAFKA_NODE_ID=2 \
-e KAFKA_ENABLE_KRAFT=false \
-e KAFKA_HEAP_OPTS="-Xmx180m -Xms180m" \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://101.200.219.220:9092 \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
bitnami/kafka
# 节点2
docker run -d --name=kafka3 \
-p 9093:9092 \
-e ALLOW_PLAINTEXT_LISTENER=yes \
-e KAFKA_CFG_ZOOKEEPER_CONNECT=101.200.219.220:2181 \
-e KAFKA_BROKER_ID=3 \
-e KAFKA_NODE_ID=3 \
-e KAFKA_ENABLE_KRAFT=false \
-e KAFKA_HEAP_OPTS="-Xmx180m -Xms180m" \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://101.200.219.220:9093 \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
bitnami/kafka
参数释义:
- KAFKA_BROKER_ID=0:在Kafka集群中,每个Kafka实例都有一个唯一的BROKER_ID来标识自己。
- KAFKA_ZOOKEEPER_CONNECT=81.70.199.213:2181:Kafka配置ZooKeeper连接地址。
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://81.70.199.213:9092:将Kafka的地址和端口注册到ZooKeeper。
- KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092:配置Kafka的监听端口,即容器内部Kafka占用的端口。
- KAFKA_HEAP_OPTS=“-Xmx180m -Xms180m”:设置Kafka占用的内存。
2. 副本的概念
副本是对分区的备份。在集群中,不同的副本会被部署在不同的broker上。以下例子创建1个主题,2个分区、2个副本。
进入kafka2容器的bin目录,执行以下命令:
./kafka-topics.sh --create --topic my-replicated-topic --bootstrap-server 101.200.219.220:9092 --replication-factor 2 --partitions 2
./kafka-topics.sh --bootstrap-server 101.200.219.220:9092 --topic my-replicated-topic --describe
Topic: my-replicated-topic TopicId: eJ0M58k5RR6MwImWeHBebQ PartitionCount: 2 ReplicationFactor: 2 Configs:
Topic: my-replicated-topic Partition: 0 Leader: 3 Replicas: 3,2 Isr: 3,2
Topic: my-replicated-topic Partition: 1 Leader: 2 Replicas: 2,3 Isr: 2,3
通过查看topic信息,其中的关键数据:
- replicas:当前副本所在的broker节点
- leader:副本中的重要概念
- 每个partition都有一个broker作为leader。
- 消息发送方需将消息发送给leader所在的broker。副本中的leader专门用于接收消息。
- 接收到消息后,其他follower通过poll方式同步数据。
isr:可以同步的broker节点和已同步的broker节点,存放在isr(In-Sync Replicas)集合中。
通过停止leader后再查看主题情况
# 停止leader
docker stop kafka3
# 查看topic情况
./kafka-topics.sh --bootstrap-server 81.70.199.213:9092 --topic my-replicated-topic --describe
##########
Topic: my-replicated-topic TopicId: x61TtWHyTzCg1XlAcOQQ5w PartitionCount: 2 ReplicationFactor: 2 Configs: segment.bytes=1073741824
Topic: my-replicated-topic Partition: 0 Leader: 2 Replicas: 3,2 Isr: 2
Topic: my-replicated-topic Partition: 1 Leader: 2 Replicas: 2,3 Isr: 2
删除topic命令
./kafka-topics.sh --bootstrap-server 81.70.199.213:9092 --delete --topic my-replicated-topic
查看当前存在的topic
./kafka-topics.sh --bootstrap-server 81.70.199.213:9092 -list
3. broker、主题、分区、副本
- Kafka集群由多个broker组成
- 一个broker中存放一个topic的不同partition——副本
注意:副本的数量不能超过集群节点的数量
向集群中的某个topic发送数据时,集群会首先计算出这条数据归哪个partition存储。确定partition后,数据存储到该分区对应的leader中。随后,其他副本节点会将这条新数据同步到自己的Kafka副本中。下面这张图演示的是3个broker,一个topic1,topic1有2个分区,3个副本的情况。
输入图片说明
4. Kafka集群消息的发送
创建topic
./kafka-topics.sh --create --topic my-replicated-topic --bootstrap-server 81.70.199.213:9092 --replication-factor 2 --partitions 2
# 查看topic情况
./kafka-topics.sh --bootstrap-server 81.70.199.213:9092 --topic my-replicated-topic --describe
发送数据
./kafka-console-producer.sh --broker-list 81.70.199.213:9092 --topic my-replicated-topic
5. Kafka集群消息的消费
./kafka-console-consumer.sh --bootstrap-server 81.70.199.213:9092 --from-beginning --topic my-replicated-topic
6. 关于分区消费组消费者的细节
./kafka-console-consumer.sh --bootstrap-server 81.70.199.213:9092 --from-beginning --topic my-replicated-topic --consumer-property group.id=testGroup1
./kafka-consumer-groups.sh --bootstrap-server 81.70.199.213:9092 --describe --group testGroup1
##运行结果
Consumer group 'testGroup1' has no active members.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID
testGroup1 my-replicated-topic 0 3 3 0 - - -
testGroup1 my-replicated-topic 1 0 0 0 - - -
输入图片说明
图中Kafka集群有两个broker,每个broker中有多个partition。一个partition只能被一个消费组里的某一个消费者消费,从而保证消费顺序。Kafka只在partition范围内保证消息消费的局部顺序性,不能在同一个topic的多个partition中保证总体消费顺序性。一个消费者可以消费多个partition。
消费组中消费者的数量不能超过一个topic中的partition数量,否则多出的消费者将无法消费到消息。
Kafka集群创建topic时分区和副本选择的基本概念
对于由7个broker组成的集群,创建一个topic时,设置7个分区是最优选择。如果选择5个分区也可以,但有两台机器没有发挥作用。选择10个分区也可以,但会有三台机器做双份工作,产生两个leader。
设置7个副本是最优选择。选择5个副本也可以,但有两台机器没有起到备份作用。选择10个副本是不可行的,因为在创建逻辑上存在问题。
’
六、Kafka的Java客户端——生产者
1. 引入依赖
Kafka的Maven依赖版本需要与Docker安装的Kafka版本相匹配
进入Kafka容器:cd /opt/bitnami/kafka/libs
<dependencies>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka_2.12</artifactId>
<version>3.4.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
</dependencies>
2. 生产者发送消息的基本实现
// 消息的发送方
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
public class MyProducer {
private static final String TOPIC_NAME = "my-replicated-topic";
public static void main(String[] args) throws ExecutionException, InterruptedException, JsonProcessingException {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "81.70.199.213:9092,81.70.199.213:9093");
// 将发送的key从字符串序列化为字节数组
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 将发送消息value从字符串序列化为字节数组
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
Producer<String, String> producer = new KafkaProducer<>(props);
Order order = new Order("123123", "订单12");
ObjectMapper objectMapper = new ObjectMapper();
ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, "订单", objectMapper.writeValueAsString(order));
RecordMetadata metadata = producer.send(producerRecord).get();
// ===== 阻塞 =====
System.out.println("同步方式发送消息结果:" + "topic-" + metadata.topic() + "|partition-" + metadata.partition() + "|offset-" + metadata.offset());
}
}
3. 发送消息到指定分区
ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, 0, "555", objectMapper.writeValueAsString(order));
4. 未指定分区时的分区选择
// 未指定发送分区时,具体发送的分区计算公式:hash(key) % partitionNum
ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, order.getOrderId(), objectMapper.writeValueAsString(order));
5. 同步发送
生产者同步发送消息时,在收到Kafka的ACK确认发送成功之前会一直处于阻塞状态
// 等待消息发送成功的同步阻塞方法
RecordMetadata metadata = producer.send(producerRecord).get();
System.out.println("同步方式发送消息结果:" + "topic-" + metadata.topic() + "|partition-" + metadata.partition() + "|offset-" + metadata.offset());
6. 异步发送消息
生产者发送消息后,无需等待broker回复,直接执行后续业务逻辑。
可以提供回调函数,让broker异步调用该函数,通知生产者消息发送结果
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Arrays;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class MyProducer {
private static final String TOPIC_NAME = "my-replicated-topic";
public static void main(String[] args) throws ExecutionException, InterruptedException, JsonProcessingException {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "81.70.199.213:9092,81.70.199.213:9093");
// 将发送的key从字符串序列化为字节数组
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 将发送消息value从字符串序列化为字节数组
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
Producer<String, String> producer = new KafkaProducer<>(props);
Order order = new Order("123123", "订单12");
ObjectMapper objectMapper = new ObjectMapper();
ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, "订单", objectMapper.writeValueAsString(order));
producer.send(producerRecord, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if (exception != null) {
System.err.println("发送消息失败:" + Arrays.toString(exception.getStackTrace()));
}
if (metadata != null) {
System.out.println("异步方式发送消息结果:" + "topic-" + metadata.topic() + "|partition-" + metadata.partition() + "|offset-" + metadata.offset());
}
}
});
TimeUnit.SECONDS.sleep(10);
}
}
7. 生产者的ACK参数配置
同步发送消息时,生产者发送到broker后,ACK有三种不同选择:
- (1)acks=0:生产者无需等待任何broker确认就可继续发送下一条消息。性能最高,但最容易丢失消息。
- (2)acks=1:至少等待leader成功将数据写入本地日志,无需等待所有follower写入。这种情况下,如果follower未成功备份数据且leader宕机,消息可能丢失。
- (3)acks=-1或all:等待min.insync.replicas(默认为1,建议配置大于等于2)个副本成功写入日志。这种策略保证只要有一个备份存活就不会丢失数据,是最强的数据保证。通常用于金融级别或涉及资金的场景。
代码示例:
props.put(ProducerConfig.ACKS_CONFIG, "1");
8. 其他细节
- 默认重试3次,每次间隔100ms
- 发送的消息先进入本地缓冲区(32MB),Kafka会启动一个线程,从缓冲区取16KB数据发送到Kafka。如果10毫秒内数据未满16KB,也会发送一次(批处理)
七、Kafka的Java客户端 - 消费者
1. 消费者消费消息的基本实现
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.Arrays;
import java.util.Properties;
public class MyConsumer {
private static final String TOPIC_NAME = "my-replicated-topic";
private static final String CONSUMER_GROUP_NAME = "testGroup";
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "81.70.199.213:9092,81.70.199.213:9093");
// 消费分组名
props.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_NAME);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
// 创建一个消费者客户端
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
// 消费者订阅主题列表
consumer.subscribe(Arrays.asList(TOPIC_NAME));
while (true) {
// poll() API 是拉取消息的长轮询
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("收到消息:partition = %d, offset = %d, key = %s, value = %s%n",
record.partition(), record.offset(), record.key(), record.value());
}
}
}
}
2. 自动提交offset
- 设置自动提交参数(默认)
// 是否自动提交offset,默认为true
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
// 自动提交offset的间隔时间
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
消费者poll到消息后,默认会自动向broker的_consumer_offsets主题提交当前主题-分区消费的偏移量。
自动提交可能会导致消息丢失:如果消费者还没消费完poll下来的消息就自动提交了偏移量,此时消费者崩溃,下一个消费者会从已提交的offset的下一个位置开始消费消息。之前未被消费的消息就丢失了。
./kafka-consumer-groups.sh --bootstrap-server 81.70.199.213:9092 --describe --group testXinKai
3. 手动提交offset
当程序代码出现异常时,未提交偏移量的数据会在下一次拉取中被重新获取处理
- 设置手动提交参数
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
如果使用手动提交偏移量方式进行消费,且之前已提交过偏移量(即Kafka服务端当前offset中已有偏移量),那么下次消费时会从已有的偏移量开始拉取数据,从而重新消费之前未提交偏移量的数据。
如果使用手动提交偏移量方式进行消费,且未提交偏移量,则会根据消费配置(默认从最新位置拉取,也可设置为从头开始拉取)进行数据消费。这是因为Kafka在消费时会根据消费者组、分区、偏移量来判断需要消费的消息。如果没有提交偏移量,Kafka会将其视为新的消费者,新消费者会根据配置决定从头还是从最新位置开始消费数据。
./kafka-consumer-groups.sh --bootstrap-server 81.70.199.213:9092 --describe --group testtGroup21
当提交模式设置为手动提交,且尚未进行提交,Kafka上没有offset值。此时,根据auto.offset.reset属性(默认值为latest),消费者会从最后的offset开始消费。
在消费完消息后进行手动提交
- 手动同步提交
if (records.count() > 0) {
// 业务处理代码
// 手动同步提交offset,当前线程会阻塞直到offset提交成功
// 一般使用同步提交,因为提交之后通常没有其他逻辑代码
consumer.commitSync();
}
- 手动异步提交
if (records.count() > 0) {
// 手动异步提交offset,当前线程提交offset不会阻塞,可以继续处理后续程序逻辑
// 处理逻辑
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
System.err.println("Commit failed for " + offsets);
System.err.println("Commit failed exception: " + exception.getStackTrace());
} else {
System.out.println("提交当前偏移");
}
});
}
4. 消费者poll消息的过程(了解)
- 消费者与broker建立长连接,开始poll消息。
- 默认一次poll 500条消息
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
可以根据消费速度来设置,因为如果两次poll的时间间隔超过30秒,Kafka会认为其消费能力过弱,将其踢出消费组,并将分区分配给其他消费者。
可以通过以下设置调整时间间隔:
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 30 * 1000);
如果在1秒内没有poll到任何消息,会继续poll消息,循环往复,直到poll到消息。如果超过1秒,则此次长轮询结束。
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
消费者发送心跳的时间间隔
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 1000);
如果Kafka超过10秒没有收到消费者的心跳,会将消费者踢出消费组,进行rebalance,把分区分配给其他消费者。
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10 * 1000);
5. 指定分区消费(了解)
注意:注释掉之前消费这个topic的配置
下面这句代码需要替代consumer.subscribe
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
6. 消息回溯消费(了解)
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seekToBeginning(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
7. 指定offset消费(了解)
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seek(new TopicPartition(TOPIC_NAME, 0), 10);
8. 从指定时间点消费(了解)
List<PartitionInfo> topicPartitions = consumer.partitionsFor(TOPIC_NAME);
// 从1小时前开始消费
long fetchDataTime = new Date().getTime() - 1000 * 60 * 60;
Map<TopicPartition, Long> map = new HashMap<>();
for (PartitionInfo par : topicPartitions) {
map.put(new TopicPartition(TOPIC_NAME, par.partition()), fetchDataTime);
}
Map<TopicPartition, OffsetAndTimestamp> parMap = consumer.offsetsForTimes(map);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : parMap.entrySet()) {
TopicPartition key = entry.getKey();
OffsetAndTimestamp value = entry.getValue();
if (key == null || value == null) continue;
Long offset = value.offset();
System.out.println("partition-" + key.partition() + "|offset-" + offset);
System.out.println();
// 根据消费里的timestamp确定offset
if (value != null) {
consumer.assign(Arrays.asList(key));
consumer.seek(key, offset);
}
}
9. 新消费组的消费偏移量
当消费主题的是一个新的消费组,或者指定offset的消费方式,offset不存在,那么应该如何消费?
- latest(默认):只消费自己启动之后发送到主题的消息
- earliest:第一次从头开始消费,以后按照消费offset记录继续消费,这需要区别于consumer.seekToBeginning(每次都从头开始消费)
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
八、在 Spring Boot 中使用 Kafka [使用频率一般]
1. 引入依赖
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
2. 配置文件
spring:
kafka:
bootstrap-servers: 81.70.199.213:9092,81.70.199.213:9093
producer:
retries: 3 # 设置大于 0 的值,客户端会重新发送失败的记录
batch-size: 16384
buffer-memory: 33554432
acks: 1
# 指定消息 key 和消息体的编解码方式
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer:
group-id: default-group
enable-auto-commit: false
auto-offset-reset: latest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
max-poll-records: 500
listener:
# 手动调用 Acknowledgment.acknowledge() 后立即提交,通常使用这种方式
ack-mode: MANUAL_IMMEDIATE
3. 消息生产者
- 发送消息到指定 topic
@RestController
public class KafkaController {
private static final String TOPIC_NAME = "my-replicated-topic";
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@RequestMapping("/send")
public void send() {
kafkaTemplate.send(TOPIC_NAME, 0, "key", "this is a msg");
}
}
4. 消息消费者
- 设置消费组,消费指定 topic
@KafkaListener(topics = "my-replicated-topic", groupId = "MyGroup1")
public void listenGroup(ConsumerRecord<String, String> record, Acknowledgment ack) {
String value = record.value();
System.out.println(value);
System.out.println(record);
// 手动提交 offset
ack.acknowledge();
}
- 设置消费组、多 topic、指定分区、指定偏移量消费及设置消费者个数。(了解)
@KafkaListener(groupId = "testGroup", topicPartitions = {
@TopicPartition(topic = "topic1", partitions = {"0", "1"}),
@TopicPartition(topic = "topic2", partitions = "0", partitionOffsets = @PartitionOffset(partition = "1", initialOffset = "100"))
}, concurrency = "3") // concurrency 是同组下的消费者个数,即并发消费数,建议小于等于分区总数
public void listenGroup(ConsumerRecord<String, String> record, Acknowledgment ack) {
String value = record.value();
System.out.println(value);
System.out.println(record);
// 手动提交 offset
ack.acknowledge();
}
九、Kafka集群Controller、Rebalance和HW [面试题]
1. Controller
- Kafka集群中的broker在ZooKeeper中创建临时序号节点,序号最小的节点(最先创建的节点)将作为集群的Controller,负责管理整个集群中的所有分区和副本的状态:
- 当某个分区的leader副本出现故障时,Controller负责为该分区选举新的leader副本。
- 当检测到某个分区的ISR集合发生变化时,Controller负责通知所有broker更新其元数据信息。
- 当使用kafka-topics.sh脚本为某个topic增加分区数量时,Controller负责让新分区被其他节点感知到。
2. Rebalance机制
前提是:消费者没有指定分区消费。当消费组里消费者和分区的关系发生变化时,会触发rebalance机制。
这个机制会重新调整消费者消费的分区。
在触发rebalance机制之前,消费者消费分区的策略有三种:
- Range:通过公式计算某个消费者消费哪个分区
- 轮询:消费者轮流消费分区
- Sticky:在触发rebalance后,在保持消费者原有分区不变的基础上进行调整
3. HW和LEO
HW(High Watermark)俗称高水位,是ISR(In-Sync Replicas)中最小的LEO(Log End Offset)。consumer最多只能消费到HW所在的位置。每个replica都有HW,leader和follower各自负责更新自己的HW状态。对于leader新写入的消息,consumer不能立即消费,leader会等待该消息被所有ISR中的replicas同步后更新HW,此时消息才能被consumer消费。这样就保证了如果leader所在的broker失效,该消息仍然可以从新选举的leader中获取。
十、Kafka线上问题优化 [面试题]
1.如何防止消息丢失
- 发送方: ack是 1 或者-1/all 可以防止消息丢失,如果要做到99.9999%,ack设成all,把min.insync.replicas配置成分区备份数
- 消费方:把自动提交改为手动提交。
2.如何防止消息的重复消费
一条消息被消费者消费多次。如果为了消息的不重复消费,而把生产端的重试机制关闭、消费端的手动提交改成自动提交,这样反而会出现消息丢失,那么可以直接在防治消息丢失的手段上再加上消费消息时的幂等性保证,就能解决消息的重复消费问题。
幂等性如何保证:
- mysql 插入业务id作为主键,主键是唯一的,所以一次只能插入一条
- 使用redis或zk的分布式锁(主流的方案)
3.如何做到顺序消费
- 发送方:在发送时将ack不能设置 0 ,关闭重试,使用同步发送,等到发送成功再发送下一条。确保消息是顺序发送的。
- 接收方:消息是发送到一个分区中,只能有一个消费组的消费者来接收消息。因此,kafka的顺序消费会牺牲掉性能。
4.解决消息积压问题
消息积压会导致很多问题,比如磁盘被打满、生产端发消息导致kafka性能过慢,就容易出现服务雪崩,就需要有相应的手段:
- 方案一:在一个消费者中启动多个线程,让多个线程同时消费。——提升一个消费者的消费能力(增加分区增加消费者)。
- 方案二:如果方案一还不够的话,这个时候可以启动多个消费者,多个消费者部署在不同的服务器上。其实多个消费者部署在同一服务器上也可以提高消费能力——充分利用服务器的cpu资源。
- 方案三:让一个消费者去把收到的消息往另外一个topic上发,另一个topic设置多个分区和多个消费者 ,进行具体的业务消费。
5.延迟队列
延迟队列的应用场景:在订单创建成功后如果超过 30 分钟没有付款,则需要取消订单,此时可用延时队列来实现
- 创建多个topic,每个topic表示延时的间隔
- topic_5s: 延时5s执行的队列
- topic_1m: 延时 1 分钟执行的队列
- topic_30m: 延时 30 分钟执行的队列
- 消息发送者发送消息到相应的topic,并带上消息的发送时间
- 消费者订阅相应的topic,消费时轮询消费整个topic中的消息
- 如果消息的发送时间,和消费的当前时间超过预设的值,比如 30 分钟
- 如果消息的发送时间,和消费的当前时间没有超过预设的值,则不消费当前的offset及之后的offset的所有消息都消费
- 下次继续消费该offset处的消息,判断时间是否已满足预设值
总结
这个文档介绍了Kafka的一些基本概念和使用方法,包括Kafka的架构、消息的发送和消费、Kafka集群的Controller、Rebalance和HW、以及Kafka线上问题的优化。其中还介绍了如何防止消息丢失、如何防止消息的重复消费、如何做到顺序消费、如何解决消息积压问题和如何使用延迟队列。