文件IO原理及Kafka高效读写原因分析

2/18/2023 文件I/O原理Kafka高效读写计算机体系结构高性能计算HPC存储器层次局部性原理inode索引节点FIO性能测试

# 1. 前言

# 1.1 计算机体系结构

计算机体系结构是计算机系统的基本组成部分,指的是计算机硬件和软件之间的接口和交互方式,也称为计算机架构。它包括了计算机的组成部分,如中央处理器(CPU)、内存、输入/输出(I/O)设备和总线,以及它们之间的互连方式。

计算机体系结构定义了计算机系统的基本特征和功能,如指令集、寄存器、数据通路、中断和异常处理、存储器结构、I/O 接口等。它决定了计算机的性能、可扩展性、可靠性、安全性等方面的特性。

计算机体系结构还可以分为不同的层次,包括指令集架构(ISA)、微体系结构(Microarchitecture)和系统结构(System Architecture)。指令集架构定义了计算机可执行的指令集,包括指令的操作码、操作数、寻址方式等。微体系结构则定义了实现指令集架构的具体实现方式,包括CPU中的逻辑电路、控制单元、寄存器、数据通路等。系统结构则定义了计算机系统的整体结构和互连方式,包括I/O设备、总线、存储器等。

计算机体系结构是计算机科学和工程中的重要领域,它对计算机硬件和软件的设计、优化和评估都具有重要的意义。

# 1.2 高性能计算HPC

随着数据激增,以及数据密集型和 AI 驱动型应用程序和用例的出现,用户对高性能计算(HPC)的需求不断增长。

  • HPC是一类可处理计算密集型任务的应用程序和工作负载。
  • 随着对 HPC 的需求不断增长,在学术和工业环境中,涉及大量数据的 AI 驱动型用例日益增多。
  • HPC 集群基于高性能处理器而构建,并搭载高速内存和存储以及其他高级组件。

个人通俗解释:高性能计算是一种专用于某些情景的高性能架构设计,它基于计算机体系结构进行优化,是直接使用底层原理的。通过开发高性能文件系统、虚拟CPU等相应的软件,替代掉我们通用系统中的某些模块,从体系结构、基本调度等层面去大幅提升性能。

# 1.3 文件 I/O 基本架构

文件 I/O 性能是后台应用的主要瓶颈之一,本文将系统性的概述文件I/O基本原理及性能优化方向,文件I/O基本架构图如下:

文件IO基本架构

# 1.4 Kafka为什么基于磁盘还那么快

Kafka是大数据领域无处不在的消息中间件,目前广泛使用在企业内部的实时数据管道,并帮助企业构建自己的流计算应用程序。Kafka虽然是基于磁盘做的数据存储,但却具有高性能、高吞吐、低延时的特点。

Kafka的存在是基于文件系统的,虽然在我们的认知中文件系统的应该是“缓慢的”存在,但实际上现代的操作系统针对磁盘的读写已经做了一些优化方案来加快磁盘的访问速度。本文将从Kafka的文件存储结构及基本原理去分析Kafka为什么是基于磁盘的还能这么快,下图摘自 Kafka官方文档 (opens new window)(Google机翻):

Kafka基于磁盘为什么快-摘自官方文档

# 2. 存储器基本原理

# 2.1 存储器层次结构

实质上就是一个具有不同容量、成本和访问时间的存储设备的层次结构。从快到慢依次为:CPU寄存器、高速缓存、主存(即内存)、磁盘。基于缓存的存储器层次结构行之有效,除了因为较慢的存储设备比较快的设备更便宜,还因为一个编写良好的计算机程序通常都具有良好的局部性。

存储器层次结构

# 2.2 Cache基本模型

CPU 通过总线从主存取指令和数据,完成计算之后再将结果写回内存。这个模型的瓶颈在于 CPU 的超级快的运算速度和主存相对慢的多的运算速度无法匹配,导致大量的时间都浪费在内存上。既然内存比较慢那么就尽量减少 CPU 对内存的访问,于是在 CPU 和 主存之间增加一层 Cache。

Cache基本模型

在计算机中,Cache 就是访问速度快的计算机内存被用来保存频繁访问或者最近访问的指令和内存。通常 Cache 的造价比较高,所以相对 Memory 来说,容量比较小,保存的数据也有限。总而言之,由于 CPU 和内存之间的指令和数据访问存在瓶颈,所以增加了一层 Cache,用来尽力消除 CPU 和内存之间的瓶颈。

# 2.3 局部性原理

程序倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身,这种倾向性被称作局部性原理。局部性通常有 2 种不同的形式:时间局部性(temporal locality)和空间局部性 (spatial locality)。

  • 在一个具有良好时间局部性的程序中,被引用过一次的内存地址很可能在不远的将来会再被多次引用。
  • 在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来会引用附近的一个内存位置。

局部性原理

# 3. inode索引节点

inode与block是理解Linux文件系统的重要基础概念,在此基础上实现了文件、目录、硬链接、软链接。

# 3.1 inode及block

# 3.1.1 block和inode的含义

文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector,常见512B),多个扇区组成的一个“块”(block,常见4096B,即连续8个sector组成一个block),操作系统一次性读取一个块(8个连续8个扇区),以提高磁盘IO效率。因此,块是磁盘读写的最小单位。

同时,块是文件存取的最小单位,多个块组成文件内容。显然,还需要一个结构存储文件元信息(文件的创建者、创建日期、内容大小等),也就引出了索引节点(inode即Index Node)的概念,索引节点用于索引一个文件的所有块。

# 3.1.2 inode的内容

inode维护文件的元信息,可以用stat <path>命令查看某个文件的inode信息。

$ stat test.py

  File: test.py
  Size: 773             Blocks: 8          IO Block: 4096   regular file
Device: 802h/2050d      Inode: 983849      Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2022-11-03 15:04:29.416155856 +0800
Modify: 2022-11-03 15:10:34.912766488 +0800
Change: 2022-11-03 15:10:34.912766488 +0800
 Birth: 2022-11-03 15:04:29.416155856 +0800
1
2
3
4
5
6
7
8
9
10

除了文件名以外的所有文件信息,都存在inode之中,至于为什么没有文件名,下面会有详细解释。

# 3.1.3 inode大小与inode数量上限

inode也会消耗硬盘空间,所以硬盘格式化的时候,操作系统自动将硬盘分成两个区域:一个是数据区(data area),存放block;另一个是inode区(也叫inode表,inode table),存放inode。实际上还有其他区,暂时不考虑。

每个inode节点的大小,一般是128B或256B。可以用dumpe2fs -h <disk_path>命令查看:

$ dumpe2fs -h /dev/sda1 | grep "Inode size"
dumpe2fs 1.46.2 (28-Feb-2021)
Inode size:               256
1
2
3

注:/dev/sda1指的是挂载的SATA硬盘。

inode节点的最大数量,在格式化时就给定。在初始化文件系统时,inode table初始大小为0,然后,操作系统每分配1024B或2048B给data area,就分配inode大小的空间(128B或256B)给inode table。注意,此处仅仅是保持data area与inode table的分配比例(如8:1),与未来一个inode指向多少block无关。

假定在一块2GB的硬盘中,inode size为256B,则data area与inode table的分配比例为8 : 1,inode table占用11.11%的磁盘空间,即227.56MB,inode数量的上限为932067。

使用df -i命令可查看每个硬盘分区的inode数量上限、已用数量、剩余数量等:

$ df -i
Filesystem       Inodes   IUsed   IFree IUse% Mounted on
udev            1016432     318 1016114    1% /dev
tmpfs           1020502    1233 1019269    1% /run
/dev/sda2      10289664 1009655 9280009   10% /
tmpfs           1020502       1 1020501    1% /dev/shm
tmpfs           1020502       4 1020498    1% /run/lock
/dev/sda1         36560     347   36213    1% /boot
...
1
2
3
4
5
6
7
8
9

既然inode节点的数量存在上限,而每个文件又必须有一个inode,因此有可能发生inode已用光,但硬盘还未存满的情况(如每个文件的大小都小于2048B)。这时,就无法在硬盘上创建新文件了。

# 3.1.4 inode号

Linux系统使用inode号来识别文件,文件名仅仅是文件的别名。

表面上,用户通过文件名打开文件——实际上,系统内部分三步完成这一过程:

  • Step1:找到文件名对应的inode号(内存)。
  • Step2:通过inode号码,获取inode内容(磁盘)。
  • Step3:最后,根据inode内容,找到文件数据存储的所有block,读出数据(磁盘)。

因此,如果只获取文件名与inode号码,是不需要读取磁盘的。

使用ls -i <path>命令可查看文件名对应的inode号码。

$ ls -i test.py
983849 test.py
1
2

# 3.2 文件及目录

# 3.2.1 文件

介绍inode时,已经说明了文件的实现——每个文件都对应一个inode,文件名是文件的别名,inode是文件的本体,操作系统维护文件名到inode的映射。

Linux系统中,一切皆文件,包括后面要解释的目录、硬链接、软链接,包括socket、设备、内存等。

# 3.2.2 目录

Linux系统中,目录(directory)也是一种文件。打开目录,实际上就是打开目录文件。

目录文件的结构非常简单,就是一系列目录项(dirent)的列表,目录项是一个二元组<文件名, inode号码>

ls <dir>命令只列出目录文件中的所有文件名。

ls -i <dir>命令列出整个目录文件,即文件名和inode号码。

  • 注:前面ls -i <file>的时候只需要查询文件名到inode的map,而此处ls -i <dir>则多一次读取目录文件的开销。

如果要查看文件的详细信息,就必须根据inode号码,读取inode内容(磁盘),ls -l <path>命令列出文件的详细信息。

# 3.2.3 硬链接

一般情况下,文件名和inode号码是“一一对应”的。但是,Linux系统允许多个文件名指向同一个inode号码,也就是硬链接(hard link)。

一旦建立硬链接,就不再区分则源文件与硬链接文件的概念,二者是指向相同inode的“平等的两个文件”(如果以文件名区分)。由于指向相同的inode,硬链接的行为就很好理解了:

  • 如果修改文件内容,则通过其他文件名访问的内容也随之修改。
  • 如果删除文件,则系统检查是否还有其他文件名指向inode。如果没有,则删除inode与block;否则,仅删除当前文件名到inode的映射。

使用ln <src> <hard_link>命令创建硬链接:

$ ln test.txt test_hard_link.txt
1

修改一下test.txt文件的内容,发现test_hard_link.txt的内容也会随之改变。

$ ls -li
total 8
637314 -rw-r--r-- 2 root root 4 Feb 15 17:28 test_hard_link.txt
637314 -rw-r--r-- 2 root root 4 Feb 15 17:28 test.txt
1
2
3
4

第一列是inode号,第3列是指向该inode的文件名的数量,即链接数。可见,test.txt、test_hard_link.txt的inode号都为637314,2个文件名都指向inode 637314。

删除test.txt文件后,test_hard_link.txt仍然存在,再用ls -li命令进行查看,链接数变为了1。

另注,“.”和“..”也是目录,通过硬链接分别指向当前目录和父目录。因此,所有目录的链接数必然大于等于2。

# 3.2.4 软链接

硬链接使用起来不是那么方便,管理起来也比较麻烦。于是使用另一种更简单的方式实现了软链接(soft link,或称符号链接symbolic link):如果文件A软链指向文件B,则标记文件A为软链文件,并在文件A中记录文件B的路径。

此时,文件A与文件B使用不同的inode。那么,如何通过文件A访问到文件B的内容呢?操作系统会在我们访问文件A时,发现文件A是软链接文件,则自动将访问者导向文件B。因此,无论打开哪一个文件,最终读取的都是文件B。

使用ln -s <src> <soft_link>命令创建软链接:

$ ln -s test.txt test_soft_link.txt
$ ls -li
total 4
639695 lrwxrwxrwx 1 root root 8 Feb 15 17:43 test_soft_link.txt -> test.txt
637314 -rw-r--r-- 1 root root 4 Feb 15 17:28 test.txt
1
2
3
4
5

对于读、修改操作,硬链接与软连接实现的效果相同。但删除(包括rename)操作上的表现不同:

  • 如果删除文件A,则文件B无影响。
  • 如果删除文件B,则文件A依然存在,但访问文件A时会报错”没有那个文件或目录”。

# 3.3 文件存储的整体架构

Linux 一切皆是文件,所以目录项也是文件,不过这个文件中存储的是目录下的文件及子目录组织结构,相应的文件指向了 inode 的节点,这里需要说明每个文件对应一个 inode 节点,之后通过 inode 节点中有关数据区块的信息找到对应的数据。

  • 超级块:文件系统中第一个块,存放的是文件系统本身的结构信息,包括每个区域的大小以及未被使用的磁盘块等等信息.
  • inode 节点表:超级块的下部分就是 inode table。每个 inode 节点对应一个文件(或目录)的结构,包括了文件的创建时间、权限等信息。
  • 数据区:显然它就是用来保存文件内容的区域,磁盘上的块大小一样,一般来说为 4kb,即连续的八个扇区(512 字节),块是文件存取的最小单位,超过块大小的文件会放到下一个块中。

文件存储结构的整体架构,如下图所示:

文件存储整体架构

# 3.3 文件的查找、创建、删除原理

接下来从文件的查找、创建、删除原理三个方面来加深inode和block的理解。

  • 文件的查找:比如要查找/var/log/message
  • 文件的创建:比如要创建/backup/test.txt(前提backup目录存在)
  • 文件的删除:删除/backup/test.txt

# 3.3.1 文件查找

下面是文件查找的基本流程:

  • Step1:首先根目录会自引用,找到inode表中对应的条目。
  • Step2:inode表中对应的信息指向根目录对应的块,其中有var目录对应的信息和inode号。
  • Step3:系统根据var目录inode号回头找到inode表中var目录其对应的条目。
  • Step4:根据var目录对应的块信息找到对应的块,其中有log目录对应的信息和inode号。
  • Step5:系统又根据log目录inode号在inode表中找到其对应的条目。
  • Step6:根据log目录对应的块信息找到对应的块,其中有messages文件。
  • Step7:根据messages文件的inode号在inode表中找到其对应的条目。
  • Step8:最后系统根据messages对应的条目信息告诉我们messages文件对应了多少的块,最后给我们呈现的就是所看到的数据。
文件查找原理

# 3.3.2 文件创建

下面是文件创建的基本流程:

  • Step1:首先扫描inode位图,找空闲的inode号,找到之后占用。
  • Step2:根目录自引用找到inode表中对应的条目,并根据条目找到对应的块。
  • Step3:依据根目录对应的块中backup目录对应的inode号,回头找到inode表中条目。
  • Step4:inode表中条目又找到对应的块,在这个块的dentry中新建了一个文件test.txt。
  • Step5:然后在最开始扫描占用的inode号给到此test.txt文件。
文件创建原理

当要存数据给test.txt文件时,会进行如下操作:

  • 系统扫描块位图(加速查找磁盘空闲块,记录是否空闲等),找到空闲块,分配大于数据大小的块,最后再回收(避免磁盘碎片)。比如要创建10k大小的test.txt文件,一个块4k,只需要3个块,系统分配4个或者多于,最后再去把这多余的块分配到空闲块中等待使用。

# 3.3.3 文件删除

当要删除一个文件时,其实就是把其使用的block位图标记为空闲,inode位图的相关位置成空,相当于不被占用,系统就认为此文件删除。但是数据还在,在下次创建文件时,就可覆盖原已清空block位图和inode位图的文件的数据。

  • 文件粉粹也只是使用一些随机数据填充进原来文件的数据块中,导致文件难以恢复。

# 4. Page Cache技术

# 4.1 Page Cache 是什么

为了提升对文件的读写效率,Linux 内核会以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为“页缓存”)与文件中的数据块进行绑定。

为了理解 Page Cache,不妨先看一下 Linux 的文件 I/O 系统,如下图所示:

Linux文件IO系统

上图中,红色部分为 Page Cache。可见 Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。

通过读取 /proc/meminfo 文件,能够实时获取系统内存情况:

$ cat /proc/meminfo

MemTotal:        8164016 kB
MemFree:          270068 kB
MemAvailable:    2870592 kB
Buffers:          162796 kB
Cached:          2577016 kB
SwapCached:       147284 kB
Active:          3951220 kB
Inactive:        3450404 kB
Active(anon):    2078824 kB
Inactive(anon):  2626044 kB
Active(file):    1872396 kB
Inactive(file):   824360 kB
...
Shmem:             80996 kB
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Page Cache 包含 Cached、SwapCached 与 Buffers。

Page Cache = Cached + SwapCached + Buffers
1

# 4.2 page与Page Cache

page是内存管理分配的基本单位,Page Cache 由多个page构成。page在操作系统中通常为4KB 大小(32bits/64bits),而Page Cache的大小则为4KB的整数倍。

[1] 并不是所有 page 都被组织为 Page Cache。

Linux 系统上供用户可访问的内存分为两个类型,即:

  • File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘;
  • Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性);

Page Cache 的主要难点在于脏页回盘。下面比较一下 File-backed pages 与 Anonymous pages 在 Swap 机制下的性能。

内存是一种珍惜资源,当内存不够用时,内存管理单元(Memory Mangament Unit)需要提供调度算法来回收相关内存空间。内存空间回收的方式通常就是 swap,即交换到持久化存储设备上。

File-backed pages(Page Cache)的内存回收代价较低。 Page Cache 通常对应于一个文件上的若干顺序块,因此可以通过顺序 I/O 的方式落盘。另一方面,如果 Page Cache 上没有进行写操作(所谓的没有脏页),甚至不会将 Page Cache 回盘,因为数据的内容完全可以通过再次读取磁盘文件得到。

Anonymous pages 的内存回收代价较高。这是因为 Anonymous pages 通常随机地写入持久化交换设备。另一方面,无论是否有写操作,为了确保数据不丢失,Anonymous pages 在 swap 时必须持久化到磁盘。

[2] 为什么 Linux 不把 Page Cache 称为 block cache?

这是因为从磁盘中加载到内存的数据不仅仅放在 Page Cache 中,还放在 buffer cache 中,例如通过 Direct I/O 技术的磁盘文件就不会进入 Page Cache 中。当然,这个问题也有 Linux 历史设计的原因。

# 4.3 Swap 与缺页中断

Swap 机制指的是当物理内存不够用,内存管理单元(Memory Mangament Unit,MMU)需要提供调度算法来回收相关内存空间,然后将清理出来的内存空间给当前内存申请方。

Swap 机制存在的本质原因是 Linux 系统提供了虚拟内存管理机制,每一个进程认为其独占内存空间,因此所有进程的内存空间之和远远大于物理内存。所有进程的内存空间之和超过物理内存的部分就需要交换到磁盘上。

操作系统以 page 为单位管理内存,当进程发现需要访问的数据不在内存时,操作系统可能会将数据以页的方式加载到内存中。上述过程被称为缺页中断,当操作系统发生缺页中断时,就会通过系统调用将 page 再次读到内存中。

但主内存的空间是有限的,当主内存中不包含可以使用的空间时,操作系统会从选择合适的物理内存页驱逐回磁盘,为新的内存页让出位置,选择待驱逐页的过程在操作系统中叫做页面替换(Page Replacement),替换操作又会触发 swap 机制。

如果物理内存足够大,那么可能不需要 Swap 机制,但是 Swap 在这种情况下还是有一定优势:对于有发生内存泄漏几率的应用程序(进程),Swap 交换分区更是重要,这可以确保内存泄露不至于导致物理内存不够用,最终导致系统崩溃。但内存泄露会引起频繁的 swap,此时非常影响操作系统的性能。

Linux 通过一个 swappiness 参数来控制 Swap 机制:这个参数值为 0-100,控制系统 swap 的优先级。

  • 高数值:较高频率的 swap,进程不活跃时主动将其转换出物理内存。
  • 低数值:较低频率的 swap,这可以确保交互式不因为内存空间频繁地交换到磁盘而提高响应延迟。

# 4.4 Page Cache 与 buffer cache

[1] Buffers 也是 Page Cache 的一部分

当匿名页(Inactive(anon) 以及 Active(anon))先被交换(swap out)到磁盘上后,然后再加载回(swap in)内存中,由于读入到内存后原来的 Swap File 还在,所以 SwapCached 也可以认为是 File-backed page,即属于 Page Cache。

Buffers也是Page-Cache的一部分

[2] 查看 Page Cache 与 buffer cache

执行 free -m 命令即可查看

$ free -m
               total        used        free      shared  buff/cache   available
Mem:            7972        4784         293          79        2894        2839
Swap:           5119        1915        3204
1
2
3
4

buff/cache 合并在一起显示的原因:

在 Linux 2.4 版本的内核之前,Page Cache 与 buffer cache 是完全分离的。但是,块设备大多是磁盘,磁盘上的数据又大多通过文件系统来组织,这种设计导致很多数据被缓存了两次,浪费内存。所以在 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了。只有那些没有文件表示的块,或者绕过了文件系统直接操作(如dd命令)的块,才会真正放到 buffer cache 里。因此,我们现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者,统称为 Page Cache。

其中,cached 列表示当前的页缓存(Page Cache)占用量,buffers 列表示当前的块缓存(buffer cache)占用量。用一句话来解释:Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据。页是逻辑上的概念,因此 Page Cache 是与文件系统同级的;块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的。

Page Cache 与 buffer cache 的共同目的都是加速数据 I/O:写数据时首先写到缓存,将写入的页标记为 dirty,然后向外部存储 flush,也就是缓存写机制中的 write-back(另一种是 write-through,Linux 默认情况下不采用);读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存。操作系统总是积极地将所有空闲内存都用作 Page Cache 和 buffer cache,当内存不够用时也会用 LRU 等算法淘汰缓存页。

# 4.5 Page Cache 与预读

操作系统为基于 Page Cache 的读缓存机制提供预读机制(PAGE_READAHEAD),一个例子是:

  • 用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于局部性原理会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;

下图代表了操作系统的预读机制:

操作系统的预读机制

上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制完成了 16KB 数据的读取。

# 4.6 Page Cache 与文件持久化的一致性

现代 Linux 的 Page Cache 正如其名,是对磁盘上 page 的内存缓存,同时可以用于读/写操作。任何系统引入缓存,就会引发一致性问题:内存中的数据与磁盘中的数据不一致,例如常见后端架构中的 Redis 缓存与 MySQL 数据库就存在一致性问题。

Linux 提供多种机制来保证数据一致性,但无论是单机上的内存与磁盘一致性,还是分布式组件中节点1 与节点2 、节点3 的数据一致性问题,理解的关键是 trade-off:吞吐量与数据一致性保证是一对矛盾。

首先,需要我们理解一下文件的数据。文件 = 数据 + 元数据。元数据用来描述文件的各种属性(包括:文件大小、创建时间、访问时间、属主属组等信息),也必须存储在磁盘上。因此,我们说保证文件一致性其实包含了两个方面:数据一致+元数据一致。

我们考虑如下一致性问题:如果发生写操作并且对应的数据在 Page Cache 中,那么写操作就会直接作用于 Page Cache 中,此时如果数据还没刷新到磁盘,那么内存中的数据就领先于磁盘,此时对应 page 就被称为 Dirty page。

当前 Linux 下以两种方式实现文件一致性:

  • Write Through(写穿):向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性;
  • Write back(写回):系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中文件脏数据块,这是默认的 Linux 一致性方案;

上述两种方式最终都依赖于系统调用,主要分为如下三种系统调用:

方法 含义
fsync(intfd) 将 fd 代表的文件的脏数据和脏元数据全部刷新至磁盘中。
fdatasync(int fd) 将 fd 代表的文件的脏数据刷新至磁盘,同时对必要的元数据刷新至磁盘中,这里所说的必要的概念是指:对接下来访问文件有关键作用的信息,如文件大小,而文件修改时间等不属于必要信息。
sync() 对系统中所有的脏的文件数据元数据刷新至磁盘中。

Write Through 与 Write back 在持久化的可靠性上有所不同:

  • Write Through 以牺牲系统 I/O 吞吐量作为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失;
  • Write back 在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题。不过,在程序挂了,例如被 kill -9,Page Cache 中的数据操作系统还是会确保落盘;

# 4.7 Page Cache 的优劣势

Page Cache 的优势:

[1] 加快数据访问

  • 如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I/O 了,直接命中内存缓存即可。由于内存访问比磁盘访问快很多,因此加快数据访问是 Page Cache 的一大优势。

[2] 减少 I/O 次数,提高系统磁盘 I/O 吞吐量

  • 得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理,因此通过一次 I/O 将多个 page 装入 Page Cache 能够减少磁盘 I/O 次数, 进而提高系统磁盘 I/O 吞吐量。

Page Cache 的劣势:

[1] 占用额外物理内存空间

  • Page Cache 最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。

[2] 对应用层并没有提供很好的管理 API

  • Page Cache 的另一个缺陷是对应用层并没有提供很好的管理 API,几乎是透明管理。应用层即使想优化 Page Cache 的使用策略也很难进行。因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache,例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理。

[3] 某些应用场景下比 Direct I/O 多一次读写I/O

  • Page Cache 在某些应用场景下比 Direct I/O 多一次磁盘读 I/O 以及磁盘写 I/O。

# 5. DMA与零拷贝技术

# 5.1 数据的四次拷贝与四次上下文切换

很多应用程序在面临客户端请求时,可以等价为进行如下的系统调用:

  • File.read(file, buf, len);
  • Socket.send(socket, buf, len);

例如消息中间件 Kafka 就是这个应用场景,从磁盘中读取一批消息后原封不动地写入网卡(NIC,Network interface controller)进行发送。

在没有任何优化技术使用的背景下,操作系统为此会进行 4 次数据拷贝,以及 4 次上下文切换,如下图所示:

数据的四次拷贝与四次上下文切换

4 次 copy:

  • CPU 负责将数据从磁盘搬运到内核空间的 Page Cache 中;(物理设备 <-> 内存)
  • CPU 负责将数据从内核空间的 Socket 缓冲区搬运到的网络中;(物理设备 <-> 内存)
  • CPU 负责将数据从内核空间的 Page Cache 搬运到用户空间的缓冲区;(内存内部拷贝)
  • CPU 负责将数据从用户空间的缓冲区搬运到内核空间的 Socket 缓冲区中;(内存内部拷贝)

4 次上下文切换:

  • read 系统调用时:用户态切换到内核态;
  • read 系统调用完毕:内核态切换回用户态;
  • write 系统调用时:用户态切换到内核态;
  • write 系统调用完毕:内核态切换回用户态;

如果没有优化,读取磁盘数据,再通过网卡传输的场景性能比较差。

# 5.2 DMA 参与下的数据四次拷贝

本质上,DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。

DMAC 是在“协助”CPU完成对应的数据传输工作。在 DMAC 控制数据传输的过程中,DMAC 还是被 CPU 控制,只是数据拷贝行为不再由 CPU 来完成。

DMAC 的价值在如下情况中尤其明显:当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。

  • 比如说,我们用千兆网卡或者硬盘传输大量数据的时候,如果都用 CPU 来搬运的话,肯定忙不过来,所以可以选择 DMAC。而当数据传输很慢的时候,DMAC 可以等数据到齐了,再发送信号,给到 CPU 去处理,而不是让 CPU 在那里忙等待。

原本,计算机所有组件之间的数据拷贝(流动)必须经过 CPU。现在,DMAC 代替了 CPU 负责内存与磁盘、内存与网卡之间的数据搬运,CPU 作为 DMAC 的控制者,如下图所示:

DMAC

但是 DMAC 有其局限性,DMAC 仅仅能用于设备间交换数据时进行数据拷贝,但是设备内部的数据拷贝还需要 CPU 来亲力亲为。

# 5.3 零拷贝技术

# 5.3.1 零拷贝技术是什么

零拷贝技术是一个思想,指的是计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。

零拷贝的特点是 CPU 不全程负责内存中的数据写入其他组件,CPU 仅仅起到管理的作用。但注意,零拷贝不是不进行拷贝,而是 CPU 不再全程负责数据拷贝时的搬运工作。如果数据本身不在内存中,那么必须先通过某种方式拷贝到内存中(这个过程 CPU 可以仅仅负责管理,DMAC 来负责具体数据拷贝),因为数据只有在内存中,才能被转移,才能被 CPU 直接读取计算。

零拷贝技术的具体实现方式有很多,例如:sendfile、mmap、Direct I/O,不同的零拷贝技术适用于不同的应用场景,下面会依次进行分析。

# 5.3.2 sendfile

snedfile 的应用场景是:用户从磁盘读取一些文件数据后不需要经过任何计算与处理就通过网络传输出去,此场景的典型应用是消息队列。

sendfile 主要使用到了以下技术:1)DMA 技术 2)传递文件描述符代替数据拷贝 3)一次系统调用代替两次系统调用

[1] 利用 DMA 技术

sendfile 依赖于 DMA 技术,将四次 CPU 全程负责的拷贝与四次上下文切换减少到两次。DMA 负责磁盘到内核空间中的 Page cache(read buffer)的数据拷贝以及从内核空间中的 socket buffer 到网卡的数据拷贝。

[2] 传递文件描述符代替数据拷贝

传递文件描述可以代替数据拷贝,这是由于两个原因:1)page cache 以及 socket buffer 都在内核空间中;2)数据在传输中没有被更新。

[3] 一次系统调用代替两次系统调用

由于 sendfile 仅仅对应一次系统调用,而传统文件操作则需要使用 read 以及 write 两个系统调用。正因为如此,sendfile 能够将用户态与内核态之间的上下文切换从 4 次降到 2 次。

sendfile一次系统调用代替两次系统调用

另一方面,我们需要注意 sendfile 系统调用的局限性。如果应用程序需要对从磁盘读取的数据进行写操作,例如解密或加密,那么 sendfile 系统调用就完全没法用。这是因为用户线程根本就不能够通过 sendfile 系统调用得到传输的数据。

# 5.3.3 mmap

mmap 即 memory map,也就是内存映射,如下图所示:

mmap技术

即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

mmap 也是一种零拷贝技术,其 I/O 模型如下图所示:

mmap的IO模型

mmap 技术有如下特点:

  • 利用 DMA 技术来取代 CPU 来在内存与其他组件之间的数据拷贝,例如从磁盘到内存,从内存到网卡;
  • 用户空间的 mmap file 使用虚拟内存,实际上并不占据物理内存,只有在内核空间的 kernel buffer cache 才占据实际的物理内存;
  • mmap() 函数需要配合 write() 系统调动进行配合操作,这与 sendfile() 函数有所不同,后者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切换;
  • mmap 仅仅能够避免内核空间到用户空间的全程 CPU 负责的数据拷贝,但是内核空间内部还是需要全程 CPU 负责的数据拷贝;

利用 mmap() 替换 read(),配合 write() 调用的整个流程如下:

  • 用户进程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  • DMA 控制器将数据从硬盘拷贝到内核缓冲区(可见其使用了 Page Cache 机制);
  • mmap() 返回,上下文从内核态切换回用户态;
  • 用户进程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  • CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  • DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  • write() 返回,上下文从内核态切换回用户态。

mmap 的优势:

  • 简化用户进程编程:在用户空间看来,通过 mmap 机制以后,磁盘上的文件仿佛直接就在内存中,把访问磁盘文件简化为按地址访问内存。这样一来,应用程序自然不需要使用文件系统的 write、read、fsync等系统调用,因为现在只要面向内存的虚拟空间进行开发。
  • 读写效率提高:避免内核空间到用户空间的数据拷贝。
  • 避免只读操作时的 swap 操作:在 mmap 下,如果虚拟空间没有发生写操作,那么由于通过 mmap 操作得到的内存数据完全可以通过再次调用 mmap 操作映射文件得到。但是,通过其他方式分配的内存,在没有发生写操作的情况下,操作系统并不知道如何简单地从现有文件中(除非其重新执行一遍应用程序,但是代价很大)恢复内存数据,因此必须将内存 swap 到磁盘上。
  • 节约内存:由于用户空间与内核空间实际上共用同一份数据,因此在大文件场景下在实际物理内存占用上有优势。

mmap 的缺陷:

  • 由于 mmap 使用时必须实现指定好内存映射的大小,因此 mmap 并不适合变长文件;
  • 如果更新文件的操作很多,mmap 避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机 I/O 上,所以在随机写很多的情况下,mmap 方式在效率上不一定会比带缓冲区的一般写快;
  • 读/写小文件(例如 16K 以下的文件),mmap 与通过 read 系统调用相比有着更高的开销与延迟;同时 mmap 的刷盘由系统全权控制,但是在小数据量的情况下由应用本身手动控制更好;
  • mmap 受限于操作系统内存大小:例如在 32-bits 的操作系统上,虚拟内存总大小也就 2GB,但由于 mmap 必须要在内存中找到一块连续的地址块,此时就无法对 4GB 大小的文件完全进行 mmap,在这种情况下必须分多块分别进行 mmap,但是此时地址内存地址已经不再连续,使用 mmap 的意义大打折扣,而且引入了额外的复杂性;

mmap 的适用场景实际上非常受限,在如下场合下可以选择使用 mmap 机制:

  • 多个线程以只读的方式同时访问一个文件,这是因为 mmap 机制下多线程共享了同一物理内存空间,因此节约了内存。案例:多个进程可能依赖于同一个动态链接库,利用 mmap 可以实现内存仅仅加载一份动态链接库,多个进程共享此动态链接库。
  • mmap 非常适合用于进程间通信,这是因为对同一文件对应的 mmap 分配的物理内存天然多线程共享,并可以依赖于操作系统的同步原语。
  • mmap 虽然比 sendfile 等机制多了一次 CPU 全程参与的内存拷贝,但是用户空间与内核空间并不需要数据拷贝,因此在正确使用情况下并不比 sendfile 效率差。

# 5.3.4 Direct I/O

Direct I/O 即直接 I/O。其名字中的”直接”二字用于区分使用 page cache 机制的缓存 I/O。

  • 缓存文件 I/O:用户空间要读写一个文件并不直接与磁盘交互,而是中间夹了一层缓存,即 page cache;
  • 直接文件 I/O:用户空间读取的文件直接与磁盘交互,没有中间 page cache 层;

“直接”在这里还有另一层语义:其他所有技术中,数据至少需要在内核空间存储一份,但是在 Direct I/O 技术中,数据直接存储在用户空间中,绕过了内核。

Direct I/O 模式如下图所示:

Direct-IO机制

Direct I/O 的读写非常有特点:

  • Write 操作:由于其不使用 page cache,所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存);
  • Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。

事实上,即使 Direct I/O 还是可能需要使用操作系统的 fsync 系统调用。

  • 虽然文件的数据本身没有使用任何缓存,但是文件的元数据仍然需要缓存,包括 VFS 中的 inode cache 和 dentry cache 等。
  • 在部分操作系统中,在 Direct I/O 模式下进行 write 系统调用能够确保文件数据落盘,但是文件元数据不一定落盘。如果在此类操作系统上,那么还需要执行一次 fsync 系统调用确保文件元数据也落盘。否则,可能会导致文件异常、元数据缺失等情况。

Direct I/O 的优缺点:

[1] 优点

  • Linux 中的直接 I/O 技术省略掉缓存 I/O 技术中操作系统内核缓冲区的使用,数据直接在应用程序地址空间和磁盘之间进行传输,从而使得自缓存应用程序可以省略掉复杂的系统级别的缓存结构,而执行程序自己定义的数据读写管理,从而降低系统级别的管理对应用程序访问数据的影响。
  • 与其他零拷贝技术一样,避免了内核空间到用户空间的数据拷贝,如果要传输的数据量很大,使用直接 I/O 的方式进行数据传输,而不需要操作系统内核地址空间拷贝数据操作的参与,这将会大大提高性能。

[2] 缺点

  • 由于设备之间的数据传输是通过 DMA 完成的,因此用户空间的数据缓冲区内存页必须进行 page pinning(页锁定),这是为了防止其物理页框地址被交换到磁盘或者被移动到新的地址而导致 DMA 去拷贝数据的时候在指定的地址找不到内存页从而引发缺页错误,而页锁定的开销并不比 CPU 拷贝小,所以为了避免频繁的页锁定系统调用,应用程序必须分配和注册一个持久的内存池,用于数据缓冲。
  • 如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。
  • 在应用层引入直接 I/O 需要应用层自己管理,这带来了额外的系统复杂性;

Direct I/O应用场景:

  • 自缓存应用程序可以选择使用 Direct I/O。用户应用需要实现用户空间内的缓存区,读/写操作应当尽量通过此缓存区提供。如果有性能上的考虑,那么尽量避免频繁地基于 Direct I/O 进行读/写操作。

# 5.4 典型案例及总结

# 5.4.1 Kafka典型应用案例

Kafka 作为一个消息队列,涉及到磁盘 I/O 主要有两个操作:

  • Provider 向 Kakfa 发送消息,Kakfa 负责将消息以日志的方式持久化落盘;
  • Consumer 向 Kakfa 进行拉取消息,Kafka 负责从磁盘中读取一批日志消息,然后再通过网卡发送;

Kafka 服务端接收 Provider 的消息并持久化的场景下使用 mmap 机制,能够基于顺序磁盘 I/O 提供高效的持久化能力,使用的类为 java.nio.MappedByteBuffer。

Kakfa 服务端向 Consumer 发送消息的场景下使用 sendfile 机制,这种机制主要两个好处:

  • sendfile 避免了内核空间到用户空间的 CPU 全程负责的数据移动;
  • sendfile 基于 Page Cache 实现,因此如果有多个 Consumer 在同时消费一个主题的消息,那么由于消息一直在 page cache 中进行了缓存,因此只需一次磁盘 I/O,就可以服务于多个 Consumer;

使用 mmap 来对接收到的数据进行持久化,使用 sendfile 从持久化介质中读取数据然后对外发送是一对常用的组合。但是注意,你无法利用 sendfile 来持久化数据,利用 mmap 来实现 CPU 全程不参与数据搬运的数据拷贝。

# 5.4.2 零拷贝技术总结

DMA 技术:DMA 负责内存与其他组件之间的数据拷贝,CPU 仅需负责管理,而无需负责全程的数据拷贝。

使用 page cache 的 zero copy:

  • sendfile:一次代替 read/write 系统调用,通过使用 DMA 技术以及传递文件描述符,实现了 zero copy。
  • mmap:仅代替 read 系统调用,将内核空间地址映射为用户空间地址,write 操作直接作用于内核空间。通过 DMA 技术以及地址映射技术,用户空间与内核空间无须数据拷贝,实现了 zero copy。

不使用 page cache 的 Direct I/O:读写操作直接在磁盘上进行,不使用 page cache 机制,通常结合用户空间的用户缓存使用。通过 DMA 技术直接与磁盘/网卡进行数据交互,实现了 zero copy。

# 6. 文件分区及顺序读写

# 6.1 文件分区是什么

文件分区包括两件事:

  • 将原本一个大文件分为多个小文件。例如,将一个大数据量的文件分为多个小文件进行存储。例如 Kafka 中为避免 WAL Log 过大,使用了 LogSegment 概念,当某一个 LogSegment 足够大时,就创建一个新的 LogSegment,用于后续的日志写入;
  • 将用途不同的文件存储于不同的磁盘上。例如,MySQL 通常推荐将事务日志文件与数据文件分别存储于本机挂载的不同磁盘上。

# 6.2 文件分区的意义

[1] 减少文件锁粒度,提高并发 I/O 潜力

为了确保实现物理上真正的顺序 I/O,在涉及多线并发读写时,我们必须使用锁机制。这里的锁通常是文件锁,一个文件一把锁。锁可以由操作系统提供,但更常见的是在用户应用中额外使用一把锁。

依赖于锁机制的线程并发能力提高可以通过细化锁粒度实现。一个大文件分为多个小文件,意味着将一个大锁分为多个小锁,这样一来,锁粒度细化,线程冲突可能性降低,系统的整体 I/O 能力得到提高。

[2] 简化索引实现

一般的日志存储系统有如下特点:

  • 每一条日志所占数据长度不一致;
  • 日志顺序存储,例如在磁盘上编号 101 的日志顺序存储于编号 100 的日志之后;

为了提高日志存储系统的查询效率,我们必然需要实现索引。Kafka 的日志索引就基于此思想实现。

  • 在不进行日志文件分区的情况下,即只有一个日志文件,那么如果要提供随机日志查询与范围查询的能力,那么索引系统必须为每一条日志设计一个 Tree 上的节点。但是如果日志非常多,那么索引 Tree 将拥有非常多的节点,存在查询效率降低的问题。

  • 如果进行日志分区,大日志文件分为多个小日志文件进行存储,那么如果要提供随机日志查询与范围查询能力,那么我们可以为每一个小日志文件分别设计一个索引文件,由于每一个索引 Tree 的节点数不会特别多,因此我们总是能够确保较好的查询效率。

[3] 分磁盘存储文件提高磁盘 I/O 效率

文件分区的另一个含义是将不同文件存储于不同磁盘上,不过前提自然是你拥有多个磁盘与需要多个文件。MySQL 通常推荐将事务日志文件(WAL)与数据文件(B+Tree)分别存储于本机上挂载的不同磁盘上。

分磁盘存储文件的主要优势如下:

  • 实现并行磁盘 I/O;
  • 不同文件的 I/O 模式特质不同(顺序 or 随机);
  • 更可靠的持久化保证;

# 6.3 顺序读写为什么快

一个公认的事实是:无论是传统机械磁盘,还是固态硬盘,顺序读写都远比随机读写效率更高,甚至比内存随机读写效率都略高。

磁盘顺序读写效率

顺序读写快的原因主要有两个:

  • 顺序写比随机写更节约磁盘指针的移动总长度;
  • 顺序写最大化利用到了文件系统的缓存机制,提高了缓存命中率;

下面说一说原因 2 的由来:

顺序读写利用缓存机制

如上图所示,以顺序读为例,当用户发起一个 fileChannel.read(4kb) 之后,实际发生了两件事:

  • 操作系统从磁盘加载了 16kb 进入 PageCache,这被称为预读
  • 操作通从 PageCache 拷贝 4kb 进入用户内存;

最终我们在用户内存访问到了 4kb,当用户继续访问接下来的 [4kb,16kb] 的磁盘内容时,便是直接从 PageCache 去访问了。发生 1 次磁盘 I/O+4 次内存 I/O 快呢肯定要比发生 4 次磁盘 I/O 快,这一切都是 PageCache 带来的优化。

# 6.4 FIO磁盘及文件系统性能测试

# 6.4.1 FIO简介

FIO工具是主流的第三方I/O测试工具,它提供了大量的可定制化选项,可以用来测试裸磁盘、文件系统在各种场景下的I/O性能。

# 6.4.2 FIO命令

以下是其中一些请求参数支持:

filename=/dev/emcpowerb 支持文件系统或者裸设备
direct=1                 测试过程绕过机器自带的buffer,使测试结果更真实
rw=randwread             测试随机读的I/O
rw=randwrite             测试随机写的I/O
rw=randrw                测试随机混合写和读的I/O
rw=read                  测试顺序读的I/O
rw=write                 测试顺序写的I/O
rw=rw                    测试顺序混合写和读的I/O
bs=4k                    单次io的块文件大小为4k
bsrange=512-2048         同上,提定数据块的大小范围
size=5g                  本次的测试文件大小为5g,以每次4k的io进行测试
numjobs=30               本次的测试线程为30
runtime=1000             测试时间为1000秒,如果不写则一直将5g文件分4k每次写完为止
ioengine=psync           表示 I/O 引擎,它支持同步(sync)、异步(libaio)、内存映射(mmap)、网络(net)等各种 I/O 引擎
rwmixwrite=30            在混合读写的模式下,写占30%
group_reporting          关于显示结果的,汇总每个进程的信息
lockmem=1g               只使用1g内存进行测试
zero_buffers             用0初始化系统buffer
nrfiles=8                每个进程生成文件的数量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

输出结果参数含义:

bw=平均IO带宽
iops=IOPS
runt=线程运行时间
slat=提交延迟
clat=完成延迟
lat=响应时间
bw=带宽
cpu=利用率
IO depths=io队列
IO submit=单个IO提交要提交的IO数
IO complete=Like the above submit number, but for completions instead.
IO issued=The number of read/write requests issued, and how many of them were short.
IO latencies=IO完延迟的分布

io=总共执行了多少size的IO
aggrb=group总带宽
minb=最小.平均带宽.
maxb=最大平均带宽.
mint=group中线程的最短运行时间.
maxt=group中线程的最长运行时间.

ios=所有group总共执行的IO数.
merge=总共发生的IO合并数.
ticks=Number of ticks we kept the disk busy.
io_queue=花费在队列上的总共时间.
util=磁盘利用率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 6.5 磁盘I/O及性能优化

磁盘I/O的系统结构如下图所示:

磁盘IO架构图

优化方向:零拷贝技术、多路复用技术、页缓存技术

  • 页缓存读取策略:当进程发起一个读操作,它首先会检查需要的数据是否在页缓存中。如果在,则放弃访问磁盘,而直接从页缓存中读取。如果不在,则内核调度块 I/O 操作从磁盘去读取数据,并读入紧随其后的少数几个页面,然后将数据放入页缓存中。
  • 页缓存写策略:当进程发起 write 系统调用写数据到文件中,先写到页缓存,然后方法返回。此时数据还没有真正的保存到文件中去,Linux 仅仅将页缓存中的这一页数据标记为 “脏”,并且被加入到脏页链表中。然后,由 flusher 回写线程周期性将脏页链表中的页写到磁盘,让磁盘中的数据和内存中保持一致,后清理“脏”标识。

# 7. Kafka设计目标与设计概述

# 7.1 Kafka设计目标

设计一个系统,精准的目标是第一步。Kafka官方的设计思想是将其做成一个可以帮助大型公司应对各种可能的实时数据流处理的通用平台。这句话里边有几个重点:“大型公司”、“实时”、“通用”,对应到系统设计上,就是需要支持大量数据的低延迟处理,并且需要考虑各种不同的数据处理场景。在官方阐述中,Kafka 着眼于以下几个核心指标:

  • 高吞吐量:因为 Kafka 需要处理大量的消息;
  • 低延迟:消息系统的关键设计指标;
  • 支持加载离线数据:这是 Kafka 考虑的所谓“各种可能的”数据处理场景,支持从离线系统中加载数据,或者将数据加载到离线系统中,都是无法逃避的;
  • 支持分区的、分布式的、实时的数据流处理以产生新的、派生的数据流:这个指导了 Kafka 里 topic 分区模型以及消费者模型的设计;
  • 容错与可靠性:Kafka 作为消息中间件,核心场景之一就是作为系统间的连接器,需要保证整体业务的正常运作,可靠的消息投递机制以及应对节点故障的高可用设计等,必不可少。

# 7.2 Kafka设计概述

Kafka 的整体设计细节比较多,但是归根结底都是围绕这几个核心指标去做的设计:

核心指标 实现的角度 具体设计手段
高吞吐量 读写缓存 依赖文件系统自身的 Page Cache,而不是自己实现内存缓存
高吞吐量 高效的数据结构 采用顺序读写的结构,而不是 B 树等
高吞吐量 降低大量小的 I/O 消息分批发布,按批投递
高吞吐量 提高消息投递吞吐量 由消费者批量拉取
高吞吐量 支持分批消息 支持异步发送消息
低延迟 避免昂贵的字节拷贝 统一的消息格式,零拷贝技术
低延迟 优化传输性能 通过批量消息压缩减小传输数据量
低延迟 提升读取性能 顺序读,日志文件分段存储,应用二分查找
低延迟 降低负载均衡延迟 producer 直连 broker
离线数据加载 支持周期性大量数据加载 依赖存储层顺序读写的常量时间复杂度的访问优势以及低廉的磁盘成本要求
离线数据处理 支持并行处理 通过分区设计以及 consumer 的 offset,支持 Hadoop 一类的并行作业以及断点作业
可靠性 支持“有且仅有一次”的消息投递语义 producer 的 ID 与消息 Sequence Number,类事务提交语义
可靠性 容错处理与高可用 ISR 机制与 Leader 均匀分布设计

# 8. Kafka文件存储结构及高效读写原因

# 8.1 Kafka文件存储结构

  • topic: 可以理解为一个消息队列的名字
  • partition:为了实现扩展性,一个非常大的topic可以分布到多个 broker(即服务器)上,一个topic可以分为多个partition,每个partition是一个有序的队列
  • segment:partition物理上由多个segment组成
  • message:每个segment文件中实际存储的每一条数据就是message
  • offset:每个partition都由一系列有序的、不可变的消息组成,这些消息被连续的追加到partition中,partition中的每个消息都有一个连续的序列号叫做offset,用于partition唯一标识一条消息
存储文件结构

# 8.2 Kafka高效读写原因

# 8.2.1 页缓存技术

Kafka是基于操作系统的page cache来实现写入的。Kafka在写入磁盘文件的时候,可以直接写入到这个page cache里,也就是仅仅写入到内存中,接下来由操作系统自己决定什么时候把page cache里的数据真的刷入磁盘文件中,这样可以很大提升写性能。

# 8.2.2 磁盘顺序写

Kafka写数据的时候,Kafka的消息是不断追加到文件末尾的,而不是在文件的随机位置写入数据,这个特性使Kafka可以充分利用磁盘的顺序读写性能。顺序读写不需要磁盘磁头的寻道时间,避免了随机磁盘寻址的浪费,只需很少的扇区旋转时间,所以速度远快于随机读写。

Kafka中每个分区是一个有序的,不可变的消息序列,新的消息不断追加到Partition的末尾,在Kafka中Partition只是一个逻辑概念,Kafka将Partition划分为多个Segment,每个Segment对应一个物理文件,Kafka对segment文件追加写,这就是顺序读写。

# 8.2.3 零拷贝

在消费数据的时候,实际上要从kafka的磁盘文件里读取某条数据然后发送给下游的消费者。Kafka在读数据的时候为了避免多余的数据拷贝,使用了零拷贝技术。也就是说直接让page cache里的数据发送到网卡后然后传输给下游的消费者,跳过中间从page cache拷贝到kafka进程缓存和再拷贝到socket缓存中的两次缓存,同时也减少了上下文切换。

在Linux Kernel2.2之后出现了一种叫做“零拷贝(zero-copy)”系统调用机制,就是跳过“用户缓冲区”的拷贝,建立一个磁盘空间和内存的直接映射,数据不再复制到“用户缓冲区”。Kafka 使用到了 mmap+write(持久化数据) 和 sendfile(发送数据) 的方式来实现零拷贝。分别对应 Java 的 MappedByteBuffer 和 FileChannel.transferTo。

# 8.2.4 分区并发

kafka中的topic中的内容可以分在多个分区(partition)存储,每个partition又分为多个段segment,所以每次操作都是针对一小部分做操作,很轻便,并且增加并行操作的能力。

# 8.2.5 批量发送

Kafka允许进行批量发送消息,Productor发送消息的时候,可以将消息缓存在本地,等到了固定条件(等消息条数到固定条数、一段时间发送一次)发送到kafka,可减少IO延迟。

# 8.2.6 数据压缩

Kafka还支持对消息集合进行压缩,Producer可以通过GZIP或Snappy格式对消息集合进行压缩,压缩的好处就是减少传输的数据量,减轻对网络传输的压力。批量发送和数据压缩一起使用,消息发送时默认不会压缩,可使用compression.type来指定压缩方式,可选的值为snappy、gzip和lz4。

# 8.3 Kafka的分区数应设置多少

# 8.3.1 分区多的优点

Kafka使用分区将topic的消息打散到多个分区分布保存在不同的broker上,实现了producer和consumer消息处理的高吞吐量。Kafka的producer和consumer都可以多线程地并行操作,而每个线程处理的是一个分区的数据。因此分区实际上是调优Kafka并行度的最小单元。对于producer而言,它实际上是用多个线程并发地向不同分区所在的broker发起Socket连接同时给这些分区发送消息;而consumer,同一个消费组内的所有consumer线程都被指定topic的某一个分区进行消费。所以说,如果一个topic分区越多,理论上整个集群所能达到的吞吐量就越大。

# 8.3.2 分区不是越多越好

分区不是越多越好,每个分区都有自己的开销。

[1] 客户端/服务器端需要使用的内存就越多

  • Kafka0.8.2之后,在客户端producer有个参数batch.size,默认是16KB。它会为每个分区缓存消息,一旦满了就打包将消息批量发出。看上去这是个能够提升性能的设计。不过很显然,因为这个参数是分区级别的,如果分区数越多,这部分缓存所需的内存占用也会更多。
  • 服务器端的开销也不小,Kafka服务器端的很多组件都在内存中维护了分区级别的缓存,比如controller,FetcherManager等。因此分区数越多,这种缓存的成本就越大。

[2] 文件句柄的开销

  • 每个分区在底层文件系统都有属于自己的一个目录。该目录下通常会有两个文件:base_offset.log和base_offset.index。Kafka的controller和ReplicaManager会为每个broker都保存这两个文件句柄(file handler)。很明显,如果分区数越多,所需要保持打开状态的文件句柄数也就越多,最终可能会突破你的ulimit -n的限制。

[3] 降低高可用性

  • Kafka通过副本(replica)机制来保证高可用,具体做法就是为每个分区保存若干个副本(replica_factor指定副本数),每个副本保存在不同的broker上。其中的一个副本充当leader 副本,负责处理producer和consumer请求。其他副本充当follower角色,由Kafka controller负责保证与leader的同步。
  • 如果leader所在的broker挂掉了,contorller会检测到,然后在zookeeper的帮助下重选出新的leader,这中间会有短暂的不可用时间窗口,虽然大部分情况下可能只是几毫秒级别。但如果你有10000个分区,10个broker,也就是说平均每个broker上有1000个分区。此时这个broker挂掉了,那么zookeeper和controller需要立即对这1000个分区进行leader选举。比起很少的分区leader选举而言,这必然要花更长的时间,并且通常不是线性累加的。如果这个broker还同时是controller情况就更糟了。

# 8.3.3 如何确定分区数量

可以遵循一定的步骤来尝试确定分区数:创建一个只有1个分区的topic,然后测试这个topic的producer吞吐量和consumer吞吐量。假设它们的值分别是Tp和Tc,单位可以是MB/s。然后假设总的目标吞吐量是Tt,那么分区数 = Tt / max(Tp, Tc)

说明:Tp表示producer的吞吐量,测试producer通常是很容易的,因为它的逻辑非常简单,就是直接发送消息到Kafka就好了。Tc表示consumer的吞吐量,测试Tc通常与应用的关系更大, 因为Tc的值取决于你拿到消息之后执行什么操作,因此Tc的测试通常也要麻烦一些。

# 9. 参考资料

[1] 文件 I/O 简明概述 from spongecaptain (opens new window)

[2] 深入理解 Linux I/O 系统 from ITPUB技术栈 (opens new window)

[3] Kafka 核心设计思考——来自官方文档的总结 from HackerPie (opens new window)

[4] Kafka为什么吞吐量大、速度快?from 知乎 (opens new window)

[5] Kafka文件存储机制 from segmentfault (opens new window)

[6] Kafka高吞吐量的原因 from 杨七 (opens new window)

[7] Kafka性能测试实例 from 哥不是小萝莉 (opens new window)

[8] Kafka 为什么能那么快 | Kafka高效读写数据的原因 from 稀土掘金 (opens new window)

[9] Kafka 性能篇:为何 Kafka 这么快?from segmentfault (opens new window)

[10] Kafka 高性能剖析 from 稀土掘金 (opens new window)

[11] 关于顺序磁盘IO比内存随机IO快的讨论 from 博客园 (opens new window)

[12] 必知必会-存储器层次结构 from 知乎 (opens new window)

[13] 深入理解存储器层次结构 from 简书 (opens new window)

[14] Linux inode 详解 from 博客园 (opens new window)

[15] inode 索引节点 from Linux官方文档 (opens new window)

[16] Linux下对inode和块的理解 from 博客园 (opens new window)

[17] 第八章虚拟文件系统 from Linux内核之旅 (opens new window)

[18] Linux虚拟文件系统 from jame_xhs blog (opens new window)

[19] 云原生分布式存储系统FastCFS安装简明教程 from CSDN (opens new window)

[20] 文件系统读写性能测试实战 from CSDN (opens new window)

[21] linux 文件系统-inode 学习整理 from InfoQ (opens new window)

[22] Linux文件系统:inode&block,文件&目录,硬链&软链 from 猴子007 (opens new window)

[23] kafka如何做到每秒几十万的吞吐量 from CSDN (opens new window)

[24] Apache Kafka基准测试:每秒写入2百万(在三台廉价机器上)from 并发编程网 (opens new window)

[25] Kafka的分区数是不是越多越好?from 简书 (opens new window)

[26] kafka分区数设置多少合适 from 稀土掘金 (opens new window)

[27] Kafka的高性能的源头 from Spongecaptain's Blog (opens new window)

Last Updated: 7/1/2023, 11:13:47 PM