设计和实现
3FS 系统有四个组件:集群管理器、元数据服务、存储服务和客户端。所有组件都通过 RDMA 网络(InfiniBand 或 RoCE)连接。
元数据和存储服务向集群管理器发送心跳。集群管理器处理成员变更并将集群配置分发给其他服务和客户端。部署多个集群管理器,其中一个被选为主管理器。当主管理器发生故障时,另一个管理器被提升为主管理器。集群配置通常存储在可靠的分布式协调服务中,例如 ZooKeeper 或 etcd。在我们的生产环境中,我们使用与文件元数据相同的键值存储来减少依赖性。
文件元数据操作(例如打开或创建文件/目录)被发送到实现文件系统语义的元数据服务。元数据服务是无状态的,因为文件元数据存储在事务键值存储中(例如 FoundationDB)。客户端可以连接到任何元数据服务。
每个存储服务管理一些本地 SSD 并提供一个块存储接口。存储服务实现带分配查询的链式复制 (CRAQ) 以确保强一致性。CRAQ 的“全部写入-任意读取”方法有助于释放 SSD 和 RDMA 网络的吞吐量。3FS 文件被分割成大小相等的块,这些块在多个 SSD 上进行复制。
为应用程序开发了两个客户端:FUSE 客户端和本机客户端。大多数应用程序使用 FUSE 客户端,其采用门槛较低。性能关键型应用程序与本机客户端集成。
文件系统接口
对象存储正成为数据分析和机器学习的热门选择。然而,文件系统语义和文件按目录组织的统一命名空间为应用程序提供了更大的灵活性。
- 原子目录操作对象存储可以通过在对象键中使用斜杠 (/) 来近似分层目录结构。但是,它本身不支持原子移动文件/目录或递归删除整个目录等操作。实际上,我们内部应用程序中的一个常见模式是创建一个临时目录,将文件写入其中,然后将目录移动到其最终位置。处理大量小文件时,目录的递归删除至关重要。没有它,应用程序必须遍历每个目录并逐个删除文件。
- 符号和硬链接我们的应用程序利用符号和硬链接来创建动态更新的数据集的轻量级快照,其中新数据作为单独的文件附加。
- 熟悉的界面文件界面众所周知且随处可见。无需学习新的存储 API。许多数据集都存储为 CSV/Parquet 文件。调整基于文件的数据加载器以使用 3FS FUSE 客户端或本机客户端非常简单。
FUSE 的局限性
FUSE(用户空间文件系统)通过 FUSE 内核模块将 I/O 操作重定向到用户空间进程,从而简化了文件系统客户端的开发。它给人一种错觉,好像应用程序正在访问远程文件系统,就像它是本地文件系统一样。但是,它存在性能限制:
- 内存复制开销用户空间文件系统守护进程无法访问应用程序内存。内核和用户空间之间的数据传输会消耗内存带宽并增加端到端延迟。
- 原始多线程支持当应用程序发起 I/O 请求时,FUSE 会将这些请求放入受自旋锁保护的多线程共享队列中。然后,用户空间文件系统守护进程会从此队列检索并处理请求。由于锁争用,FUSE 的 I/O 处理能力无法随线程数的增加而扩展。我们的基准测试结果表明,FUSE 每秒仅处理大约 400K 4KiB 读取。随着锁争用加剧,进一步增加并发性并不能提高性能。
perf分析显示,内核空间自旋锁消耗了大量 CPU 时间。
大多数应用程序(例如数据分析)在 3FS 上执行大块写入,或者它们可以在内存中缓冲数据并在写入缓冲区已满时将其刷新到 3FS。但是,Linux 5.x 上的 FUSE 不支持对同一文件并发写入。应用程序通过同时写入多个文件来克服此限制,从而最大限度地提高总吞吐量。
读取操作表现出更复杂的模式。一些训练作业需要随机访问数据集样本,每个样本的读取大小从几千字节到几兆字节不等。而且样本在文件中通常不是 4K 对齐的。数据加载器专门设计用于获取批量样本。但它们在处理 FUSE 安装的 3FS 上的小随机读取时性能不佳。SSD 和 RDMA 网络的带宽未得到充分利用。
异步零拷贝 API
将文件系统客户端实现为 VFS 内核模块可避免上述性能问题。但内核模块开发比用户空间系统编程更具挑战性。错误很难诊断,并且可能导致生产环境中的灾难性故障。例如,机器可能会崩溃并且没有留下任何日志消息供调试。升级内核模块时,必须彻底停止使用文件系统的所有进程;否则,需要重新启动机器。
出于这些原因,我们选择在 FUSE 守护进程中实现本机客户端。此客户端提供支持异步零拷贝 I/O 操作的接口。文件元操作仍由 FUSE 守护进程处理(例如打开/关闭/统计文件)。应用程序调用open()以获取文件描述符 (fd) 并通过本机 API 注册它。然后,它们可以使用本机客户端对文件执行 I/O 操作。这种方法可确保元数据操作与 POSIX API 的一致性,从而更轻松地迁移现有代码。
异步、零拷贝 API 的灵感来自 Linux io_uring。以下是 API 中的关键数据结构:
- Iov一个用于零拷贝读/写操作的大型内存区域,由用户进程和本机客户端共享。InfiniBand 内存注册由客户端管理。在本机 API 中,所有读取数据都将读入 Iov,并且所有写入数据都应在调用 API 之前写入 Iov。
- Ior一个小型共享环形缓冲区,用于用户进程与本机客户端之间的通信。Ior 的使用类似于 Linux
io_uring,其中用户进程将读/写请求入队,本机客户端将这些请求出队以完成。请求分批执行,其大小由io_depth参数控制。多个批次并行处理,无论是来自不同的环还是同一个环。但是,对于多线程应用程序,仍然建议使用多个环,因为共享一个环需要同步,这会影响性能。
在本机客户端中,会生成多个线程来从 IOR 获取 I/O 请求。这些请求被分批并分派到存储服务,从而减少小型读取请求导致的 RPC 开销。
文件元数据存储
文件块的位置
3FS 将文件数据划分为大小相等的块,并将它们条带化到多个复制链上。用户可以按目录指定文件的链表、块大小和条带大小。每个块独立存储在多个存储服务上,其块 ID 由文件的 inode id 和块索引连接而成。
创建新文件时,元数据服务采用循环策略,根据条带大小从指定链表中选择连续的复制链。接下来,生成随机种子来对选定的链进行混洗。此分配策略可确保链和 SSD 之间的数据平衡分布。
当应用程序打开文件时,客户端会联系元服务以获取文件的数据布局信息。然后,客户端可以独立计算数据操作的块 ID 和链,从而最大限度地减少元服务在关键路径中的参与。
事务键值存储上的文件元数据
3FS 使用 FoundationDB 作为其元数据的分布式存储系统。FoundationDB 提供键值存储接口并支持具有可序列化快照隔离 (SSI) 的事务。3FS 将所有元数据作为键值对存储在 FoundationDB 中。元服务遵循无状态架构,允许管理员无缝升级或重新启动服务而不会中断,从而大大增强了可维护性。当客户端遇到请求失败或超时时,它们可以自动故障转移到其他可用服务。
文件系统元数据主要由两个核心结构组成:inode 和目录条目。inode 存储文件、目录和符号链接的属性信息,每个信息都由一个单调递增的全局唯一 64 位标识符标识。inode 键由“INOD”前缀与 inode id 连接而成,后者以小端字节顺序编码,以将 inode 分布在多个 FoundationDB 节点上。inode 值因类型而异:
- 所有 inode 类型都包含基本属性:所有权、权限、访问/修改/更改时间。
- 文件 inode 的附加属性:文件长度、块大小、链表中的选定范围、混洗种子。
- 目录 inode 的附加属性:父目录的 inode id、子目录/文件的默认布局配置(链表、块大小、条带大小)。父目录的 inode id 是移动目录时检测循环所必需的。移动
dir_a/dir_b到时dir_c/,我们需要确保dir_c不是 的后代dir_b,这可以通过检查 的所有祖先来实现dir_c。 - 符号链接 inode 的附加属性:目标路径字符串。
目录条目键由“DENT”前缀、父 inode ID 和条目名称组成。目录条目值存储目标 inode ID 和 inode 类型。目录中的所有条目自然形成一个连续的键范围,允许通过范围查询高效地列出目录。
元操作利用 FoundationDB 的事务:
- 用于元数据查询的只读事务:fstat、lookup、listdir 等。
- 用于元数据更新的读写事务:创建、链接、取消链接、重命名等。
对于写事务,FoundationDB 会跟踪读/写键集以形成冲突检测集。当检测到并发事务冲突时,元服务会自动重试该事务。这种设计使多个元服务能够并行处理请求,同时保持文件系统元数据的一致性。
动态文件属性
在大多数本地文件系统中,删除已打开的文件会延迟到所有相关文件描述符都关闭为止。因此,有必要跟踪该文件的所有文件描述符。训练作业在启动期间会打开大量文件。存储所有文件描述符会给元服务和 FoundationDB 带来沉重的负担。由于训练作业不依赖此功能,因此 3FS 不会跟踪以只读模式打开的文件描述符。
3FS 为每个以写入模式打开的文件描述符 (fd) 维护一个文件会话,因为删除写入打开的文件可能会导致并发写入产生无法回收的垃圾块。当删除具有活动写入会话的文件时,元服务会延迟删除,直到其所有 fd 都关闭。为了防止离线客户端的会话延迟,3FS 元服务会定期检查客户端活动情况并清理离线客户端的会话。
文件长度存储在 inode 中。对于正在主动更新的文件,存储在 inode 中的长度可能与实际长度不一致。客户端会定期(默认为 5 秒)向元服务报告以写入模式打开的每个文件的最大写入位置。如果此位置超出 inode 中的长度,并且没有并发截断操作,则采用此位置作为新的文件长度。
由于可能存在多个客户端并发写入的情况,上述方法只能确保文件长度的最终一致性。在处理 close/fsync 操作时,元服务通过从存储服务查询最后一个块的 ID 和长度来获取精确的文件长度。由于文件数据被分到多个链上,此操作会产生不可忽略的开销。
多个元服务同时更新同一文件的长度可能会导致事务冲突并导致重复的文件长度计算。为了缓解这种情况,元服务使用 inode ID 和会合哈希算法将文件长度更新任务分发到多个元服务中。
我们的生产环境使用较大的条带大小:200。对于小文件,包含文件块的链数远低于此数字。可能使用的链数存储在文件 inode 中,并用作更新长度时的提示。它以初始值 16 开始,每次将额外的文件块写入更多链时都会加倍。这使我们能够避免在更新小文件长度时查询所有 200 条链。此优化还可以扩展到小文件的删除。
块存储系统
块存储系统的设计目标是即使在存储介质发生故障时也能实现尽可能高的带宽。3FS 的读/写吞吐量应与 SSD 数量以及客户端和存储服务之间的网络带宽平分呈线性关系。应用程序以不依赖位置的方式访问存储服务。
数据放置
每个文件块都使用带分配查询的链式复制 (CRAQ) 在存储目标链上进行复制。在 CRAQ 中,写入请求被发送到头目标并沿链传播。读取请求可以发送到任何存储目标。通常,读取流量均匀分布在链中的所有目标之间,以实现更好的负载平衡。在每个 SSD 上创建多个存储目标,这些目标加入不同的链。
假设有6个节点:A,B,C,D,E,F,每个节点有1块SSD,每个SSD上创建5个存储target:1,2,…,5,那么一共有30个target:A1,A2,A3,…,F5,如果每个chunk有3个replica,那么构建链表如下。
| 链 | 版本 | 目标 1(头部) | 目标 2 | 目标 3(尾部) |
|---|---|---|---|---|
| 1 | 1 | A1 | B1 | C1 |
| 2 | 1 | D1 | E1 | F1 |
| 3 | 1 | A2 | B2 | C2 |
| 4 | 1 | D2 | E2 | F2 |
| 5 | 1 | A3 | B3 | C3 |
| 6 | 1 | D3 | E3 | F3 |
| 7 | 1 | A4 | B4 | C4 |
| 8 | 1 | D4 | E4 | F4 |
| 9 | 1 | A5 | B5 | C5 |
| 10 | 1 | D5 | E5 | F5 |
每个链都有一个版本号。如果链发生变化(例如存储目标离线),版本号就会增加。只有主集群管理器才会更改链表。
可以构建一些链表来支持不同的数据放置要求。例如,可以创建两个链表,一个用于批处理/离线作业,另一个用于在线服务。这两个表由互斥节点和 SSD 上的存储目标组成。
从逻辑上讲,每个链的状态都会独立变化。每个链可以包含在多个链表中。创建链表的概念是为了让元数据服务为每个文件选择一个表,并将文件块分条到表中的链上。
恢复期间平衡流量
假设读取流量在上述链表中的所有存储目标之间均匀分布。当 A 发生故障时,其读取请求将被重定向到 B 和 C。在重负载下,B、C 的读取带宽会立即饱和,B、C 成为整个系统的瓶颈。更换发生故障的 SSD 并将数据同步到新 SSD 可能需要几个小时。在此期间,读取吞吐量会受到影响。
为了降低性能影响,我们可以让更多 SSD 共享重定向流量。在下面的链表中,A 与其他每个 SSD 配对。当 A 发生故障时,其他每个 SSD 都会收到 A 的 1/5 读取流量。
| 链 | 版本 | 目标 1(头部) | 目标 2 | 目标 3(尾部) |
|---|---|---|---|---|
| 1 | 1 | B1 | E1 | F1 |
| 2 | 1 | A1 | B2 | D1 |
| 3 | 1 | A2 | D2 | F2 |
| 4 | 1 | C1 | D3 | E2 |
| 5 | 1 | A3 | C2 | F3 |
| 6 | 1 | A4 | B3 | E3 |
| 7 | 1 | B4 | C3 | F4 |
| 8 | 1 | B5 | C4 | E4 |
| 9 | 1 | A5 | C5 | D4 |
| 10 | 1 | D5 | E5 | F5 |
为了在恢复期间实现最大的读取吞吐量,负载平衡问题可以表述为平衡的不完全块设计。使用整数规划求解器获得最优解。
数据复制
CRAQ 是一种针对读取密集型工作负载优化的“全部写入任意读取”复制协议。利用所有副本的读取带宽对于在全闪存存储系统中实现最高读取吞吐量至关重要。
当存储服务收到写入请求时,它会经历以下步骤:
- 该服务检查写入请求中的链版本是否与最新已知版本匹配;如果不匹配,则拒绝该请求。写入请求可以由客户端或链中的前任发送。
- 该服务发出 RDMA 读取操作来提取写入数据。如果客户端/前任发生故障,RDMA 读取操作可能会超时,并且写入会被中止。
- 一旦写入数据被提取到本地内存缓冲区中,就会从锁管理器获取要更新的块的锁。对同一块的并发写入将被阻止。所有写入都在头部目标处序列化。
- 服务将块的已提交版本读入内存,应用更新,并将更新的块存储为待处理版本。存储目标可以存储块的两个版本:已提交版本和待处理版本。每个版本都有一个单调递增的版本号。已提交版本和待处理版本的版本号分别为
v和u,且满足u = v + 1。 - 如果服务是尾部,则已提交的版本将自动替换为待处理版本,并向前者发送确认消息。否则,写入请求将转发给后继者。当已提交的版本更新时,当前链版本将作为字段存储在块元数据中。
- 当确认消息到达存储服务时,该服务会用待处理版本替换已提交版本,并继续将消息传播给其前任。然后释放本地块锁。
假设链中有3个目标:A, B, C。 一个写请求刚刚进入步骤5。将请求转发给后继A。然后立即失败,转发的写请求丢失。 当集群管理器检测到的故障时,它标记为离线并将其移至链的末尾,并广播更新的链表。 一旦收到最新的链表,它会将写请求转发给新的后继。可能尚未收到最新的链表并拒绝该请求。 但可以继续将请求转发给。 最终获取最新的链表并接受请求。A``B``B``B``B``A``C``C``A``C``C
当读取请求到达存储服务时:
- 当服务仅具有块的已提交版本时,会将该版本返回给客户端。
- 与 CRAQ 不同,我们的实现不会向尾部目标发出版本查询。当同时存在已提交和待处理版本时,服务会回复一个特殊状态代码来通知客户端。客户端可能会等待一小段时间然后重试。或者客户端可以发出一个轻松的读取请求来获取待处理版本。
故障检测
集群管理器依靠心跳来检测故障停止故障。如果集群管理器在可配置的时间间隔(例如 T 秒)内未收到来自服务的心跳,则声明服务失败。如果服务在 T/2 秒内无法与集群管理器通信,则停止处理请求并退出。心跳可以看作是管理器授予的“续订租约”的请求。
元数据服务是无状态的。集群管理器提供的在线元服务列表是一种简单的服务发现机制,可帮助客户端创建与元数据服务的连接。如果一个元服务发生故障,客户端可以切换到任何其他元数据服务。
集群管理器在存储服务的成员变更中起着更关键的作用。它维护链表和存储目标状态的全局视图。每个存储目标都有一个公共状态和一个本地状态。
公共状态指示它是否已准备好处理读取请求以及写入请求是否会传播给它。公共状态存储在链表中并分发给服务和客户端。
| 公共状态 | 读 | 写 | 笔记 |
|---|---|---|---|
| 服务 | 是 | 是 | 服务处于活动状态并响应客户端请求 |
| 同步 | 否 | 是 | 服务处于活动状态并且数据恢复正在进行中 |
| 等待 | 否 | 否 | 服务处于活动状态但数据恢复尚未开始 |
| 上次服务 | 否 | 否 | 服务中断,这是最后一个服务目标 |
| 离线 | 否 | 否 | 服务中断或存储介质故障 |
本地状态只有存储服务和集群管理器知道,并存储在集群管理器的内存中。如果存储目标发生介质故障,相关服务会在心跳中将目标的本地状态设置为离线。如果存储服务发生故障,则该服务管理的存储目标将被标记为离线。
| 当地州 | 笔记 |
|---|---|
| 最新 | 服务处于活动状态并响应客户端请求 |
| 在线的 | 服务处于活动状态且目标处于同步或等待状态 |
| 离线 | 服务中断或存储介质故障 |
存储目标可以根据最新的本地状态从一个公共状态更改为另一个公共状态。本地状态充当触发事件的角色。集群管理器定期扫描每条链,并根据状态转换表更新链上目标的公共状态。
- 如果链更新,则链版本会增加。
- 如果存储目标被标记为离线,它将被移至链的末尾。
- 如果存储服务发现任何本地存储目标的公共状态为 lastsrv 或离线,则立即退出。该服务可能因网络分区错误而与集群管理器隔离。
- 一旦同步状态的存储目标的数据恢复完成,存储服务会在随后发送给集群管理器的心跳消息中将目标的本地状态设置为最新。
| 当地州 | 当前公共状态 | 前任的公共状态 | 下一个公共状态 |
|---|---|---|---|
| 最新 | 服务 | (任何) | 服务 |
| 同步 | (任何) | 服务 | |
| 等待 | (任何) | 等待 | |
| 上次服务 | (任何) | 服务 | |
| 离线 | (任何) | 等待 | |
| 在线的 | 服务 | (任何) | 服务 |
| 同步 | 服务 | 同步 | |
| 不服务 | 等待 | ||
| 等待 | 服务 | 同步 | |
| 不服务 | 等待 | ||
| 上次服务 | (任何) | 服务 | |
| 离线 | (任何) | 等待 | |
| 离线 | 服务 | 没有前例 | 上次服务 |
| 有前任 | 离线 | ||
| 同步 | (任何) | 离线 | |
| 等待 | (任何) | 离线 | |
| 上次服务 | (任何) | 上次服务 | |
| 离线 | (任何) | 离线 |
数据恢复
当存储服务退出(例如进程崩溃或在升级期间重新启动)或发生存储介质故障时,所有相关存储目标将被标记为脱机并由集群管理器移至链的末尾。服务重新启动后,服务上的每个目标都会独立进入恢复过程。整个恢复过程与正常活动重叠,并最大限度地减少任何中断。
当先前脱机的存储服务启动时:
- 该服务定期从集群管理器中提取最新的链表。但它不会发送心跳,直到其所有存储目标在最新链表中都已标记为离线。这确保其所有目标都将经历数据恢复过程。
- 当恢复期间收到写入请求时,该请求始终是全块替换写入。本地已提交版本会更新,任何现有待处理版本都会被放弃。由于当前服务是尾部,因此会向前者发送确认消息。通过连续的全块替换写入流,将前者的完整状态复制到返回服务。
- 在存储目标开始数据恢复之前,前任会向返回服务发送 dump-chunkmeta 请求,然后服务会迭代本地的 Chunk 元数据存储,收集目标上所有 Chunk 的 id、链版本和提交/待处理版本号,并将收集到的元数据回复给前任。
- 当同步完成消息到达时,服务知道存储目标是最新的。它会在发送给集群管理器的心跳消息中将目标的本地状态设置为最新。
当存储服务发现先前离线的后继者在线时:
-
服务开始将正常的写请求转发给后继者。客户端可能只更新块的一部分,但转发的写请求应该包含整个块,即全块替换写。
-
该服务向后继者发送 dump-chunkmeta 请求。一旦收到后继目标上所有块的元数据,它就会收集其本地目标上的块元数据。然后,它会比较两份块元数据副本,以决定应传输哪些块。
-
通过发出全块替换写请求将选定的块传输给后继者。
- 首先为每个块获取块锁。
- 通过发送全块替换请求,读取链版本、提交的版本号和块内容,并将其传输给后继。
- 块锁被释放。
-
当所有必需的块都已传输后,将向后继者发送同步完成消息。
用于决定应该传输哪些块的规则是:
- 如果某个块仅存在于本地目标上,则应将其转移。
- 如果某个块仅存在于远程目标上,则应将其删除。
- 如果本地chunk副本的链版本大于远程chunk副本的链版本,则需要进行转移。
- 如果本地/远程 Chunk 副本的链版本相同,但是本地已提交的版本号不等于远程待提交的版本号,则需要进行转移。
- 否则,两个块副本要么相同,要么正在由正在进行的写请求更新。
块和元数据
文件块存储在块引擎中。在每个 SSD 上,块引擎的持久存储由固定数量的数据文件(用于存储块数据)和一个 RocksDB 实例(用于维护块元数据和其他系统信息)组成。此外,块引擎还维护一个块元数据的内存缓存,以提高查询性能。实现了一个块分配器,用于快速分配新的块。块引擎接口通过以下操作提供线程安全访问:
- 打开/关闭通过从 RocksDB 加载元数据并重建块分配器状态来初始化引擎。
- 通过哈希表缓存检索块元数据和引用计数句柄,实现具有 O(1)平均复杂度的并发访问。
- update通过在修改数据之前分配新块来实现写时复制 (COW) 语义。旧块保持可读状态,直到所有句柄都被释放。
- commit通过批量写入的方式将更新的块元数据提交给 RocksDB,以确保原子更新;同步刷新块元数据缓存。
块数据最终将存储在物理块上。物理块大小以 2 的幂为增量,从 64KiB 到 64MiB 不等,总共有 11 种不同的大小。分配器将分配大小与实际块大小最接近的物理块。为每个物理块大小构建一个资源池,每个池包含 256 个物理文件。物理块的使用状态使用位图维护在内存中。回收物理块时,其位图标志设置为 0。块的实际存储空间保持保留,并将优先用于后续分配。当没有可用的物理块时,fallocate()将用于在物理文件中分配连续的大空间,创建 256 个新的物理块 - 这种方法有助于减少磁盘碎片。
当对一个块执行写操作时,分配器首先分配一个新的物理块。然后系统将现有的块数据读入缓冲区,应用更新,并将更新后的缓冲区写入新分配的块。针对追加操作实现了优化的过程,其中数据直接就地添加到现有块的末尾。根据新块的位置和现有的块元数据构建一份新的元数据副本。随后,新的块元数据以及新旧物理块的状态都会在 RocksDB 中以原子方式更新。