一, Kafka基本概念
网上很多,随便列几个:
kafka笔记_千峰kafka笔记_kyrielx的博客-CSDN博客
Kafka 基本原理(8000 字小结)
基础没看完,下面别看了,容易头晕。
二, 高可用
Partition+Replication 再加上consumer group 基本就保证了高可用。
三, 高性能
这个需要重点说下,因为市面上消息队列产品很多, kafka据说时目前最快, 总得有个原因把。直接上图:
3.1 Producer
传统的数据库或者消息中间件都是想办法让 client 端更轻量,将 server 设计成重量级,仅让 client 充当应用程序和 server 之间的接口。但是kafka将许多工作放在了客户端完成,这样的好处是减轻了服务端的压力。
在客户端部分,kafka主要采取了以下几种措施进行优化:
- 批量发送消息
- 消息压缩
- 高效序列化
- 内存池复用
批量发送消息
Kafka 作为一个消息队列,很显然是一个 IO 密集型应用,它所面临的挑战除了磁盘 IO(broker 端对消息持久化),还有网络 IO(producer到 broker,broker 到 consumer)。
至于磁盘IO,我们到broker优化的时候再说,现在先看看客户端对网络IO做了哪些优化。
我们知道,在一个topic队列中是会进行分区partition的,基于这个背景, Kafka 采用了批量发送消息的方式,将多条消息按照分区进行分组,然后每次发送一个消息集合,从而大大减少了网络传输的开销(这里的开销主要指一些头部控制信息,由于每次发送都要带上它们,因此也被称为系统开销)。
主要是下面几个配置:
// default is 16KB(16384)
public static final String BATCH_SIZE_CONFIG = "batch.size";
**Notes: **
有一个属性容易和batch.size 混淆,
//default value is 1024KB
public static final String MAX_REQUEST_SIZE_CONFIG = "max.request.size";
看一下文档:
max.request.size:
The maximum size of a request in bytes. This setting will limit the number of record batches the producer will send in a single request to avoid sending huge requests. This is also effectively a cap on the maximum uncompressed record batch size. Note that the server has its own cap on the record batch size (after compression if compression is enabled) which may be different from this.
batch.size:
The producer will attempt to batch records together into fewer requests whenever multiple records are being sent to the same partition. This helps performance on both the client and the server. This configuration controls the default batch size in bytes. "
+ "<p>"
+ "No attempt will be made to batch records larger than this size. "
+ "<p>"
+ "Requests sent to brokers will contain multiple batches, one for each partition with data available to be sent. "
+ "<p>"
+ "A small batch size will make batching less common and may reduce throughput (a batch size of zero will disable "
+ "batching entirely). A very large batch size may use memory a bit more wastefully as we will always allocate a "
+ "buffer of the specified batch size in anticipation of additional records."
+ "<p>"
+ "Note: This setting gives the upper bound of the batch size to be sent. If we have fewer than this many bytes accumulated "
+ "for this partition, we will 'linger' for the <code>linger.ms</code> time waiting for more records to show up. "
+ "This <code>linger.ms</code> setting defaults to 0, which means we'll immediately send out a record even the accumulated "
+ "batch size is under this <code>batch.size</code> setting.
简单描述就是, 都采用默认配置的情况下,一个request 发送 (1024÷16=64)个records batch. 也就是64个ByteBuffer. 在内存复用中会介绍ByteBuffer.
消息压缩
在客户端发送消息之前会对数据进行压缩,有了前面批量发送的前提,压缩可以大大的提高网络传输率(数据量越大,压缩效果越好)。kafka支持三种压缩算法:gzip、snappy、lz4,对比如下:
其实压缩消息不仅仅减少了网络 IO,它还大大降低了磁盘 IO。因为批量消息在持久化到 broker 中的磁盘时,仍然保持的是压缩状态,最终是在 consumer 端做了解压缩操作。
主要是下面几个配置:
// values: none,gzip,snappy,lz4,zstd,
//default is none,
public static final String COMPRESSION_TYPE_CONFIG = "compression.type";
注意下面几点:
- 如果broker端也指定了压缩算法,那么producer指定的最好和broker保持一致。否则消息到达broker端会重新解压缩,再按照broker压缩算法压缩。默认情况下,broker使用的是produer配置的算法
- 建议只有在producer cpu资源充裕的情况下,才开启压缩,否则会使机器cpu资源耗尽,反而得不偿失;
- 如果宽带资源比较紧张,建议开启压缩,可以使用zstd,极大的减少网络资源开销
高效序列化
kafka 消息中的 Key 和 Value,都支持自定义类型,只需要提供相应的序列化和反序列化器即可。因此,用户可以根据实际情况选用快速且紧凑的序列化方式(比如 ProtoBuf、Avro)来减少实际的网络传输量以及磁盘存储量,进一步提高吞吐量。如各种基本数据类型的序列化实现IntegerSerializer,DoubleSerializer。
内存池复用
前面说过 producer发送消息是批量的,因此消息都会先写入 producer的内存中进行缓冲,直到多条消息组成了一个 Batch,才会通过网络把 Batch 发给 broker。
当这个 Batch 发送完毕后,显然这部分数据还会在 producer端的 JVM 内存中,由于不存在引用了,它是可以被 JVM 回收掉的。但是大家都知道,JVM GC 时一定会存在 Stop The World 的过程,这对于 Kafka 这种高并发场景肯定会带来性能上的影响。
于是便引出了 Kafka 的内存池机制,它和连接池、线程池的本质一样,都是为了提高复用,减少频繁的创建和释放。具体是如何实现的呢?
其实很简单:producer一上来就会占用一个固定大小的内存块,比如 32MB**(buffer.memory),然后将 32 MB 划分成 M 个小内存块(比如一个小内存块大小是 16KB(batch.size)**)。
当需要创建一个新的 Batch 时,直接从内存池中取出一个 16 KB 的内存块即可,然后往里面不断写入消息,但最大写入量就是 16 KB,接着将 Batch 发送给 Broker ,此时该内存块就可以还回到缓冲池中继续复用了,根本不涉及垃圾回收。
主要是下面几个配置:
//default is 32MB
public static final String BUFFER_MEMORY_CONFIG = "buffer.memory";
** 具体实现:**
在kafka初始化的时候,会对内存池进行初始化,在Kafka Producer端,有一个BufferPool,与它相关的配置参数是buffer.memory和batch.size,buffer.memory它代表缓冲区内存的大小,默认为32M,batch.size代表消息批次的大小,默认为16kb,在BufferPool中,batch.size其实就是代表一个ByteBuffer的大小,因为BufferPool只管理batch.size大小的ByteBuffer,在kafka初始化的时候,就会创建缓冲区(new BufferPool),如下,在创建消息收集器RecordAccumulator的时候,就创建了BufferPool。
this.accumulator = new RecordAccumulator(logContext,
config.getInt(ProducerConfig.BATCH_SIZE_CONFIG),
this.compressionType,
lingerMs(config),
retryBackoffMs,
deliveryTimeoutMs,
metrics,
PRODUCER_METRIC_GROUP_NAME,
time,
apiVersions,
transactionManager,
new BufferPool(this.totalMemorySize, config.getInt(ProducerConfig.BATCH_SIZE_CONFIG), metrics, time, PRODUCER_METRIC_GROUP_NAME));
kafka使用内存池的条件是我们的消息的大小必须小于等于
batch.size
的值,这样内存池才能发挥它的作用,如果我们的消息很大,然而也没对
batch.size
进行设置,使用的是默认值,那么将不能使用内存池,不能发挥它的性能。
3.2 Broker
在服务器端的优化主要是对消息的读取和存储,实现方式包括了以下几点:
- IO多路复用
- 磁盘顺序写
- Page Cache
- 分区分段结构
IO多路复用
首先要解决的问题,肯定是和produce、consumer之间的消息传递了。为了高效的进行网络通信,kafka采取了Reactor 模型(多Reactor 多线程)。
简单记忆就是1+N+M:
1:表示 1 个 主线程,当MainReactor监听到建立连接的事件后,会通过Acceptor获取新的连接,然后将新连接交给 Processor 线程处理。(主Reactor )
N:表示 N 个 Processor 线程,每个 Processor 都有自己的 selector,负责从 socket 中读写数据。(从Reactor )
M:表示 M 个 KafkaRequestHandler 业务处理线程,它通过调用 KafkaApis 进行业务处理,然后生成 response,再交由给 Processor 线程。其实就是Netty里面的主从Reactor多线程模型。
磁盘顺序写
作为服务器,存储消息是必不可少的。但我们知道磁盘IO是很慢的,kafka是如何做到将数据保存在磁盘中还做到高性能的呢?
Kafka 选用的是「日志文件」来存储消息,并且采用的是磁盘顺序写的方式。
磁盘随机IO是很慢的,但如果是顺序写入,就可大大节省磁盘寻道和盘片旋转的时间,提高效率。为什么kafka可以做到顺序写呢?
这得益于kafka的特性。kafka 作为消息队列,本质上就是一个队列,是先进先出的,而且消息一旦生产了就不可变。这种有序性和不可变性使得 kafka 完全可以顺序写日志文件,也就是说,仅仅将消息追加到文件末尾即可。
PageCache页缓存
当用户对文件进行读写时,实际上是对文件的页缓存进行读写。内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把页缓存的数据拷贝给用户。写入数据时会先写到页缓存中,然后内核会定时把这些页缓存刷新到磁盘中。
Page Cache 缓存的是最近会被使用的磁盘数据,利用的是时间局部性原理,依据是:最近访问的数据很可能接下来再访问到。而预读到 Page Cache 中的磁盘数据,又利用了空间局部性原理,依据是:数据往往是连续访问的。
kafka作为消息队列,消息先是顺序写入,然后马上就会被消费者读取,这不是完美契合?所以,页缓存可以说是 Kafka 做到高吞吐的重要因素之一。
分区分段结构【Segment】
前面说了kafka会将topic进行分区,其实在 Kafka 的存储底层,在分区之下还有一层:那便是「分段」。简单理解:分区对应的其实是文件夹,分段对应的才是真正的日志文件。
为什么有了 Partition 之后,还需要 Segment 呢?
如果不引入 Segment,一个 Partition 只对应一个文件,那这个文件会一直增大,势必造成单个 Partition 文件过大,查找和维护不方便。
此外,在做历史消息删除时,必然需要将文件前面的内容删除,只有一个文件显然不符合 Kafka 顺序写的思路。而在引入 Segment 后,则只需将旧的 Segment 文件删除即可,保证了每个 Segment 的顺序写
Kafka存储策略:
1)kafka以topic来进行消息管理,每个topic包含多个partition,每个partition对应一个逻辑log,有多个segment组成。
2)每个segment中存储多条消息(见下图),消息id由其逻辑位置决定,即从消息id可直接定位到消息的存储位置,避免id到位置的额外映射。
3)每个part在内存中对应一个index,记录每个segment中的第一条消息偏移。
4)发布者发到某个topic的消息会被均匀的分布到多个partition上(或根据用户指定的路由规则进行分布),broker收到发布消息往对应partition的最后一个segment上添加该消息
当某个segment上的消息条数达到配置值或消息发布时间超过阈值时,segment上的消息会被flush到磁盘,只有flush到磁盘上的消息订阅者才能订阅到
segment达到一定的大小后将不会再往该segment写数据,broker会创建新的segment。
由于生产者生产的消息会不断追加到log文件末尾,为防止log文件过大导致数据定位效率低下,Kafka采取了分片和索引机制,将每个partition分为多个segment。每个segment对应两个文件——“.index”文件和“.log”文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号。例如,first这个topic有三个分区,则其对应的文件夹为first-0,first-1,first-2。
1个segment的大小是1G,大于1G之后就会在新建segment。
3.3 Consumer
消费者的目的主要是先通过broker读取数据,然后通过io拉取数据。主要包括以下优化:
- 稀疏索引
- mmap
- 零拷贝
- 批量拉取
稀疏索引
消费者要消费数据,首先就是根据offset获取broker中存储的数据。那么如何高效获取数据,我们肯定会想到索引。我们可以通过哈希索引,在内存中维护一个「从 offset 到日志文件偏移量」的映射关系即可,每次根据 offset 查找消息时,从哈希表中得到偏移量,再去读文件即可。
但是当数据越来越来大,内存中肯定无法存储所有数据的。
我们发现消息的 offset 完全可以设计成有序的(实际上是一个单调递增 long 类型的字段),这样消息在日志文件中本身就是有序存放的了,我们便没必要为每个消息建 hash 索引了,完全可以将消息划分成若干个 block,只索引每个 block 第一条消息的 offset 即可,先根据大小关系找到 block,然后在 block 中顺序搜索,这便是 Kafka 稀疏索引 的设计思想。
mmap
稀疏索引解决了 查询数据的问题,但是读写数据还可以进行优化,kafka采取了mmap的方式读写稀疏索引文件。
如何理解 mmap?常规的文件操作为了提高读写性能,使用了 Page Cache 机制,但是由于页缓存处在内核空间中,不能被用户进程直接寻址,所以读文件时还需要通过系统调用,将页缓存中的数据再次拷贝到用户空间中。
而采用 mmap 后,它将磁盘文件与进程虚拟地址做了映射,并不会招致系统调用,以及额外的内存 copy 开销,从而提高了文件读取效率。
为什么log文件不用mmap?
因为mmap 有多少字节可以映射到内存中与地址空间有关,32 位的体系结构只能处理 4GB 甚至更小的文件。Kafka 日志通常足够大,可能一次只能映射部分,因此读取它们将变得非常复杂。然而,索引文件是稀疏的,它们相对较小。将它们映射到内存中可以加快查找过程,这是内存映射文件提供的主要好处。
有没有想过java nio里面关于mmap的是啥东西?
MappedByteBuffer mappedByteBuffer = fileChannel.map(xxx).
零拷贝
零拷贝主要是用来解决,broker读取数据后,将数据发送到socket的问题。
传统的文件传输是这样的:
afka采用的是sendfile()零拷贝,传输数据是这样的:
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
零拷贝在Java nio里面是啥呢?
//fileChannel.transferTo(xxx);
fileChannel.transferFrom(xxx);
关于零拷贝,推荐大家看这篇文章:零拷贝
批量拉取
最后就是消息的接收了,消费者拉取消息的时候也是批量拉取的,每次拉取一个消息集合,和生产者很相似。当拉取完消息之后,会在消费者端将消息进行解压缩。
- fetch.max.bytes: 客户端单个Fetch请求一次拉取的最大字节数,默认为50M,根据上面的源码分析得知,Kafka会按Broker节点为维度进行拉取, 即按照队列负载算法分配在同一个Broker上的多个队列进行聚合,同时尽量保证各个分区的拉取平衡,通过max.partition.fetch.bytes参数设置。
- max.partition.fetch.bytes 一次fetch拉取单个队列最大拉取字节数量,默认为1M。
- max.poll.records: 调用一次KafkaConsumer的poll方法,返回的消息条数,默认为500条。
实践思考:fetch.max.bytes默认是max.partition.fetch.bytes的50倍,也就是默认情况一下,一个消费者一个Node节点上至少需要分配到50个队列,才能尽量满额拉取。但50个分区(队列)可以来源于这个消费组订阅的所有的topic。
max.partition.fetch.bytes:
该属性指定了服务器从每个分区里返回给消费者的最大字节数。它的默认值是1MB。KafkaConsumer.poll()方法从每个分区里返回的记录最多不超过max.partition.fetch.bytes指定的字节。如果一个主题有20个分区和5个消费者,那么每个消费者需要至少4MB的可用内存来接收记录。在为消费者分配内存时,可以给它们多分配一些,因为如果群组里有消费者发生崩溃,剩下的消费者需要处理更多的分区。
max.partition.fetch.bytes的值必须比broker能够接收的最大消息的字节数(max.message.size)大,否则消费者可能无法读取这些消息,导致消费者一直挂起重试。
在设置此值时,还需要考虑消费者处理数据的时间。消费者需要频繁的调用poll()方法来避免会话过期和发生分区的再均衡,如果单次调用poll()返回的数据太多,消费者需要更多的时间来处理,可能无法及时进行下一个轮询来避免会话过期。出现这种情况,可以把max.partition.fetch.bytes改小,或者延长会话过期时间。
版权归原作者 EQuaker 所有, 如有侵权,请联系我们删除。