Linux块IO浅析

块存储,简单来说就是使用块设备为系统提供存储服务。块存储分多钟类型,有单机块存储(磁盘)、网络存储(NAS、SAN等),分布式存储(云硬盘)。通常块存储的表现形式就是一块设备,用户看到的就是类似于sda、sdb这样的逻辑设备。

可以说,生活中最常见的块存储设备就是硬盘了,接下来将以硬盘为例简要说明Linux块IO。

整体架构

类似于网络协议栈的分层结构,Linux内核块设备I/O总体结构图如下所示。从图中可以看出,以对磁盘的读请求为例,读操作会依次经过虚拟文件系统层(VFS),Cache层(Page Cache)、具体的文件系统层(比如xfs),通用块层(Generic Block Layer)、I/O调度层、块设备驱动层,最后到达物理块设备层。当然,这其中还包括绕开Page Cache的直接(Direct)I/O模式。下面对每一层做简要介绍。

虚拟文件系统层

VFS虚拟文件系统是一种软件机制,扮演着文件系统管理者的角色,与它相关的数据结构只存在于物理内存当中。它的作用是屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。也正是由于VFS的存在,Linux中允许众多不同的文件系统共存并且对文件的操作可以跨文件系统而执行。
Linux中VFS主要依靠四个数据结构来描述相关信息,分别是超级块、索引节点、目录项和文件对象。
(1)超级块(Super Block)
超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。超级块一般存储在磁盘的特定扇区中,但是对于那些基于内存的文件系统(如proc、sysfs),超级块是在使用时创建在内存中。
(2)索引节点(Inode)
索引节点是VFS中的核心概念,它包含内核在操作文件或目录时需要的全部信息。
一个索引节点代表文件系统的一个文件(这里的文件不仅是指我们平时所认为的普通的文件,还包括目录、特殊设备文件等)。
索引节点和超级块一样是实际存储在磁盘上的,当被应用程序访问时才会在内存中创建。
(3)目录项(Dentry)
引入目录项对象的概念主要是出于方便查找文件的目的。不同于前面的两个对象,目录项对象没有对应的磁盘数据结构,只存在于内存中,一个路径的各个组成部分,不管是目录还是普通的文件,都是一个目录项对象。在路径/home/source/test.cpp中,目录/, home, source和文件test.cpp都对应一个目录项对象。VFS在查找的时候,根据一层层的目录项找到对应的目录项Inode,就可以找到最终的文件。
(4)文件对象(File)
文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。一个文件对应的文件对象可能不是惟一的,但是其对应的索引节点和目录项对象肯定是惟一的。

Page Cache层

引入Page Cache层的目的是为了提高Linux操作系统对磁盘访问的性能。Cache层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在Cache中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。
在Linux的实现中,文件Cache分为两个层面,一是Page Cache,另一个是Buffer Cache,前者主要用来作为文件系统上的文件数据的缓存使用,尤其是针对当进程对文件有read/write操作的时候。Buffer Cache则主要设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。关于二者的详细对比说明,请参考一期一会
磁盘Cache有两大功能:预读和回写。预读其实就是利用了局部性原理,具体过程是:对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(通常是三个页面),这时的预读称为同步预读。对于第二次读请求,如果所读页面不在Cache中,即不在前次预读的页中,则表明文件访问不是顺序访问,系统继续采用同步预读;如果所读页面在Cache中,则表明前次预读命中,操作系统把预读页的大小扩大一倍,此时预读过程是异步的,应用程序可以不等预读完成即可返回,只要后台慢慢读页面即可,这时的预读称为异步预读。任何接下来的读请求都会处于两种情况之一:第一种情况是所请求的页面处于预读的页面中,这时继续进行异步预读;第二种情况是所请求的页面处于预读页面之外,这时系统就要进行同步预读。
回写是通过暂时将数据存在Cache里,然后统一异步写到磁盘中。通过这种异步的数据I/O模式解决了程序中的计算速度和存储速度不匹配的鸿沟,减少了访问底层存储介质的次数,使存储系统的性能大大提高。

文件系统层

VFS的下一层是具体的文件系统。比如Ext2、Ext3、xfs等。
限于个人知识水平,暂时不对具体的文件系统做介绍。待后面研究过后,再做分析。

通用块层

通用块层的主要工作是:接收上层发出的磁盘请求,并最终发出I/O请求。该层隐藏了底层硬件块设备的特性,为块设备提供了一个通用的抽象视图。
对于VFS和具体的文件系统来说,块(Block)是基本的数据传输单元,当内核访问文件的数据时,它首先从磁盘读取一个块。但是对于磁盘来说,扇区是最小的可寻址单元,块设备无法对比它还小的单元进行寻址和操作。由于扇区是磁盘的最小可寻址单元,所以块不能比扇区还小,只能整数倍于扇区大小,即一个块对应磁盘上的一个或多个扇区。一般来说,块大小是2的整数倍,而且由于Page Cache层的最小单元是页(Page),所以块大小不能超过一页的长度。
大多数情况下,数据的传输通过DMA方式。旧的磁盘控制器,仅仅支持简单的DMA操作:,只能传输磁盘上相邻的扇区,即数据在内存中也是连续的。这是因为如果传输非连续的扇区,会导致磁盘花费更多的时间在寻址操作上。而现在的磁盘控制器支持“分散/聚合”DMA操作,这种模式下,数据传输可以在多个非连续的内存区域中进行。为了利用“分散/聚合”DMA操作,块设备驱动必须能处理被称为段(segments)的数据单元。一个段就是一个内存页面或一个页面的部分,它包含磁盘上相邻扇区的数据。
通用块层是粘合所有上层和底层的部分,一个页的磁盘数据布局如下所示:

I/O调度层

I/O调度层的功能是管理块设备的请求队列。即接收通用块层发出的I/O请求,缓存请求并试图合并相邻的请求,并根据设置好的调度算法,回调驱动层提供的请求处理函数,以处理具体的I/O请求。
如果简单地以内核产生请求的次序直接将请求发给块设备的话,那么块设备性能肯定让人难以接受,因为磁盘寻址是整个计算机中最慢的操作之一。为了优化寻址操作,内核不会一旦接收到I/O请求后,就按照请求的次序发起块I/O请求。为此Linux实现了几种I/O调度算法,算法基本思想就是通过合并和排序I/O请求队列中的请求,以此大大降低所需的磁盘寻道时间,从而提高整体I/O性能。
常见的I/O调度算法包括Noop调度算法(No Operation)、CFQ(完全公正排队I/O调度算法)、DeadLine(截止时间调度算法)、AS预测调度算法等。

块设备驱动层

驱动层中的驱动程序对应具体的物理块设备。它从上层中取出I/O请求,并根据该I/O请求中指定的信息,通过向具体块设备的设备控制器发送命令的方式,来操纵设备传输数据。

磁盘I/O优化技巧

前文已经叙述了Linux通过Cache以及排序合并I/O请求来提高系统的性能,其本质原因是磁盘随机读写慢,顺序读写快。本节介绍一些现有开源系统常见的针对磁盘I/O特性的优化技巧。

追加写

在进行系统设计时,良好的读性能和写性能往往不可兼得。在许多常见的开源系统中都是在优先保证写性能的前提下来优化读性能的。使系统拥有良好的写性能的一个常见方法就是采用追加写,每次将数据添加到文件。由于完全是顺序的,所以可以具有非常好的写操作性能。但是这种方式也存在一些缺点:从文件中读一些数据时将会需要更多的时间:需要倒序扫描,直到找到所需要的内容。
LevelDB所采用的LSM(日志结构合并树)是一种较好的追加写设计方案,LSM的思想是将整个磁盘看做一个日志,在日志中存放永久性数据及其索引,每次都添加到日志的末尾。并且通过将很多小文件的存取转换为连续的大批量传输,使得对于文件系统的大多数存取都是顺序的,从而提高磁盘I/O。其具体设计可参考LevelDB原理剖析

文件合并和元数据优化

目前的大多数文件系统,如XFS/Ext4、HDFS,在元数据管理、缓存管理等实现策略上都侧重大文件。这些文件系统在面临海量小文件时在性能和存储效率方面都大幅降低,根本原因是磁盘最适合顺序的大文件I/O读写模式,而非常不适合随机的小文件I/O读写模式。主要原因体现在元数据管理低效和数据布局低效:
(1)元数据管理低效:由于小文件数据内容较少,因此元数据的访问性能对小文件访问性能影响巨大。Ext2文件系统中Inode和Data Block分别保存在不同的物理位置上,一次读操作需要至少经过两次的独立访问。在海量小文件应用下,Inode的频繁访问,使得原本的并发访问转变为了海量的随机访问,大大降低了性能。另外,大量的小文件会快速耗尽Inode资源,导致磁盘尽管有大量Data Block剩余也无法存储文件,会浪费磁盘空间。
(2)数据布局低效:Ext2在Inode中使用多级指针来索引数据块。对于大文件,数据块的分配会尽量连续,这样会具有比较好的空间局部性。但是对于小文件,数据块可能零散分布在磁盘上的不同位置,并且会造成大量的磁盘碎片,不仅造成访问性能下降,还大量浪费了磁盘空间。数据块一般为1KB、2KB或4KB,对于小于4KB的小文件,Inode与数据的分开存储破坏了空间局部性,同时也造成了大量的随机I/O。
对于海量小文件应用,常见的I/O流程复杂也是造成磁盘性能不佳的原因。对于小文件,磁盘的读写所占用的时间较少,而用于文件的open()操作占用了绝大部分系统时间,导致磁盘有效服务时间非常低,磁盘性能低下。针对于问题的根源,优化的思路大体上分为:
(1)针对数据布局低效,采用小文件合并策略,将小文件合并为大文件;
(2)针对元数据管理低效,优化元数据的存储和管理。

小文件合并

小文件合并为大文件后,首先减少了大量元数据,提高了元数据的检索和查询效率,降低了文件读写的I/O操作延时。其次将可能连续访问的小文件一同合并存储,增加了文件之间的局部性,将原本小文件间的随机访问变为了顺序访问,大大提高了性能。同时,合并存储能够有效的减少小文件存储时所产生的磁盘碎片问题,提高了磁盘的利用率。最后,合并之后小文件的访问流程也有了很大的变化,由原来许多的open操作转变为了seek操作,定位到大文件具体的位置即可。如何寻址这个大文件中的小文件呢?其实就是利用一个旁路数据库来记录每个小文件在这个大文件中的偏移量和长度等信息。其实小文件合并的策略本质上就是通过分层的思想来存储元数据。中控节点存储一级元数据,也就是大文件与底层块的对应关系;数据节点存放二级元数据,也就是最终的用户文件在这些一级大块中的存储位置对应关系,经过两级寻址来读写数据。
TFS是采用小文件合并存储策略的例子。TFS默认Block大小为64M,每个块中会存储许多不同的小文件,但是这个块只占用一个Inode。假设一个Block为64M,数量级为1PB。那么NameServer上会有16.7M个Block。假设每个Block的元数据大小为0.1K,则占用内存不到2G。在TFS中,文件名中包含了Block ID和File ID,通过Block ID定位到具体的DataServer上,然后DataServer会根据本地记录的信息来得到File ID所在Block的偏移量,从而读取到正确的文件内容。

元数据管理优化

一般来说元数据信息包括名称、文件大小、设备标识符、用户标识符、用户组标识符等等,在小文件系统中可以对元数据信息进行精简,仅保存足够的信息即可。元数据精简可以减少元数据通信延时,同时相同容量的Cache能存储更多的元数据,从而提高元数据使用效率。另外可以在文件名中就包含元数据信息,从而减少一个元数据的查询操作。最后针对特别小的一些文件,可以采取元数据和数据并存的策略,将数据直接存储在元数据之中,通过减少一次寻址操作从而大大提高性能。