(TaoCloud团队原创 作者:林世跃@TaoCloud)
FUSE是用户空间的文件系统接口,FUSE内核模块为普通应用程序与内核虚拟文件系统VFS的交互提供了一个桥梁。基于FUSE用户空间模块,开发人员可以不必了解VFS内核机制就能快速便捷地开发POSIX兼容的文件系统交互接口。
本文主要介绍GlusterFS基于FUSE的POSIX文件系统接口的实现机制和工作原理,给出通过修改FUSE读写数据块大小提升大I/O带宽性能的具体方法,并在分析FUSE瓶颈的基础上提出进一步的优化思路。
FUSE项目简介
FUSE(File system in User Space)是一个用户空间的文件系统框架,通过FUSE开发人员可以在用户态实现文件系统,并且不需要特权用户的支持。使用 FUSE,可以像可执行二进制文件一样来开发文件系统,它们需要链接到 FUSE 库上。换而言之,这个文件系统框架并不需要您了解文件系统的内幕和内核模块编程的知识。
FUSE作为类UNIX系统平台上的用户空间文件系统就是为了非特权开发者能够在用户层开发一套功能完备文件系统。2.8版本之前,所有的模块是在用户态,高于2.8版本的FUSE内核模块已经移植进去操作系统的内核。如果使用2.8版本以上则需要从新编译Linux内核代码。对于读写虚拟文件系统来说,FUSE是个很好的选择。
使用FUSE可以开发功能完备的文件系统,其具有简单的 API 库,可以被非特权用户访问,并可以安全的实施。更重要的是,FUSE以往的表现充分证明了其稳定性。在FUSE基础之上,用户空间的文件系统设计就被极大简化了。基于FUSE的文件系统的实现实例包括GlusterFS,MooseFS,SSHFS,FTPFS,GmailFS 等著名项目。
(1)FUSE特点
1.1. 库文件简单,安装简便;
1.2. 模块化,可重构某个模块;
1.3. 执行安全,系统使用稳定;
1.4. 用户态和内核态接口高效;
1.5. 支持C/C++/JavaTM 绑定;
(2)FUSE模块组成
FUSE可以分成3个模块:FUSE文件系统模块、FUSE设备驱动模块、FUSE用户态模块。FUSE设备驱动模块主要是作为FUSE文件系统模块与FUSE用户态模块的通信,交换数据作用。这里的FUSE设备驱动相当于一个代理。当用户使用FUSE挂载了一个客户端,每次的请求会写入链表,这时候FUSE的用户态模块监控到数据,读取解析之后,执行相应的操作。操作结束返回到FUSE设备驱动,最后返回挂载点应答。FUSE文件系统内核模块实现与VFS的交互和处理,而具体的文件系统I/O则由用户空间程序处理,基于FUSE提供的用户态lib库可以实现POSIX兼容交互接口。
(3)FUSE工作流程
使用FUSE挂载一个客户端,应用程序执行调用系统函数write写数据,write调用vfs_write,vfs虚拟文件系统在接受到上层的写请求会根据fuse的注册的函数调用fuse_file_aio_write写入到request pending queue,然后进入睡眠等待应答。用户态文件系统会启动一个守护进程去轮询fuse设备,最后会调用fuse_kern_chan_receive读取request pending queue的数据,解析request pending queue数据执行相应的write操作。当用户态执行完相应的写操作完成之后,这时候会调用fuse_reply_write应答回到fuse设备。最后返回挂载点处的应用程序。
下图显示GlusterFS使用FUSE设备于挂载点应用程序的通信过程。虽然FUSE提供的用户态代码实现了和FUSE设备驱动的交互,而GlusterFS重构了部分代码。所以下图的应答回FUSE设备是不一样的函数。
图中省略了内核机制,只是呈现了write的一个流程以及用户态部分GlusterFS实现,应用在挂载点写一个块经过vfs,vfs调用FUSE注册的相应函数。而GlusterFS在这里相当于fuse模块中的fuse用户态模块。挂载点之上的应用和glusterfs经过了fuse设备的通信。
(4)VFS/FUSE/GlusterFS关系
Linux中文件系统是一个很重要的子系统,vfs作为了Linux一个抽象的文件系统,提供了统一的接口,屏蔽了所有的底层的磁盘文件系统的类型提供了一套统一的接口,这样在用户态的用户需要文件系统编程的时候,只需要关注vfs提供的API(posix接口)。在Linux平台有众多的磁盘文件系统,比如xfs、ext4、btrfs,磁盘文件系统在格式化初始化时候都会在vfs注册自己的信息以及相关的ops操作,这样在挂载之后上层应用操作文件时候,vfs则能够在注册信息里面选择相应的磁盘文件系统,磁盘文件则会选择出相应的操作执行。而fuse不同于磁盘文件系统,fuse作为一个用户态的文件系统,在调用register_filesystem(struct file_system_type *)函数在vfs虚拟文件系统中注册信息以及fuse的ops操作之后把如果实现文件的读写等操作留给了开发人员。这样在用fuse的挂载目录下执行文件系统类操作,经过vfs层,vfs会选择相应的fuse注册函数执行。这时候fuse会把请求写入到等待队列当中,进入睡眠等待上层应用处理。glusterfs文件作为一个分布式文件系统(用户态类)这时候会启动线程去轮询读fuse的设备,得出请求的ops类型,执行结束之后返回fuse设备。解析执行操作,具体讲解在下一节。
(5)Dokan项目简介
Dokan(https://dokan-dev.github.io)是一个开源的Windows平台下的用户态文件系统,它的作用功能和FUSE一样,被称为Windows平台下的FUSE。Dokan为windows平台开发者提供了一个文件系统的开发模块,开发者能够在windows下方便快捷实现文件系统客户端,可以不必使用CIFS协议挂载,从而获得更高的安全性和高性能。Dokan在2003年后有一段时间停止了代码更新,而且有开发人员发现内存泄漏和稳定性等问题。目前Dokan已经重新开始更新,开源社区活跃度有了很大提升,keybase/seafile等项目已在使用。
GlusterFS POSIX接口实现
GlusterFS是如何利用FUSE来实现分布式文件系统接口呢?在介绍GlusterFS的fuse层之前,首先从整体介绍一下GlusterFS的堆栈式模块化设计思想和主要函数的调用。
GlusterFS实现了副本/条带/纠删码等文件存储模式,为在不同的应用场景提供了不同的解决方案,并且实现了很多性能层功能来提升性能,比如io-cache、预读、回写等。GlusterFS采用了堆栈式设计,这样的设计模式一方面流程清晰简洁,另一方面功能模块之间互不影响,使得用户能在特定环境下可以移除不必要的功能,也有利于开发者实现自己实现的功能模块。
在代码级别层上,GlusterFS使用了xlator结构体定义保存了每一层信息,也就是说每一个功能都有自己的一个xlator结构体。每一层都会定义相同类型的operations函数以及相应的回调函数。glusterfs定义了STACK_WIND函数从某一层下发到另一个层,当执行结束则会调用STACK_UNWIND回调到上一层的函数。
要使用FUSE来创建一个文件系统,首先需要定义 fuse_operations 类型的结构变量。Glusterfs定义的fuse_operations结构体。
static fuse_handler_t *fuse_std_ops[FUSE_OP_HIGH] = {
[FUSE_LOOKUP] = fuse_lookup,
[FUSE_RMDIR] = fuse_rmdir,
[FUSE_RENAME] = fuse_rename,
[FUSE_LINK] = fuse_link,
[FUSE_OPEN] = fuse_open,
[FUSE_READ] = fuse_readv,
[FUSE_WRITE] = fuse_write,
[FUSE_STATFS] = fuse_statfs,
[FUSE_SETXATTR] = fuse_setxattr,
[FUSE_GETXATTR] = fuse_getxattr,
[FUSE_ACCESS] = fuse_access,
[FUSE_CREATE] = fuse_create,
..........................
};
这里只是显示部分的fuse_operation函数,而这些函数在glusterfs中可以简称为fop,所以本文后面会把所有的fuse_operation函数称为fop类函数或者ops操作。
glusterfs定义了fop类函数来处理从fuse驱动设备读取出来的信息,因为glusterfs用的是堆栈式设计,所以glusterfs还会针对于fop函数类一一实现对应的回调函数。glusterfs从/dev/fuse 设备读取数据出来则交由fop函数去处理,处理结束之后则调用回调函数去处理最后发送回去fuse设备。
glusterfs代码是堆栈式结构,定义xlator结构体去实现每一个功能层,在glusterfs代码中,每一个xlator代表一个功能。init()函数实现了每个功能层是初始化以及该层的配置等设置。
现在具体看glusterfs用fuse实现的文件系统流程。fuse层是glusterfs posix接口的开始的层,这里首先先看看glusterfs fuse客户端在主函数里面的基本设置,以及fuse层的挂载,初始化参数等。用户在终端上执行mount挂载glusterfs客户端posix客户端,则会启动glusterfs进程,开始在主函数执行,首先会解析传入的参数确定执行的函数流,执行是客户端还是brick进程或者是glusterd守护进程。
1、在用户执行mount -t glusterfs ip:volume /mountpoint之后,启动了glusterfs客户端进程,在主函数首先是设置全局变量,配置iobuf存储池的大小,设置每一页存储池大小为128K。设置事件池的大小,frame,stack等池大小。接着检测传入系统参数,确定为客户端进程,启动协程池。解析卷模式等信息。
2、运行fuse的init函数,在init函数中,配置acl,selinux等一些参数。设置挂载点的参数,权限设置,读写块大小等。
3、打开/dev/fuse设备。保存fd到私有结构体,最后mount挂载点,进程切换为守护进程。
4、当这些工作结束之后,这时候会下发首次的lookup操作,检测brick进程是否在线,如果这是本次卷的首次挂载还会对目录进行分区区间的操作。这时候会启动线程fuse_thread_proc(),这个进程轮询的去读取/dev/fuse设备传输过来的数据,解析数据,选择相应的fop操作。
到了这里glusterfs客户端已经挂载到了一个目录上,准备工作做完可以开始工作了。
初始化结束之后,客户端挂载到了远端的volume。这时就可以在目录下进行正常的读写了,应用程序在读写文件时候是怎么一个流程?即IO流程,简单来说就是应用程序在挂载点读写,经过vfs层。vfs层在接受会调用fuse的注册函数,相应的读写函数会放在读写链表当中,这时候glusterfs启动的线程fuse_thread_proc()函数会轮询读取/dev/fuse设备,从设备读出数据流,解析具体操作,调用相应的fop函数,一层一层下发到client层,client运用了rpc技术,数据传输到brick,当执行结束之后就会调用对应的fop的回调函数返回。最后写回fuse设备。
执行完主函数设置好基本的环境变量,配置好参数,目录挂载。这时候就可以正常使用glusterfs文件系统了。这里主要是讲glusterfs从/dev/fuse读取数据之后的处理部分,也就是fuse实现的用户态文件系统提供服务部分,读写显示等操作。这里可以也可以理解成fuse的用户态部分。为了解释glusterfs是如何从fuse设备传入数据,这里首先解释几个结构体。
struct iovec {
void *iov_base;
size_t iov_len;
};
这是一个集合读写的操作集合,iov_base存储数据,iov_len保存了数据的长度。在fuse层中会定义两个iovec,一个存放操作类型,权限,用户,文件名等。一个用来存读写的数据。
struct iobuf {
union {
struct list_head list;
struct {
struct iobuf *next;
struct iobuf *prev;
};
};
struct iobuf_arena *iobuf_arena;
gf_lock_t lock;
int ref;
void *ptr;
void
};
iobuf结构是glusterfs中用来做io操作的io内存管理。每次做io操作,glusterfs都会从iobuf内存池中申请空间,glusterfs的iobuf存储池其实有几个结构体组成,池,域,buf三个级别。这里只关注buf,也就是一次io申请的内存大小。每一次有数据读写操作会申请一个iobuf。iobuf的默认值为128Kb,这个是为了对应fuse的io大小一致。从fuse设备读取出数据块保存到这个iobuf中。
struct fuse_in_header {
__u32 len;
__u32 opcode;
__u64 unique;
__u64 nodeid;
__u32 uid;
__u32 gid;
__u32 pid;
__u32 padding;
};
fuse_in_header结构保存着从fuse设备读取到的数据,从fuse读取数据一般分为两类,一类是fop操作类型,一类既是应用读写的数据流。fuse_in_header主要几个成员:opcode代表的是指向哪个fop函数,len是本次的数据长度,uid,gid,pid既是用户id,组id,进程id。利用这个函数,则可以知道调用哪个函数,已及能够识别用户,进程,操作的文件从而不会出现数据流混乱。
static fuse_handler_t *fuse_std_ops[FUSE_OP_HIGH] = {
[FUSE_LOOKUP] = fuse_lookup,
[FUSE_FORGET] = fuse_forget,
[FUSE_GETATTR] = fuse_getattr,
[FUSE_SETATTR] = fuse_setattr,
[FUSE_READLINK] = fuse_readlink,
[FUSE_SYMLINK] = fuse_symlink,
[FUSE_MKNOD] = fuse_mknod,
[FUSE_MKDIR] = fuse_mkdir,
[FUSE_UNLINK] = fuse_unlink,
......
[FUSE_LSEEK] = fuse_lseek,
}
fuse_handler_t *fuse_std_ops结构体定义了所有的fop操作,这是与fuse的客户端一样的文件操作函数。从fuse设备读取出数据,根据fuse_in_header结构体的opcode选中fuse_std_ops相应的函数执行。
首先glusterfs调用readv()函数从/dev/fuse设备读取出两iovec 类型数据,强制转化类型为fuse_in_header_t结构,fuse_in_header_t成员opcode保存了相应的fop类型,根据opcaode类型选择fuse_std_ops中的函数执行。到这里可以确定了文件的fop操作类型,但是还没有确定操作的文件,文件路径以及glusterfs所需要的gfid。这时候glusterfs会执行一个fuse_resolve_and_resume()函数,从iovec 第二个结构中解析出文件名,解析inode id,path,父目录的路径,gfid。当这些执行结束下发下一层处理。从这里可以了解glusterfs fuse主要功能:与fuse设备通信,获取文件操作的fop,解析出文件的路径,文件名等必要信息。当这些工作结束,fuse也就下发到下一层处理。而下一层可能是预读层,缓存层。从这里也可以看出glusteefs设计的堆栈的优秀性。每一层有各自的工作,各自的处理方式。这样可以很方便的增加一个功能层。比如glusterfs自身带的io-cache,read-ahead等。对应开发人员来说,在一些特殊的场景,这时候需要增加新的功能,只需要确定在堆栈的位置,利用xlator定义一个自身的结构体。定义一样的fop类函数,编写makefile文件,在glusterd代码加入所在层的编译。而这些工作在已有的代码上做简单复制则可以实现。比如在很多媒体,医院的应用场景,对权限有更多的要求,这时候利用nfs,cifs等无法实现的时候,只需要在增加一层权限层,对相应权限在每个fop中进行控制。而这样的控制也就忽略了上层所用的客户端,不管是nfs,cifs,还是利用API编写的函数,实现了权限在文件系统上的管理。
现在重点来看fuse层是如何处理从/dev/fuse设备读取数据,然后处理这些数据的。也就是从fuse_thread_proc()函数开始的流程。
到这里已经挂载到了一个目录上面,这时候glusterfs已经可以给上层应用提供服务。 对于glusterfs客户端来说,只要能保证fuse层,dht层,client层,卷模式层就可以正常的工作,现在假设我们创建了一个2+1的纠删码卷。了解glusterfs的整个IO流程,从挂载点到brick的客户端。这里以ops的写writev为例。
数据流在文件和操作系统和fuse驱动设备之后,glusterfs接受解析之后处理之后通过网络发送到服务端。
服务端在接受到客户端的请求开始执行brick上的工作。
图中忽略了很多功能层,如io-cache,write-behind,read-behind。brick节点上也忽略了一些io-stat等层只保留了基本的serve和posix层。虽然忽略掉这些层,但是事实上有这些层已经可以工作了。省略这部分主要是为了更清晰的看出glusterfs是如何借用fuse来实现和挂载点上的应用进行读写操作。
1. 对应用来说,glusterfs只是提供一个目录,应用该目录下的文件读写,当应用调用Linux系统调用write写入一个128Kb的数据时候,writev是vfs层提供,这时候vfs接受到应用的writev,根据fuse注册是函数调用fuse_file_aio_write,将写请求放入fuse connection的request pending queue, 随后进入睡眠等待应用程序reply。
2. glusterfs是Linux用户态的文件系统,会启动一个fuse_proc_fuse()轮询的读取/dev/fuse设备,glusterfs从/dev/fuse读取的数据出来保存在两个iov_in结构体当中,一个保存fop类型,一个保存写入的数据。解析数据结构的操作类型,根据fuse_ops函数结构体选择对应的fop函数执行,如果本次操作为写操作,那么会从第二个iov_in读取出数据,在相应的fop函数当中会执行解析文件名,gfid,构建inode等信息。准备好这些信息,调用STACK_WIND下发到下一个xlator,下发到dht层,dht是一个文件定位,文件迁移,分层功能实现的xlator。这里只解释文件定位,文件会根据文件名获得一个32位数值,根据父目录确实能够文件所在brick。当文件定位了远端服务器哪个brick之后,dht层开始下发写到client,下发到client层时候。Client利用rpc技术调用远端的brick函数,随后进入ping等待。
3. 当服务端的brick接受到客户端的调用,下发经过到posix,posix会在确定绝对路径之后对文件进行读写。而glusterfs支持文件IO方式,阻塞,非阻塞,异步AIO等方式。
glusterfs为用户提供了fuse这个基本文件系统的接口。从上面的流程可以知道,使用fuse客户端需要经过vfs,fuse,最后还会通过/dev/fuse设备回到用户态的glusterfs操作。使用fuse需要先用户态,经过内核态,最后又回到用户态。所以glusterfs又提供了一种API访问模式,API访问方式需要用户自己去编写相应的函数。API的方式抛弃了fuse设备使用户直接对gluster操作,也不必要去挂载一个目录,这样的话在用户到glusterfs中减少了经过vfs,fuse。也就不必要先用户态到内核态最后再到用户态。API的方式直接在用户态完成了操作。虽然API方式缩减了流程,但是把编码技巧和性能的提升问题留给了用户,这样对于一些没有学习过大并发高性能的编程人员还会觉得API方式性能低下不可用。
目前利用API开发的cifs方式在性能方面还是可以满足非编视频的应用,在fio测试软件中也设计了glusterfs API方式测试,可以利用fio软件测试对比API和fuse的方式区别。
修改FUSE数据块提升性能
现在了解了数据块是怎么从应用到落盘等整个流程。假设应用需要写100GB容量的文件,100GB容量的情况可以分成两种情况,1.一个文件容量为一个100GB。2. 262144个4KB的文件。这两个情况相当于大文件的读写问题,小文件的读写问题。
为了理清这两个问题,首先了解一个文件在glusterfs是如何从文件头读取完一个文件的,因为glusterfs对目录深度需要一步一步的查找,然后定位到文件。而且在分布式集群当中,客户端会跟多个brick去通信。所以这里假设环境是一个客户端一个brick,文件落在根节点上。
1、首先下发fop函数lookup查看文件父目录时候完整,这里是根目录,而且只有一个brick,所以不会出现检测父目录的问题。这里只执行了一个lookup操作。当然,在大规模的集群当中,这里会出现可能父目录的检测,修复等问题。
2、当父目录在确定的情况下,下发lookup函数去查看这个文件是否存在。当确定文件存在,会调用stat函数去获取文件的状态和属性。因为这里没有别的brick,所以也不会出现T文件情况,不会再次发生lookup,stata等情况。
3、文件确定下来之后,执行open函数操作,在brick上真实的打开文件(系统的fd),在客户端上会保存一个虚拟fd。
4、接着调用writev/readv函数,这里用了writev集合写加快写的速度。一次读写的最大块是128KB。
5、当读写完成之后,调用release关闭这个文件描述符。
从1和2可以知道,在文件定位和检测目录有和可能导致操作函数变多,这里规定了环境情况,所以这些函数不会执行过多。假设这些函数执行的速率是一致的(事实上,writev/readv这些函数执行的时间远大于其他,而且这些函数还会受到磁盘,磁盘文件系统的影响)。
假设一个100GB的文件,每次读写的块大小为128KB。Writev/Readv这需要819200函数的调用,则一次完整的读写则大概需要819207次fop函数的执行。如果把每次读写的块大小提升到1MB,一次完整的读写则回变成102407次。函数执行速度一致的情况下,这里相当于缩短了8倍的时间,事实上,在很多应用上的环境,速率会受很多方面的影响,磁盘,网络,应用的读写方式等等。而这里确实看出了当每次读写的块变大,对于大文件来说,读写速率会增加。
现在说一下如何修改fuse的最大块改为1MB从而提升性能。这里以fuse2.8版本为例,fuse-2.8版本以上,fuse的模块移入到操作系统内核里面。
1、获取操作系统的版本uname -a
2、登录官网下载Linux操作系统版本内核源码
3、修改FUSE内核代码部分
../fs/fuse/fuse_i.h
#define FUSE_MAX_PAGES_PER_REQ 32
=======》#define FUSE_MAX_PAGES_PER_REQ 256
提高每次读写分配的页数
../include/linux/mm.h
#define VM_MAX_READAHEAD 1024
fc->bdi.ra_pages = VM_MAX_READAHEAD/ PAGE_CACHE_SIZE;
这里需要调整VM_MAX_READAHEAD为1024X1024,但是这个是在内存模块,修改这部分可能导致系统出错,所以这里修改fc->bdi.ra_pages = 1024*1024/ PAGE_CACHE_SIZE
修改FUSE的用户态模块:
/lib/fuse_kern_chan.c
#define MIN_BUFSIZE 0x100000
这部分同时修改利用了fuse设备的块部分。
修改glusterfs代码模块:
xlators/mount/fuse/src/fuse-bridge.c
fuse_init()
fino.max_readahead = 1 << 20;
fino.max_write = 1 << 20;
Init():
gf_asprintf (&mnt_args, "%s%s%sallow_other,max_read=1048576",
priv->acl ? "" : "default_permissions,",
priv->fuse_mountopts ? priv->fuse_mountopts : "",
priv->fuse_mountopts ? "," : "");
修改glusterfs fuse部分,挂载点的参数等:
glusterfsd/src/glusterfsd.c
ctx->page_size = 1024 * GF_UNIT_KB;
修改默认读取分配的iobuf块大小
libglusterfs/src/iobuf.c
struct iobuf_init_config gf_iobuf_init_config[] 这里的结构体需要修改成最大2Mb每一个页,分配的页面和页大小需要根据系统的内存分配,这里最大的页面2Mb.
iobuf_pool_new ():
iobuf_pool->default_page_size = 1024 * GF_UNIT_KB;
关于iobuf分配,如果没有找到对应的页面大小,会获取默认页面大小。
xlators/performance/io-cache/src/io-cache.c
#define IOC_PAGE_SIZE (1024 * 1024)
Io cache层的cache页大小,这部分如果cache不匹配会导致io cache页面变多甚至出现页面miss情况。
xlators/performance/write-behind/src/write-behind.c
#define WB_AGGREGATE_SIZE 1048576
回写功能模块,在glusterfs很多功能层当中默认的最大块是128KB,而且glusterfs客户端有api。nfs,cifs客户端形式,这时候需要去检测每一层每一层读写块的大小,根据glusterfs配置文件每一层的writev/readv可以检测每一层的读写块大小。
通过调整FUSE数据块大小为1MB,目前在单客户端单节点/万兆网络/brick磁盘没有限制情况下,GlusterFS posix客户端读写能都把万兆带宽跑满。
GlusterFS POSIX深度优化
在上面的IO流程中,只是一个ops操作,而一个完整的文件读写需要调用多个ops,首先需要lookup定位文件,接着会调用stat获取文件的属性状态,而在一些情况下还会调用getfattr获取文件的扩展属性,随后open文件,这时候为了数据的安全性,glusterfs还会增加一个文件锁,而在副本,纠删码这种模式下,文件分布到不同的机器的brick上,这时候还需要多个brick加锁。就绪之后调用writev()开始读写文件。每一次最大的读写块为128KB。当文件读写结束之后,释放锁,随后调用release()释放fd。这样一次的文件操作结束。
在glusterfs当中定义了一个io_stat层,这个层主要是用来收集每个io的调用次数,对于开发人员,如果想要知道文件ops调用次数。打开这个功能选项可以观察到每一个ops操作的次数和ops的延迟。现在简单画出glusterfs io的一个文件的读写流程。
根据上面的ops流程图,可以发现在读写一个文件时候需要调用多个ops,而像lookup,stat等操作很多时候只是为了验证文件存在与否,随后读写文件。在大文件的情况下,这些ops在整个操作流程占用比例很少,操作的时间大部分消耗在磁盘的读写操作上和网络通信上。但是小文件情况下,假如一个文件为4KB,文件基本就是lookup一次,stat一次,open一次,readv/writev一次(不考虑异常处理方式)。大量的4KB文件情况下,比如百万/千万级别,这时候会发现文件ops中的读写只是占了极少的部分,大部分时间都花在文件定位,获取文件的属性等方面,而真正的读写时间特别短。这时候应该尽量缩短这些定位获得属性的操作,如构建元数据服务器,合并ops操作,合并文件为大文件。在glusterfs中有一个功能quick_read,开启这个功能,如果文件小于64MB情况下,在lookup时候就会把文件读取出来,这样减少了ops操作。
简化整个过程分成三个模块: 应用的操作,fuse的ops请求队列,glusterfs的ops处理。这时候可以发现,整个性能提升的核心在glustefs,当glusterfs能够快速处理完成,则能够快速的应答。根据上面的流程会发现,fuse_proc_fuse()读取之后需要先处理完成才会从新写回到fuse,在没有处理完成,fuse一直在等待。这时候可以并发ops的操作,这样能够同时的完成多个ops。
对于这种小文件情况,glusterfs主要从两个方面去优化。一,减少ops操作数,如quick_read。二,快速定位文件,如元数据服务器。对于小文件的存储读取在这里就不多说。
文件的io是用户程序发起,fuse设备为中介,glusterfs最后实现了整个文件的落盘和操作。从这里得出要是优化的话可以分成3部分: 用户的程序,fuse设备,gluster文件系统。
对于用户程序来说,可以使用API模式去绕过了fuse设备,简化了流程,但是这样的话会把所有的缓存机制,并发机制,buf等提高性能方面的工作留给了用户,而且在用户层这方面,更多用户是利用已有的软件去对文件读写,所以这方面优化来说不是很有针对性。对于fuse设备来说,根据上面的修改块方式,其实相当于扩大的页面数,在Linux操作系统里面IO都是以page页为单位,内核会将写入的请求按照PAGE_SIZE划分成多个page,然后再对page进行操作,简洁而优美,而glusterfs也是借用了内核的这种结构,缓存,iobuf等。这也是glusterfs作为一个文件系统的优势,简洁易上手。事实上上面修改块的大小是可以换成修改内核page的大小方式。在这里扩展说一下fuse设备一些优化的方式。
1、延长元数据的有效时间
元数据一般指文件的路径,文件的大小,文件名......在应用层能够在使用stat函数去获取这些属性,在一些分布式的文件系统中会缓存这些元数据,这样在定位文件的时候能够快速的获得文件的元数据。fuse保存元素结构是struct dentry和struct inode,这两个结构体文件系统的基础,所有的文件的操作都是先要填充着两个结构体再往后走。为什么说延长元数据的必要性?根据上面的图7,我们假设一下一个路径为 /taocloud-xdfs/glusterfs/app/file查找的流程和在glusterfs的ops操作。
首先调用look和stat ops操作去获取每个目录的元数据,先taocloud-xdfs,接着glusterfs,app,file。这样每个文件目录在fuse都会需要去填充struct dentry,struct inode。最后才会readv/writev。从这里面可以看见目录的深度以及每个目录都需要去准备这些元数据。虽然这里用了glusterfs的ops操作。不过这不影响理解fuse的操作,事实上在文件系统,inode,dentry,ops操作这些都是相通的。再假设一下,如果有成千上万的文件。假设n表示ops的操作延迟(即函数的时间),m表示ops的操作次数,k表示文件的个数。
file_time = m * lookup(n) + m * stat(n) + open(n) + m * readv(n)/writev(n) + release(n)
lookup stat的m表示目录的深度,readv/writev的m表示读写次数,就像fuse修改块的大小就是减少了m的次数,当然我们brick上做的raid就是为了减少n的时间,在减少n的时间上,Linux的AIO,非阻塞等也是一种方式。
当大文件时候,10个,100个这个样数量的大文件lookup,stat等这些文件是的m是极少的,但是如果是十万个百万个4k的文件呢?很明显,这是readv/writev已经不是很严重的问题,lookup,stat这时候已经大量的增加了,大部分时间都是消耗在这两个函数中,从这两个文件去获取元数据。如果这时候在fuse增加了缓存有效时间,获取的时候会直接在内存,内存的读写是磁盘的数量级。这样会性能有帮助,但是百万级别的文件这样的缓存是根本不行,一个是内存没那么大,一个是这时候可能导致缓存miss的情况加重。
像这样的情况,glusterfs提供了一种qiuck_read的功能,这种方式是减少ops次数。,在市面上的分布式存储很多会采用元数据服务器方式,文件合并方式。如ceph,利用几个(奇数)服务器去缓存元数据,这样每次文件去读写,先服务器然后到存储的osd中读取文件的内容。这样确实增加了小文件的性能,但是也暴露了问题,元数据出现错误时候这时候就会导致文件的出错而且维护这些元数据服务器也增加了运维的负担。这是一种不错的办法,但是需要不断的优化。了解ceph发展历史,ceph的fs模块是最开始出现的,而正真发展的对象和块。而在最近fs模块才慢慢去产品化。而另一种方式,合并各种小文件为大文件,这样操作时候就能够减少定位。只需要根据偏移量和大小去读写,这种方式类似视频文件,先占一大块几T,然后才慢慢填充文件,但是这样方式有需要预判文件的大小,当往这个文件增加内容时候,超出了自己分配的大小怎么办,或者最后自己独立成为一个大文件。这些无形中增加了文件的块迁移。
各种方式都有自己的优点缺点,这些方式都是得根据自己的应用,存储的数据去应用。在glusterfs中我们知道有一个分层功能tier,如果在这个功能之上,开发一个元数据服务器或者定义一个规则,小文件迁移到这个文件。这样能够glusterfs小文件的优化,而也缩短的小文件的路径。
2、增加数据块大小,增加每次读写数据流。根据上1中的方式,这种方式只能对大文件起到效果。
3、开启内核读缓存,Linux文件系统充分利用内存缓存文件数据,这样很多用户在读写文件时候根本不需要去对磁盘进行io,根据图8,也就是必要经过glusterfs。但是这样很可能读当脏数据。这时候在用户态的程序很难控制这种行为。在fuse中,我们可以在fuse挂载时候加上–o kernel_cache –o auto_cache来开启这个功能。
4、使用DirectIO取代BufferIO。这种方式也是fuse挂载一种参数,但是这种方式在顺序写时候会提升性能,其他应该情况则就下降。毕竟大部分应用都是为了存储数据,到最后更多是读。这样会导致最后不可以接受。
5、fuse设备request pending queue队列的优化,在fuse中每次客户端的读写都会放到一个等待队列当中,随后等待,使用了AIO的异步读写方式。这一块如果能够借鉴内存的调度方式,多个队列,队列的优先级别也是对性能有帮助。
6、在glusterfs层的优化,也既是图8中的应用文件系统优化,一种在glustefs产家种比较常见的方法:线程池的并发,一般这种方式是在glustefs的fuse层上开发,这种方式就是定义一个线程池,初始化多个线程等待工作队列,在这种方式,会首先在读取/dev/fuse的ops放入工作队列,随后线程读取工作队列去执行ops操作。但是从图7中可以看出其实文件操作的ops是有一个顺序流程。这时候怎么处理好这个流程是一个问题。而且glusterfs中ec卷模式几乎每次写都需要去读一次(纠删码的写损耗),这时候如果出现下一个块比前一个块先写入,这时候能否保证文件的数据安全性也是需要去验证。
参考文献
1.FUSE源码剖析 http://blog.sae.sina.com.cn/archives/2308
2.使用 FUSE 开发自己的文件系统 https://www.ibm.com/developerworks/cn/linux/l-fuse
3.基于fuse文件系统优化方法总结 https://baijia.baidu.com/s?old_id=493750