无论是系统运维人员还是解决方案技术支持人员,关注系统的性能都是最重要的工作,本系列文章试图从基本概念开始对磁盘、存储、操作系统层等端到端进行综合归纳,让大家能够对IO性能相关的基本概念,IO性能的监控和调整有个比较全面的了解。
本文从I/O(Block)的流向介绍,试图解读整个I/O流与存储性能之间的些许联系。整个I/O流经历一下几个节点都会对IO性能有影响,此文将从一个IO起点出发,就带你完成整个IO之旅,最后通过SQL Serve应用说明结束这次旅行。
不同的应用程序对存储的数据访问类型有所不同。本文描述典型的不同应用程序的存储IO类型。帮助读者了解不同应用程序存储IO类型的同时,提供的数据也可以为存储模拟和压力测试的数据参考。
>>>如何正确描述存储IO类型
下文从IO聚合成满分条写优化写惩罚,常见业务模型和特征,FC链路带宽和链路利用率计算、读写比、RAID惩罚分析、随机顺序IO和Cache对存储性能影响等进行详细分析。
下文详细分析磁盘IO是如何计算的,包括单盘寻址、旋转和传输对磁盘性能的影响和计算方法,站在磁盘的视角对IO性能做了详细分析。
下面分别从主机应用层、操作系统层、文件系统层、Page Cache层、通用块层、IO调度层和块设备驱动层分别对IO特征和优化进行详细描述。
主机应用层
主机侧发下来的业务IO模型:IO是否顺序,是否连续,与主机业务软件本身、主机侧块设备、卷管理策略、HBA卡拆分策略等相关。主机下发的IO越顺序、越连续,到达阵列后的合并效果越好。
对于顺序小IO而言,基本上能够实现将IO都合并成满分条后下盘。而对于IO随机程度较高的数据库业务,各厂商都无法确保所有IO都能够合并,只能尽量通过排序和合并,将相邻地址的小IO合成大IO,但合并程度由于算法实现和内存大小等因素可能会有所差异。

OLTP、OLAP、VDI和SPC-1是当前性能评估中常见的三类业务场景。SPC-1是业界通用的随机IOPS型的IO模型,在不清楚实际业务类型的条件下,常用此模型来进行性能评估 (以下内容节选自“美团点评技术团队”,作者喻枭)。
通常情况下,由于操作系统和系统调度等程序优化,使得磁盘的实际IOPS往往大于磁盘计算出的理论最大 IOPS 。那么操作系统是如何操作硬盘的呢,下图显示了 Linux 系统中对于磁盘的一次读请求在核心空间中所要经历的层次模型。

对于磁盘的一次读请求,首先经过虚拟文件系统层(VFS Layer),其次是具体的文件系统层(例如 Ext2),接下来是 Cache 层(Page Cache Layer)、通用块层(Generic Block Layer)、I/O 调度层(I/O Scheduler Layer)、块设备驱动层(Block Device Driver Layer),最后才到物理块设备层(Block Device Layer)。
VFS(Virtual File System)虚拟文件系统是一种软件机制,更确切的说扮演着文件系统管理者的角色,与它相关的数据结构只存在于物理内存当中。它的作用就是屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。正是因为有了这个层次,Linux 中允许众多不同的文件系统共存并且对文件的操作可以跨文件系统而执行。
VFS 中包含着向物理文件系统转换的一系列数据结构,如 VFS 超级块、VFS 的 Inode、各种操作函数的转换入口等。Linux 中 VFS 依靠四个主要的数据结构来描述其结构信息,分别为超级块、索引结点、目录项和文件对象。
超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如 Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。
索引结点对象存储了文件的相关元数据信息(文件大小、设备标识符、用户标识符、用户组标识符等等)。Inode 分为VFS 的 Inode和具体文件系统的 Inode。前者在内存中,后者在磁盘中。
引入目录项对象的概念主要是出于方便查找文件的目的。文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。一个文件对应的文件对象可能不是惟一的,但是其对应的索引节点和目录项对象肯定是惟一的。
VFS 的下一层即是具体的文件系统,Ext2 中的 Super Block 和 Inode Table 分别对应 VFS 中的超级块和索引结点,存放在磁盘。每个块组都有一个块组描述符 GDT(Group Descriptor Table),存储一个块组的描述信息,例如在这个块组中从哪里开始是 Inode 表,从哪里开始是数据块等等。Block Bitmap 和 Inode Bitmap 分别表示 Block 和 Inode 是否空闲可用。Data Block 数据块是用来真正存储文件内容数据的地方,下面我们看一下具体的存储规则。
在 Ext2 文件系统中所支持的 Block 大小有 1K、2K、4K 三种。在格式化时 Block 的大小就固定了,且每个 Block 都有编号,方便 Inode 的记录。每个 Block 内最多只能够放置一个文件的数据,如果文件大于 Block 的大小,则一个文件会占用多个 Block;如果文件小于 Block,则该 Block 的剩余容量就不能够再被使用了,即磁盘空间会浪费。
Inode 要记录的数据非常多,但大小仅为固定的 128 字节,同时记录一个 Block 号码就需要 4 字节,假设一个文件有 400MB 且每个 Block 为 4K 时,那么至少也要十万笔 Block 号码的记录。Inode 不可能有这么多的记录信息,因此 Ext2 将 Inode 记录 Block 号码的区域定义为 12 个直接、一个间接、一个双间接与一个三间接记录区。
引入Cache 层的目的是为了提高 Linux 操作系统对磁盘访问的性能。Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 Cache 中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。Cache 层也正是磁盘 IOPS 为什么能突破 200 的主要原因之一。
在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。Page Cache 主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有 read/write 操作的时候。Buffer Cache 则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。
磁盘 Cache 有两大功能:预读和回写。预读其实就是利用了局部性原理,具体过程是:对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(通常是三个页面),这时的预读称为同步预读。对于第二次读请求,如果所读页面不在 Cache 中,即不在前次预读的页中,则表明文件访问不是顺序访问,系统继续采用同步预读;如果所读页面在 Cache 中,则表明前次预读命中,操作系统把预读页的大小扩大一倍,此时预读过程是异步的,应用程序可以不等预读完成即可返回,只要后台慢慢读页面即可,这时的预读称为异步预读。
回写是通过暂时将数据存在 Cache 里,然后统一异步写到磁盘中。通过这种异步的数据 I/O 模式解决了程序中的计算速度和数据存储速度不匹配的鸿沟,减少了访问底层存储介质的次数,使存储系统的性能大大提高。
通用块层
通用块层的主要工作是接收上层发出的磁盘请求,并最终发出 I/O 请求。该层隐藏了底层硬件块设备的特性,为块设备提供了一个通用的抽象视图。
对于 VFS 和具体的文件系统来说,块是基本的数据传输单元,当内核访问文件的数据时,它首先从磁盘上读取一个块。但是对于磁盘来说,扇区是最小的可寻址单元,块设备无法对比它还小的单元进行寻址和操作。由于扇区是磁盘的最小可寻址单元,所以块不能比扇区还小,只能整数倍于扇区大小,即一个块对应磁盘上的一个或多个扇区。一般来说,块大小是 2 的整数倍,而且由于 Page Cache 层的最小单元是页,所以块大小不能超过一页的长度。
大多情况下,数据的传输通过 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 预测调度算法等。
在许多的开源框架如 Kafka、HBase 中,都通过追加写的方式来尽可能的将随机 I/O 转换为顺序 I/O,以此来降低寻址时间和旋转延时,从而最大限度的提高 IOPS。
块设备驱动层
驱动层中的驱动程序对应具体的物理块设备。它从上层中取出 I/O 请求,并根据该 I/O 请求中指定的信息,通过向具体块设备的设备控制器发送命令的方式,来操纵设备传输数据。这里不再赘述。
在上一节中我们了解了 Linux 系统中请求到达磁盘的一次完整过程,期间 Linux 通过 Cache 以及排序合并 I/O 请求来提高系统的性能。其本质就是由于磁盘随机读写慢、顺序读写快。本节针对常见开源系统阐述一些基于磁盘 I/O 特性的设计技巧。
1、采用追加写
在进行系统设计时,良好的读性能和写性能往往不可兼得。在许多常见的开源系统中都是优先在保证写性能的前提下来优化读性能。那么如何设计能让一个系统拥有良好的写性能呢?一个好的办法就是采用追加写,每次将数据添加到文件。由于完全是顺序的,所以可以具有非常好的写操作性能。但是这种方式也存在一些缺点:从文件中读一些数据时将会需要更多的时间:需要倒序扫描,直到找到所需要的内容。当然在一些简单的场景下也能够保证读操作的性能:
数据是被整体访问:HDFS 建立在一次写多次读的模型之上。在 HDFS 中就是采用了追加写并且设计为高数据吞吐量;高吞吐量必然以高延迟为代价,所以 HDFS 并不适用于对数据访问要求低延迟的场景;由于采用是的追加写,也并不适用于任意修改文件的场景。
HDFS 设计为流式访问大文件,使用大数据块并且采用流式数据访问来保证数据被整体访问,同时最小化硬盘的寻址开销,只需要一次寻址即可,这时寻址时间相比于传输时延可忽略,从而也拥有良好的读性能。HDFS 不适合存储小文件,原因之一是由于 NameNode 内存不足问题,还有就是因为访问大量小文件需要执行大量的寻址操作,并且需要不断的从一个 datanode 跳到另一个 datanode,这样会大大降低数据访问性能。
知道文件明确的偏移量,比如 Kafka
在 Kafka 中,采用消息追加的方式来写入每个消息,每个消息读写时都会利用 Page Cache 的预读和后写特性,同时 partition 中都使用顺序读写,以此来提高 I/O 性能。虽然 Kafka 能够根据偏移量查找到具体的某个消息,但是查找过程是顺序查找,因此如果数据很大的话,查找效率就很低。
所以 Kafka 中采用了分段和索引的方式来解决查找效率问题。Kafka 把一个 patition 大文件又分成了多个小文件段,每个小文件段以偏移量命名,通过多个小文件段,不仅可以使用二分搜索法很快定位消息,同时也容易定期清除或删除已经消费完的文件,减少磁盘占用。
为了进一步提高查找效率,Kafka 为每个分段后的数据建立了索引文件,并通过索引文件稀疏存储来降低元数据占用大小。
在面对更复杂的读场景(比如按 Key)时,如何来保证读操作的性能呢?简单的方式是像 Kafka 那样,将文件数据有序保存,使用二分查找来优化效率;或者通过建索引的方式来进行优化;也可以采用 hash 的方式将数据分割为不同的桶。以上的方法都能增加读操作的性能,但是由于在数据上强加了数据结构,又会降低写操作的性能。
比如如果采用索引的方式来优化读操作,那么在更新索引时就需要更新 B-tree 中的特定部分,这时候的写操作就是随机写。那么有没有一种办法在保证写性能不损失的同时也提供较好的读性能呢?一个好的选择就是使用 LSM-tree。LSM-tree 与 B-tree 相比,LSM-tree 牺牲了部分读操作,以此大幅提高写性能。
日志结构的合并树 LSM(Merge-Tree)是 HBase,LevelDB 等 NoSQL 数据库的存储引擎。Log-Structured 的思想是将整个磁盘看做一个日志,在日志中存放永久性数据及其索引,每次都添加到日志的末尾。并且通过将很多小文件的存取转换为连续的大批量传输,使得对于文件系统的大多数存取都是顺序的,从而提高磁盘 I/O。
LSM-tree 就是这样一种采用追加写、数据有序以及将随机 I/O 转换为顺序 I/O 的延迟更新,批量写入硬盘的数据结构。LSM-tree 将数据的修改增量先保存在内存中,达到指定的大小限制后再将这些修改操作批量写入磁盘。因此比较旧的文件不会被更新,重复的纪录只会通过创建新的纪录来覆盖,这也就产生了一些冗余的数据。所以系统会周期性的合并一些数据,移除重复的更新或者删除纪录,同时也会删除上述的冗余。
在进行读操作时,如果内存中没有找到相应的 key,那么就是倒序从一个个磁盘文件中查找。如果文件越来越多那么读性能就会越来越低,目前的解决方案是采用页缓存来减少查询次数,周期合并文件也有助于提高读性能。在文件越来越多时,可通过布隆过滤器来避免大量的读文件操作。
LSM-tree 牺牲了部分读性能,以此来换取写入的最大化性能,特别适用于读需求低,会产生大量插入操作的应用环境。
目前的大多数文件系统,如 XFS/Ext4、GFS、HDFS,在元数据管理、缓存管理等实现策略上都侧重大文件。上述基于磁盘 I/O 特性设计的系统都有一个共性特点就是都运行在这些文件系统之上。这些文件系统在面临海量时在性能和存储效率方面都大幅降低,本节来探讨下海量小文件下的系统设计。
常见文件系统在海量小文件应用下性能表现不佳的根本原因是磁盘最适合顺序的大文件 I/O 读写模式,而非常不适合随机的小文件 I/O 读写模式。
对于海量小文件应用,常见的 I/O 流程复杂也是造成磁盘性能不佳的原因。对于小文件,磁盘的读写所占用的时间较少,而用于文件的 open() 操作占用了绝大部分系统时间,导致磁盘有效服务时间非常低,磁盘性能低下。
针对于问题的根源,针对数据布局低效,采用小文件合并策略,将小文件合并为大文件。针对元数据管理低效,优化元数据的存储和管理。针对这两种优化方式,业内也出现了许多优秀的开源软件。
3、小文件合并
小文件合并为大文件后,首先减少了大量元数据,提高了元数据的检索和查询效率,降低了文件读写的 I/O 操作延时。其次将可能连续访问的小文件一同合并存储,增加了文件之间的局部性,将原本小文件间的随机访问变为了顺序访问,大大提高了性能。同时,合并存储能够有效的减少小文件存储时所产生的磁盘碎片问题,提高了磁盘的利用率。
最后,合并之后小文件的访问流程也有了很大的变化,由原来许多的 open 操作转变为了 seek 操作,定位到大文件具体的位置即可。如何寻址这个大文件中的小文件呢?其实就是利用一个旁路数据库来记录每个小文件在这个大文件中的偏移量和长度等信息。其实小文件合并的策略本质上就是通过分层的思想来存储元数据。中控节点存储一级元数据,也就是大文件与底层块的对应关系;数据节点存放二级元数据,也就是最终的用户文件在这些一级大块中的存储位置对应关系,经过两级寻址来读写数据。
4、元数据管理优化
一般来说元数据信息包括名称、文件大小、设备标识符、用户标识符、用户组标识符等等,在小文件系统中可以对元数据信息进行精简,仅保存足够的信息即可。元数据精简可以减少元数据通信延时,同时相同容量的 Cache 能存储更多的元数据,从而提高元数据使用效率。另外可以在文件名中就包含元数据信息,从而减少一个元数据的查询操作。最后针对特别小的一些文件,可以采取元数据和数据并存的策略,将数据直接存储在元数据之中,通过减少一次寻址操作从而大大提高性能。




