ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

3fs设计说明

3fs设计说明 设计与实现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 每秒仅能处理约 40 万次 4KiB 读取。进一步提高并发度并不能改善性能因为锁竞争会加剧。性能剖析显示内核空间自旋锁消耗了大量 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 受 Linuxio_uring启发。以下是该 API 中的关键数据结构Iov用于零拷贝读写操作的大内存区域由用户进程和原生客户端共享。InfiniBand 内存注册由客户端管理。在原生 API 中所有读取数据都会被读入 Iov所有写入数据在调用 API 前都应写入 Iov。Ior用于用户进程与原生客户端之间通信的小型共享环形缓冲区。Ior 的用法类似于 Linuxio_uring用户进程将读写请求入队原生客户端将这些请求出队并完成。请求按批执行其大小由io_depth参数控制。多个批次可以并行处理无论它们来自不同的环还是同一个环。不过对于多线程应用程序仍建议使用多个环因为共享一个环需要同步这会影响性能。在原生客户端内部会启动多个线程从 Ior 中获取 I/O 请求。这些请求会被批处理并分发到存储服务从而减少小读请求造成的 RPC 开销。文件元数据存储文件块的位置3FS 将文件数据划分为大小相等的块并将它们条带化到多个复制链上复制链和链表在第“数据放置”节中定义。用户可以按目录指定文件的链表、块大小和条带大小。每个块独立存储在多个存储服务上其块 ID 由文件的 inode id 和块索引拼接生成。创建新文件时元数据服务根据条带大小采用轮询策略从指定链表中选取连续的复制链。接下来生成一个随机种子来打乱所选链的顺序。这种分配策略确保数据在链和 SSD 之间均衡分布。当应用程序打开文件时客户端联系元数据服务以获取文件的数据布局信息。然后客户端可以独立计算数据操作的块 ID 和链从而最大限度减少元数据服务在关键路径中的参与。事务性键值存储上的文件元数据3FS 使用 FoundationDB 作为其元数据的分布式存储系统。FoundationDB 提供键值存储接口并支持具有可串行化快照隔离SSI的事务。3FS 将所有元数据作为键值对存储在 FoundationDB 中。元数据服务采用无状态架构通过允许管理员无中断地无缝升级或重启服务极大地增强了可维护性。当客户端遇到请求失败或超时时它们可以自动故障转移到其他可用服务。文件系统元数据主要由两个核心结构组成inode 和目录项。Inode 存储文件、目录和符号链接的属性信息每个 inode 由一个全局唯一且单调递增的 64 位标识符标识。Inode 键通过将“INOD”前缀与 inode id 拼接构建其中 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 等。用于元数据更新的读写事务create、link、unlink、rename 等。对于写事务FoundationDB 会跟踪读/写键集以形成冲突检测集。当检测到并发事务冲突时元数据服务会自动重试事务。这种设计使多个元数据服务能够并行处理请求同时保持文件系统元数据一致性。动态文件属性在大多数本地文件系统上删除已打开的文件会推迟到所有关联的文件描述符关闭之后。因此有必要跟踪文件的所有文件描述符。训练任务在启动期间会打开大量文件。存储所有文件描述符会给元数据服务和 FoundationDB 带来沉重负载。由于训练任务不依赖此功能3FS 不跟踪以只读模式打开的文件描述符。3FS 为每个以写模式打开的文件描述符fd维护一个文件会话因为删除以写模式打开的文件可能会因并发写入而留下无法回收的垃圾块。当删除具有活动写会话的文件时元数据服务会推迟删除直到其所有 fd 都关闭。为防止离线客户端留下滞留会话3FS 元数据服务会定期检查客户端存活状态并清理离线客户端的会话。文件长度存储在 inode 中。对于正在被积极更新的文件inode 中存储的长度可能与实际长度不一致。客户端会定期默认 5 秒向元数据服务报告每个以写模式打开的文件的最大写入位置。如果该位置超过 inode 中的长度且不存在并发截断操作则该位置会被采用为新的文件长度。由于可能存在来自多个客户端的并发写入上述方法只能确保文件长度的最终一致性。在处理 close/fsync 操作时元数据服务通过向存储服务查询最后一个块的 ID 和长度来获取精确的文件长度。由于文件数据条带化到多个链上此操作会带来不可忽视的开销。多个元数据服务并发更新同一文件长度可能导致事务冲突并导致重复计算文件长度。为缓解此问题元数据服务使用 inode ID 和 rendezvous 哈希算法将文件长度更新任务分布到多个元数据服务上。我们的生产环境使用较大的条带大小200。对于小文件包含文件块的链数远低于这个数字。可能使用的链数存储在文件 inode 中并在更新长度时作为提示使用。它从初始值 16 开始每当更多文件块写入更多链时翻倍。这使我们能够避免在更新小文件长度时查询全部 200 条链。这种优化也可以扩展到小文件的删除。块存储系统块存储系统的设计目标是在存储介质发生故障时仍能实现尽可能高的带宽。3FS 的读写吞吐量应随 SSD 数量以及客户端与存储服务之间的二分网络带宽线性扩展。应用程序以位置无关的方式访问存储服务。数据放置每个文件块通过带分摊查询的链式复制CRAQ复制到一条存储目标链上。在 CRAQ 中写请求被发送到头目标并沿链传播。读请求可以发送到任意存储目标。通常读流量会在链中的所有目标之间均匀分布以实现更好的负载均衡。每个 SSD 上会创建多个存储目标这些目标加入不同的链。假设有 6 个节点A、B、C、D、E、F。每个节点有 1 个 SSD。在每个 SSD 上创建 5 个存储目标1、2、… 5。那么总共有 30 个目标A1、A2、A3、…、F5。如果每个块有 3 个副本则构建如下链表。链版本目标 1头目标 2目标 3尾11A1B1C121D1E1F131A2B2C241D2E2F251A3B3C361D3E3F371A4B4C481D4E4F491A5B5C5101D5E5F5每条链都有一个版本号。如果链发生变化例如某个存储目标离线版本号会增加。只有主集群管理器才能更改链表。可以构建多个链表以支持不同的数据放置需求。例如可以创建两个链表一个用于批处理/离线作业另一个用于在线服务。这两个表由位于互斥节点和 SSD 上的存储目标组成。从逻辑上讲每条链的状态独立变化。每条链可以包含在多个链表中。链表这一概念的引入是为了让元数据服务为每个文件选择一个表并将文件块条带化到该表中的各条链上。恢复期间的流量均衡假设读流量在上述链表中的所有存储目标之间均匀分布。当 A 发生故障时其读请求会被重定向到 B 和 C。在重负载下B、C 的读带宽会立即饱和B、C 成为整个系统的瓶颈。更换故障 SSD 并将数据同步到新 SSD 可能需要数小时。在此期间读吞吐量会受到影响。为减少性能影响我们可以让更多 SSD 分担被重定向的流量。在下面的链表中A 与所有其他 SSD 配对。当 A 发生故障时其他每个 SSD 会接收 A 读流量的 1/5。链版本目标 1头目标 2目标 3尾11B1E1F121A1B2D131A2D2F241C1D3E251A3C2F361A4B3E371B4C3F481B5C4E491A5C5D4101D5E5F5为了在恢复期间实现最大读吞吐量负载均衡问题可以被表述为平衡不完全区组设计。最优解可通过整数规划求解器获得。数据复制CRAQ 是一种针对读密集型工作负载优化的“写全部、读任意”复制协议。利用所有副本的读带宽对于在全闪存存储系统中实现最高读吞吐量至关重要。当存储服务收到写请求时会经历以下步骤服务检查写请求中的链版本是否与已知最新版本匹配如果不匹配则拒绝该请求。写请求可能由客户端或链中的前驱发送。服务发出 RDMA Read 操作以拉取写入数据。如果客户端/前驱失败RDMA Read 操作可能超时写入会被中止。一旦写入数据被取到本地内存缓冲区就会从锁管理器获取要更新的块的锁。对同一块的并发写入会被阻塞。所有写入都在头目标处串行化。服务将块的已提交版本读入内存应用更新并将更新后的块存储为待定版本。一个存储目标可能存储一个块的两个版本已提交版本和待定版本。每个版本都有一个单调递增的版本号。已提交版本和待定版本的版本号分别为v和u并满足u v 1。如果服务是尾目标则已提交版本会被待定版本原子替换并向其前驱发送确认消息。否则写请求会被转发给后继。当已提交版本更新时当前链版本会作为字段存储在块元数据中。当确认消息到达存储服务时服务用待定版本替换已提交版本并继续将消息传播给其前驱。随后释放本地块锁。假设链中有 3 个目标A、B、C。一个写请求刚刚在 A 处进入第 5 步。A 将请求转发给后继 B。然后 B 立即失败被转发的写请求丢失。当集群管理器检测到 B 的故障时会将 B 标记为离线并将其移动到链尾同时广播更新后的链表。一旦 A 收到最新链表它就会将写请求转发给新的后继 C。C 可能尚未收到最新链表并拒绝该请求。但 A 可以继续向 C 转发请求。最终 C 会获得最新链表并接受请求。当读请求到达存储服务时当服务只有该块的已提交版本时返回该版本给客户端。与 CRAQ 不同我们的实现不会向尾目标发出版本查询。当同时存在已提交版本和待定版本时服务会回复一个特殊状态码以通知客户端。客户端可以等待一小段时间后重试。或者客户端可以发出宽松读请求以获取待定版本。故障检测集群管理器依赖心跳来检测 fail-stop 故障。如果集群管理器在可配置的时间间隔例如 T 秒内未收到某服务的心跳则宣布该服务失败。如果某服务在 T/2 秒内无法与集群管理器通信则停止处理请求并退出。心跳可以被视为请求续订由管理器授予的租约。元数据服务是无状态的。集群管理器提供的在线元数据服务列表是一种简单的服务发现机制帮助客户端与元数据服务建立连接。如果某个元数据服务宕机客户端可以切换到任何其他元数据服务。集群管理器在存储服务的成员变更中扮演更关键的角色。它维护链表和存储目标状态的全局视图。每个存储目标都有一个公共状态和一个本地状态。公共状态表示它是否已准备好服务读请求以及写请求是否会被传播到它。公共状态与链表一起存储并分发给服务和客户端。公共状态读写说明serving是是服务存活并正在服务客户端请求syncing否是服务存活且数据恢复正在进行waiting否否服务存活且数据恢复尚未开始lastsrv否否服务宕机且它曾是最后一个服务目标offline否否服务宕机或存储介质故障本地状态仅由存储服务和集群管理器知晓并存储在集群管理器的内存中。如果某个存储目标发生介质故障相关服务会在心跳中将该目标的本地状态设置为 offline。如果某个存储服务宕机该服务管理的存储目标会被标记为 offline。本地状态说明up-to-date服务存活并正在服务客户端请求online服务存活且目标处于 syncing 或 waiting 状态offline服务宕机或存储介质故障存储目标可以根据最新本地状态从一个公共状态转换到另一个公共状态。本地状态充当触发事件。集群管理器会定期扫描每条链并根据状态转换表更新链上目标的公共状态。如果链被更新链版本会增加。如果某个存储目标被标记为 offline它会被移动到链尾。如果某个存储服务发现任何本地存储目标的公共状态为 lastsrv 或 offline它会立即退出。该服务可能因网络分区错误而与集群管理器隔离。一旦处于 syncing 状态的存储目标完成数据恢复存储服务会在随后发送给集群管理器的心跳消息中将该目标的本地状态设置为 up-to-date。本地状态当前公共状态前驱的公共状态下一个公共状态up-to-dateserving任意servingsyncing任意servingwaiting任意waitinglastsrv任意servingoffline任意waitingonlineserving任意servingsyncingservingsyncing非 servingwaitingwaitingservingsyncing非 servingwaitinglastsrv任意servingoffline任意waitingofflineserving没有前驱lastsrv有前驱offlinesyncing任意offlinewaiting任意offlinelastsrv任意lastsrvoffline任意offline数据恢复当某个存储服务退出例如进程崩溃或升级期间重启或发生存储介质故障时所有相关存储目标都会被集群管理器标记为 offline 并移动到链尾。一旦服务重启该服务上的每个目标都会独立进入恢复过程。整个恢复过程与正常活动重叠并尽量减少任何中断。当先前离线的存储服务启动时服务会定期从集群管理器拉取最新链表。但在其所有存储目标都在最新链表中被标记为 offline 之前它不会发送心跳。这确保其所有目标都会经历数据恢复过程。当恢复期间收到写请求时该请求始终是全块替换写入。本地已提交版本会被更新任何现有待定版本都会被放弃。由于当前服务是尾目标会向前驱发送确认消息。前驱的完整状态会通过持续的全块替换写入流复制到重新返回的服务。在某个存储目标的数据恢复开始之前前驱会向返回的服务发送 dump-chunkmeta 请求。然后服务会遍历本地块元数据存储收集该目标上所有块的 id、链版本以及已提交/待定版本号并将收集到的元数据回复给前驱。当收到 sync-done 消息时服务知道该存储目标已 up-to-date。它会在发送给集群管理器的心跳消息中将该目标的本地状态设置为 up-to-date。当某个存储服务发现先前离线的后继在线时服务开始将正常写请求转发给后继。客户端可能只更新块的一部分但转发的写请求应包含整个块即全块替换写入。服务向后继发送 dump-chunkmeta 请求。一旦收到后继目标上所有块的元数据它就收集本地目标上的块元数据。然后比较两份块元数据以决定应传输哪些块。选定的块通过发出全块替换写请求传输给后继。首先为每个块获取块锁。读取链版本、已提交版本号和块内容并通过发送全块替换请求将其传输给后继。释放块锁。当所有所需块都已传输完成时向后继发送 sync-done 消息。用于决定应传输哪些块的规则是如果某个块仅存在于本地目标上则应传输。如果某个块仅存在于远程目标上则应删除。如果本地块副本的链版本大于远程块副本的链版本则应传输。如果本地/远程块副本的链版本相同但本地已提交版本号不等于远程待定版本号则应传输。否则两个块副本要么相同要么正在被进行中的写请求更新。块与元数据文件块存储在块引擎中。在每个 SSD 上块引擎的持久化存储由固定数量的用于存储块数据的数据文件以及一个用于维护块元数据和其他系统信息的 RocksDB 实例组成。此外块引擎还维护块元数据的内存缓存以提升查询性能。实现了块分配器以快速分配新块。块引擎接口通过以下操作提供线程安全访问open/close通过从 RocksDB 加载元数据并重建块分配器状态来初始化引擎。get通过 hashmap 缓存检索块元数据和带引用计数的句柄以 O(1) 平均复杂度支持并发访问。update通过在修改数据前分配新块来实现写时复制COW语义。旧块在所有句柄释放之前仍可读。commit通过写批次将更新后的块元数据提交到 RocksDB以确保原子更新同步刷新块元数据缓存。块数据最终会存储在物理块上。物理块大小从 64KiB 到 64MiB以 2 的幂递增共 11 种不同大小。分配器会分配大小最接近实际块大小的物理块。每种物理块大小都会构建一个资源池每个池包含 256 个物理文件。物理块的使用状态通过位图在内存中维护。当某个物理块被回收时其位图标志会被设置为 0。该块的实际存储空间仍会保留并优先用于后续分配。当没有可用物理块时将使用fallocate()在物理文件中分配一段连续的大空间创建 256 个新物理块——这种方法有助于减少磁盘碎片。对块执行写操作时分配器首先分配一个新的物理块。然后系统将现有块数据读入缓冲区应用更新并将更新后的缓冲区写入新分配的块。对于追加操作实现了一种优化流程即数据直接在现有块末尾就地添加。根据新块的位置和现有块元数据构造一份新的元数据副本。随后新的块元数据以及新旧物理块的状态会在 RocksDB 中原子更新。脚注https://elixir.bootlin.com/linux/v5.4.284/source/fs/fuse/file.c#L1573 ↩
返回列表