四、kafka快的原因
kafka为什么这么快, 5个点:顺序读写、网络模型、存储原理(partition分片)、日志压缩
4.1 顺序读写page cache
见上一节文件系统
使用6个7200rpm、SATA接口、RAID-5的磁盘阵列在JBOD配置下的顺序写入的性能约为600MB/秒,但随机写入的性能仅约为100k/秒,相差6000倍以上。
4.2 网络模型
4.2.1 reactor模型
4.2.2 epoll
见:epoll原理
4.2.3 sendfile(零拷贝)
零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
使用 sendfile 方法,可以允许操作系统将数据从 pagecache 直接发送到网络,这样避免重新复制数据。
直接内存访问(DMA),是一种完全由硬件执行IO交换的工作方式。DMA控制器从CPU完全接管对总线的控制,数据交换不经过CPU,而直接在内存和IO设备之间进行。
CPU 来告诉 DMA 控制器 传输什么数据,从哪里传输到哪里。CPU 不再参与任何与数据搬运相关的事情,全都是DMA控制器完成,这样 CPU 就可以去处理别的事务。
早期DMA只存在于主板上,如今基本上每个I/0设备都有自己的DMA控制器。
在sendfile前,先了解一下传统IO、mmap
传统IO
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
一般会需要两个系统调用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将数据页从页缓存中再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。
文件到套接字的常见数据传输路径
- 操作系统从磁盘读取数据到内核空间的 pagecache (read1)
- 应用程序读取内核空间的数据到用户空间的缓冲区 (read2)
- 然后用户进程再把数据写入到Socket,数据流入内核空间的socket buffer上 (write1)
- OS再把数据从buffer中拷贝到网卡 NIC buffer上,这样完成一次发送 (write2)
传统IO,传统IO 传输文件从磁盘到网卡,4次上下文切换4次拷贝。
要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
mmap
mmap简介
mmap是一种内存映射文件的方法,mmap将一个文件或者其它对象映射到内存(进程的地址空间),实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。文件被映射到多个页上(PageCache)。
实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
mmap共享存储映射又称为 存储I/O映射,是Unix 共享内存 概念中的一种。
Linux 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据,因为Linux使用的虚拟内存机制,用户空间的数据可能被换出,当内核空间使用用户空间指针时,对应的数据可能不在内存中。
Linux内核地址映射模型采用了段页式地址映射模型。进程代码中的地址为逻辑地址,经过段页式地址映射后,才真正访问物理内存。
mmap()实现了这样的一个映射过程:它将用户空间的一段内存与设备内存关联,当用户访问用户空间的这段地址范围时,实际上会转化为对设备的访问。**mmap()必须以PAGE_SIZE为单位进行映射,实际上,内存只能以页为单位进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行页对齐,强行以PAGE_SIZE的倍数大小进行映射.
mmap + write (4次上下文切换3次拷贝)**
buf = mmap(file, len);
write(sockfd, buf, len);
mmap()系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作,它替换了read()系统调用函数。
mmap技术特点
- 利用 DMA 技术来取代 CPU 来在内存与其他组件之间的数据拷贝,例如从磁盘到内存,从内存到网卡;
- 用户空间的 mmap file 使用虚拟内存,实际上并不占据物理内存,只有在内核空间的 kernel buffer cache 才占据实际的物理内存;
- mmap() 函数需要配合 write() 系统调动进行配合操作,这与 sendfile() 函数有所不同,后者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切换;
- mmap 仅仅能够避免内核空间到用户空间的全程 CPU 负责的数据拷贝,但是内核空间内部还是需要全程 CPU 负责的数据拷贝;
sendfile(真正零拷贝)
sendfile可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
新版本:从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下,sendfile() 系统调用的过程发生了点变化,具体过程如下:
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和2次数据拷贝,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运
PageCache (延伸)
文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)在内存里。
通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
Page Cache是内核管理的内存,不属于用户内存
PageCache 的优点主要是两个:
缓存最近被访问的数据;
预读功能;
但是,**在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。**每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。
由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。
在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
传输大文件的时候,使用「异步 I/O + 直接 I/O」;
传输小文件的时候,则使用「零拷贝技术」;
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
location /video/ {
sendfile on;
aio on;
directio 1024m;
}
当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。
4.3 Partition机制
分片多文件同步写入
4.4 批量发送和日志压缩
批量发送
小型的 I/O 操作发生在客户端和服务端之间以及服务端自身的持久化操作中。为了避免这种情况,kafka的协议是建立在一个 “消息块” 的抽象基础上,合理将消息分组。将多个消息打包成一组,而不是每次发送一条消息,从而使整组消息分担网络中往返的开销。
日志压缩
当Topic中的cleanup.policy(默认为delete)设置为compact时,Kafka的后台线程会定时将Topic遍历两次,第一次将每个Key的哈希值最后一次出现的offset记录下来,第二次检查每个offset对应的Key是否在较为后面的日志中出现过,如果出现了就删除对应的日志。
日志压缩是允许删除的,这个删除标记将导致删除任何先前带有该Key的消息,但是删除标记的特殊之处在于,它们将在一段时间后从日志中清理,以释放空间。这些需要注意的是,日志压缩是针对Key的,所以在使用时应注意每个消息的Key值不为NULL。
压缩是在Kafka后台通过定时的重新打开Segment来完成的
日志压缩可以确保的内容,这里笔者总结了以下几点:
- 任何保持在日志头部以内的使用者都将看到所写的每条消息,这些消息将具有顺序偏移量。可以使用Topic的min.compaction.lag.ms属性来保证消息在被压缩之前必须经过的最短时间。也就是说,它为每个消息在(未压缩)头部停留的时间提供了一个下限。可以使用Topic的max.compaction.lag.ms属性来保证从编写消息到消息符合压缩条件之间的最大延时
- 消息始终保持顺序,压缩永远不会重新排序消息,只是删除一些而已
- 消息的偏移量永远不会改变,它是日志中位置的永久标识符
- 从日志开始的任何使用者将至少看到所有记录的最终状态,按记录的顺序写入。另外,如果使用者在比Topic的log.cleaner.delete.retention.ms短的时间内到达日志的头部,则会看到已删除记录的所有delete标记。保留时间默认是24小时。