Kafka 性能优化之道
顺序读写
Kafka 高度依赖于文件系统来存储和缓存消息。对于磁盘来说,它有一个特性,就是顺序读写的性能要远远好于随机读写。在SSD (固态硬盘)上,顺序读写的性能要比随机读写快几倍,如果是机械硬盘,这个差距会达到几十倍。据Kafka 官方网站介绍:6 块7200r/min SATA RAID-5 阵列的磁盘线性写的速度为600 MB/s ,而随机写的速度为100KB/s ,线性写的速度约是随机写的6000 多倍。由此看来磁盘的快慢取决于我们是如何去应用磁盘。加之现代的操作系统提供了预读(read-ahead) 和延迟写(write-behind) 技术,使得磁盘的写速度并不是大家想象的那么慢。操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进行数据读写。如果是机械硬盘,这个寻址需要比较长的时间,因为它要移动磁头,这是个机械运动,机械硬盘工作的时候会发出咔咔的声音,就是移动磁头发出的声音。顺序读写相比随机读写省去了大部分的寻址时间,它只要寻址一次,就可以连续地读写下去,所以说,性能要比随机读写要好很多。
Kafka 就是充分利用了磁盘的这个特性。它的存储设计非常简单,对于每个分区,它把从Producer 收到的消息,顺序地写入对应的log 文件中,一个文件写满了,就开启一个新的文件这样顺序写下去。消费的时候,也是从某个全局的位置开始,也就是某一个log 文件中的某个位置开始,顺序地把消息读出来。
对于传统的message queue 而言,一般会删除已经被消费的消息,而Kafka 是不会删除数据的,它会把所有的数据都保留下来,每个消费者(Consumer)对每个Topic 都有一个offset 用来表示读取到了第几条数据。
即便是顺序写入硬盘,硬盘的访问速度还是不可能追上内存。所以Kafka 的数据并不是实时的写入硬盘,它充分利用了现代操作系统分页存储来利用内存提高I/O 效率。
利用PageCache 加速消息读写
在Kafka 中,它会利用PageCache 加速消息读写。PageCache 是现代操作系统都具有的一项基本特性。通俗地说,PageCache 就是操作系统在内存中给磁盘上的文件建立的缓存。无论我们使用什么语言编写的程序,在调用系统的API 读写文件的时候,并不会直接去读写磁盘上的文件,应用程序实际操作的都是PageCache ,也就是文件在内存中缓存的副本。应用程序在写入文件的时候,操作系统会先把数据写入到内存中的PageCache ,然后再一批一批地写到磁盘上。读取文件的时候,也是从PageCache 中来读取数据,这时候会出现两种可能情况。
一种是PageCache 中有数据,那就直接读取,这样就节省了从磁盘上读取数据的时间;
另一种情况是,PageCache 中没有数据,这时候操作系统会引发一个缺页中断,应用程序的读取线程会被阻塞,操作系统把数据从文件中复制到PageCache 中,然后应用程序再从PageCache 中继续把数据读出来,这时会真正读一次磁盘上的文件,这个读的过程就会比较慢。
用户的应用程序在使用完某块PageCache 后,操作系统并不会立刻就清除这个PageCache ,而是尽可能地利用空闲的物理内存保存这些PageCache ,除非系统内存不够用,操作系统才会清理掉一部分PageCache 。清理的策略一般是LRU 或它的变种算法,这个算法我们不展开讲,它保留PageCache 的逻辑是:优先保留最近一段时间最常使用的那些PageCache 。
Kafka 在读写消息文件的时候,充分利用了PageCache 的特性。一般来说,消息刚刚写入到服务端就会被消费,按照LRU 的“优先清除最近最少使用的页”这种策略,读取的时候,对于这种刚刚写入的PageCache ,命中的几率会非常高。也就是说,大部分情况下,消费读消息都会命中PageCache ,带来的好处有两个:一个是读取的速度会非常快,另外一个是,给写入消息让出磁盘的IO 资源,间接也提升了写入的性能。
ZeroCopy:零拷贝技术
在Linux Kernal 2.2 之后出现了一种叫做“零拷贝(zero-copy) ”系统调用机制,就是跳过“用户缓冲区”的拷贝,建立一个磁盘空间和内存空间的直接映射,数据不再复制到“用户态缓冲区”系统上下文切换减少2 次,可以提升一倍性能。
更多零拷贝相关介绍参阅《Concurrent-Notes/ 零拷贝 》
我们知道,在服务端,处理消费的大致逻辑是这样的:
首先,从文件中找到消息数据,读到内存中;
然后,把消息通过网络发给客户端。
这个过程中,数据实际上做了2 次或者3 次复制:
从文件复制数据到PageCache 中,如果命中PageCache ,这一步可以省掉;
从PageCache 复制到应用程序的内存空间中,也就是我们可以操作的对象所在的内存;
从应用程序的内存空间复制到Socket 的缓冲区,这个过程就是我们调用网络应用框架的API 发送数据的过程。
Kafka 使用零拷贝技术可以把这个复制次数减少一次,上面的2 、3 步骤两次复制合并成一次复制。直接从PageCache 中把数据复制到Socket 缓冲区中,这样不仅减少一次数据复制,更重要的是,由于不用把数据复制到用户内存空间,DMA 控制器可以直接完成数据复制,不需要CPU 参与,速度更快。下面是这个零拷贝对应的系统调用:
#include <sys/socket.h>
ssize_t sendfile ( int out_fd , int in_fd , off_t * offset , size_t count ) ;
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。如果你遇到这种从文件读出数据后再通过网络发送出去的场景,并且这个过程中你不需要对这些数据进行处理,那一定要使用这个零拷贝的方法,可以有效地提升性能。
消费者(读取数据)
传统模式下我们从硬盘读取一个文件是这样的:先复制到内核空间(read 是系统调用,放到了DMA ,所以用内核空间) ,然后复制到用户空间(1、2) ;从用户空间重新复制到内核空间(你用的socket 是系统调用,所以它也有自己的内核空间) ,最后发送给网卡(3、4) 。Zero Copy 中直接从内核空间(DMA 的)到内核空间(Socket 的) ,然后发送网卡,Nginx 也是用的这种技术。
实际上,Kafka 把所有的消息都存放在一个一个的文件中,当消费者需要数据的时候Kafka 直接把“文件”发送给消费者。当不需要把整个文件发出去的时候,Kafka 通过调用Zero Copy 的sendfile 这个函数,这个函数包括:
out_fd 作为输出(一般及时socket 的句柄)
in_fd 作为输入文件句柄
off_t 表示in_fd 的偏移(从哪里开始读取)
size_t 表示读取多少个