当前位置:   article > 正文

Kafka集群搭建、Kafka命令行使用、Kafka与springboot集成、Kafka生产者、Kafka消费者、Kafka面试题

kafka集群

1. Kafka 概述

1.1 kafka简介

Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。Kafka是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者在网站中的所有动作流数据。

具备以下特点:

通过O(1)的磁盘数据结构提供消息的持久化,这种结构对于即使数以TB的消息存储也能够保持长时间的稳定性能。
高吞吐量:即使是非常普通的硬件Kafka也可以支持每秒数百万的消息。
支持通过Kafka服务器和消费机集群来分区消息。

1.2 消息队列
1.2.1 使用消息队列的好处

1)解耦
允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。

2)可恢复性
系统的一部分组件失效时,不会影响到整个系统。消息队列降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理。

3)缓冲
有助于控制和优化数据流经过系统的速度,解决生产消息和消费消息的处理速度不一致的情况。

4)灵活性 & 峰值处理能力
在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量并不常见。如果为以能处理这类峰值访问为标准来投入资源随时待命无疑是巨大的浪费。使用消息队列能够使关键组件顶住突发的访问压力,而不会因为突发的超负荷的请求而完全崩溃。

5)异步通信
很多时候,用户不想也不需要立即处理消息。消息队列提供了异步处理机制,允许用户把一个消息放入队列,但并不立即处理它。想向队列中放入多少消息就放多少,然后在需要的时候再去处理它们。

1.2.2 消息队列的两种模式

(1)点对点模式(一对一,消费者主动拉取数据,消息收到后消息清除)
消息生产者生产消息发送到Queue中,然后消息消费者从Queue中取出并且消费消息。
消息被消费以后,queue 中不再有存储,所以消息消费者不可能消费到已经被消费的消息。
Queue 支持存在多个消费者,但是对一个消息而言,只会有一个消费者可以消费。
在这里插入图片描述
2)发布/订阅模式(一对多,消费者消费数据之后不会清除消息)
消息生产者(发布)将消息发布到 topic 中,同时有多个消息消费者(订阅)消费该消息。和点对点方式不同,发布到 topic 的消息会被所有订阅者消费。
在这里插入图片描述

1.3 Kafka 基础架构

在这里插入图片描述1)Producer :消息生产者,就是向 kafka broker 发消息的客户端;

2)Consumer :消息消费者,向 kafka broker 取消息的客户端;

3)Consumer Group (CG):消费者组,由多个 consumer 组成。消费者组内每个消费者负责消费不同分区的数据,一个分区只能由一个组内消费者消费;消费者组之间互不影响。所有的消费者都属于某个消费者组,即消费者组是逻辑上的一个订阅者。

4)Broker :一台 kafka 服务器就是一个 broker。一个集群由多个 broker 组成。一个 broker可以容纳多个 topic。

5)Topic :可以理解为一个队列,生产者和消费者面向的都是一个 topic;

6)Partition:为了实现扩展性,一个非常大的 topic 可以分布到多个 broker(即服务器)上,一个 topic 可以分为多个 partition,每个 partition 是一个有序的队列;

7)Replica:副本,为保证集群中的某个节点发生故障时,该节点上的 partition 数据不丢失,且 kafka 仍然能够继续工作,kafka 提供了副本机制,一个 topic 的每个分区都有若干个副本,一个 leader 和若干个follower。

8)leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是 leader。

9)follower:每个分区多个副本中的“从”,实时从 leader 中同步数据,保持和 leader 数据的同步。leader 发生故障时,某个 follower 会成为新的 follower。

2.单机版kafka搭建

2.1 下载kafka

https://kafka.apache.org/downloads.html
前面的2.11代表的是Scala的版本后面0.11为kafka的版本号
在这里插入图片描述上传到linux服务器
cd /usr/share
mkdir kafka
cp kafka_2.11-0.11.0.0.tgz kafka
tar -xvf kafka_2.11-0.11.0.0.tgz

2.2 修改配置

cd config
cp server.properties server0.properties
需要修改的有:broker.id、delete.topic.enable、listeners、log.dirs、zookeeper.connect
zookeeper没有搭建集群,采用的单机版。

单机版zookeeper安装

将localhost改为内网ip

#broker 的全局唯一编号,不能重复
broker.id=0
#删除 topic 功能使能
delete.topic.enable=true
# broker监听器的csv列表,格式是[协议]://[主机名]:[端口]。该参数主要用于客户端连接broker使用,可以认为是
# broker端开放给clients的监听端口
listeners=PLAINTEXT://localhost:9092
#处理网络请求的线程数量
num.network.threads=3
#用来处理磁盘 IO 的现成数量
num.io.threads=8
#发送套接字的缓冲区大小
socket.send.buffer.bytes=102400
#接收套接字的缓冲区大小
socket.receive.buffer.bytes=102400
#请求套接字的缓冲区大小
socket.request.max.bytes=104857600
#kafka 运行日志存放的路径
log.dirs=/usr/share/kafka/kafka_2.11-0.11.0.0/logs
#topic 在当前 broker 上的分区个数
num.partitions=1
#用来恢复和清理 data 下数据的线程数量
num.recovery.threads.per.data.dir=1
#segment 文件保留的最长时间,超时将被删除
log.retention.hours=168
#配置连接 Zookeeper 集群地址
zookeeper.connect=localhost:2181
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
2.3 配置环境变量

配置环境变量

vim /etc/profile
#kafka
export KAFKA_HOME=/usr/share/kafka
export PATH=$PATH:$KAFKA_HOME/bin
source /etc/profile
  • 1
  • 2
  • 3
  • 4
  • 5
2.4 kafka常用命令行
2.4.1 启动和关闭kafka

bin/kafka-server-start.sh -daemon config/server0.properties

-daemon:表示启动后的kafka服务器后台运行。

查看kafka是否启动成功:jps kafka的主进程名是Kafka。
在这里插入图片描述关闭kafka
bin/kafka-server-stop.sh

2.4.2 查询命令

查看当前服务器中的所有 topic
bin/kafka-topics.sh --zookeeper localhost:2181 --list

查看某个 Topic 的详情
bin/kafka-topics.sh --zookeeper localhost:2181 --describe --topic first

2.4.3 创建与删除

创建 topic
bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic first
–topic 定义 topic 名
–replication-factor 定义副本数
–partitions 定义分区数

删除topic
bin/kafka-topics.sh --zookeeper localhost:2181 --delete --topic first
需要 server.properties 中设置 delete.topic.enable=true 否则只是标记删除。

2.4.4 生产与消费

发送消息
==当不知道kafka在哪台机器部署时,可以使用windows客户端生产和消费消息,来验证kafka是否正常启动。==命令行和linux一致,只是把sh换成了bat。快速进入windows系统指定目录的cmd环境,先进入指定目录,然后在地址栏输入cmd,然后回车就可以了。

bin/kafka-console-producer.sh --broker-list localhost:9092 --topic first
>hello world
>success !!!
  • 1
  • 2
  • 3

消费消息
bin/kafka-console-consumer.sh --zookeeper localhost:2181 --topic first (旧)
0.8以前的kafka,消费的进度(offset)是写在Zookeeper中的,所以consumer需要知道zk的地址
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic first (新)
从现在开始接受producer发送的消息
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --from-beginning --topic first
–from-beginning: 会把first主题中以往所有的数据都读取出来,即从头读;

消费消息,同时过滤出含有Target的消息,可用户查询kafka是否收到了某条消息
./kafka-console-consumer.sh --zookeeper localhost:2181 --topic first |grep Target

2.5 生产者消费者吞吐量测试

生产者
bin/kafka-producer-perf-test.sh --topic first --num-records 500000 --record-size 200 --throughput -1 --producer-props bootstrap.servers=localhost:9092 acks=-1
500000 records sent, 105999.576002 records/sec (20.22 MB/sec), 1150.11 ms avg latency, 1700.00 ms max latency, 1169 ms 50th, 1654 ms 95th, 1687 ms 99th, 1698 ms 99.9th.
表明这台机器上运行一个kafka producer的平均吞吐量是20MB/s,即占用160Mb/s,评价每秒能发送105999条消息,平均延时是1.15秒,最大延时是1.7秒,平均有50%的消息发送需要花费1.16秒。

消费者
bin/kafka-consumer-perf-test.sh --broker-list localhost:9092 --fetch-size 2000 --messages 500000 --topic first
start.time, end.time, data.consumed.in.MB, MB.sec, data.consumed.in.nMsg, nMsg.sec
2022-08-14 23:02:56:436, 2022-08-14 23:02:59:881, 95.3675, 27.6829, 500002, 145138.4615
表明这台机器consumer在3秒多的时间内总共消费了95MB的消息,吞吐量为27MB/s。

3.集群版搭建

3.1 新增集群配置

分别复制新增server1、server2的配置:
cp server0.properties server1.properties
cp server0.properties server2.properties

修改配置文件内容:
vim server1.properties
broker.id=1
listeners=PLAINTEXT://localhost:9093
log.dirs=/usr/share/kafka/kafka_2.11-0.11.0.0/logs1

vim server2.properties
broker.id=2
listeners=PLAINTEXT://localhost:9094
log.dirs=/usr/share/kafka/kafka_2.11-0.11.0.0/logs2

启动server1、server2
bin/kafka-server-start.sh -daemon config/server1.properties
bin/kafka-server-start.sh -daemon config/server2.properties

linux执行后发现没报错,jps查看只有一个kafka,怀疑启动失败。

bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --partitions 1 --topic second
Error while executing topic command : replication factor: 3 larger than available brokers: 1
副本数多于brokers数,创建topic失败

取消后台启动,查看启动时的报错日志
bin/kafka-server-start.sh config/server1.properties

Java HotSpot™ 64-Bit Server VM warning: INFO: os::commit_memory(0x00000000fa980000, 59244544, 0)

报错原因是内存不足。通过jps拿到kafka的pid,通过 jmap -heap pid 查看kafka启动时配置的默认内存为1G.
修改kafka启动时占用的最大内存为256M
cd bin
vim kafka-server-start.sh
在这里插入图片描述

3.2 启动集群

然后重新启动
启动server0、server1、server2。jps命令可查看到有三个kafka的进程
bin/kafka-server-start.sh -daemon config/server0.properties
bin/kafka-server-start.sh -daemon config/server1.properties
bin/kafka-server-start.sh -daemon config/server2.properties

创建 topic
bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --partitions 1 --topic second

查看topic详情
bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic second

Topic:second PartitionCount:1 ReplicationFactor:3 Configs:
Topic: second Partition: 0 Leader: 1 Replicas: 1,2,0 Isr: 1,2,0
第一行是所有分区的摘要,其次,每一行提供一个分区信息,因为我们只有一个分区,所以只有一行。
leader":该节点负责该分区的所有的读和写,每个节点的leader都是随机选择的。
“replicas”:备份的节点列表,无论该节点是否是leader或者目前是否还活着,只是显示。
“isr”:“同步备份”的节点列表,详情介绍见后续内容。

生产者发送消息测试
bin/kafka-console-producer.sh --broker-list localhost:9092 --topic second
nihao
wohenhao

消费者接收消息测试
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --from-beginning --topic second
nihao
wohenhao

测试集群容错,干掉leader节点。之前leader是1,对应server1
查看server1的进程pid
在这里插入图片描述kill -9 20843
bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic second
Topic:second PartitionCount:1 ReplicationFactor:3 Configs:
Topic: second Partition: 0 Leader: 2 Replicas: 1,2,0 Isr: 2,0
发现2成为了leader,Isr中1消失了

重新启动1
bin/kafka-server-start.sh -daemon config/server1.properties
bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic second
Topic:second PartitionCount:1 ReplicationFactor:3 Configs:
Topic: second Partition: 0 Leader: 2 Replicas: 1,2,0 Isr: 2,0,1
发现2还是leader,Isr中1又加入了。

至此,kafka集群搭建完成!

4.Kafka核心概念

4.1 Kafka 工作流程及文件存储机制

在这里插入图片描述复习几个 kafka 重要概念:

Topic:一类消息,例如 page view 日志、click 日志等都可以以 topic 的形式存在,Kafka 集群能够同时负责多个 topic 的分发。

Partition:topic 物理上的分组,一个 topic 可以分为多个 partition,每个 partition 是一个有序的队列。

Segment:partition 物理上由多个 segment 组成,下面有详细说明。

offset:每个 partition 都由一系列有序的、不可变的消息组成,这些消息被连续的追加到 partition中。partition 中的每个消息都有一个连续的序列号叫做 offset,用于 partition中唯一标识的这条消息。

Kafka 中消息是以 topic 进行分类的,生产者生产消息,消费者消费消息,都是面向 topic的。

在这里插入图片描述Kafka topic和partition 都是逻辑上的概念, 而分区副本则是对应一个Log(文件夹);每个Log对应一至多个LogSegment(文件),每个还可以细分为索引文件(.index)、日志存储文件(.log)和快照文件等 ;
.log(消息存储), .index(偏移量索引), .timeindex(时间戳索引),还会有其他的各种文件;
该 log 文件中存储的就是 producer 生产的数据。Producer 生产的数据会被不断追加到该log 文件末端,且每条数据都有自己的 offset。消费者组中的每个消费者,都会实时记录自己消费到了哪个 offset,以便出错恢复时,从上次的位置继续消费。

日志清理: (broker参数: log .cleanup.policy); 粒度可到topic级别;
日志删除(delete):默认策略,「基于时间」「基于大小」「基于偏移量」;
日志压缩(compact):Log Compaction 对于有相同 key 的不同 value 值,只保留最后一个版本;
同时支持两种策略;

message(消息)是通信的基本单位,每个producer可以向一个topic(主题)发布一些消息。如果consumer订阅了这个主题,那么新发布的消息就会广播给这些consumer。

每个 partion(目录)相当于一个巨型文件被平均分配到多个大小相等 segment(段)数据文件中。但每个段 segment file 消息数量不一定相等,这种特性方便 old segment file 快速被删除。(默认情况下每个文件大小为 1G)

每个 partiton 只需要支持顺序读写就行了,segment 文件生命周期由服务端配置参数决定。

由于生产者生产的消息会不断追加到 log 文件末尾,为防止 log 文件过大导致数据定位效率低下,Kafka 采取了分片和索引机制,将每个 partition 分为多个 segment。每个 segment主要对应有两个文件——“.index”文件和“.log”文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic 名称+分区序号。例如,first 这个 topic 有三个分区,则其对应的文件夹为 first-0,first-1,first-2。

在这里插入图片描述“.index”文件存储大量的索引信息,“.log”文件存储大量的数据,索引文件中的元数据指向对应数据文件中 message 的物理偏移地址。

每个日志文件都是“log entries”序列,每一个 log entry 包含一个 4 字节整型数(值为 N),其后跟 N 个字节的消息体。每条消息都有一个当前 partition 下唯一的 64 字节的 offset,它指明了这条消息的起始位置。磁盘上存储的消息格式如下:

消息长度: 4 bytes (value: 1 + 4 + n)
版本号: 1 byte
CRC 校验码: 4 bytes
具体的消息: n bytes

kafka 日志分为 index 与 log,两个成对出现;index 文件存储元数据(用来描述数据的数据,这也
可能是为什么 index 文件这么大的原因了),log 存储消息。索引文件元数据指向对应 log 文件中 message
的迁移地址;例如 2,128 指 log 文件的第 2 条数据,偏移地址为 128;而物理地址(在 index 文件中指定)
+ 偏移地址可以定位到消息。

在这里插入图片描述

4.2 Kafka 生产者
4.2.1 生产者分区策略

分区的原因:

1)方便在集群中扩展,每个 Partition 可以通过调整以适应它所在的机器,而一个 topic又可以有多个 Partition 组成,因此整个集群就可以适应任意大小的数据了;
2)可以提高并发,因为可以以 Partition 为单位读写了。

分区的原则:

我们需要将 producer 发送的数据封装成一个 ProducerRecord 对象。
1)指明 partition 的情况下,直接将指明的值直接作为 partiton 值;
2)没有指明 partition 值但有 key 的情况下,将 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值;
3)既没有 partition 值又没有 key 值的情况下,第一次调用时随机生成一个整数(后面每次调用在这个整数上自增),将这个值与 topic 可用的 partition 总数取余得到 partition 值,也就是常说的 round-robin 算法。

4.2.2 数据可靠性保证

为保证 producer 发送的数据,能可靠的发送到指定的 topic,topic 的每个 partition 收到producer 发送的数据后,都需要向 producer 发送 ack(acknowledgement 确认收到),如果producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。

在这里插入图片描述Kafka 选择了第二种方案,原因如下:
1).同样为了容忍 n 台节点的故障,第一种方案需要 2n+1 个副本,而第二种方案只需要 n+1个副本,而 Kafka 的每个分区都有大量的数据,第一种方案会造成大量数据的冗余。
2).虽然第二种方案的网络延迟会比较高,但网络延迟对 Kafka 的影响较小。

4.2.3 ISR

采用第二种方案之后,设想以下情景:leader 收到数据,所有 follower 都开始同步数据,但有一个 follower,因为某种故障,迟迟不能与 leader 进行同步,那 leader 就要一直等下去,直到它完成同步,才能发送 ack。这个问题怎么解决呢?

Leader 维护了一个动态的 in-sync replica set (ISR),意为和 leader 保持同步的 follower 集合。当 ISR 中的 follower 完成数据的同步之后,leader 就会给 follower 发送 ack。如果 follower长时间 未 向 leader 同 步 数 据 , 则 该 follower 将 被 踢 出 ISR , 该 时 间 阈 值 由
replica.lag.time.max.ms 参数设定。Leader 发生故障之后,就会从 ISR 中选举新的 leader。

4.2.4 ack 应答机制

对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没必要等 ISR 中的 follower 全部接收成功。
所以 Kafka 为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡,选择以下的配置。
acks 参数配置:
acks:
0:producer 不等待 broker 的 ack,这一操作提供了一个最低的延迟,broker 一接收到还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据;
1:producer 等待 broker 的 ack,partition 的 leader 落盘成功后返回 ack,如果在 follower同步成功之前 leader 故障,那么将会丢失数据;
-1(all):producer 等待 broker 的 ack,partition 的 leader 和 follower 全部落盘成功后才返回 ack。但是如果在 follower 同步完成后,broker 发送 ack 之前,leader 发生故障,那么会造成数据重复。

4.2.5 故障处理细节

在这里插入图片描述LEO:指的是每个副本最大的 offset;
HW:指的是消费者能见到的最大的 offset,ISR 队列中最小的 LEO。 (1)follower 故障

1)follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后,follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 leader 进行同步。等该 follower 的 LEO 大于等于该 Partition 的 HW,即 follower 追上 leader 之后,就可以重
新加入 ISR 了。

(2)leader 故障
leader 发生故障之后,会从 ISR 中选出一个新的 leader,之后,为保证多个副本之间的数据一致性,其余的 follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 leader同步数据。
注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

4.2.6 Exactly Once 语义

将服务器的 ACK 级别设置为-1,可以保证 Producer 到 Server 之间不会丢失数据,即 AtLeast Once 语义。相对的,将服务器 ACK 级别设置为 0,可以保证生产者每条消息只会被发送一次,即 At Most Once 语义。

At Least Once 可以保证数据不丢失,但是不能保证数据不重复;相对的,At Least Once可以保证数据不重复,但是不能保证数据不丢失。但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义。在 0.11 版
本以前的 Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。0.11 版本的 Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指 Producer 不论
向 Server 发送多少次重复数据,Server 端都只会持久化一条。幂等性结合 At Least Once 语义,就构成了 Kafka 的 Exactly Once 语义。即:

At Least Once + 幂等性 = Exactly Once

要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true 即可。Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的 Producer 在初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带 Sequence Number。而
Broker 端会对<PID, Partition, SeqNumber>做缓存,当具有相同主键的消息提交时,Broker 只会持久化一条。
但是 PID 重启就会变化,同时不同的 Partition 也具有不同主键,所以幂等性无法保证跨分区跨会话的 Exactly Once。

4.3 Kafka 消费者
4.3.1 消费方式

作为一个 message system,kafka 遵循了传统的方式,选择由 kafka 的 producer 向 broker push 信息,而 consumer 从 broker pull 信息。

consumer 采用 pull(拉)模式从 broker 中读取数据。
push(推)模式很难适应消费速率不同的消费者,因为消息发送速率是由 broker 决定的。它的目标是尽可能以最快速度传递消息,但是这样很容易造成 consumer 来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而 pull 模式则可以根据 consumer 的消费能力以适当的速率消费消息。

pull 模式不足之处是,如果 kafka 没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,Kafka 的消费者在消费数据时会传入一个时长参数 timeout,如果当前没有数据可供消费,consumer 会等待一段时间之后再返回,这段时长即为 timeout。

pull 与 push 的区别:

pull 技术:
客户机向服务器请求信息;
kafka 中,consuemr 根据自己的消费能力以适当的速率消费信息;

push 技术:
服务器主动将信息发往客户端的技术;
push 模式的目标就是尽可能以最快的速率传递消息。

4.3.2 分区分配策略

一个 consumer group 中有多个 consumer,一个 topic 有多个 partition,所以必然会涉及到 partition 的分配问题,即确定那个 partition 由哪个 consumer 来消费。
Kafka 有两种分配策略,一是 RoundRobin,一是 Range。

同一个消费者组里面的不同消费者不能消费同一个分区,但可以消费同一个主题。消费同一个分区会造成重复消费。不同消费者组可以消费同一分区。

只有存在消费者组时才存在分区分配

使用roundrobin时,要保证同一个消费者组消费的主题是相同的

range是根据主题来划分的,roundrobin是根据组来划分的

官方文档

Range:

公共类RangeAssignor
扩展 org.apache.kafka.clients.consumer.internals.AbstractPartitionAssignor
范围分配器在每个主题的基础上工作。对于每个主题,我们按数字顺序排列可用分区,按字典顺序排列消费者。然后,我们将分区数除以消费者总数,以确定分配给每个消费者的分区数。如果不均匀划分,那么前几个消费者将有一个额外的分区。

例如,假设有两个消费者C0和C1,两个主题t0和 t1,每个主题有 3 个分区,产生分区t0p0、t0p1、 t0p2、t1p0、t1p1和t1p2。

任务将是:
C0: [t0p0,t0p1,t1p0,t1p1]
C1: [t0p2,t1p2]

例如,假设有两个消费者C0和C1,三个主题t0、t1、t2,每个主题有 3 个分区,产生分区t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:

C0:[t0p0,t0p1,t1p0,t1p1]
C1:[t0p2,t0p3,t1p2,t1p3]

RoundRobin:

公共类RoundRobinAssignor
扩展 org.apache.kafka.clients.consumer.internals.AbstractPartitionAssignor
轮询分配器布置所有可用的分区和所有可用的消费者。然后它继续执行从分区到消费者的循环分配。如果所有消费者实例的订阅相同,则分区将均匀分布。(即,分区所有权计数将在所有消费者的增量范围内。)

例如,假设有两个消费者C0和C1,两个主题t0和t1,每个主题有 3 个分区,产生分区t0p0、t0p1、t0p2、t1p0、t1p1和t1p2。

任务将是:
C0: [t0p0, t0p2, t1p1]
C1: [t0p1, t1p0, t1p2]

当消费者实例的订阅不同时,分配过程仍会以循环方式考虑每个消费者实例,但如果实例未订阅主题,则会跳过该实例。与订阅相同的情况不同,这可能导致分配不平衡。
例如,3个消费者C0、C1和C2,它们共订阅了3个主题:t0、t1、t2。消费者C0订阅的是主题t0,消费者C1订阅的是主题t0和t1,消费者C2订阅的是主题t0、t1和t2。这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。
该任务将是:
C0: [t0p0]
C1: [t1p0]
C2: [t1p1, t2p0, t2p1, t2p2]

RoundRobinAssignor策略也不是十分完美,这样分配其实并不是最优解,完全可以将分区t1p1分配给C1。

4.3.3 offset 的维护

由于 consumer 在消费过程中可能会出现断电宕机等故障,consumer 恢复后,需要从故障前的位置的继续消费,所以 consumer 需要实时记录自己消费到了哪个 offset,以便故障恢复后继续消费。

Kafka 0.9 版本之前,consumer 默认将 offset 保存在 Zookeeper 中,从 0.9 版本开始,consumer 默认将 offset 保存在 Kafka 一个内置的 topic 中,该 topic 为__consumer_offsets。

4.3.4 Zookeeper 在 Kafka 中的作用

Zookeeper 在 Kafka 中的作用
Kafka 集群中有一个 broker 会被选举为 Controller,负责管理集群 broker 的上下线,所
有 topic 的分区副本分配和 leader 选举等工作。
Controller 的管理工作都是依赖于 Zookeeper 的。

4.4 Kafka 高速读写

不要畏惧文件系统!

Kafka 大量依赖文件系统去存储和缓存消息。对于硬盘有个传统的观念是硬盘总是很慢,这使很多人怀疑基于文件系统的架构能否提供优异的性能。实际上硬盘的快慢完全取决于使用它的方式。设计良好的硬盘架构可以和内存一样快。

在 6 块 7200 转的 SATA RAID-5 磁盘阵列的线性写速度差不多是 600MB/s,但是随即写的速度却是100k/s,差了差不多 6000 倍。现在的操作系统提供了预读取和后写入的技术。实际上发现线性的访问磁盘,很多时候比随机的内存访问快得多。

为了提高性能,现代操作系统往往使用内存作为磁盘的缓存,现代操作系统乐于把所有空闲内存用作磁盘缓存,虽然这可能在缓存回收和重新分配时牺牲一些性能。所有的磁盘读写操作都会经过这个缓存,这不太可能被绕开除非直接使用 I/O。所以虽然每个程序都在自己的线程里只缓存了一份数据,但在操作系统的缓存里还有一份,这等于存了两份数据。

基于 jvm 内存有以下缺点:

Java 对象占用空间是非常大的,差不多是要存储的数据的两倍甚至更高
随着堆中数据量的增加,垃圾回收回变的越来越困难,而且可能导致错误

基于以上分析,如果把数据缓存在内存里,因为需要存储两份,不得不使用两倍的内存空间,Kafka 基于JVM,又不得不将空间再次加倍,再加上要避免 GC 带来的性能影响,在一个 32G 内存的机器上,不得不使用到 28-30G 的内存空间。并且当系统重启的时候,又必须要将数据刷到内存中( 10GB 内存差不多要用 10 分钟),就算使用冷刷新(不是一次性刷进内存,而是在使用数据的时候没有就刷到内存)也会导致最初的时候新能非常慢。

基于操作系统的文件系统来设计有以下好处:

可以通过 os 的 pagecache 来有效利用主内存空间,由于数据紧凑,可以 cache 大量数据,并且没有 gc 的压力
即使服务重启,缓存中的数据也是热的(不需要预热)。而基于进程的缓存,需要程序进行预热,而且会消耗很长的时间。(10G 大概需要 10 分钟)
大大简化了代码。因为在缓存和文件系统之间保持一致性的所有逻辑都在 OS 中。以上建议和设计使得代码实现起来十分简单,不需要尽力想办法去维护内存中的数据,数据会立即写入磁盘。总的来说,Kafka 不会保持尽可能多的内容在内存空间,而是尽可能把内容直接写入到磁盘。所有的数据都及时的以持久化日志的方式写入到文件系统,而不必要把内存中的内容刷新到磁盘中。

1)顺序写磁盘
Kafka 的 producer 生产数据,要写入到 log 文件中,写的过程是一直追加到文件末端,为顺序写。官网有数据表明,同样的磁盘,顺序写能到 600M/s,而随机写只有 100K/s。这与磁盘的机械机构有关,顺序写之所以快,是因为其省去了大量磁头寻址的时间。
2)零拷贝技术
零拷贝并不是不需要拷贝,而是减少不必要的拷贝次数。通常是说在 IO 读写过程中。
把磁盘中的某个文件内容发送到远程服务器上:
在这里插入图片描述
那么它必须要经过几个拷贝的过程:

从磁盘中读取目标文件内容拷贝到内核缓冲区
CPU控制器再把内核缓冲区的数据赋值到用户空间的缓冲区中
接着在应用程序中,调用write()方法,把用户空间缓冲区中的数据拷贝到内核下的Socket Buffer中。
最后,把在内核模式下的SocketBuffer中的数据赋值到网卡缓冲区(NIC Buffer)
网卡缓冲区再把数据传输到目标服务器上。

在这个过程中我们可以发现,数据从磁盘到最终发送出去,要经历4次拷贝,而在这四次拷贝过程中,有两次拷贝是浪费的,分别是:
从内核空间赋值到用户空间
从用户空间再次复制到内核空间

除此之外,由于用户空间和内核空间的切换会带来CPU的上线文切换,对于CPU性能也会造成性能影响。

零拷贝通过DMA(Direct Memory Access)技术把文件内容复制到内核空间中的Read Buffer,接着把包含数据位置和长度信息的文件描述符加载到Socket Buffer中,DMA引擎直接可以把数据从内核空间中传递给网卡设备。

在这个流程中,数据只经历了两次拷贝就发送到了网卡中,并且减少了2次cpu的上下文切换,对于效率有非常大的提高。

4.5Kafka-副本机制

首先 Kafka 会将接收到的消息分区(partition),每个主题(topic)的消息有不同的分区。这样一方面消息的存储就不会受到单一服务器存储空间大小的限制,另一方面消息的处理也可以在多个服务器上并行。

其次为了保证高可用,每个分区都会有一定数量的副本(replica)。这样如果有部分服务器不可用,副本所在的服务器就会接替上来,保证应用的持续性。

但是,为了保证较高的处理效率,消息的读写都是在固定的一个副本上完成。这个副本就是所谓的Leader,而其他副本则是 Follower。而 Follower 则会定期地到 Leader 上同步数据。

副本机制(Replica):一主多从(主leader 副follow, 副本因子);
leader: 负责处理读写请求;follower: 负责与leader副本消息同步;
当 leader 副本出现故障时,从 follower 副本中重新选举新的 leader 副本;

AR(Assigned Replicas), 分区中的所有副本;
ISR(In-Sync Replicas), 所有与 leader 副本保持一定程度同步的副本(包括 leader 副本在内);
OSR(Out-of-Sync Replicas), 与leader副本同步滞后太多的副本;

ISR和OSR都是AR集合中的一个子集;
并且AR=ISR+OSR,正常来说OSR应该是0;
如果follow副本落后太多,leader会将其
从ISR移到OSR;同样,如果OSR达到条件,会被移动到ISR;

只有ISR的副本才能进行leader选举;

在这里插入图片描述

4.6 Kafka-副本选举

如果某个分区所在的服务器除了问题,不可用,kafka 会从该分区的其他的副本中选择一个作为新的Leader。之后所有的读写就会转移到这个新的 Leader 上。现在的问题是应当选择哪个作为新的 Leader。

显然,只有那些跟 Leader 保持同步的 Follower 才应该被选作新的 Leader。Kafka 会在 Zookeeper 上针对每个 Topic 维护一个称为 ISR(in-sync replica,已同步的副本)的集合,该集合中是一些分区的副本。只有当这些副本都跟 Leader 中的副本同步了之后,kafka 才会认为消息已提交,并反馈给消息的生产者。如果这个集合有增减,kafka 会更新 zookeeper 上的记录。

如果某个分区的 Leader 不可用,Kafka 就会从 ISR 集合中选择一个副本作为新的 Leader。显然通过 ISR,kafka 需要的冗余度较低,可以容忍的失败数比较高。假设某个 topic 有 f+1 个副本,kafka 可以容忍 f 个服务器不可用。

在这里插入图片描述

4.6.1 为什么不用少数服用多数的方法???

少数服从多数是一种比较常见的一致性算法和 Leader 选举法。它的含义是只有超过半数的副本同步了,系统才会认为数据已同步;选择 Leader 时也是从超过半数的同步的副本中选择。这种算法需要较高的冗余度。譬如只允许一台机器失败,需要有三个副本;而如果只容忍两台机器失败,则需要五个副本。

而 kafka 的 ISR 集合方法,分别只需要两个和三个副本。
如果所有的 ISR 副本都失败了怎么办?
此时有两种方法可选,一种是等待 ISR 集合中的副本复活,一种是选择任何一个立即可用的副本,而这个副本不一定是在 ISR 集合中。这两种方法各有利弊,实际生产中按需选择。如果要等待 ISR 副本复活,虽然可以保证一致性,但可能需要很长时间。而如果选择立即可用的副本,则很可能该副本并不一致。

4.6.2具体选举过程

Kafka Leader副本选举:并非多数投票机制,而是优先副本(Preferred Replica);
优先副本:AR集合中第一个存活的副本,并且该副本在ISR集合中;比如AR集合[0,1,2],那么优先副本就是0;理想情况下,优先副本应该就是该分区的leader 副本,所以也可以称之为 Preferred Leader;

auto.leader. rebalance.enable, 自动执行优先副本的选举;

最简单最直观的方案是,leader 在 zk 上创建一个临时节点,所有 Follower 对此节点注册监听,当leader 宕机时,此时 ISR 里的所有 Follower 都尝试创建该节点,而创建成功者(Zookeeper 保证只有一个能创建成功)即是新的 Leader,其它 Replica 即为 Follower。

实际上的实现思路也是这样,只是优化了下,多了个代理控制管理类(controller)。引入的原因是,当kafka 集群业务很多,partition 达到成千上万时,当 broker 宕机时,造成集群内大量的调整,会造成大量 Watch 事件被触发,Zookeeper 负载会过重。zk 是不适合大量写操作的。
在这里插入图片描述
Controller 提供:
增加删除 topic
更新分区副本数量
选举分区 leader
集群 broker 增加和宕机后的调整
自身的选举 controller leader 功能
这些功能都是 controller 通过监听 Zookeeper 间接节点出发,然后 controller 再跟其他的 broker 具体的去交互实现的(rpc 的方式)。

controller 的内部设计:
当前 controller 启动时会为集群中所有 broker 创建一个各自的连接。假设你的集群中有 100 台broker,那么 controller 启动时会创建 100 个 Socket 连接(也包括与它自己的连接!)。具体的类NetworkClient 类,底层就是 Java NIO reactor 模型)。Controller 会为每个连接都创建一个对应的请求发送线程(RequestSendThread)。

controller 实现如上功能,要先熟悉 kafka下 zk 上的数据存储结构:
brokers 列表:ls /brokers/ids
某个 broker 信息:get /brokers/ids/0
topic 信息:get /brokers/topics/kafka10-topic-xxx
partition 信息:get /brokers/topics/kafka10-topic-xxx/partitions/0/state
controller 中心节点变更次数:get /controller_epoch
conrtoller leader 信息:get /controller

5.kafka实战

5.1Springboot集成Kafka
5.1.1 新建module boot-kafka-demo
5.1.2 改pom

kafka与springboot版本对应
kafka:broker、client、spring-kafka版本间的关系
寻找jar包所有的版本号

kafka-client与kafka-broker版本存在对应关系,spring-kafka又集成kafka-clinet,springboot与spring-kafka又存在对应关系。
服务器安装的kafka版本,kafka_2.11-0.11.0.0。前面的2.11代表的是Scala的版本后面0.11.0.0为kafka的版本号
高版本client(0.10.2.0以后)可以与低版本broker通信。
引入spring-kafka的依赖,springboot版本选择2.2.1.RELEASE,spring-kafka版本选择2.4.1.RELEASE

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <version>2.2.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.2.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
            <version>2.4.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
5.1.3 改yml
server:
  port: 9100
spring:
  application:
    name: boot-kafka-demo
  kafka:
    bootstrap-servers: localhost:9092,localhost:9093,localhost:9094
    template:
      # 指定默认topic id
      default-topic: kafkademo
      producer:
        # 发生错误后,消息重发的次数。
        retries: 0
        #当有多个消息需要被发送到同一个分区时,生产者会把它们放在同一个批次里。该参数指定了一个批次可以使用的内存大小,按照字节数计算。
        batch-size: 16384
        # 设置生产者内存缓冲区的大
        buffer-memory: 33554432
        # 键的序列化方式
        key-serializer: org.apache.kafka.common.serialization.StringSerializer
        # 值的序列化方式
        value-serializer: org.apache.kafka.common.serialization.StringSerializer
        # acks=0 : 生产者在成功写入消息之前不会等待任何来自服务器的响应。
        # acks=1 : 只要集群的首领节点收到消息,生产者就会收到一个来自服务器成功响应。
        # acks=all :只有当所有参与复制的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。
        acks: 1
      consumer: # consumer消费者
        group-id: test-consumer-group # 默认的消费组ID
        enable-auto-commit: false # 是否自动提交offset
        # 自动提交的时间间隔 在spring boot 2.X 版本中这里采用的是值的类型为Duration 需要符合特定的格式,如1S,1M,2H,5D
        auto-commit-interval: 1S

        # earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
        # latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
        # none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
        auto-offset-reset: earliest
        key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
        #      value-deserializer: com.itheima.demo.config.MyDeserializer
        value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      listener:
        # 在侦听器容器中运行的线程数。
        concurrency: 5
        #listner负责ack,每调用一次,就立即commit
        ack-mode: manual_immediate
        missing-topics-fatal: false
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
5.1.4 启动类与业务类

启动类

@SpringBootApplication
@Slf4j
public class KafkaMainApplication {
    public static void main(String[] args) {
        SpringApplication.run(KafkaMainApplication.class,args);
        log.info("==============KafkaMainApplication启动成功===============");
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

生产者

@RestController
@RequestMapping("/produce")
public class ProducerController {
    @Resource
    private KafkaTemplate kafkaTemplate;

    @GetMapping(value = "/send/{topic}/{msg}")
    public String send(@PathVariable(value = "topic")String topic, @PathVariable(value = "msg")String msg){
        //消息发送
        kafkaTemplate.send(topic,msg);
        return "SUCCESS";
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

消费者

@Component
public class KafkaConsumer {
    @KafkaListener(groupId = "test-consumer-group",topics = {"second"})
    public void onMessage1(ConsumerRecord<?, ?> record) {
        // 消费的哪个topic、partition的消息,打印出消息内容
        System.out.println("暴打消费主义:" + record.topic() + "-" + record.partition() + "-" + record.value());
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
5.1.5 启动工程

启动后报错:Caused by: java.lang.IllegalStateException: No group.id found in consumer config, container properties, or @KafkaListener annotation; a group.id is required when group management is used.

在@KafkaListener注解中没有标注groupId ,加上后不再报该错

但是,报了另外一个错:kafka.common.KafkaException: Socket server failed to bind to xx:9092: Cannot assign req
百度后发现是因为server.properties配置的是内网地址,而在工程中访问的外网地址。
关闭kafka集群,依次修改server配置文件,加上如下配置 advertised.listeners=PLAINTEXT://外网ip:9092
原来的是内网ip,保持不变 listeners=PLAINTEXT://内网ip:9092
此时一个完整的kafka客户端访问服务端的流程:

客户端访问外网ip:9092,被kafka宿主机所在环境映射到内网ip:9092,访问到了kafka节点,请求获得kafka服务端的访问地址
kafka从zookeeper拿到自己和其他兄弟节点通过advertised.listeners注册到zookeeper的外网ip:9092等外网地址,作为kafka的服务端访问地址返回给客户端
客户端拿这些地址访问kafka集群,被kafka宿主机所在环境映射到各kafka节点的内网ip,访问到了kafka服务端......完美循环
  • 1
  • 2
  • 3

然后启动kafka集群,再启动工程,发现启动成功。

测试:http://localhost:9100/produce/send/second/csdn
控制台打印: 暴打消费主义:second-0-csdn
linux的consumer打印:csdn

5.1.6 发送消息添加监听

当我们使用kafkaTemplate的时候,kafka对调用发送支持回调,在回调函数中调用了producerListener的方法,所以我们只需要实现ProducerListener接口,重写发送成功、失败的方法即可。

@Component
@Slf4j
public class MyProducerListener implements ProducerListener<String,String> {
    @Override
    public void onSuccess(ProducerRecord<String, String> producerRecord, RecordMetadata recordMetadata) {
        log.info("发送消息成功!topic:{},partition:{},key:{},value:{},recordMetadata:{}",producerRecord.topic(), producerRecord.partition(), producerRecord.key(), producerRecord.value(), recordMetadata);
    }

    @Override
    public void onError(ProducerRecord<String,String> producerRecord, Exception exception) {
        log.info("发送消息失败!topic:{},partition:{},key:{},value:{},exception:{}",producerRecord.topic(), producerRecord.partition(), producerRecord.key(), producerRecord.value(), exception);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
@Configuration
public class KafkaConfig {
    @Bean
    public ProducerListener producerListener() {
        return new MyProducerListener();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

6、Kafka 面试题

1)、Kafka中的ISR、AR又代表什么
ISR:与leader保持同步的follower集合
AR:分区的所有副本

2)、Kafka中的HW、LEO等分别代表什么?
LEO:没个副本的最后条消息的offset
HW:一个分区中所有副本最小的offset

3)、Kafka中是怎么体现消息顺序性的?
每个分区内,每条消息都有一个offset,故只能保证分区内有序。

4)、Kafka中的分区器、序列化器、拦截器是否了解?它们之间的处理顺序是什么?
拦截器 -> 序列化器 -> 分区器

5)、Kafka生产者客户端的整体结构是什么样子的?使用了几个线程来处理?分别是什么?
消息发送的过程中,涉及到了两个线程——main 线程和 Sender 线程,以及一个线程共享变量——RecordAccumulator。main 线程将消息发送给 RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka broker。

6)、“消费组中的消费者个数如果超过topic的分区,那么就会有消费者消费不到数据”这句话是否正确?
正确

7)、消费者提交消费位移时提交的是当前消费到的最新消息的offset还是offset+1?
offset+1

8)、有哪些情形会造成重复消费?
而先消费后提交 offset,有可能会造成数据的重复消费。

9)、那些情景会造成消息漏消费?
先提交 offset 后消费,有可能造成数据的漏消费

10)、当你使用kafka-topics.sh创建(删除)了一个topic之后,Kafka背后会执行什么逻辑?
会在zookeeper中的/brokers/topics节点下创建一个新的topic节点,如:/brokers/topics/first
触发Controller的监听程序。
kafka Controller 负责topic的创建工作,并更新metadata cache

11)、topic的分区数可不可以增加?如果可以怎么增加?如果不可以,那又是为什么?
可以增加
bin/kafka-topics.sh --zookeeper localhost:2181/kafka --alter --topic topic-config --partitions 3

12)、topic的分区数可不可以减少?如果可以怎么减少?如果不可以,那又是为什么?
不可以减少,被删除的分区数据难以处理

13)、Kafka有内部的topic吗?如果有是什么?有什么所用?
__consumer_offsets,保存消费者offset

14)、Kafka分区分配的概念?
一个topic多个分区,一个消费者组多个消费者,故需要将分区分配个消费者(roundrobin、range)

15)、简述Kafka的日志目录结构?
每个分区对应一个文件夹,文件夹的命名为topic-0,topic-1,内部为.log和.index文件

16)、如果我指定了一个offset,Kafka Controller怎么查找到对应的消息?
在这里插入图片描述
17)、聊一聊Kafka Controller的作用?
负责管理集群broker的上下线,所有topic的分区副本分配和leader选举等工作

18)、Kafka中有那些地方需要选举?这些地方的选举策略又有哪些?
partition leader(ISR),controller(先到先得)

19)、失效副本是指什么?有那些应对措施?
不能及时与leader同步,暂时踢出ISR,等其追上leader之后再重新加入

20)、Kafka的那些设计让它有如此高的性能?
分区,顺序写磁盘,0-copy

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/IT小白/article/detail/400297
推荐阅读
相关标签
  

闽ICP备14008679号