1. 背景与目的
在工作中,很多时候会遇到一些性能相关的问题。例如
因此熟知
- 机器能够提供的能力
- 程序预期消耗的性能
- 程序实际消耗的性能
是必不可缺的。
通常我们评估机器的性能,会分为如下几个方面:
尤其是对于我们业务(支付金融类)来说,了解存储(因为需要落地,需要事务等等)性能至关重要,包括Mysql,KeyValue存储等
2. 了解存储性能
我们从存储使用的各个层级了解其中的原理,包括其局限,应对方法等。
主要从下面几个层面来看(自底向上):
2.1 硬盘
硬盘最主要的功能就是数据存储,也就是将电信号持久化到某些物理介质。
目前主流的硬盘分为两种架构,一种是基于电磁转换原理(磁盘),一种是基于电子存储介质进行数据存储和读取的磁盘(SSD磁盘)
2.1.1 磁盘
以下内容主要参考:
几句话概括:
- 写数据时:磁头通电时,电信号产生了磁场,将磁盘盘面上的磁性物质的变为与磁场同方向的,这样就记录了数据
- 读数据时:磁头经过磁面,磁信号产生了不同方向的感应电流,经过脉冲,转换为电信号,这样就读取了数据
- 磁盘结构:磁盘分为磁道/扇区/柱面/磁盘面
- 寻道时间:Tseek是指将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O操作越快,目前磁盘的平均寻道时间一般在3-15ms。
- 旋转延迟:Trotation是指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的1/2表示。比如:7200rpm的磁盘平均旋转延迟大约为60*1000/7200/2 = 4.17ms,而转速为15000rpm的磁盘其平均旋转延迟为2ms
2.1.2 SSD盘
以下内容主要参考:
固态盘中,在存储单元晶体管的栅(Gate)中,注入不同数量的电子,通过改变栅的导电性能,改变晶体管的导通效果,实现对不同状态的记录和识别。
同时SSD磁盘基于mapping table,维护逻辑地址到物理地址的映射。每次读写时,可以通过逻辑地址直接查表计算出物理地址,与传统的机械磁盘相比,省去了寻道时间和旋转时间。
需要注意的地方是:
- 需要充分利用其随机读写快的特性
- 尽可能在软件层面更新小块数据,减轻SSD写放大问题(按页存储,按块更新)
- 避免频繁的更新数据,减轻SSD写放大及寿命减少的问题,尽可能使用追加的方式写数据(不能直接更新,只能Copy后更新)
2.2 操作系统(Linux)的IO操作
以下内容主要参考:
Linux的整体IO分为7层
- VFS层:虚拟文件系统层。由于内核要跟多种文件系统打交道,而每一种文件系统所实现的数据结构和相关方法都可能不尽相同,所以,内核抽象了这一层,专门用来适配各种文件系统,并对外提供统一操作接口。
- 文件系统层:不同的文件系统实现自己的操作过程,提供自己特有的特征
- 页缓存层:负责针对page的缓存。
- 通用块层:由于绝大多数情况的io操作是跟块设备打交道,所以Linux在此提供了一个类似vfs层的块设备操作抽象层。下层对接各种不同属性的块设备,对上提供统一的Block IO请求标准。
- IO调度层:因为绝大多数的块设备都是类似磁盘这样的设备,所以有必要根据这类设备的特点以及应用的不同特点来设置一些不同的调度算法和队列。以便在不同的应用环境下有针对性的提高磁盘的读写效率,这里就是大名鼎鼎的Linux电梯所起作用的地方。针对机械硬盘的各种调度方法就是在这实现的。
- 块设备驱动层:驱动层对外提供相对比较高级的设备操作接口,往往是C语言的,而下层对接设备本身的操作方法和规范。
- 块设备层:这层就是具体的物理设备了,定义了各种真对设备操作方法和规范。
其中,块设备和块设备驱动层接触的比较少,与硬件设备关系比较紧密,暂时不写。
其中,通用块层和VFS层都是一层封装,为了对上游提供统一的接口,降低复杂度,只选取其中VFS层讲述一下。
因此针对Linux操作系统着重介绍下面的几个部分内容
- IO调度层(IO Scheduler)
- 页缓存(Page Cache)
- Ext文件系统
- VFS(Virtual FileSystem)
2.2.1 IO调度层(IO Scheduler)
以下内容主要参考:
IO调度层的目的是为了提升机械硬盘的读写性能。具体的实现方式是:
每个块设备或者块设备的分区,都对应有自身的请求队列(request_queue),而每个请求队列都可以选择一个I/O调度器来协调所递交的request。I/O调度器的基本目的是将请求按照它们对应在块设备上的扇区号进行排列,以减少磁头的移动,提高效率。每个设备的请求队列里的请求将按顺序被响应。实际上,除了这个队列,每个调度器自身都维护有不同数量的队列,用来对递交上来的request进行处理,而排在队列最前面的request将适时被移动到请求队列中等待响应。
内核中实现的调度器有下面几种:
- Noop算法: 也叫作电梯调度算法,它将IO请求放入到一个FIFO队列中,然后逐个执行这些IO请求,当然对于一些在磁盘上连续的IO请求,Noop算法会适当做一些合并。这个调度算法特别适合那些不希望调度器重新组织IO请求顺序的应用。(例如SSD已经做了优化)
- Deadline算法:Deadline算法的核心在于保证每个IO请求在一定的时间内一定要被服务到,以此来避免某个请求饥饿。实现的方式是:Deadline 实现了四个队列,其中两个分别处理正常的 read 和 write,按扇区号排序,进行正常 IO 的合并处理以提高吞吐量。
因为 IO 请求可能会集中在某些磁盘位置,这样会导致新来的请求一直被合并,可能会有其他磁盘位置的 IO 请求被饿死。
因此实现了另外两个处理超时 read 和 write 的队列,按请求创建时间排序,如果有超时的请求出现,就放进这两个队列,
调度算法保证超时(达到最终期限时间)的队列中的请求会优先被处理,防止请求被饿死。
- CFQ(Completely Fair Queuing)算法:它试图为竞争块设备使用权的所有进程分配一个请求队列和一个时间片,在调度器分配给进程的时间片内,进程可以将其读写请求发送给底层块设备,当进程的时间片消耗完,进程的请求队列将被挂起,等待调度。 每个进程的时间片和每个进程的队列长度取决于进程的IO优先级,每个进程都会有一个IO优先级,CFQ调度器将会将其作为考虑的因素之一,来确定该进程的请求队列何时可以获取块设备的使用权。IO优先级从高到低可以分为三大类:RT(real time),BE(best try),IDLE(idle),其中RT和BE又可以再划分为8个子优先级。实际上,我们已经知道CFQ调度器的公平是针对于进程而言的,而只有同步请求(read或syn write)才是针对进程而存在的,他们会放入进程自身的请求队列,而所有同优先级的异步请求,无论来自于哪个进程,都会被放入公共的队列,异步请求的队列总共有8(RT)+8(BE)+1(IDLE)=17个
2.2.2 页面缓存(Page Cache)
2.2.2.1 预读和回写
以下内容主要参考:
PageCache有两个非常重要的思想需要学习
预读其实就是利用了局部性原理,具体过程是:对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(通常是三个页面),这时的预读称为同步预读。对于第二次读请求,如果所读页面不在Cache中,即不在前次预读的页中,则表明文件访问不是顺序访问,系统继续采用同步预读;如果所读页面在Cache中,则表明前次预读命中,操作系统把预读页的大小扩大一倍,此时预读过程是异步的,应用程序可以不等预读完成即可返回,只要后台慢慢读页面即可,这时的预读称为异步预读。任何接下来的读请求都会处于两种情况之一:第一种情况是所请求的页面处于预读的页面中,这时继续进行异步预读;第二种情况是所请求的页面处于预读页面之外,这时系统就要进行同步预读。
回写是通过暂时将数据存在Cache里,然后统一异步写到磁盘中。通过这种异步的数据I/O模式解决了程序中的计算速度和数据存储速度不匹配的鸿沟,减少了访问底层存储介质的次数,使存储系统的性能大大提高。Linux 2.6.32内核之前,采用pdflush机制来将脏页真正写到磁盘中,什么时候开始回写呢?下面两种情况下,脏页会被写回到磁盘:
- 在空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。当脏页在内存中驻留超过一定的阈值时,内核必须将超时的脏页写会磁盘,以确保脏页不会无限期地驻留在内存中。
回写开始后,pdflush会持续写数据,直到满足以下两个条件:
- 已经有指定的最小数目的页被写回到磁盘。
- 空闲内存页已经回升,超过了阈值。
Linux 2.6.32内核之后,放弃了原有的pdflush机制,改成了bdi_writeback机制。bdi_writeback机制主要解决了原有fdflush机制存在的一个问题:在多磁盘的系统中,pdflush管理了所有磁盘的Cache,从而导致一定程度的I/O瓶颈。bdi_writeback机制为每个磁盘都创建了一个线程,专门负责这个磁盘的Page Cache的刷新工作,从而实现了每个磁盘的数据刷新在线程级的分离,提高了I/O性能。
回写机制存在的问题是回写不及时引发数据丢失(可由sync |
fsync解决),回写期间读I/O性能很差。 |
2.2.2.2 PageCache的产生
PageCache可以从下面几个方式产生PageCache
- Buffered I/O(标准 I/O)如:read/write/sendfile等;(标准 I/O 是写的 (write(2)) 用户缓冲区 (Userpace Page 对应的内存),然后再将用户缓冲区里的数据拷贝到内核缓冲区 (Pagecache Page 对应的内存);如果是读的 (read(2)) 话则是先从内核缓冲区拷贝到用户缓冲区,再从用户缓冲区读数据,也就是 buffer 和文件内容不存在任何映射关系)
- Memory-Mapped I/O(存储映射 I/O)如:mmap;
2.2.1 虚拟文件系统(VFS)
VFS(Virtual File System)虚拟文件系统是一种软件机制,更确切的说扮演着文件系统管理者的角色,与它相关的数据结构只存在于物理内存当中。它的作用是:屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。正是因为有了这个层次,Linux中允许众多不同的文件系统共存并且对文件的操作可以跨文件系统而执行。 –磁盘I/O那些事
问:为什么要加一层抽象层,而不是直接使用原生文件系统?
答:为了让上游使用方更容易,业务开发方不需要适配各种文件系统接口就能读写对应文件系统上的文件。 这也体现了软件设计的重要方法之一:基于接口而非实现编程
VFS中包含着向物理文件系统转换的一系列数据结构,如VFS超级块、VFS的Inode、各种操作函数的转换入口等。Linux中VFS依靠四个主要的数据结构来描述其结构信息,分别为超级块、索引结点、目录项和文件对象。
-
超级块(Super Block):超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。VFS超级块存在于内存中,它在文件系统安装时建立,并且在文件系统卸载时自动删除。同时需要注意的是对于每个具体的文件系统来说,也有各自的超级块,它们存放于磁盘。
-
索引结点(Inode):索引结点对象存储了文件的相关元数据信息,例如:文件大小、设备标识符、用户标识符、用户组标识符等等。Inode分为两种:一种是VFS的Inode,一种是具体文件系统的Inode。前者在内存中,后者在磁盘中。所以每次其实是将磁盘中的Inode调进填充内存中的Inode,这样才是算使用了磁盘文件Inode。当创建一个文件的时候,就给文件分配了一个Inode。一个Inode只对应一个实际文件,一个文件也会只有一个Inode。
-
目录项(Dentry):引入目录项对象的概念主要是出于方便查找文件的目的。不同于前面的两个对象,目录项对象没有对应的磁盘数据结构,只存在于内存中。一个路径的各个组成部分,不管是目录还是普通的文件,都是一个目录项对象。如,在路径/home/source/test.java中,目录 /, home, source和文件 test.java都对应一个目录项对象。VFS在查找的时候,根据一层一层的目录项找到对应的每个目录项的Inode,那么沿着目录项进行操作就可以找到最终的文件。
-
文件对象(File):文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。一个文件对应的文件对象可能不是惟一的,但是其对应的索引节点和目录项对象肯定是惟一的。
VFS更多的是抽象,对于性能优化的不是特别多,值得学习其中抽象的思路。
2.2.2 Ext文件系统
以下主要参考下面的文章:
上文中提了几个概念:
- 磁盘扇区:物理的概念,512字节,每次读写的最小单位
- 磁盘逻辑块:一个或者多个扇区组成的逻辑单元,由磁盘驱动器维护,提升读写性能,简称LBA
- 操作系统块:操作系统维护的逻辑概念,通过这些块来查找LBA,进而找到磁盘扇区,加载数据
后续所说的块都是指操作系统的块,称之为block
- Inode:Inode(Index Node)索引节点,保存了文件的Block指针和文件信息等,用于快速获取文件数据
- Bmap:海量的Block,如何快速找到空闲的Block? 讲Block数组组织为Bmap形式,使用Bitmap快速定位空闲的块
- Inode表:
- 如何降低Inode本身的消耗?Inode本身需要存储,一般需要128字节,如果一个Inode需要一个Block,负载较低,因此可以考虑多个inode合并存储在block中,对于128字节的inode,一个block存储8个inode,对于256字节的inode,一个block存储4个inode。这就使得每个存储inode的块都不浪费。
- 将这些物理上存储inode的block组合起来,在逻辑上形成一张inode表(inode table)来记录所有的inode。
- Imap:类似于Bmap,是为了快速定位哪些inode已经分配出去(inode在格式化操作系统之后,英全部确定了),同样是使用bitmap机制
- 块组:分级的概念,文件系统占用的block划分成块组(block group),解决bmap、inode table和imap太大的问题
- GDT: 组描述符表GDT(group descriptor table)用于描述每个块组的信息和属性元数据
- 文件名和inode号不是存储在其自身的inode中,而是存储在其所在目录的data block中
- data_block本身是一个 tlv的结构体
所以最终的组织结构如下:
2.3 应用软件层设计方法(举例)
上面可以看到由于磁盘的物理结构,导致磁盘 ** 随机读写慢,顺序读写快 **,操作系统使用了一些优化的技术手段 Cache/IO调度(重排)提升磁盘性能,但终归无法从本质上提升。一些优秀的开源软件在设计时考虑到磁盘的特性,也有一些特别的设计技巧值得学习,个人简单列举了一些自己了解的软件设计机制。(其实每一个设计技巧都值得一篇单独的文章来介绍和了解,本文只是简单的介绍)
2.3.1 MySQL(B+树索引)
以下内容主要参考:
MySQL(InnoDB引擎),通常使用B+树结构做为索引(索引分为主键索引和非主键索引,差别在于主键索引的叶子节点是真实的数据,非主键索引的叶子节点是指向主键索引位置的值,每一张表都只有一个主键索引)
B+树有下面几个特点:
- 让一个节点的大小等于一个块的大小,节点内存储可以装下多个元素的有序数组。这样就可以充分利用操作系统按块读取的特性,使得读取效率最大化。
- 将节点区分为内部节点和叶子节点。他们结构相同,但存储的内容不同。
- 内部节点存储 key 和维持树形结构的指针
- 叶子节点存储 key 和数据
这么做可以使得内部节点存储更多的索引数据。
- 所有节点通过双向链表串联,方面范围查找
2.3.2 LevelDB(LSM-Tree追加写)
以下内容主要参考:
LSM-Tree 核心的思路是通过预写日志(WAL),将所有的写入请求(写入,更新,删除)以Append的模式追加写实现写入高性能。再通过一些手段优化读性能来保证可用,例如 内存cache,合并数据,布隆过滤器等
2.3.3 Kakfa(追加写和sendfile)
以下内容主要参考:
使用Page Cache:
- I/O Scheduler会将连续的小块写组装成大块的物理写从而提高性能
- I/O Scheduler会尝试将一些写操作重新按顺序排好,从而减少磁盘头的移动时间
- 充分利用所有空闲内存(非JVM内存)。如果使用应用层Cache(即JVM堆内存),会增加GC负担
- 读操作可直接在Page Cache内进行。如果消费和生产速度相当,甚至不需要通过物理磁盘(直接通过Page Cache)交换数据
- 如果进程重启,JVM内的Cache会失效,但Page Cache仍然可用
Broker收到数据后,写磁盘时只是将数据写入Page Cache,并不保证数据一定完全写入磁盘。从这一点看,可能会造成机器宕机时,Page Cache内的数据未写入磁盘从而造成数据丢失。但是这种丢失只发生在机器断电等造成操作系统不工作的场景,而这种场景完全可以由Kafka层面的Replication机制去解决。
以上是我个人了解到的关于磁盘IO的一些流程和方法。
后面会补充如下内容:
- 对IO性能指标的度量方法(不能度量就不能改进)
- 部分优秀代码的阅读记录