分布式技术的发展,深刻地改变了我们编程的模式和思考软件的模式。值 2019 岁末,PingCAP 联合 InfoQ 共同策划出品“分布式系统前沿技术 ”专题, 邀请众多技术团队共同参与,一起探索这个古老领域的新生机。本文出自 UCloud 后台研发工程师邓瑾。
为了应对 IO 性能要求很高的数据分析、AI 训练、高性能站点等场景,UFS 团队又推出了一款基于 NVMe SSD 介质的性能型 UFS,以满足高 IO 场景下业务对共享存储的需求。性能型 UFS 的 4K 随机写的延迟能保持在 10ms 以下,4K 随机读延迟在 5ms 以下。
性能的提升不仅仅是因为存储介质的升级,更有架构层面的改进,本文将从协议、索引、存储设计等几方面来详细介绍性能型 UFS 升级改造的技术细节。
此前容量型 UFS 设计时支持的协议为 NFSv3,其设计理念是接口无状态,故障恢复的逻辑简单。此外 NFSv3 在 Linux 和 Windows 上被广泛支持,更易于跨平台使用。但是 NFSv3 的设计缺点导致的高延迟在高 IO 场景下是不可接受的,所以在性能型 UFS 中,我们选择仅支持性能更好、设计更先进的 NFSv4 协议。
NFSv4 与 NFSv3 相比,更先进的特性包括:支持有状态的 lock 语义、多协议间的 compound 机制等。特别是 compound 机制,可以让多次 NFS 协议交互在一个 RTT 中完成,很好地解决了 NFSv3 性能低效的问题。一次典型的 open for write 操作,在 NFSv3 和 NFSv4 上分别是这样的:
可以看到,在关键的 IO 部分,NFSv4 比 NFSv3 节省一半的交互次数,可以显著降低 IO 延迟。
除了协议以外,性能型 UFS 的核心由业务索引和底层存储两部分组成,由于底层 IO 性能的提升,这两部分都需要进行深度改造以适应这种结构性的改变。下面我们将分别介绍这两部分的改造细节。
索引服务是分布式文件系统的核心功能之一。相比对象存储等其它存储服务,文件存储的索引需要提供更为复杂的语义,所以会对性能产生更大影响。
索引服务的功能模块设计是基于单机文件系统设计思路的一种『仿生』,分为两大部分:
目录索引:实现树状层级目录,记录各个目录下的文件和子目录项。
文件索引:记录文件元数据,包含数据块存储信息和访问权限等。
索引服务各模块的功能是明确的,主要解决两个问题:
业务特性:除了实现符合文件系统语义的各类操作外,还要保证索引数据的外部一致性,在各类并发场景下不对索引数据产生静态修改从而产生数据丢失或损坏。
分布式系统特性:包括系统拓展性、可靠性等问题,使系统能够应对各类节点和数据故障,保证系统对外的高可用性和系统弹性等。
虽然功能有区别,目录索引和文件索引在架构上是类似的,所以我们下面只介绍文件索引 (FileIdx) 架构。在以上的目标指导下,最终 FileIdx 采用无状态设计,依靠各索引节点和 master 之间的租约( Lease )机制来做节点管理,实现其容灾和弹性架构。
master 模块负责维护一张路由表,路由表可以理解成一个由虚节点组成的一致性哈希环,每个 FileIdx 实例负责其中的部分虚节点,master 通过心跳和各个实例节点进行存活性探测,并用租约机制告知 FileIdx 实例和各个 NFSServer 具体的虚节点由谁负责处理。如果某个 FileIdx 实例发生故障,master 只需要在当前租约失效后将该节点负责的虚节点分配给其他实例处理即可。
当 NFSServer 需要向文件服务请求具体操作 (比如请求分配 IO 块) 时,会对请求涉及的文件句柄做哈希操作确认负责该文件的虚节点由哪个 FileIdx 处理,将请求发至该节点。每个节点上为每个文件句柄维持一个处理队列,队列按照 FIFO 方式进行执行。本质上这构成了一个悲观锁,当一个文件的操作遇到较多并发时,我们保证在特定节点和特定队列上的排队,使得并发修改导致的冲突降到最低。
尽管租约机制一定程度上保证了文件索引操作的并发安全性,但是在极端情况下租约也不能保持并发操作的绝对互斥及有序。所以我们在索引数据库上基于 CAS 和 MVCC 技术对索引进行更新保护,确保索引数据不会因为并发更新而丧失外部一致性。
在性能型 UFS 中,底层存储的 IO 延迟大幅降低带来了更高的 IOPS 和吞吐,也对索引模块特别是 IO 块的分配性能提出了挑战。频繁地申请 IO 块导致索引在整个 IO 链路上贡献的延迟比例更高,对性能带来了损害。一方面我们对索引进行了读写分离改造,引入缓存和批量更新机制,提升单次 IO 块分配的性能。
同时,我们增大了 IO 块的大小,更大的 IO 数据块降低了分配和获取数据块的频率,将分配开销进行均摊。后续我们还将对索引关键操作进行异步化改造,让 IO 块的分配从 IO 关键路径上移除,最大程度降低索引操作对 IO 性能的影响。
存储功能是一个存储系统的重中之重,它的设计实现关系到系统最终的性能、稳定性等。通过对 UFS 在数据存储、数据操作等方面的需求分析,我们认为底层存储 (命名为 nebula) 应该满足如下的要求:
简单:简单可理解的系统有利于后期维护。
可靠:必须保证高可用性、高可靠性等分布式要求。
拓展方便:包括处理集群扩容、数据均衡等操作。
支持随机 IO。
充分利用高性能存储介质。
基于以上目标,我们将底层存储系统 Nebula 设计为基于 append-only 的存储系( immutable storage )。面向追加写的方式使得存储逻辑会更简单,在多副本数据的同步上可以有效降低数据一致性的容错复杂度。更关键的是,由于追加写本质上是一个 log-based 的记录方式,整个 IO 的历史记录都被保存,在此之上实现数据快照和数据回滚会很方便,在出现数据故障时,更容易做数据恢复操作。
在现有的存储系统设计中,按照数据寻址的方式可以分为去中心化和中心化索引两种,这两者的典型代表系统是 Ceph 和 Google File System。去中心化的设计消除了系统在索引侧的故障风险点,并且降低了数据寻址的开销。但是增加了数据迁移、数据分布管理等功能的复杂度。出于系统简单可靠的设计目标,我们最终选择了中心化索引的设计方式,中心化索引使集群扩容等拓展性操作变得更容易。
中心化索引面临的性能瓶颈主要在数据块的分配上,我们可以类比一下单机文件系统在这方面的设计思路。早期文件系统的 inode 对数据块的管理是 block-based,每次 IO 都会申请 block 进行写入,典型的 block 大小为 4KB,这就导致两个问题:
1. 4KB 的数据块比较小,对于大片的写入需要频繁进行数据块申请操作,不利于发挥顺序 IO 的优势。
2. inode 在基于 block 的方式下表示大文件时需要更大的元数据空间,能表示的文件大小也受到限制。
在 Ext4/XFS 等更先进的文件系统设计中,inode 被设计成使用 extent-based 的方式来实现,每个 extent 不再被固定的 block 大小限制,相反它可以用来表示一段不定长的磁盘空间,如下图所示:
显然地,在这种方式下,IO 能够得到更大更连续的磁盘空间,有助于发挥磁盘的顺序写能力,并且有效降低了分配 block 的开销,IO 的性能也得到了提升,更关键的是,它可以和追加写存储系统非常好地结合起来。我们看到,不仅仅在单机文件系统中,在 Google File System、Windows Azure Storage 等分布式系统中也可以看到 extent-based 的设计思想。我们的 nebula 也基于这一理念进行了模型设计。
在 Nebula 系统中存储的数据按照 Stream 为单位进行组织,每个 Stream 称为一个数据流,它由一个或多个 extent 组成,每次针对该 Stream 的写入操作以 block 为单位在最后一个 extent 上进行追加写,并且只有最后一个 extent 允许写入,每个 block 的长度不定,可由上层业务结合场景决定。而每个 extent 在逻辑上构成一个副本组,副本组在物理上按照冗余策略在各存储节点维持多副本,Stream 的 IO 模型如下:
streamsvr 和 extentsvr
基于这个模型,存储系统被分为两大主要模块:
streamsvr:负责维护各个 stream 和 extent 之间的映射关系以及 extent 的副本位置等元数据,并且对数据调度、均衡等做管控。
extentsvr:每块磁盘对应一个 extentsvr 服务进程,负责存储实际的 extent 数据存储,处理前端过来的 IO 请求,执行 extent 数据的多副本操作和修复等。
在存储集群中,所有磁盘通过 extentsvr 表现为一个大的存储池,当一个 extent 被请求创建时,streamsvr 根据它对集群管理的全局视角,从负载和数据均衡等多个角度选取其多副本所在的 extentsvr,之后 IO 请求由客户端直接和 extentsvr 节点进行交互完成。在某个存储节点发生故障时,客户端只需要 seal 掉当前在写入的 extent,创建一个新的 extent 进行写入即可,节点容灾在一次 streamsvr 的 rpc 调用的延迟级别即可完成,这也是基于追加写方式实现带来的系统简洁性的体现。
由此,存储层各模块的架构图如下:
至此,数据已经可以通过各模块的协作写入到 extentsvr 节点,至于数据在具体磁盘上的存储布局,这是单盘存储引擎的工作。
前面的存储架构讲述了整个 IO 在存储层的功能分工,为了保证性能型 UFS 的高性能,我们在单盘存储引擎上做了一些优化。
存储介质性能的大幅提升对存储引擎的设计带来了全新的需求。在容量型 UFS 的 SATA 介质上,磁盘的吞吐较低延迟较高,一台存储机器的整体吞吐受限于磁盘的吞吐,一个单线程 / 单进程的服务就可以让磁盘吞吐打满。随着存储介质处理能力的提升,IO 的系统瓶颈逐渐从磁盘往处理器和网络带宽方面转移。
在 NVMe SSD 介质上由于其多队列的并行设计,单线程模型已经无法发挥磁盘性能优势,系统中断、网卡中断将成为 CPU 新的瓶颈点,我们需要将服务模型转换到多线程方式,以此充分发挥底层介质多队列的并行处理能力。为此我们重写了编程框架,新框架采用 one loop per thread 的线程模型,并通过 Lock-free 等设计来最大化挖掘磁盘性能。
让我们思考一个问题,当客户端写入了一片数据 block 之后,读取时如何找到 block 数据位置? 一种方式是这样的,给每个 block 分配一个唯一的 blockid,通过两级索引转换进行寻址:
第一级:查询 streamsvr 定位到 blockid 和 extent 的关系。
第二级:找到 extent 所在的副本,查询 blockid 在 extent 内的偏移,然后读取数据。
这种实现方式面临两个问题,( 1 )第一级的转换需求导致 streamsvr 需要记录的索引量很大,而且查询交互会导致 IO 延迟升高降低性能。( 2 )第二级转换以 Facebook Haystack 系统为典型代表,每个 extent 在文件系统上用一个独立文件表示,extentsvr 记录每个 block 在 extent 文件中的偏移,并在启动时将全部索引信息加载在内存里,以提升查询开销,查询这个索引在多线程框架下必然因为互斥机制导致查询延迟,因此在高性能场景下也是不可取的。而且基于文件系统的操作让整个存储栈的 IO 路径过长,性能调优不可控,也不利于 SPDK 技术的引入。
为避免上述不利因素,我们的存储引擎是基于裸盘设计的,一块物理磁盘将被分为几个核心部分:
superblock:超级块,记录了 segment 大小,segment 起始位置以及其他索引块位置等。
segment:数据分配单位,整个磁盘除了超级块以外,其他区域全部都是 segment 区域,每个 segment 是定长的 (默认为 128MB),每个 extent 都由一个或多个 segment 组成。
extent index / segment meta region:extent/segment 索引区域,记录了每个 extent 对应的 segment 列表,以及 segment 的状态 (是否可用) 等信息。
基于这个设计,我们可以将 block 的寻址优化为无须查询的纯计算方式。当写完一个 block 之后,将返回该 block 在整个 stream 中的偏移。客户端请求该 block 时只需要将此偏移传递给 extentsvr,由于 segment 是定长的,extentsvr 很容易就计算出该偏移在磁盘上的位置,从而定位到数据进行读取,这样就消除了数据寻址时的查询开销。
我们之前出于简单可靠的理念将存储系统设计为 append-only,但是又由于文件存储的业务特性,需要支持覆盖写这类随机 IO 场景。
因此我们引入了一个中间层 FileLayer 来支持随机 IO,在一个追加写的引擎上实现随机写,该思路借鉴于 Log-Structured File System 的实现。LevelDB 使用的 LSM-Tree 和 SSD 控制器里的 FTL 都有类似的实现,被覆盖的数据只在索引层面进行间接修改,而不是直接对数据做覆盖写或者是 COW(copy-on-write),这样既可以用较小的代价实现覆盖写,又可以保留底层追加写的简单性。
FileLayer 中发生 IO 操作的单元称为 dataunit,每次读写操作涉及的 block 都在某个 dataunit 上进行处理,dataunit 的逻辑组成由如下几个部分:
dataunit 由多个 segment 组成 (注意这和底层存储的 segment 不是一个概念),因为基于 LSM-Tree 的设计最终需要做 compaction,多 segment 的划分类似于 LevelDB 中的多层 sst 概念,最下层的 segment 是只读的,只有最上层的 segment 允许写入,这使得 compaction 操作可以更简单可靠地进行甚至回滚,而由于每次 compaction 涉及的数据域是确定的,也便于我们检验 compaction 操作的 invariant:回收前后数据域内的有效数据必须是一样的。每个 segment 则由一个索引流和一个数据流组成,它们都存储在底层存储系统 nebula 上,每次写入 IO 需要做一次数据流的同步写,而为了提升 IO 性能,索引流的写入是异步的,并且维护一份纯内存索引提升查询操作性能。为了做到这一点,每次写入到数据流中的数据是自包含的,这意味着如果索引流缺失部分数据甚至损坏,我们可以从数据流中完整构建整个索引。客户端以文件为粒度写入到 dataunit 中,dataunit 会给每个文件分配一个全局唯一的 fid,fid 作为数据句柄存储到业务索引中 (FileIdx 的 block 句柄)。dataunit 本身则由 fileserver 服务进程负责,每个 fileserver 可以有多个 dataunit,coordinator 根据各节点的负载在实例间进行 dataunit 的调度和容灾。整个 FileLayer 的架构如下:
至此,存储系统已经按照设计要求满足了我们文件存储的需求,下面我们来看一看各个模块是如何一起协作来完成一次文件 IO 的。
从整体来说,一次文件写 IO 的大致流程是这样的:
1. 用户在主机上发起 IO 操作会在内核层被 nfs-client 在 VFS 层截获 (仅以 Linux 系统下为例),通过被隔离的 VPC 网络发往 UFS 服务的接入层。
2. 接入层通过对 NFS 协议的解析和转义,将这个操作分解为索引和数据操作。
3. 经过索引模块将这个操作在文件内涉及的 IO 范围转化为由多个 file system block (固定大小,默认 4MB )表示的 IO 范围。
4. NFSServer 拿到需要操作的 block 的句柄 (bid) 后去请求 FileLayer 进行 IO 操作 (每个 bid 在 FileLayer 中代表一个文件)。
请求会被 NFSServer 发往负责处理该 bid 对应的文件的 fileserver 上,fileserver 获取该文件所在的 dataunit 编号 (此编号被编码在 bid 中) 后,直接往该 dataunit 当前的数据流 (stream) 中进行追加写,完成后更新索引,将新写入的数据的位置记录下来,本次 IO 即告完成,可以向 NFSServer 返回回应了。类似地,当 fileserver 产生的追加写 IO 抵达其所属的 extentsvr 的时候,extentsvr 确定出该 stream 对应的最后一个 extent 在磁盘上的位置,并执行一次追加写落地数据,在完成多副本同步后返回。
至此,一次文件写 IO 就完成了。
经过前述的设计和优化,性能型 UFS 的实际性能数据如下:
本文从 UFS 性能型产品的需求出发,详细介绍了基于高性能存储介质构建分布式文件系统时,在协议、业务架构、存储引擎等多方面的设计考虑和优化,并最终将这些优化落实到产品中去。性能型 UFS 的上线丰富了产品种类,各类对 IO 延迟要求更高的大数据分析、AI 训练等业务场景将得到更好的助力。
后续我们将在多方面继续提升 UFS 的使用体验,产品上会支持 SMB 协议,提升 Windows 主机使用文件存储的性能;底层存储会引入 SPDK、RDMA 等技术,并结合其它更高性能的存储介质;在冷存数据场景下引入 Erasure Coding 等技术;使用户能够享受到更先进的技术带来的性能和价格红利。
作者介绍:邓瑾,UCloud 后台研发工程师。
本文是「分布式系统前沿技术」专题文章,目前该专题在持续更新中,欢迎大家保持关注👇