kafka pageCache 和 zeroCopy
2025-01-22 08:19:30    3.2k 字   
This post is also available in English and alternative languages.

pageCache 和 zeroCopy,这两个知识点需要不少Linux知识背景;

我把相关背景知识整理出来,如果有疑问,可以在查阅"参考资料"下详细链接,或与我联系一起讨论。。

1. Kafka 为什么这么快?

Kafka在底层摒弃了Java堆缓存机制,采用了操作系统级别的 页缓存(Page Cache),同时将随机写操作改为 顺序写,再结合 Zero-Copy 的特性极大地改善了IO性能。

每次写入操作都只是把数据写入到操作系统的页缓存(page cache)中,然后由操作系统自行决定什么时候把页缓存的数据写回到磁盘中。

Kafka写入操作采用追加写入(append)方式,避免了磁盘随机写操作。


同时Kafka还使用了 分区(partition),通过将topic的消息打散到多个分区并分布保存在不同的broker上实现了消息处理(不管是producer还是consumer)的高吞吐量。

(和 elasticsearch 分片差不多的思路)

Kafka快除了上面提到的四点外,还在其他地方做了优化,有兴趣的可以阅读一下这篇博文:《Why Kafka Is so Fast》


1.1. 用户态、内核态

用户态和内核态(图片来自网络)

操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限(CPU资源、存储资源、I/O资源等)。

从宏观上来看,Linux操作系统的体系架构分为 用户态内核态

内核态:控制计算机的硬件资源,并提供上层应用程序运行的环境。比如 socket I/O操作、文件的读写等操作。

用户态:上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。

系统调用:为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用

用户空间、内核空间

对32位操作系统而言,它的虚拟空间为4G(2的32次方),也就是说一个进程的最大地址空间为4G。

操作系统将虚拟地址空间划分为两部分,一部分为 内核空间,另一部分为 用户空间

针对Linux操作系统而言,最高的1G字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。
而较低的3G字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。

内核空间是内核代码运行的地方,用户空间是用户程序代码运行的地方


1.2. 用户态和内核态的切换

因为操作系统的资源是有限的,为了减少有限资源的访问和使用冲突,Unix/Linux的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。

简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。

Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。

运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。

例如:JVM属于用户态,无权访问内核,只能通过系统调用完成用户态到内核态的调用(JNI调用)

很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到从 用户态与内核态之间的切换

用户态 和 内核态 切换耗时、代价很大。

这个知识点,触及了知识盲区,先贴网上的资料,有机会再补充。

系统调用一般都需要保存用户程序得上下文(context), 在进入内核得时候需要保存用户态得寄存器,在内核态返回用户态得时候会恢复这些寄存器得内容。这是一个开销的地方。

如果需要在不同用户程序间切换的话,那么还要更新cr3寄存器,这样会更换每个程序的虚拟内存到物理内存映射表的地址,也是一个比较高负担的操作。

https://segmentfault.com/q/1010000000522752

1.3. Linux I/O 缓冲区

从宏观上看,Linux操作系统分为 用户态 和 内核态,在处理I/O操作的时候,两者都提供了缓存。

用户态的称为标准I/O缓存,也称为用户空间缓存,而内核态的称为缓冲区高速缓存,也叫页面高速缓存。

IO缓冲小结(Linux/UNIX系统编程手册 第13章)

图中自上而下,涉及 用户态 -> 内核态 的调用。

stdio库会将用户数据传输到stdio缓冲区,该缓冲区位于用户态内存区。

当该缓冲区填满时,stdio库会调用write()系统调用,将数据传递到内核高速缓冲区(位于内核态)。

最终由内核发起操作,将数据写入磁盘


2. Page Cache

在Linux的实现中,文件Cache分为两个层面,一是 Page Cache,另一个 Buffer Cache

每一个 Page Cache 包含若干 Buffer Cache。

Buffer Cache 不在本篇的知识点内,因涉及Page Cache捎带提一下。


Page Cache

Page Cache以Page为单位,缓存文件内容。

缓存在Page Cache中的文件数据,能够更快的被用户读取。

同时对于带buffer的写入操作,数据在写入到Page Cache中即可立即返回,而不需等待数据被实际持久化到磁盘,进而提高了上层应用读写文件的整体性能。

百度百科:

page cache,又称pcache,其中文名称为页高速缓冲存储器,简称页高缓。
page cache的大小为一页,通常为4K。
在linux读写文件时,它用于缓存文件的逻辑内容,从而加快对磁盘上映像和数据的访问。

Buffer Cache

磁盘的最小数据单位为sector,每次读写磁盘都是以sector为单位对磁盘进行操作。如果裸读磁盘,那意味着数据读取的效率会非常低。

为了尽可能的提升磁盘访问性能,内核会在磁盘sector上构建一层缓存,他以sector的整数倍力度单位(block),缓存部分sector数据在内存中,当有数据读取请求时,他能够直接从内存中将对应数据读出。

当有数据写入时,他可以直接再内存中直接更新指定部分的数据,然后再通过异步方式,把更新后的数据写回到对应磁盘的sector中。这层缓存则是块缓存 Buffer Cache。


Page Cache和Buffer Cache是一个事物的两种表现:对于一个Page而言,对上,他是某个File的一个Page Cache,而对下,他同样是一个Device上的一组Buffer Cache

注意:Page Cache 和 Buffer Cache 在内核态。

下图,假定了 Page 的大小是4K(64位系统上为8k),磁盘块的大小是 1K;

file_page_buffer_block_disk关系(图片来自网络)

(真正指向磁盘block的其实是buffer cache里面的指针。)

file_page_buffer_block_disk关系2(图片来自网络)


2.1. 什么是 Page Cache 机制?

当应用程序需要读取磁盘上文件数据时,linux会先分配一些 page cache空间,将数据从磁盘读取到 page cache 中,然后再传输给应用程序。

当应用程序需要写数据到磁盘中时,linux会先分配 page cache空间 接收用户数据,然后再将数据从 page cache 写入磁盘。


2.2. Page Cache 预读

对于每个文件第一次的读请求,系统读入所请求的页面并继续读入紧随其后的几个页面(不少于一个,通常是三个),称为 同步预读

第二次读请求,如果页面不在同一个cache中,说明不是顺序读取,继续重复第一次的同步预读。

第二次读请求,如果页面在同一个cache中,说明是顺序读取,会将预读范围扩大一倍,将不在cache中的文件数据读进来,称为 异步预读

kafka采取的顺序读写,可以很好的利用linux的这个机制


2.3. kafka 对 Page Cache 的使用

kafka为什么要使用 Page Cache 而不使用进程缓存?

  1. JVM中对象内存开销太高。

  2. 会受GC影响,过大的堆会导致GC效率降低,降低吞吐量(回收pageCache的代价是很低的)。

  3. 如果使用进程缓存(in-process cache),程序意外崩溃,缓存的数据会丢失。


官网文档:

Furthermore, we are building on top of the JVM, and anyone who has spent any time with Java memory usage knows two things:

1.The memory overhead of objects is very high, often doubling the size of the data stored (or worse).

2.Java garbage collection becomes increasingly fiddly and slow as the in-heap data increases.

Furthermore, this cache will stay warm even if the service is restarted, whereas the in-process cache will need to be rebuilt in memory (which for a 10GB cache may take 10 minutes) or else it will need to start with a completely cold cache (which likely means terrible initial performance). This also greatly simplifies the code as all logic for maintaining coherency between the cache and filesystem is now in the OS, which tends to do so more efficiently and more correctly than one-off in-process attempts. If your disk usage favors linear reads then read-ahead is effectively pre-populating this cache with useful data on each disk read.


Kafka中的broker、producer、consumer 与 Page Cache的关系,如下图:

producer_broker_consumer(图片来自网络)

producer生产消息时,会使用pwrite()系统调用【对应到Java NIO中是FileChannel.write() API】按偏移量写入数据,并且都会先写入page cache里。

consumer消费消息时,会使用sendfile()系统调用【对应FileChannel.transferTo() API】,通过 零拷贝 将数据从page cache传输到broker的Socket buffer,再通过网络传输。

同理,follower分区 和 leader分区 之间也能够通过零拷贝机制将数据从 leader分区所在的broker page cache 传输到 follower分区 所在的broker。

Page Cache 中的数据会随着内核中线程的调度写回到磁盘,就算进程崩溃,也不用担心数据丢失。

如果consumer要消费的消息不在page cache里,才会去磁盘读取,并且会顺便预读出一些相邻的块放入page cache,以方便下一次读取。


如果Kafka producer的生产速率与consumer的消费速率相差不大,那么就能几乎只靠对broker page cache的读写完成整个生产-消费过程,磁盘访问非常少。

并且Kafka持久化消息到各个topic的partition文件时,是只追加的顺序写,充分利用了磁盘顺序访问快的特性,效率高。


3. 零拷贝

通过上面知识点的了解,读文件I/O起码有两步。

page Cache存在:从pageCache读取缓存(内核态),传递到stdio缓存(用户态)。

page Cache不存在:从磁盘读取,传递到pageCache(内核态),再传递到stdio缓存(用户态)。

如果再通过网络发送的话,还要增加两步:

将用户空间的缓存数据,传递到socket缓冲区(内核态);然后通过网卡,将数据写入网络IO

如下图:

网络IO-1(图片来自网络)

网络IO-2(图片来自网络)


零拷贝

从上面的分析看来,前前后后,最少进行了四次数据传递,多次 内核态、用户态 的切换。

零拷贝是怎么做的?

零拷贝就是将内核态缓存中的数据,直接拷贝到socket缓存中,不再经过用户空间(用户态)。

所谓的零拷贝是针对 用户空间(用户态)而言。

零拷贝-1(图片来自网络)

零拷贝-2(图片来自网络)


4. 参考资料