相信各位小伙伴之前或多或少接触过消息队列,比较知名的包含Rocket MQ和Kafka,在京东内部使用的是自研的消息中间件JMQ,从JMQ2升级到JMQ4的也是带来了性能上的明显提升,并且JMQ4的底层也是参考Kafka去做的设计。在这里我会给大家展示Kafka它的高性能是如何设计的,大家也可以学习相关方法论将其利用在实际项目中,也许下一个顶级项目就在各位的代码中产生了。
先抛开kafka,咱们先来谈论一下高性能设计的本质,在这里借用一下网上的一张总结高性能的思维导图:
从中可以看到,高性能设计的手段还是非常多,从”微观设计”上的无锁化、序列化,到”宏观设计”上的缓存、存储等,可以说是五花八门,令人眼花缭乱。但是在我看来本质就两点:计算和IO。下面将从这两点来浅析一下我认为的高性能的”道”。
计算上的优化手段无外乎两种方式:1.减少计算量 2.加快单位时间的计算量
IO上的优化手段也可以从两个方面来体现:1.减少IO次数或者IO数据量 2.加快IO速度
理解了高性能设计的手段和本质之后,我们再来看看kafka里面使用到的性能优化方法。各类消息中间件的本质都是一个生产者-消费者模型,生产者发送消息给服务端进行暂存,消费者从服务端获取消息进行消费。也就是说kafka分为三个部分:生产者-服务端-消费者,我们可以按照这三个来分别归纳一下其关于性能优化的手段,这些手段也会涵盖在我们之前梳理的脑图里面。
之前在上面说过,高性能的”道”在于计算和IO上,咱们先来看看在IO上kafka是如何做设计的。
IO上的优化
kafka是一个消息中间件,数据的载体就是消息,如何将消息高效的进行传递和持久化是kafka高性能设计的一个重点。基于此分析kafka肯定是IO密集型应用,producer需要通过网络IO将消息传递给broker,broker需要通过磁盘IO将消息持久化,consumer需要通过网络IO将消息从broker上拉取消费。
1.kafka负载均衡设计
Kafka有主题(Topic)概念,他是承载真实数据的逻辑容器,主题之下还分为若干个分区,Kafka消息组织方式实际上是三级结构:主题-分区-消息。主题下的每条消息只会在某一个分区中,而不会在多个分区中被保存多份。
Kafka这样设计,使用分区的作用就是提供负载均衡的能力,对数据进行分区的主要目的就是为了实现系统的高伸缩性(Scalability)。不同的分区能够放在不同的节点的机器上,而数据的读写操作也都是针对分区这个粒度进行的,每个节点的机器都能独立地执行各自分区读写请求。我们还可以通过增加节点来提升整体系统的吞吐量。Kafka的分区设计,还可以实现业务级别的消息顺序的问题。
2.具体分区策略
1.线程模型
之前已经说了kafka是选择批量发送消息来提升整体的IO性能,具体流程是kafka生产者使用批处理试图在内存中积累数据,主线程将多条消息通过一个ProduceRequest请求批量发送出去,发送的消息暂存在一个队列(RecordAccumulator)中,再由sender线程去获取一批数据或者不超过某个延迟时间内的数据发送给broker进行持久化。
优点:
缺点:
1.序列化的优势
Kafka 消息中的 Key 和 Value,都支持自定义类型,只需要提供相应的序列化和反序列化器即可。因此,用户可以根据实际情况选用快速且紧凑的序列化方式(比如 ProtoBuf、Avro)来减少实际的网络传输量以及磁盘存储量,进一步提高吞吐量。
2.内置的序列化器
1.压缩的目的
压缩秉承了用时间换空间的经典trade-off思想,即用CPU的时间去换取磁盘空间或网络I/O传输量,Kafka的压缩算法也是出于这种目的。并且通常是:数据量越大,压缩效果才会越好。
因为有了批量发送这个前期,从而使得 Kafka 的消息压缩机制能真正发挥出它的威力(压缩的本质取决于多消息的重复性)。对比压缩单条消息,同时对多条消息进行压缩,能大幅减少数据量,从而更大程度提高网络传输率。
2.压缩的方法
想了解kafka消息压缩的设计,就需要先了解kafka消息的格式:
每条消息都含有自己的元数据信息,kafka会将一批消息相同的元数据信息给提升到外层的消息集合里面,然后再对整个消息集合来进行压缩。批量消息在持久化到 Broker 中的磁盘时,仍然保持的是压缩状态,最终是在 Consumer 端做了解压缩操作。
压缩算法效率对比
Kafka 共支持四种主要的压缩类型:Gzip、Snappy、Lz4 和 Zstd,具体效率对比如下:
kafka相比其他消息中间件最出彩的地方在于他的高吞吐量,那么对于服务端来说每秒的请求压力将会巨大,需要有一个优秀的网络通信机制来处理海量的请求。如果 IO 有所研究的同学,应该清楚:Reactor 模式正是采用了很经典的 IO 多路复用技术,它可以复用一个线程去处理大量的 Socket 连接,从而保证高性能。Netty 和 Redis 为什么能做到十万甚至百万并发?它们其实都采用了 Reactor 网络通信模型。
1.kafka网络通信层架构
从图中可以看出,SocketServer和KafkaRequestHandlerPool是其中最重要的两个组件:
2.请求流程
基本结构的展示
Kafka是一个Pub-Sub的消息系统,无论是发布还是订阅,都须指定Topic。Topic只是一个逻辑的概念。每个Topic都包含一个或多个Partition,不同Partition可位于不同节点。同时Partition在物理上对应一个本地文件夹(也就是个日志对象Log),每个Partition包含一个或多个Segment,每个Segment包含一个数据文件和多个与之对应的索引文件。在逻辑上,可以把一个Partition当作一个非常长的数组,可通过这个“数组”的索引(offset)去访问其数据。
2.Partition的并行处理能力
3.过期消息的清除
1.稀疏索引
可以从上面看到,一个segment包含一个.log后缀的文件和多个index后缀的文件。那么这些文件具体作用是干啥的呢?并且这些文件除了后缀不同文件名都是相同,为什么这么设计?
2.优化的二分查找算法
kafka没有使用我们熟知的跳表或者B+Tree结构来设计索引,而是使用了一种更为简单且高效的查找算法:二分查找。但是相对于传统的二分查找,kafka将其进行了部分优化,个人觉得设计的非常巧妙,在这里我会进行详述。
在这之前,我先补充一下kafka索引文件的构成:每个索引文件包含若干条索引项。不同索引文件的索引项的大小不同,比如offsetIndex索引项大小是8B,timeIndex索引项的大小是12B。
这里以offsetIndex为例子来详述kafka的二分查找算法:
1)普通二分查找
offsetIndex每个索引项大小是8B,但操作系统访问内存时的最小单元是页,一般是4KB,即4096B,会包含了512个索引项。而找出在索引中的指定偏移量,对于操作系统访问内存时则变成了找出指定偏移量所在的页。假设索引的大小有13个页,如下图所示:
由于Kafka读取消息,一般都是读取最新的偏移量,所以要查询的页就集中在尾部,即第12号页上。根据二分查找,将依次访问6、9、11、12号页。
当随着Kafka接收消息的增加,索引文件也会增加至第13号页,这时根据二分查找,将依次访问7、10、12、13号页。
可以看出访问的页和上一次的页完全不同。之前在只有12号页的时候,Kafak读取索引时会频繁访问6、9、11、12号页,而由于Kafka使用了mmap来提高速度,即读写操作都将通过操作系统的page cache,所以6、9、11、12号页会被缓存到page cache中,避免磁盘加载。但是当增至13号页时,则需要访问7、10、12、13号页,而由于7、10号页长时间没有被访问(现代操作系统都是使用LRU或其变体来管理page cache),很可能已经不在page cache中了,那么就会造成缺页中断(线程被阻塞等待从磁盘加载没有被缓存到page cache的数据)。在Kafka的官方测试中,这种情况会造成几毫秒至1秒的延迟。
2)kafka优化的二分查找
Kafka对二分查找进行了改进。既然一般读取数据集中在索引的尾部。那么将索引中最后的8192B(8KB)划分为“热区”(刚好缓存两页数据),其余部分划分为“冷区”,分别进行二分查找。这样做的好处是,在频繁查询尾部的情况下,尾部的页基本都能在page cahce中,从而避免缺页中断。
下面我们还是用之前的例子来看下。由于每个页最多包含512个索引项,而最后的1024个索引项所在页会被认为是热区。那么当12号页未满时,则10、11、12会被判定是热区;而当12号页刚好满了的时候,则11、12被判定为热区;当增至13号页且未满时,11、12、13被判定为热区。假设我们读取的是最新的消息,则在热区中进行二分查找的情况如下:
当12号页未满时,依次访问11、12号页,当12号页满时,访问页的情况相同。当13号页出现的时候,依次访问12、13号页,不会出现访问长时间未访问的页,则能有效避免缺页中断。
3.mmap的使用
利用稀疏索引,已经基本解决了高效查询的问题,但是这个过程中仍然有进一步的优化空间,那便是通过 mmap(memory mapped files) 读写上面提到的稀疏索引文件,进一步提高查询消息的速度。
究竟如何理解 mmap?前面提到,常规的文件操作为了提高读写性能,使用了 Page Cache 机制,但是由于页缓存处在内核空间中,不能被用户进程直接寻址,所以读文件时还需要通过系统调用,将页缓存中的数据再次拷贝到用户空间中。
1)常规文件读写
tips:这一过程实际上发生了四次数据拷贝。首先通过系统调用将文件数据读入到内核态Buffer(DMA拷贝),然后应用程序将内存态Buffer数据读入到用户态Buffer(CPU拷贝),接着用户程序通过Socket发送数据时将用户态Buffer数据拷贝到内核态Buffer(CPU拷贝),最后通过DMA拷贝将数据拷贝到NIC Buffer。同时,还伴随着四次上下文切换。
2)mmap读写模式
tips:采用 mmap 后,它将磁盘文件与进程虚拟地址做了映射,并不会招致系统调用,以及额外的内存 copy 开销,从而提高了文件读取效率。具体到 Kafka 的源码层面,就是基于 JDK nio 包下的 MappedByteBuffer 的 map 函数,将磁盘文件映射到内存中。只有索引文件的读写才用到了 mmap。
对于我们常用的机械硬盘,其读取数据分3步:
前两个,即寻找数据位置的过程为机械运动。我们常说硬盘比内存慢,主要原因是这两个过程在拖后腿。不过,硬盘比内存慢是绝对的吗?其实不然,如果我们能通过顺序读写减少寻找数据位置时读写磁头的移动距离,硬盘的速度还是相当可观的。一般来讲,IO速度层面,内存顺序IO > 磁盘顺序IO > 内存随机IO > 磁盘随机IO。这里用一张网上的图来对比一下相关IO性能:
Kafka在顺序IO上的设计分两方面看:
为了优化读写性能,Kafka利用了操作系统本身的Page Cache,就是利用操作系统自身的内存而不是JVM空间内存。这样做的好处有:
相比于使用JVM或in-memory cache等数据结构,利用操作系统的Page Cache更加简单可靠。
通过操作系统的Page Cache,Kafka的读写操作基本上是基于内存的,读写速度得到了极大的提升。
生产者是批量发送消息,消息者也是批量拉取消息的,每次拉取一个消息batch,从而大大减少了网络传输的 overhead。在这里kafka是通过fetch.min.bytes参数来控制每次拉取的数据大小。默认是 1 字节,表示只要 Kafka Broker 端积攒了 1 字节的数据,就可以返回给 Consumer 端,这实在是太小了。我们还是让 Broker 端一次性多返回点数据吧。
并且,在生产者高性能设计目录里面也说过,生产者其实在 Client 端对批量消息进行了压缩,这批消息持久化到 Broker 时,仍然保持的是压缩状态,最终在 Consumer 端再做解压缩操作。
1.zero-copy定义
零拷贝并不是不需要拷贝,而是减少不必要的拷贝次数。通常是说在IO读写过程中。
零拷贝字面上的意思包括两个,“零”和“拷贝”:
实际上,零拷贝是有广义和狭义之分,目前我们通常听到的零拷贝,包括上面这个定义减少不必要的拷贝次数都是广义上的零拷贝。其实了解到这点就足够了。
我们知道,减少不必要的拷贝次数,就是为了提高效率。那零拷贝之前,是怎样的呢?
2.传统IO的流程
做服务端开发的小伙伴,文件下载功能应该实现过不少了吧。如果你实现的是一个web程序 ,前端请求过来,服务端的任务就是:将服务端主机磁盘中的文件从已连接的socket发出去。关键实现代码如下:
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf , n);
传统的IO流程,包括read和write的过程。
从流程图可以看出,传统IO的读写流程 ,包括了4次上下文切换(4次用户态和内核态的切换),4次数据拷贝(两次CPU拷贝以及两次的DMA拷贝 ),什么是DMA拷贝呢?我们一起来回顾下,零拷贝涉及的操作系统知识点。
3.零拷贝相关知识点
1)内核空间和用户空间
操作系统为每个进程都分配了内存空间,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。 以32位操作系统为例,它会为每一个进程都分配了4G (2的32次方)的内存空间。
2)用户态&内核态
3)上下文切换
cpu上下文
CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做CPU上下文。
cpu上下文切换
它是指,先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
一般我们说的上下文切换 ,就是指内核(操作系统的核心)在CPU上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用 来完成。系统调用的过程,会发生CPU上下文的切换 。
4)DMA技术
DMA,英文全称是Direct Memory Access ,即直接内存访问。DMA 本质上是一块主板上独立的芯片,允许外设设备和内存存储器之间直接进行IO数据传输,其过程不需要CPU的参与 。
我们一起来看下IO流程,DMA帮忙做了什么事情。
可以发现,DMA做的事情很清晰啦,它主要就是帮忙CPU转发一下IO请求,以及拷贝数据 。
之所以需要DMA,主要就是效率,它帮忙CPU做事情,这时候,CPU就可以闲下来去做别的事情,提高了CPU的利用效率。
4.kafka消费的zero-copy
1)实现原理
零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及CPU拷贝的次数。零拷贝实现有多种方式,分别是
在服务端那里,我们已经知道了kafka索引文件使用的mmap来进行零拷贝优化的,现在告诉你kafka消费者在读取消息的时候使用的是sendfile来进行零拷贝优化。
linux 2.4版本之后,对sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝,即还可以多省去一次CPU拷贝 。
sendfile+DMA scatter/gather实现的零拷贝流程如下:
可以发现,sendfile+DMA scatter/gather实现的零拷贝,I/O发生了2 次用户空间与内核空间的上下文切换,以及2次数据拷贝。其中2次数据拷贝都是包DMA拷贝 。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。
2)底层实现
Kafka数据传输通过 TransportLayer 来完成,其子类 PlaintextTransportLayer 通过Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法实现零拷贝。底层就是sendfile。消费者从broker读取数据,就是由此实现。
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
tips: transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。
文章第一部分为大家讲解了高性能常见的优化手段,从”秘籍”和”道法”两个方面来诠释高性能设计之路该如何走,并引申出计算和IO两个优化方向。
文章第二部分是kafka内部高性能的具体设计——分别从生产者、服务端、消费者来进行全方位讲解,包括其设计、使用及相关原理。
希望通过这篇文章,能够使大家不仅学习到相关方法论,也能明白其方法论具体的落地方案,一起学习,一起成长。
作者:京东物流 李鹏
来源:京东云开发者社区