Kafka 也是一个性能十分优秀的中间件,这篇文章就主要介绍 Kafka 性能优化方面的知识。
让我们一起来深入了解 Kafka “快”的内部秘密。你不仅可以学习到 Kafka 性能优化的各种手段,也可以提炼出各种性能优化的方法论,这些方法论也可以应用到我们自己的项目之中,助力我们写出高性能的项目。
Kafka 性能全景
从高度抽象的角度来看,性能问题逃不出下面三个方面:
- 网络
- 磁盘
- 复杂度
对于 Kafka 这种网络分布式队列来说,网络和磁盘更是优化的重中之重。针对于上面提出的抽象问题,解决方案高度抽象出来也很简单:
- 并发
- 压缩
- 批量
- 缓存
- 算法
知道了问题和思路,我们再来看看,在 Kafka 中,有哪些角色,而这些角色就是可以优化的点:
Producer
Broker
Consumer
是的,所有的问题,思路,优化点都已经列出来了,我们可以尽可能的细化,三个方向都可以细化,如此,所有的实现便一目了然,即使不看 Kafka 的实现,我们自己也可以想到一二点可以优化的地方。
这就是思考方式。
提出问题
列出问题点
列出优化方法
列出具体可切入的点
tradeoff和细化实现
。
现在,你也可以尝试自己想一想优化的点和方法,不用尽善尽美,不用管好不好实现,想一点是一点。
顺序写
为什么说写磁盘慢?
我们不能只知道结论,而不知其所以然。要回答这个问题,就得回到在校时我们学的操作系统课程了。65 哥还留着课本吗?来,翻到讲磁盘的章节,让我们回顾一下磁盘的运行原理。
看经典大图:
完成一次磁盘 IO,需要经过
寻道
、
旋转
和
数据传输
三个步骤。
影响磁盘 IO 性能的因素也就发生在上面三个步骤上,因此主要花费的时间就是:
- 寻道时间:Tseek 是指将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O 操作越快,目前磁盘的平均寻道时间一般在 3-15ms。
- 旋转延迟:Trotation 是指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的 1/2 表示。比如:7200rpm 的磁盘平均旋转延迟大约为 60*1000/7200/2 = 4.17ms,而转速为 15000rpm 的磁盘其平均旋转延迟为 2ms。
- 数据传输时间:Ttransfer 是指完成传输所请求的数据所需要的时间,它取决于数据传输率,其值等于数据大小除以数据传输率。目前 IDE/ATA 能达到 133MB/s,SATA II 可达到 300MB/s 的接口数据传输率,数据传输时间通常远小于前两部分消耗时间。简单计算时可忽略。
因此,如果在写磁盘的时候省去
寻道
、
旋转
可以极大地提高磁盘读写的性能。
Kafka 采用
顺序写
文件的方式来提高磁盘写入性能。
顺序写
文件,基本减少了磁盘
寻道
和
旋转
的次数。磁头再也不用在磁道上乱舞了,而是一路向前飞速前行。
Kafka 中每个分区是一个有序的,不可变的消息序列,新的消息不断追加到 Partition 的末尾,在 Kafka 中 Partition 只是一个逻辑概念,Kafka 将 Partition 划分为多个 Segment,每个 Segment 对应一个物理文件,Kafka 对 segment 文件追加写,这就是顺序写文件。
为什么 Kafka 可以使用追加写的方式呢?
这和 Kafka 的性质有关,我们来看看 Kafka 和 Redis,说白了,Kafka 就是一个
Queue
,而 Redis 就是一个
HashMap
。
Queue
和
Map
的区别是什么?
Queue
是 FIFO 的,数据是有序的;
HashMap
数据是无序的,是随机读写的。Kafka 的不可变性,有序性使得 Kafka 可以使用追加写的方式写文件。
其实很多符合以上特性的数据系统,都可以采用追加写的方式来优化磁盘性能。典型的有
Redis
的 AOF 文件,各种数据库的
WAL(Write ahead log)
机制等等。
所以清楚明白自身业务的特点,就可以针对性地做出优化。
零拷贝
什么是零拷贝?
维基百科是这样介绍零拷贝的:
零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
在 OS 层面上的
Zero-copy
通常指避免在
用户态(User-space)
与
内核态(Kernel-space)
之间来回拷贝数据。
我们从 Kafka 的场景来看,Kafka Consumer 消费存储在 Broker 磁盘的数据,从读取 Broker 磁盘到网络传输给 Consumer,期间涉及哪些系统交互。Kafka Consumer 从 Broker 消费数据,Broker 读取 Log,就使用了 sendfile。如果使用传统的 IO 模型,伪代码逻辑就如下所示:
readFile(buffer)
send(buffer)
如图,如果采用传统的 IO 流程,先读取网络 IO,再写入磁盘 IO,实际需要将数据 Copy 四次。
- 第一次:读取磁盘文件到操作系统内核缓冲区;
- 第二次:将内核缓冲区的数据,copy 到应用程序的 buffer;
- 第三步:将应用程序 buffer 中的数据,copy 到 socket 网络发送缓冲区;
- 第四次:将 socket buffer 的数据,copy 到网卡,由网卡进行网络传输。
啊,操作系统这么傻吗?copy 来 copy 去的。
并不是操作系统傻,操作系统的设计就是每个应用程序都有自己的用户内存,用户内存和内核内存隔离,这是为了程序和系统安全考虑,否则的话每个应用程序内存满天飞,随意读写那还得了。
不过,还有
零拷贝
技术,英文——
Zero-Copy
。
零拷贝
就是尽量去减少上面数据的拷贝次数,从而减少拷贝的 CPU 开销,减少用户态内核态的上下文切换次数,从而优化数据传输的性能。
常见的零拷贝思路主要有三种:
- 直接 I/O:数据直接跨过内核,在用户地址空间与 I/O 设备之间传递,内核只是进行必要的虚拟存储配置等辅助工作;
- 避免内核和用户空间之间的数据拷贝:当应用程序不需要对数据进行访问时,则可以避免将数据从内核空间拷贝到用户空间;
- 写时复制:数据不需要提前拷贝,而是当需要修改的时候再进行部分拷贝。
Kafka 使用到了
mmap
和
sendfile
的方式来实现
零拷贝
。分别对应 Java 的
MappedByteBuffer
和
FileChannel.transferTo
。
使用 Java NIO 实现
零拷贝
,如下:
FileChannel.transferTo()
在此模型下,上下文切换的数量减少到一个。具体而言,
transferTo()
方法指示块设备通过 DMA 引擎将数据读取到读取缓冲区中。然后,将该缓冲区复制到另一个内核缓冲区以暂存到套接字。最后,套接字缓冲区通过 DMA 复制到 NIC 缓冲区。
我们将副本数从四减少到三,并且这些副本中只有一个涉及 CPU。我们还将上下文切换的数量从四个减少到了两个。这是一个很大的改进,但是还没有查询零副本。当运行 Linux 内核 2.4 及更高版本以及支持收集操作的网络接口卡时,后者可以作为进一步的优化来实现。如下所示。
根据前面的示例,调用
transferTo()
方法会使设备通过 DMA 引擎将数据读取到内核读取缓冲区中。但是,使用
gather
操作时,读取缓冲区和套接字缓冲区之间没有复制。取而代之的是,给 NIC 一个指向读取缓冲区的指针以及偏移量和长度,该偏移量和长度由 DMA 清除。CPU 绝对不参与复制缓冲区。
关于
零拷贝
详情,可以详读这篇文章零拷贝 (Zero-copy) 浅析及其应用。
PageCache
producer 生产消息到 Broker 时,Broker 会使用 pwrite() 系统调用【对应到 Java NIO 的 FileChannel.write() API】按偏移量写入数据,此时数据都会先写入
page cache
。consumer 消费消息时,Broker 使用 sendfile() 系统调用【对应 FileChannel.transferTo() API】,零拷贝地将数据从 page cache 传输到 broker 的 Socket buffer,再通过网络传输。
leader 与 follower 之间的同步,与上面 consumer 消费数据的过程是同理的。
page cache
中的数据会随着内核中 flusher 线程的调度以及对 sync()/fsync() 的调用写回到磁盘,就算进程崩溃,也不用担心数据丢失。另外,如果 consumer 要消费的消息不在
page cache
里,才会去磁盘读取,并且会顺便预读出一些相邻的块放入 page cache,以方便下一次读取。
因此如果 Kafka producer 的生产速率与 consumer 的消费速率相差不大,那么就能几乎只靠对 broker page cache 的读写完成整个生产 - 消费过程,磁盘访问非常少。
网络模型
是的,Netty 是 JVM 领域一个优秀的网络框架,提供了高性能的网络服务。大多数 Java 程序员提到网络框架,首先想到的就是 Netty。Dubbo、Avro-RPC 等等优秀的框架都使用 Netty 作为底层的网络通信框架。
Kafka 自己实现了网络模型做 RPC。底层基于 Java NIO,采用和 Netty 一样的 Reactor 线程模型。
Reacotr 模型主要分为三个角色
- Reactor:把 IO 事件分配给对应的 handler 处理
- Acceptor:处理客户端连接事件
- Handler:处理非阻塞的任务
在传统阻塞 IO 模型中,每个连接都需要独立线程处理,当并发数大时,创建线程数多,占用资源;采用阻塞 IO 模型,连接建立后,若当前线程没有数据可读,线程会阻塞在读操作上,造成资源浪费
针对传统阻塞 IO 模型的两个问题,Reactor 模型基于池化思想,避免为每个连接创建线程,连接完成后将业务处理交给线程池处理;基于 IO 复用模型,多个连接共用同一个阻塞对象,不用等待所有的连接。遍历到有新数据可以处理时,操作系统会通知程序,线程跳出阻塞状态,进行业务逻辑处理
Kafka 即基于 Reactor 模型实现了多路复用和处理线程池。其设计如下:
其中包含了一个
Acceptor
线程,用于处理新的连接,
Acceptor
有 N 个
Processor
线程 select 和 read socket 请求,N 个
Handler
线程处理请求并响应,即处理业务逻辑。
I/O 多路复用可以通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。它的最大优势是系统开销小,并且不需要创建新的进程或者线程,降低了系统的资源开销。
总结: Kafka Broker 的
KafkaServer
设计是一个优秀的网络架构,有想了解 Java 网络编程,或需要使用到这方面技术的同学不妨去读一读源码。后续『码哥』的 Kafka 系列文章也将涉及这块源码的解读。
批量传输与压缩消息
Producer
向
Broker
发送消息不是一条消息一条消息的发送。使用过
Kafka
的同学应该知道,
Producer
有两个重要的参数:
batch.size
和
linger.ms
。这两个参数就和
Producer
的批量发送消息有关。
为什么要批量发送消息呢?
回顾一下,Kafka 的主要场景就是高并发读和高吞吐写,也就说 Kafka 需要写入和读取大量的消息。那有没有一种办法可以让发送和读取消息付出的代价更小呢?一个很简单的手段就是 批量传输与压缩消息。批量主要是为了让传输消息的次数变得更少,压缩主要是为了降低网络传输的消耗,提高吞吐量。
在 Kafka 中,Kafka 会对消息进行分组,发送消息之前,会先将消息组合在一起形成消息快,然后
Producer
会将消息快一起发送到
Broker
。
另外,由于,网络带宽是有限的,我们在网络中传输数据之前往往需要先对其进行压缩(网上有很多现成的压缩算法,我们直接拿来就可以了,Kafka 就是这样做的)。因此,
Producer
发送消息快我们还可以对其进行压缩。
在 Java 程序员中,生成
KafkaProducer
对象的代码可能会像下面这样:
Properties props =newProperties();
props.put("bootstrap.servers","localhost:9092");
props.put("acks","all");
props.put("retries",0);
props.put("batch.size",16384);
props.put("linger.ms",1);
props.put("buffer.memory",33554432);
props.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");// 开启GZIP压缩
props.put("compression.type","gzip");Producer<String,String> producer =newKafkaProducer<>(props);
Broker
接收到压缩后的消息块之后(建议
Broker
的压缩算法和
Producer
一样),会依次将压缩后的消息块写入文件中(注意:这个时候消息块还是压缩的状态),
Consumer
同时会依次获取消息块,当消息块到达
Consumer
后,
Consumer
才会对消息块进行解压缩(有压缩必然有解压缩)。
Kafka Producer 的执行流程如下图所示 :
发送消息依次经过以下处理器:
**Serialize**
:键和值都根据传递的序列化器进行序列化。优秀的序列化方式可以提高网络传输的效率。**Partition**
:决定将消息写入主题的哪个分区,默认情况下遵循 murmur2 算法。自定义分区程序也可以传递给生产者,以控制应将消息写入哪个分区。**Compress**
:默认情况下,在 Kafka 生产者中不启用压缩。压缩 不仅可以更快地从生产者传输到代理,还可以在复制过程中进行更快的传输。压缩有助于提高吞吐量,降低延迟并提高磁盘利用率。**Accumulate**
:Accumulate
顾名思义,就是一个消息累计器。其内部为每个 Partition 维护一个Deque
双端队列,队列保存将要发送的批次数据,Accumulate
将数据累计到一定数量,或者在一定过期时间内,便将数据以批次的方式发送出去。记录被累积在主题每个分区的缓冲区中。根据生产者批次大小属性将记录分组。主题中的每个分区都有一个单独的累加器 / 缓冲区。**Group Send**
:记录累积器中分区的批次按将它们发送到的代理分组。批处理中的记录基于batch.size
和linger.ms
属性发送到代理。记录由生产者根据两个条件发送。当达到定义的批次大小或达到定义的延迟时间时。
Kafka 支持多种压缩算法:
LZ4
、
Snappy
、
GZIP
。Kafka 2.1.0 正式支持
ZStandard
压缩算法。ZStandard 简称 Zstd ,Facebook 开源,是一个主要由 C 语言实现的旨在提供高压缩比的快速压缩算法。
ZStandard 的官方首页还提供了相关压缩算法的 Benchmark 数据对比。从数据对比图中,可以看出ZStandard 的各方面数据(解压缩速度以及压缩率)都是非常优秀的。
具体的压缩算法咱们就不聊了,有点跑题了。如果你对压缩算法比较感兴趣或者工作中需要用到类似知识的话,可以看看美团技术团队的《速度与压缩比如何兼得?压缩算法在构建部署中的优化》 这篇文章,其对常见的压缩算法(比如GZIP
、
LZ4
、
ZStandard
、
Pigz
)做了详细的对比。
Producer、Broker 和 Consumer 使用相同的压缩算法,在 producer 向 Broker 写入数据,Consumer 向 Broker 读取数据时甚至可以不用解压缩,最终在 Consumer Poll 到消息时才解压,这样节省了大量的网络和磁盘开销。
分区并发
Kafka 的 Topic 可以分成多个 Partition,每个 Paritition 类似于一个队列,保证数据有序。同一个 Group 下的不同 Consumer 并发消费 Paritition,分区实际上是调优 Kafka 并行度的最小单元,因此,可以说,每增加一个 Paritition 就增加了一个消费并发。
Kafka 具有优秀的分区分配算法——StickyAssignor,可以保证分区的分配尽量地均衡,且每一次重分配的结果尽量与上一次分配结果保持一致。这样,整个集群的分区尽量地均衡,各个 Broker 和 Consumer 的处理不至于出现太大的倾斜。
那是不是分区数越多越好呢?
当然不是。
越多的分区需要打开更多的文件句柄
在 kafka 的 broker 中,每个分区都会对照着文件系统的一个目录。在 kafka 的数据日志文件目录中,每个日志数据段都会分配两个文件,一个索引文件和一个数据文件。因此,随着 partition 的增多,需要的文件句柄数急剧增加,必要时需要调整操作系统允许打开的文件句柄数。
客户端 / 服务器端需要使用的内存就越多
客户端 producer 有个参数 batch.size,默认是 16KB。它会为每个分区缓存消息,一旦满了就打包将消息批量发出。看上去这是个能够提升性能的设计。不过很显然,因为这个参数是分区级别的,如果分区数越多,这部分缓存所需的内存占用也会更多。
降低高可用性
分区越多,每个 Broker 上分配的分区也就越多,当一个发生 Broker 宕机,那么恢复时间将很长。
文件结构
Kafka 消息是以 Topic 为单位进行归类,各个 Topic 之间是彼此独立的,互不影响。每个 Topic 又可以分为一个或多个分区。每个分区各自存在一个记录消息数据的日志文件。
Kafka 每个分区日志在物理上实际按大小被分成多个 Segment。
- segment file 组成:由 2 大部分组成,分别为 index file 和 data file,此 2 个文件一一对应,成对出现,后缀”.index”和“.log”分别表示为 segment 索引文件、数据文件。
- segment 文件命名规则:partion 全局的第一个 segment 从 0 开始,后续每个 segment 文件名为上一个 segment 文件最后一条消息的 offset 值。数值最大为 64 位 long 大小,19 位数字字符长度,没有数字用 0 填充。
index 采用稀疏索引,这样每个 index 文件大小有限,Kafka 采用
mmap
的方式,直接将 index 文件映射到内存,这样对 index 的操作就不需要操作磁盘 IO。
mmap
的 Java 实现对应
MappedByteBuffer
。
mmap 是一种内存映射文件的方法。即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
Kafka 充分利用二分法来查找对应 offset 的消息位置:
- 按照二分法找到小于 offset 的 segment 的.log 和.index
- 用目标 offset 减去文件名中的 offset 得到消息在这个 segment 中的偏移量。
- 再次用二分法在 index 文件中找到对应的索引。
- 到 log 文件中,顺序查找,直到找到 offset 对应的消息。
总结
Kafka 是一个优秀的开源项目。其在性能上面的优化做的淋漓尽致,是很值得我们深入学习的一个项目。无论是思想还是实现,我们都应该认真的去看一看,想一想。
Kafka 性能优化:
- 零拷贝网络和磁盘
- 优秀的网络模型,基于 Java NIO
- 高效的文件数据结构设计
- Parition 并行和可扩展
- 数据批量传输
- 数据压缩
- 顺序读写磁盘
- 无锁轻量级 offset
参考
- 《Kafka: The Definitive Guide》
版权归原作者 程序猿-大白 所有, 如有侵权,请联系我们删除。