写点什么

70% 读写性能提升!基于 UCloud 对象存储 US3 的用户态文件系统设计

用户头像
UCloud技术
关注
发布于: 2021 年 05 月 11 日
70%读写性能提升!基于UCloud对象存储US3的用户态文件系统设计

前言

为了解决在数据备份场景中的可靠性、容量、成本问题,越来越多的用户倾向于使用对象存储来进行备份。然而,有些场景下通过对象存储 US3 来备份还是不够方便,甚至不适用。比如在数据库备份场景下,如果直接使用对象存储备份,可能需要先把数据库通过 mysqldump 做逻辑备份,或者采用 xtrabackup 做物理备份到本地,然后使用基于对象存储的 SDK 的工具把备份文件上传到对象存储,备份过程繁琐。再例如服务的日志归档备份,为降低成本可以将日志存储到对象存储 US3 中,通过 SDK 或者工具来操作,不仅需要编写备份代码,而且管理复杂。如果能提供一种以 POSIX 接口远程访问对象存储的方式,就可以很好地解决上述问题。


开源方案实践

已经有一些开源的项目将对象存储中的 bucket 映射为文件系统,如 s3fs 和 goofys 等,在使用这些开源方案的时候,我们发现了一些问题。


s3fs

s3fs 通过 FUSE 将 s3 和支持 s3 协议的对象存储的 bucket 挂载到本地(FUSE 的介绍详见下文)。通过对 s3fs 进行测试后,我们发现其在大文件的写入方面性能特别差,研究其实现过程后,我们发现 s3fs 在写入时会优先写入本地临时文件,然后以分片上传的方式将并发的将数据写入到对象存储。如果空间不足,则会以同步的方式将分片上传,代码如下:

ssize_t FdEntity::Write(const char* bytes, off_t start, size_t size){    // no enough disk space  if(0 != (result = NoCachePreMultipartPost())){    S3FS_PRN_ERR("failed to switch multipart uploading with no cache(errno=%d)", result);    return static_cast(result);  }    // start multipart uploading  if(0 != (result = NoCacheLoadAndPost(0, start))){    S3FS_PRN_ERR("failed to load uninitialized area and multipart uploading it(errno=%d)", result);    return static_cast(result);  }}
复制代码


由于我们的主要使用场景为大文件的备份,基于云主机硬盘成本等方面的考虑,我们决定放弃这一方案。


goofys

goofys 是用 go 实现的将 s3 以及部分非 s3 协议的对象存储挂载到 linux 的文件系统,测试后,我们发现 goofys 主要有三个问题:


写入没有进行并发控制。在大文件的写入场景下,goofys 同样将文件进行分片,然后每个分片开一个协程写入到后端存储。对象存储一般通过 HTTP 协议进行通信,由于请求是同步的方式,在不限制并发数的情况下会有大量的连接,消耗大量的内存等资源。


读取采用同步方式,性能很差。FUSE 有两种读取模式 async 和 sync,通过挂载时的设置去选择,goofys 强制使用了 sync 模式,并且预读的实现为乱序读取超过三次后停止预读,代码如下:

if !fs.flags.Cheap && fh.seqReadAmount >= uint64(READAHEAD_CHUNK) && fh.numOOORead < 3 {  ...  err = fh.readAhead(uint64(offset), len(buf))  ...}
复制代码


fh.numOOORead 为乱序读取的次数,FUSE 模块会对超过 128k 的 IO 进行拆分,以 128k 对齐。简单介绍一下 FUSE 的同步读取和异步读取模式的区别。内核的读取一般入口是在底层文件系统的 read_iter 函数,然后调用 VFS 层的 generic_file_read_iter,该函数内部实现会通过调用 readpages 进行预读。如果预读后没有对应的 page 则会调用 readpage 读取单页。由于 goofys 不支持该设置,我们通过对 s3fs 设置不同的配置来测试,然后抓取读取时的调用栈对比其中的区别。设置了异步读取模式的读堆栈如下所示:

fuse_readpages+0x5/0x110 [fuse]read_pages+0x6b/0x190__do_page_cache_readahead+0x1c1/0x1e0ondemand_readahead+0x1f9/0x2c0? pagecache_get_page+0x30/0x2d0generic_file_buffered_read+0x5a50xb10? mem_cgroup_try_charge+0x8b/0x1a0? mem_cgroup_throttle_swaprate+0x17/0x10efuse_file_read_iter+0x10d/0x130 [fuse]? __handle_mm_fault+0x662/0x6a0new_sync_read+0x121/0x170vfs_read+0x91/0x140
复制代码


其中 vfs_read 是系统调用到 vfs 层的入口函数。之后会调用到 readpages 进行多页的读取。fuse_readpages 将读请求发给用户态文件系统,进而完整整个读取流程。同步读取模式的堆栈如下所示:

fuse_readpage+0x5/0x60 [fuse]                                                                                               generic_file_buffered_read+0x61a/0xb10                                                                                      ? mem_cgroup_try_charge+0x8b/0x1a0                                                                                          ? mem_cgroup_throttle_swaprate+0x17/0x10e                                                                                   fuse_file_read_iter+0x10d/0x130 [fuse]                                                                                  ? __handle_mm_fault+0x662/0x6a0                                                                                         new_sync_read+0x121/0x170vfs_read+0x91/0x140
复制代码


和异步流程相同,依然是在 generic_file_read_iter 中进行读取,当读取之后没有对应的页,会尝试读取单页。相关代码如下,内核版本基于 4.14:

no_cached_page:  /*    * Ok, it wasn't cached, so we need to create a new    * page..    */  page = page_cache_alloc_cold(mapping);  if (!page) {    error = -ENOMEM;    goto out;  }  error = add_to_page_cache_lru(page, mapping, index,      mapping_gfp_constraint(mapping, GFP_KERNEL));  if (error) {    put_page(page);    if (error == -EEXIST) {      error = 0;      goto find_page;    }    goto out;  }  goto readpage;
复制代码


如果设置了同步方式进行读取,FUSE 模块会无效内核的预读,转而进入到 no_cached_page 读取单页。所以同步模式下落到用户态文件系统的读 IO 有大块的 readpagesIO 和 readpage 的 4K 单页 IO,由于 offset 存在相同,goofys 会判断为乱序的读取,超过 3 次后停止预读,由于每次和 US3 的交互都是 4K 的 GET 请求,性能会比较差,难以满足用户的需求。


分片上传的大小不固定,无法适配 US3 。US3 目前的分片大小固定为 4M,而 goofys 的分片大小需要动态的去计算,并手动修改进行适配,代码如下:


func (fh *FileHandle) partSize() uint64 { var size uint64 if fh.lastPartId < 1000 { size = 5 * 1024 * 1024 } else if fh.lastPartId < 2000 { size = 25 * 1024 * 1024 } else { size = 125 * 1024 * 1024 } ...}
复制代码


同时,s3 协议本身没有 rename 的的接口,s3fs 和 goofys 的 rename 都是通过将源文件内容复制到目标文件,然后删除源文件实现的。


而 US3 内部支持直接修改文件名,US3FS 通过使用相关的接口实现 rename 操作,相比 s3fs 和 goofys 性能更好。同时 s3fs 和 goofys 挂载 US3 的 bucket 都需要走代理进行协议的转换,使用 US3FS 则减少了这一 IO 路径,性能上更有优势。


通过对 s3fs 和 goofys 的实践,我们发现两者在 US3 的备份场景上的性能有一些问题,同时适配的工作量也比较大,基于此,我们决定开发一款能够满足用户在数据备份场景需求的,依托对象存储作为后端的文件系统。

US3FS 设计概述


US3FS 通过 FUSE 实现部分 POSIX API。在介绍 US3FS 实现之前,先简单介绍一下 Linux 的 VFS 机制和 FUSE 实现(有这部分基础的朋友可直接跳过)。


VFS

VFS,全称 Virtual File System,是 linux 内核中一个承上启下的虚拟层,隶属于 IO 子系统。对上,为用户态应用提供了文件系统接口;对下,将具体的实现抽象为同一个函数指针供底层文件系统实现。


linux 文件系统中的元数据分为 dentry(directory entry)和 inode,我们知道,文件名并不属于文件的元数据,为了优化查询,vfs 在内存中建立 dentry 以缓存文件名和 inode 的映射以及目录树的实现。单机文件系统的实现,dentry 只存在于内存中,不会落盘,当查找某个文件时内存没有对应的 dentry,vfs 会调用具体的文件系统实现来查找对应的文件,并建立起对应的数据结构。inode 缓存了一个文件的元数据,如大小,修改时间等,会持久化到硬盘中,数据的读写通过地址空间找到对应的 page 和 block device 进行读写。


FUSE

FUSE,全称 Filesystem in Userspace,用户态文件系统,我们知道,一般直接在内核态实现某个特性是比较痛苦的事情,通常内核的 debug 比较困难,而且稍不注意就会陷入到内核的各种细节而无法自拔。FUSE 就是为了简化程序员的工作,将内核的细节隐藏起来,提供一套用户态的接口用于实现自己的文件系统,用户只需要实现对应的接口即可。内核态的 FUSE 模块和用户态的 FUSE 库的交互通过/dev/fuse 进行通信,然后调用用户自己的实现。当然,缺点就是增加了 IO 路径以及内核态/用户态的切换,对性能有一定影响。



元数据设计

US3FS 通过实现 FUSE 的接口,将 US3 中 bucket 的对象映射为文件,和分布式文件系统不同,没有 mds(metadata server)维护文件元数据,需要通过 HTTP 向 us3 获取。当文件较多时,大量的请求会瞬间发出,性能很差。为了解决这一点,US3FS 在内存中维护了 bucket 的目录树,并设置文件元数据的有效时间,避免频繁和 US3 交互。


这也带来了一致性的问题,当多个 client 修改同一 bucket 中的文件,其中的缓存一致性无法保证,需要用户自己取舍。为了提升检索的性能,文件并没有像对象存储以平铺的方式放在整个目录中,而是采用了传统文件系统类似的方式,为每一个目录构建相关数据结构来保存其中的文件,同时 inode 的设计也尽量简洁,只保存必要字段,减少内存的占用。


目前 Inode 中保存的字段有 uid,gid,size,mtime 等,通过 US3 的元数据功能在对象中持久化。例如下图所示,在 US3 的 bucket 中有一个名为"a/b/c/f1"的对象,在文件系统中,会将每一个“/"划分的前缀映射为目录,从而实现左边的目录树。



IO 流程设计

对于数据的写入,US3 支持大文件的分片上传。利用这一特性,US3FS 通过将数据写入 cache,在后台将数据以分片上传的方式,将数据以 4MB 的 chunk 写入到后端存储中。分片上传的流程如下图所示,通过令牌桶限制整个系统的写入并发数。每个分片写入的线程都会获取令牌后写入,通过当文件 close 时写入最后一个分片,完成整个上传流程。


文件的读取通过在 US3FS 的 cache 实现预读来提升性能。kernel-fuse 自身对数据的读写进行了分片,在不修改内核的情况下,IO 最大为 128K。而大文件的读取场景一般为连续的大 IO,这种场景下 IO 会被切成 128K 的片,不做预读的话,无法很好的利用网络带宽。US3FS 的预读算法如下所示:



如图所示,第一次同步读取完成后,会往后进行当前长度的预读,并将预读的中点设置为下次触发预读的 trigger。之后的读取如果不连续,则清空之前的状态,进行新的预读,如果连续,则判断当前读取的结束位置是否不小于触发预读的偏移,如果触发预读,则将预读窗口的大小扩大为 2 倍,直到达到设定的阈值。之后以新的窗口进行预读。如果未触发,则不进行预读。预读对顺序读的性能有很大提升。鉴于 US3FS 使用场景多为大文件的场景,US3FS 本身不对数据进行任何缓存。在 US3FS 之上有内核的 pagecache,当用户重复读取同一文件时 pagecache 能够很好的起作用。


数据一致性

由于对象存储的实现机制原因,当前大文件的写入,在完成所有的分片上传之前,数据是不可见的,所以对于 US3FS 的写入,在 close 之前,写入的数据都是不可读的,当 close 后,US3FS 会发送结束分片的请求,结束整个写入流程,此时数据对用户可见。


对比测试

在并发度为 64,IO 大小为 4M 测试模型下,40G 文件的顺序写和顺序读进行多次测试,平均结果如下:



测试过程中,goofys 的内存占用比较高,峰值约 3.3G,而 US3FS 比较平稳,峰值约 305M,节省了 90%内存空间。s3fs 表现相对较好,因为使用本地临时文件做缓存,所以内存占用比较少,但是写入文件比较大,硬盘空间不足时,性能会下降到表格中的数据。


在顺序读的测试中,测试结果可以验证我们的分析,goofys 由于本身设计的原因,在这种场景下性能无法满足我们的要求。另外在测试移动 1G 文件的场景中,对比结果如下:



可见在移动需求场景下,特别是大文件居多的场景,通过 US3FS 能提升上百倍的性能。

总结

总而言之,s3fs 和 goofys 在大文件的读写场景下各有优劣,相比之下,US3 自研的 US3FS 无论是读还是写都有更好的性能,而且和 US3 的适配性更强,更易于拓展。

发布于: 2021 年 05 月 11 日阅读数: 26
用户头像

UCloud技术

关注

还未添加个人签名 2020.08.14 加入

还未添加个人简介

评论

发布
暂无评论
70%读写性能提升!基于UCloud对象存储US3的用户态文件系统设计