远程文件

文件是一串可以按位置读取的字节,目录记录文件名与文件对象的对应关系。客户端发起访问,服务器保存数据并处理请求,网络在它们之间传消息。远端访问包含网络等待;数据保存在多个节点上时,还需要找到正确节点,并协调这些节点持有的记录。

思考题:把本地 inode 文件系统的 open、read、write 等接口逐一改成 RPC,远端文件服务还需要处理哪些状态、故障和读写规则?

解答:

需要重新安排文件标识、打开记录和读写位置,并定义缓存、重试和故障恢复的行为。例如 NFSv2/v3 用 LOOKUP 取得句柄,由客户端保存 fd 与游标,READ、WRITE 带上句柄、偏移和长度;目录修改还要保持名称关系的原子更新。请求超时、重复执行与服务器恢复,都需要相应协议处理。

分布式文件系统把文件保存在多台机器上,应用通过网络访问。系统负责查找文件、传输内容,并处理机器和网络故障。

用户可以通过 FTP、网盘等工具上传和下载文件,也可以挂载 NFS,直接用路径和普通文件操作访问远端内容。GFS 的应用通过专门的客户端库访问文件。每种方式都要处理网络延迟和故障。

下面介绍 NFS、GFS、HDFS 和 3FS,重点看它们怎样找到文件、保存打开状态、更新缓存和协调副本。不同的文件大小、访问方式和硬件配置,会影响这些设计。

一个直接的用途是共享不断更新的工作文件:源文件留在工作机器上,另一台机器通过挂载后的路径打开它。每次读取取得需要的范围,省去人工寻找介质、拷贝和核对副本的步骤。源文件的访问权限、网络连接与版本发布仍由相应服务管理。

分层接口

模块化先划分职责,分层进一步限制模块间的依赖关系。例如应用调用文件接口,NFS 客户端构造协议请求,RPC 层传输并匹配回复,服务端再调用本地文件实现。上层依据接口使用服务,下层可以在保持约定时调整内部实现。

若十个模块都能依赖其余模块,最多有 10*9=90 条有向依赖;若严格排成十层,每层只依赖下一层,则有九条相邻依赖。这个计算说明接口约束能减少需要共同检查的联系,实际复杂性还与每个接口的状态和行为有关。

层间接口也需要说明错误与完成条件。RPC 返回超时后,文件操作可能已部分执行;文件系统需要恢复一致的管理记录,应用还要判断自己的业务更新是否完成。分层让职责清楚,跨层的这些条件仍要一起检查。

NFS

挂载把远端文件系统接入本机某个目录。例如挂载到 /home/paul 后,程序打开 /home/paul/a.txt,内核就把这个路径下的文件请求交给 NFS 客户端。NFS 客户端是本机处理远端文件协议的程序,负责构造请求、接收数据和维护本地打开记录。

NFS 是 Network File System,中文叫网络文件系统。Sun 在 1980 年代为工作站设计了它,希望不同硬件和操作系统能共享文件,也支持没有本地磁盘的工作站。NFS 用缓存和预读减少远端访问,让每个请求带上执行所需的信息。

NFSv2/v3 的基本设计中,客户端保存打开文件和读写位置,服务器从每个请求中取得文件句柄、偏移和长度。锁服务、回复缓存会保存另外的记录,NFSv4 也增加了打开和缓存管理状态。

接口和请求

fd 是进程中的文件描述符,例如 open 返回整数 3,后续程序调用 read(3,...)。fh 是服务器提供的文件句柄,客户端在请求中带回它,服务器据此找对象。偏移是从文件开头跳过多少字节,从 0 开始;长度是希望访问多少字节。

本地操作 典型 NFS RPC 转换
open(path,flags) LOOKUP(dirfh,name) 逐级找对象,客户端建立打开记录
read(fd,buf,n) READ(fh,offset,count) 显式发句柄与偏移
write(fd,buf,n) WRITE(fh,offset,count,data) 发位置、长度和数据
lseek 无对应 RPC 修改本地游标
close 无独立 CLOSE RPC 释放本地记录,必要时先写回
stat GETATTR(fh) 查询远端属性
mkdir MKDIR(dirfh,name,attr) 创建目录项
unlink REMOVE(dirfh,name) 删除名称
rename RENAME(dirfh,name,tofh,toname) 指定源、目标目录与名称

表中列出常见操作的对应请求。创建文件时还要发送创建请求。fd 是进程内的文件描述符,fh 是服务器用来找文件的句柄。关闭时,客户端可能先发送 WRITE 或 COMMIT 等请求完成写回,再清理本地记录。

挂载和读取

服务器允许共享的目录称为导出目录。挂载先取得这个目录的句柄;查找它的子项时,LOOKUP 请求带上父目录句柄和一个名称。多级路径逐级查找,每一步得到下一级句柄,直到找到目标文件。VFS 是内核中的虚拟文件系统接口,它根据挂载记录选择对应的文件系统实现。

服务器先配置共享目录和访问权限。客户端执行 mount 192.168.1.100:/users/paul /home/paul,取得服务器目录的句柄,并把它接到本机的 /home/paul。以后访问这一路径时,内核的虚拟文件系统(VFS)把请求交给 NFS 客户端。服务器按所用版本和配置检查权限与认证信息。

NFS 的 open、read、close 与网络 RPC

图有应用、NFS 客户端、服务器三条时间线:

  1. 应用 OPEN,请求交给本地 NFS 模块。
  2. 客户端发送 LOOKUP(dirfh,"f"),服务器返回 fh 与属性。
  3. 客户端建立 fd → fh 映射并初始化游标。
  4. 应用 READ,客户端取游标并发送 READ(fh,0,n)。
  5. 服务器返回读到的数据,客户端把它交给应用,并按实际返回的字节数推进游标。
  6. 应用 CLOSE,释放本地引用,按需要写回。

客户端记录已经读到的位置。例如,第一次读了 100 B,下一次请求就携带 offset 100。图中展示了从查找文件到读取内容的基本流程;客户端缓存了路径和属性后,可以省去部分查询。

打开状态

打开状态包含这次打开对应的文件、读写方式和当前位置。例如一次读取从偏移 0 取得 100 B,下一次通常从 100 开始。客户端保存这个位置,并把 READ(fh,100,n) 发给服务器,每条请求就包含了本次访问所需的位置。

思考题:A、B 两个客户端都得到 fd=3,分别打开 f、g。若服务器保存打开文件表,应怎样区分它们?服务器重启、表中记录丢失后,READ(3,...) 又怎样找到文件?NFSv2/v3 把游标保存在哪里?

解答:

fd 属于各自进程,A 的 3 指向 f,B 的 3 指向 g。服务器若保存打开表,就要结合客户端身份和 fd 定位记录,表中还包含文件与游标;重启后只有数值 3,需先恢复或重新建立这种对应关系。NFSv2/v3 让客户端保存 fd、fh 和游标,每条 READ 带 fh、offset、count,服务器据此访问文件。

如果服务器保存每个客户端的打开文件表,它就要记录 fd 对应哪个文件、读到了哪里。重启后要恢复这些记录,客户端失联后也要清理剩下的记录。

保存一条打开记录的成本与当前有多少打开对象有关。客户端可以打开文件后暂时不发请求,服务器若保留这些记录,也要继续占用资源。把游标等信息交给客户端保存,能减少这类长时间闲置记录;服务器处理每次请求的 CPU、队列和缓存仍会受访问量影响。

经典 NFS 让客户端保存打开状态和读写位置,每个 READ、WRITE 请求都带 fh、偏移和长度。服务器恢复文件系统后,可以直接根据请求找文件并继续处理。它还需要检查句柄和权限,确认写入结果,并识别重复请求。

文本生成服务也可以采用类似方法:客户端在一次请求中带上需要的对话内容,服务器据此生成结果。这个例子说明,调用方可以自己保存历史,并在请求时把需要的信息交给服务器。

另一种接口可以传会话编号,由服务端或共享数据库保存历史。前一种方式增加重复传输的上下文,后一种方式需要管理会话的存储、恢复和访问权限。代理可能让多个客户端使用同一网络出口,服务端仍可以依据经过验证的用户与会话标识区分请求。

这里的无状态主要说明请求不依赖服务器保存本次打开的游标与会话。文件内容、目录、inode,以及某些实现中的回复缓存,都可以继续由服务器保存。

客户端超时后重新发送,服务器可能重复执行。用 xid 标识请求,并保存近期回复,可以帮助服务器识别重试。这些临时记录可能在淘汰或重启后丢失,因此系统要规定记录有效多久,以及丢失后怎样确认结果。

思考题:A 写入 a 后回复丢失,B 随后写入 b 并成功。服务器重启丢失回复缓存,A 的旧请求又被执行,最后是什么?同一个 WRITE 重复设置相同内容,为什么还要检查并发与去重?

解答:

最后可能重新变为 a,覆盖 B 已完成的 b。同一个值连续重复写入时效果可以相同,中间插入其他写入后,旧请求又可能覆盖新结果。

可以按协议保留可靠请求完成记录、检查数据版本,或协调写入顺序。需要跨重启去重时,相应记录也要持久保存,并与业务修改共同生效。

文件句柄

客户端的 fd 找到本地打开记录,RPC 带回服务器给出的文件句柄 fh。fh 是由服务器解释的不透明标识,可以包含文件系统身份、对象编号和 generation number;客户端按协议原样交回。

名称变化

目录重命名后,已打开描述符应仍访问原文件

思考题:程序 1 进入 dir1 并打开 f;程序 2 把 dir1 改为 dir2,再把 dir3 改为 dir1。程序 1 随后的 READ 应访问哪个文件?文件句柄若只保存 dir1/f,会发生什么?

解答:

程序 1 已打开的是原来的 f,它现在位于 dir2/f。目录改名只改变名称关系,打开引用仍指向原对象。若句柄保存 dir1/f 字符串,下次解析可能进入由原 dir3 改名形成的另一目录。稳定对象标识让请求继续找到原文件。

编号复用

inode 号重新分配后,用 generation number 识别旧句柄

思考题:旧文件使用 inode 7,打开后被删除,编号 7 又分给同名新文件。旧读请求怎样识别自己打开的是哪一个对象?generation number 如何参与检查?

解答:

句柄同时保存 inode 号和 generation number。旧文件取得 (7,1),编号 7 分给新文件后,当前代次为 2。旧请求仍带 1,服务器检查出与当前代次不匹配,返回句柄失效错误,防止旧请求读到新文件。

generation number 标识对象编号的分配代次。修改同一个文件的内容,则通过修改时间、变化标识或数据版本判断,不能把两类编号混用。

本地 Unix 的 unlink 由打开引用继续保留原 inode 与内容。经典 NFSv2/v3 服务端缺少完整打开会话,跨客户端删除后可能返回失效句柄;客户端在部分情况下使用临时改名辅助保留对象,应用仍需按协议处理失败。

缓存更新

缓存保存曾经读取的数据。写入方先改本地缓存时,服务器上的内容还可能保持旧值;另一个客户端从服务器或自己的缓存读取,就可能得到旧内容。修改时间或版本号用于检查缓存:发现记录改变后,客户端丢弃旧内容,再请求新数据。

思考题:远端读取的耗时由哪些部分组成?服务器设备、网络和缓存条件改变后,远端访问与本地访问的速度应怎样评估?

解答:

总耗时包含客户端处理、请求传输、服务器排队、查找与存储读取、回复传输及结果交付。服务器采用更快设备或命中缓存时,可能减少实际 I/O;较慢网络或排队则增加等待。应对相同读取范围测量实际路径,并结合缓存命中、设备速度和网络负载评估。

客户端缓存文件数据、属性和目录查找结果,可以减少 READ、GETATTR、LOOKUP、READLINK 和 READDIR 请求。服务器也用本地缓存减少磁盘读取。实际耗时取决于网络、服务器负载和缓存命中情况。

写入先留在客户端时,另一客户端暂时读到旧值

服务器最初是 v1。客户端 1 写入 v2,但新值留在本地;客户端 2 在这时打开并读取,得到 v1。客户端 1 关闭时提交写入,服务器变为 v2;客户端 2 后来重新打开并检查属性,才看到新内容。

close-to-open consistency 要求写入方关闭文件时完成相应写回,读取方打开文件时检查缓存。客户端可以查询修改时间,发现变化后重新读取数据。这适合“写完关闭,再由别人打开”的用法。两个进程长期同时打开文件时,需要额外安排同步。写回是否已进入稳定存储,还要查看具体协议和配置。

例如,文件缓存有效期为 3 s、目录缓存有效期为 30 s 时,客户端在有效期结束后重新检查相应内容。也可以在读写时检查最新状态,但这会增加网络请求。每次传输更多数据和提前读取下一段,可以减少顺序读取的等待;具体时间和传输大小由实现与配置决定。

缓存有效期控制客户端什么时候需要重新验证;实现可以在后续访问时检查,也可以按策略安排其他验证。写入者尚未发布修改、网络失联或读取者仍使用自己的旧内容时,一个超时参数无法单独决定所有读写结果。

顺序预读

一次远端读取有固定处理成本,包括进入文件接口、构造协议消息、发送、接收与处理回复。消息较小时,这些固定部分可能占主要时间,所以把附近数据一起读入缓存,可以减少后续的独立往返。

例如应用连续进行 64 次 64 B 读取,总量为 4 KiB。若客户端提前取得连续的 4 KiB,后续访问可以从本地缓存完成。收益取决于这些数据是否真的会被使用,以及客户端是否能合并请求。

MTU 表示最大传输单元,网络还会添加协议头,某些链路有最小帧等要求。具体报文大小与延迟曲线由协议、链路和实现决定。预读过多时会占用带宽与缓存,随机访问要用实际命中情况评估。

非易失内存

NVRAM 是 non-volatile random-access memory,表示能够在规定断电条件下保留内容的随机访问存储。实现可以使用具有非易失性的介质,也可以用电池保护内存、闪存备份等方式;UPS 是为断电时保留供电的一种支持方式。

服务器可以先把待完成写入可靠记录在这样的存储中,再按协议回复,随后整理到磁盘。断电后恢复程序读取记录,继续完成写入。能否确认成功,取决于该存储实际保护的故障范围、记录完整性与恢复流程。

NFS 版本

稳定存储用于在协议规定的故障后保留数据。WRITE 回复可以说明本次写入达到了哪种保存程度;COMMIT 用于确认相关数据已保存到稳定存储。复合 RPC 把多个相继进行的操作放进一条请求,服务器逐项处理,再一次返回结果,减少网络往返。

方向 主要变化
NFSv3 64 位文件大小、TCP、大块传输、COMMIT
相关实现机制 锁管理、非易失存储、动态重试、客户端磁盘缓存
NFSv4 更多打开与缓存状态、变化通知、统一导出命名空间、复合 RPC、扩展属性、ACL 和安全机制

COMMIT 用于确认写入已进入稳定存储,复合请求把几个操作放进一次往返。打开和缓存管理记录让服务器能协调更多共享操作,也增加了恢复时要处理的信息。

GFS 用途

顺序读取从文件开头或某个位置向后连续取数据,追加把新内容放到末尾。网页归档、索引构建等任务经常一次处理大量连续内容,所以每秒能持续传多少数据会影响作业完成时间。分块和复制让存储容量、读写负载分布到多台机器。

思考题:网页归档这类负载为什么经常读取大文件、在末尾追加新记录?单服务器 NFS 模型在文件容量与服务器失效时遇到什么限制?

解答:

归档把新抓取的网页等记录加入末尾,保存后又经常成批顺序读取,所以大文件、连续读取和追加很常见。单服务器 NFS 可以保存大文件,但大小仍受协议、配置和本机存储容量限制;服务器失效时,该访问路径暂停。GFS 通过跨节点分块扩展容量,通过副本提供其他读取路径。

GFS 是 Google File System。原论文发表于 SOSP 2003,面向大规模数据处理。它的常见文件有多个 GB,主要进行顺序读取和追加。集群包含许多普通硬件节点,需要经常处理机器故障。

网页归档、搜索索引和 MapReduce 等程序会批量处理大量数据。GFS 为这类访问设计接口,重点是持续读写的总速度和多个客户端的协作。

GFS 的客户端库提供 create、delete、open、close、read、write,并加入 snapshot 和 record append。它使用自定义 API,支持原子重命名;原论文第 2.7.2 节介绍了写完文件后改为正式名称的用法。硬链接和符号链接不在这个接口中。GFS 原论文

文件分块

chunk 是文件切分后的一个数据块,chunkserver 是保存这些块的服务器。handle 是块的标识,元数据服务用它记录副本位置。客户端先找元数据,再直接联系保存块的节点。校验和是根据内容计算的短数值,读取时重新计算并检查,可以发现许多意外损坏。

文件被切为 chunk,每块分布在多个 chunkserver

一个逻辑 master 管理元数据,多个 chunkserver 保存数据。图上文件是 C0、C1 等序列;下方 C0 在节点 1、2、3,C1 在节点 2、3、4。master 维护“文件 → chunk”和“chunk → 副本位置”,数据直接在客户端与 chunkserver 之间传输。

经典 chunk 大小为 64 MB,有全局唯一的 64 位 handle,默认三份副本。chunk 在节点上保存为 Linux 文件;内部每 64 KB 数据单元有 32 位校验和,用于检测损坏。增加热门数据副本可以分散读取。

大块使系统需要记录的位置条目减少,连续读取时也能反复使用同一份记录。按每块 64 MiB 计算,1 TiB 文件有 240/226=163842^{40}/2^{26}=16384 个 chunk。记录占用多少内存,取决于每项保存了哪些信息。查询还包括请求传输、排队和服务器处理时间。

思考题:采用大 chunk 对元数据条目、master 查询和连接复用有什么作用?图中读取 3 MB、每次 1 MB,小块分布在三台服务器且每次新建连接时需要几次握手?三段数据在同一大块且复用连接时又需要几次?

解答:

大 chunk 减少同一文件所需的块条目,连续访问时可复用位置记录,减少 master 查询,并反复使用同一服务器连接。按题中图示,小块位于三台服务器且每次新建连接,需要 3 次握手;三段数据位于同一大块并复用连接时,只需 1 次握手。

三次 1 MB 读取在大 chunk 内复用一条连接

小文件或热点数据集中在少数 chunk 中时,读取负载也会集中。增加副本可以分担这类读取。chunk 内的物理空间可以随数据增长逐步分配。

大 chunk 把多次连续访问的定位工作合并起来。若改用 4 KiB 逻辑块,1 TiB 文件需要 2282^{28} 个块条目;64 MiB chunk 只需 2142^{14} 个,数量相差 16384 倍。每项元数据具体占多少字节,仍取决于字段和存储结构。

块大小还与访问方式有关。一次只需少量随机数据时,大块不能直接代表相同比例的有效读取;实际请求可以读取 chunk 内的一段。每次写入也可以只修改部分 chunk,分块容量与本次传输量需要分别计算。

恢复信息

日志按顺序保存元数据修改,检查点保存某一时刻的完整元数据状态。启动时先读取检查点,再应用其后的日志,就能恢复文件名和块列表。块的实际副本位置还要从 chunkserver 报告中取得,结合当前存活节点更新。

思考题:一个 master 统一管理元数据有哪些便利?文件到 chunk 的映射为何要可靠保存,chunk 的副本位置又怎样在启动时重建?

解答:

一个 master 统一处理名称和块映射,较易安排元数据修改顺序,内存记录也便于查询。文件名到 chunk 的关系要通过日志、检查点等可靠保存,才能恢复原路径及文件组成。副本位置从 chunkserver 的块清单重建,运行时再通过心跳更新哪些节点保存了相应块。

元数据 处理方式
命名空间、文件到 chunk 的映射 在内存中查询,通过操作日志等材料持久化
chunk 的副本位置 启动时向 chunkserver 查询,运行时结合心跳更新

chunkserver 向 master 报告自己保存了哪些块,master 据此重建位置表,并通过心跳继续更新。文件名、目录关系和文件的块列表,则由日志和检查点恢复。

集中处理元数据能简化跨目录改名:源名称移除与目标名称建立由同一逻辑管理者协调。若把有关目录划给不同管理者,就需要再处理跨节点的原子更新和故障恢复。复制同一份 master 状态、分片保存不同元数据、增加请求处理前端,是三种可以分别设计的扩展工作。

GFS 的单 master 指一个逻辑上的管理入口,元数据日志等恢复材料仍可以保存副本。master 的容量与处理速度需要评估;后面的 3FS 把前端元数据服务接到共享事务存储,让多个实例依据同一组记录处理请求。

思考题:大文件经常连续读取一遍时,客户端缓存哪些信息最有帮助?文件内容还可以在哪一层获得缓存?

解答:

客户端主要缓存文件到 chunk 的编号及副本位置,后续访问同一块就可直接联系 chunkserver。大文件只连续读过一次时,文件内容的再次利用较少。chunkserver 上的操作系统页缓存仍可保存常用内容,减少重复磁盘读取;缓存和映射失效时再按协议重新检查。

客户端缓存 chunk 的编号和服务器列表,后续读取可以直接联系 chunkserver。大文件通常连续读过一次,客户端主要缓存查找信息;chunkserver 上的 Linux 文件缓存负责保存常用数据。

读取文件

把文件偏移除以块大小,商决定第几个 chunk,余数决定 chunk 内的位置。例如按 64 MiB 的块计算,偏移 70 MiB 的商为 1、余数为 6 MiB,因此读取 C1 中偏移 6 MiB 的内容。客户端取得 C1 的 handle 和节点列表后,再发送具体范围。

先获取 chunk 元数据,再直接从副本读取内容

客户端先用块长 C 计算 i=⌊offset/C⌋i=\lfloor offset/C\rfloor、r=offset mod Cr=offset\bmod C,得到 chunk 编号和块内偏移。它把文件名与 i 发给 master,取得 handle 和副本列表,再向一个可用副本发送 handle 和字节范围。图中 chunkserver 2 直接把数据返回客户端。

客户端缓存着有效记录时,可以直接读取同一 chunk 的后续范围。它也可以一次取得多个 chunk 的信息。记录过期、文件变化或副本失效后,需要重新查询并重试。

选择副本还会影响等待。客户端可以考虑网络距离、可用性与负载;计算任务也可以安排到保存输入的机器附近,减少远端数据传输。若采用同时读取或延迟后尝试另一个副本的方法,需要计入额外请求与带宽。

热门数据增加副本后,可以让更多节点共同提供读取。总收益还取决于请求能否分散、网络是否足够、各副本是否有效,以及复制本身的写入成本。

写入文件

同一位置依次写 A、B 时,最后保存 B。所有副本需要执行相同顺序,primary 因此负责为修改排序。租约是有有效期的授权:master 指定某个节点在这段时间内担任 primary;继续处理新写入前,需要保持相应授权有效。

两个并发写在不同副本上按不同顺序到达

图中节点 1、2 先写 A 再写 B,最终保存 B;节点 3 先写 B 再写 A,最终保存 A。要让副本得到相同结果,需要统一写入顺序。GFS 选一个副本担任 primary(主副本),由它为同一 chunk 的修改排序,其他 secondary(次副本)按这个顺序执行。

master 给一个副本发放租约,让它担任 primary。这里的租约有效期为 60 s,可以通过心跳续期。primary 负责安排这个 chunk 上的写入顺序,其余副本按该顺序执行。

数据阶段沿副本链推送,各节点先缓冲

第一阶段先查 primary 和 secondary 的位置,再沿链发送数据。图中数据先到一个 secondary,再经过 primary 到另一个 secondary。各节点先存入缓冲区并确认接收,实际写入由下一阶段安排。转发路径可以根据节点间的网络距离选择。

控制阶段由 primary 分配顺序并协调修改

数据到齐后,客户端向 primary 发写入命令。primary 安排顺序,自己执行,再通知 secondary。各副本返回处理结果后,primary 回复客户端。图中的 ACK 表示这个步骤完成;具体要保存到哪一层,由写入协议规定。

各副本缓冲 A、B 后,统一按 A 再 B 执行

第三张图中,primary 规定先写 A、再写 B,各副本最终都保存 B。GFS 先把数据发送到副本,再发送带顺序的写入命令。写入中断时,某些副本可能只完成部分修改,后续需要检查并恢复。

master 通过 chunk version number(块版本号)识别落后副本。发现版本落后后,系统把这些副本排除在可读列表之外,再恢复数据或重新复制。

顺序条件

把副本看成状态机时,它们从相同初态出发,按相同顺序完整执行确定的修改,才能得到相同结果。数据到达缓冲区、命令开始执行和修改完成,是不同阶段。客户端需要根据返回状态判断这一组副本是否已经按协议完成。

例如 primary 为 A 分配序号 11、为 B 分配序号 12。副本即使先收到 B 的控制消息,也要协调顺序,使 11 先完成,再应用 12。只对同一 chunk 排序的规则,还需要结合读取与跨 chunk 操作的语义理解。

原始 GFS 流程先把数据送到本次参与的副本并取得接收确认,再向 primary 请求执行;随后 secondary 返回修改结果,primary 汇总回复。错误时部分位置可能已经修改,要按失败与重试规则处理。

主副本切换

primary 失联后,还需要防止旧授权与新授权同时有效。master 可以在旧租约到期后授予新租约,并用版本等信息识别落后副本。客户端发现原 primary 不可用或不再持有授权时,重新查询。

选出一个新节点只是过程的一部分,仍要检查哪些数据有效、旧请求怎样处理、何时能继续读写。GFS 的租约与版本机制共同承担这些工作。

流水传输

先传完全部数据给节点 1,再传给节点 2、3,会让传输阶段逐段等待。流水转发则让节点收到前一小段后立即转发,后续小段同时沿各条链路前进。

设数据量为 S,每条独立链路的传输率为 B,固定启动等待为 α,分段大小为 q,传过 h 段。忽略拥塞和额外分段处理成本,整份顺序转发约需 h(α+S/B)h(\alpha+S/B);流水转发的主要项约为:

T≈SB+(h−1)qB+hαT\approx\frac{S}{B}+(h-1)\frac{q}{B}+h\alpha

第一份数据填充流水线后,各段同时工作,因此大数据量的主要时间接近一份 S 的发送时间。网络仍传输了 h 份数据,源端上行、转发能力和共享交换路径都可能限制这种重叠。

练习:把 64 MiB 数据依次送到三份副本,三段独立链路均为 1 Gbit/s。忽略启动时间,整份串行转发约多久?按 1 KiB 分段流水转发,理想值约多久?

解答:

一份数据的发送时间为 64×220/(109/8)≈0.536964\times2^{20}/(10^9/8)\approx0.5369 s,三段依次完成约为 1.6106 s。

流水转发约需 (64 MiB+2 KiB)/(1.25×108)≈0.5369(64\ \text{MiB}+2\ \text{KiB})/(1.25\times10^8)\approx0.5369 s。这个估算要求各段能够同时传输,转发处理足够快,且没有共享带宽瓶颈。

64 MiB 是本例数据量,1 KiB 是网络流水的分段大小,两者与文件系统逻辑 chunk 的职责分别理解。

追加记录

追加一条记录时,服务器选择末尾位置,并把完整记录加入文件。假设第一次已经追加成功,但回复丢失,客户端重试又追加一份,就会产生重复记录。应用为记录分配 ID 后,可以在读取时把同 ID 的重复内容识别出来。记录的原子追加保证一次记录写入的完整性,重试的次数则由请求与去重规则处理。

snapshot 用于保存文件或目录树某个时刻的副本。GFS 可以与作业调度器共同运行,为计算任务提供输入和结果文件。

这个部署例子包括 200 多个集群,其中许多有上千台机器和客户端;文件系统容量超过 4 PB,读写负载约 40 GB/s。作业调度器安排任务,GFS 为这些任务提供数据。

HDFS

HDFS 是 Hadoop Distributed File System,中文叫 Hadoop 分布式文件系统。它的设计受到 GFS 启发,用普通硬件保存大文件,并处理节点故障。下表列出主要组件。

GFS HDFS 职责
master NameNode 命名空间与块元数据
chunkserver DataNode 实际数据与副本
chunk block 文件切分单位
checkpoint image FSImage 元数据检查点
operation log EditLog 元数据变更记录

HDFS 的文件块、副本、NameNode 与恢复材料

上方文件被分成 B0、B1 等块,中间的 DataNode 保存副本。NameNode 记录每个文件包含哪些块,以及块在哪些节点。右侧 FSImage、EditLog 保存这些管理信息,用于恢复。DataNode 1 失效后,客户端可以读取其他副本,系统再补齐副本数量。恢复文件路径还需要可靠的元数据记录。

这张图使用 64 MB 块作示例。部署时可以按版本和需求配置块大小。

两套架构都把元数据与大块数据服务分开,客户端先查询位置再传输内容。具体文件写入、追加、租约和故障恢复仍按各自接口规定。HDFS 通常以一个文件的单写入者与数据流水线组织写入,不能仅凭组件图推断 GFS 的 record append 语义。HDFS 架构文档

3FS 用途

训练程序读样本、保存模型状态,推理程序读写已经计算出的注意力键和值。样本可能分布在许多文件中,所以客户端会频繁打开文件、寻找数据位置并读取小段内容。大量请求叠加后,元数据查询、内存复制和请求队列都会影响处理速度。

3FS(Fire-Flyer File System)由 DeepSeek 开发并开源,用于 AI 训练与推理。应用通过文件和目录访问数据,底层读写方式针对训练请求和硬件条件作了调整。

数据准备、训练样本、检查点和 KV cache 的四类访问

数据准备要读写中间文件,dataloader 要随机读取训练样本,每次可能读几 KB 到数 MB。保存检查点会集中写入数据,图示负载超过每节点 10 GiB/s;KV cache 例子的读取峰值为 40 GiB/s。这里 1 GiB 等于 2302^{30} 字节,数字对应图示的具体负载。

可以沿一次训练过程理解这些文件:

  1. 数据准备把图片、文本等清洗并转换为训练输入,保存处理结果。
  2. dataloader 选取下一批样本,从文件中的相应范围取得内容,再交给训练程序。
  3. 训练调整参数,并定期保存参数、优化器状态和进度等检查点材料。
  4. 故障后加载有效检查点,重新取得输入,继续训练。

许多训练任务会打乱样本顺序,减少固定排列对批次的影响。逻辑上的样本顺序与实际发出 I/O 的顺序可以通过批量、预读和缓冲协调,因此存储服务需要兼顾小范围随机访问与总吞吐。

推理中的 KV cache 保存已处理 token 的注意力计算结果。把可复用内容放在 GPU 内存之外的存储层,可以扩大保留范围,取回时又要计入查找、传输和装入计算设备的时间。复用还需要确认模型、前缀和相关计算条件相符。

思考题:从大文件顺序读取变为大量样本随机读取,配合 NVMe SSD 和高速 RDMA 网络后,元数据查询与 I/O 软件路径需要怎样调整?

解答:

大量小文件随机读会提高打开、查属性和寻找块的频率。3FS 让多个元数据服务共享事务存储,打开时缓存布局,后续按公式计算 chunk 和复制链,减少逐块查询;native I/O 通过共享请求环和数据缓冲区减少内核队列与复制工作。这样能把较快硬件用于更多实际读写。

GFS 与 3FS 的硬件和访问方式对照

GFS 的例子中,每节点有两块 80 GB 磁盘和 100 Mbps 网络。3FS 的例子中,每节点有 16 块 NVMe SSD 和两条 200 Gbps RDMA 链路。RDMA 是远程直接内存访问,网卡可以直接读写对端预先注册的内存缓冲区。图中的名义网络带宽相差 4000 倍,实际读写速度还受 CPU、内存复制和软件处理影响。

服务分工

元数据是描述文件的数据,例如名称、大小、inode 号和块布局;数据内容是文件保存的字节。图中把这两类请求分给不同服务。心跳是节点定期发送的存活消息,集群管理器用它维护成员记录;一段时间未收到时,按协议检查和处理节点失联。

思考题:3FS 的元数据保存在哪里?多个元数据服务怎样共享记录?单个服务失效后,其他服务继续处理请求需要哪些条件?

解答:

权威元数据保存在 FoundationDB 中,多个元数据服务从同一份事务键值存储读取和修改记录。一个实例失效后,其他实例需能访问共享存储及集群配置,按事务规则处理请求。进行中的事务还要确认是否提交,避免把结果不明的修改当作全新操作重复处理。

客户端、元数据服务、存储服务和集群管理器

客户端接收应用的文件操作。元数据服务处理目录、inode 和文件属性;存储服务管理 SSD 和数据块;集群管理器记录有哪些节点、它们的配置及运行状态。

图上方传递文件管理信息,下方传递文件内容。元数据服务从共享的事务型键值存储中查询和修改记录,客户端可以选择其他服务实例。集群管理器通过心跳了解节点是否还在运行,并更新成员、租约和访问位置。

多个元数据前端共享后端,实例失效后由其他实例继续接收请求

图中的 GFS 历史实现由单 master 处理元数据请求,图示的历史实现约每秒处理几千次元数据操作,处理速度和内存会限制扩展;3FS 前端共享 FoundationDB。一个前端失效后,其他实例读取同一权威记录,继续按事务规则处理;进行中的提交仍需确认最终结果。

键值存储

键值存储通过键取得对应的值。例如 "文件7" → 属性记录,查找指定键就得到该记录;修改键的值就更新相应内容。字典、哈希表等结构也提供这类基本访问,分布式存储还要处理数据分布、复制和故障。

事务键值存储把多次相关读取、检查和更新共同提交。3FS 的元数据前端把文件操作转换成这种事务,FoundationDB 负责可靠管理相应记录。多个前端可以访问同一组键,具体冲突与提交规则由存储协议处理。

需要区分前端实例和权威存储。增加前端可以分担请求,权威记录则按后端布局分片或复制。服务在其协议支持的故障范围内恢复与继续处理,存储或通信暂时不可用时,请求仍可能需要等待、重试或确认提交结果。FoundationDB 开发说明

修改目录

3FS 把 inode 和目录项写成键值对,保存在 FoundationDB 中。多个元数据服务可以处理请求。事务把相关修改放在一起提交,用于保持目录项之间的关系。

记录 键的组成 值
目录项 DENT 前缀、父目录 inode、名称 子项 inode
inode INOD 前缀、inode 号 属性与文件布局

例如,DENT|2|a.txt 表示目录 inode 2 中的 a.txt,值是目标 inode 7;INOD|7 保存 inode 7 的属性和数据布局。竖线用于分隔这里的键组成部分。

思考题:/x/a.txt 对应 inode 7,要移动到 /y/a.txt,其中目录 inode 分别为 2、5。键值存储要修改哪两个目录项?为什么要放在同一事务中?若另一个服务同时 unlink 源名称,怎样处理冲突?

解答:

在简化模型中,先读取并检查源、目标目录项,再删除 DENT|2|a.txt,建立 DENT|5|a.txt → 7。文件 inode 仍为 7,两次名称修改在一个事务中共同提交或撤销。并发 unlink 访问相关记录时,事务存储检查读写冲突,一方提交后,另一方重新读取最新状态,再决定继续处理或报告源名称已变化。

跨目录 rename 修改两个目录项,并与并发 unlink 冲突

权限检查、目标覆盖与目录环检查也应纳入实际事务。

冲突重试重新执行的是有关事务过程。发送外部消息、改变普通内存等额外操作,需要分别判断重复执行的效果。提交回复丢失时,还要处理“可能已经提交”的情况,沿用操作身份与可靠记录进行确认。

打开文件

会话记录“哪个客户端以什么方式打开了哪个文件”。写会话保留后,删除程序可以知道这个文件还有写入正在进行,等写入结束再回收。只读打开由客户端保存 fd 和文件信息,服务器省去大量相应的会话写入。

思考题:训练启动时打开很多样本,记录所有只读会话会增加什么工作?3FS 跟踪哪些打开会话?样本文件与正在写的检查点被删除时,分别怎样处理?

解答:

记录每个只读打开会产生大量会话写入与清理工作。3FS 由客户端保存只读 fd 和布局,服务器跟踪写打开。样本没有需要保留的写会话时可被删除,已打开读者的后续远端读取可能失效;正在写的检查点则延后回收,等写会话关闭或失联会话被清理。

只跟踪写打开,降低大量只读打开的会话记录成本

写会话还帮助系统判断哪些写入者仍可能产生新数据。删除时若立即回收已有块,正在执行的写入又生成新块,就可能留下无法通过文件记录找到的空间。会话、客户端存活检查与删除流程共同协调这些动作。

只读样本通常在训练期间保持稳定,省去只读会话记录能够减少大量打开带来的管理写入;应用仍要处理样本被删除或失效的读取。会话记录可以保存在共享后端,元数据前端根据请求取得状态,实例更换后继续使用这些权威记录。

查找数据块

条带宽度表示用于分配文件块的链列表长度,一条复制链记录保存同一块的多个节点。假设链列表为 [chain 1,chain 2,chain 3],宽度为 3,块 3 的余数为 0,取列表第一项 chain 1;块 4 的余数为 1,取第二项 chain 2。余数随块号循环,让不同块分布到不同链上。

思考题:为什么可以在 open 时取得布局,后续直接计算数据块位置?怎样由偏移得到块号,再用 chains[index % stripe] 选择复制链?图中的 inode 7、块 3 和块 4 各联系哪条链?

解答:

open 取得块大小、条带宽度和链列表,客户端据此计算 i=⌊offset/C⌋i=\lfloor offset/C\rfloor、块内位置 offset mod Coffset\bmod C,chunk ID 为 (inode,i),链为 chains[i % stripe]。按图中布局,(7,3) 选 chain 1,(7,4) 选 chain 2。布局与成员配置有效时,后续读取直接联系这些链上的存储服务;信息变化后再更新。

随机读取分散在多个 chunk 中的数据时,客户端需要找到这些块所在的存储节点。3FS 在打开文件时取得布局信息,之后直接计算 chunk ID 和复制链。复制链记录保存同一份数据的存储节点,客户端据此选择访问位置。

打开时缓存布局,读取不同 chunk 时直接选择链

一种简化的寻址规则为:

i=⌊offset/C⌋,chunk_id=(inode_id,i)i=\lfloor offset/C\rfloor,\qquad \mathrm{chunk\_id}=(\mathrm{inode\_id},i)

chain=chains[i mod stripe]\mathrm{chain}=\mathrm{chains}[i\bmod \mathrm{stripe}]

客户端先取得块大小 C、条带宽度和链列表。图中 inode 7 的 chunk 3 选 chain 1,chunk 4 选 chain 2,随后直接联系相应存储服务。节点地址和链成员由集群配置提供。文件布局或节点位置改变后,客户端要更新这些记录。

例如 C 为 4096 B、偏移为 13000 B,则 i=3,块内偏移为 13000-3*4096=712 B。若条带宽度为 3,3%3=0,选择已取得的链列表第 0 项,再在该链上访问 (inode,3) 的相应范围。

布局描述“逻辑块选择哪条链”,集群配置描述“链上有哪些节点、地址是什么”。这两组信息共同完成定位。节点变更时,要按配置与迁移协议更新访问目标,并检查旧配置下进行中的请求。

计算和存储

任务调度决定把一项计算放到哪台机器运行。数据就近调度把任务放到已经保存输入的位置;计算、存储分开时,调度器可以根据可用 GPU 安排任务,再通过网络取输入。网络需要及时提供这些数据,计算节点才能持续工作。

思考题:locality-oblivious 的任务访问方式怎样安排计算和存储?它对网络带宽有什么要求,为什么计算节点和存储节点可以分别扩容?

解答:

GPU 与存储节点分开部署,任务主要依据计算资源安排,输入通过网络从相应存储节点取得。网络需要支撑跨节点的总数据需求,减少 GPU 等待输入。GPU 数量和存储容量可以按各自需求增长;实际效果还取决于存储服务、链路容量、排队和数据分布。

GFS 的数据本地调度与 3FS 的计算存储分离

左图的 MapReduce 把处理 C2 的任务放到已经保存 C2 的机器,减少网络传输。右图的 GPU 节点接到任务后,通过 RDMA 从独立存储节点取数据。这种按计算资源安排任务的访问方式称为 locality-oblivious。

图中的 full bisection 表示网络要能支撑两组节点之间的总传输需求。GPU 和存储分开部署后,可以按需求分别扩容。实际远端访问仍取决于网络容量、排队和故障情况。

多个随机样本可能分布在许多存储节点上,把计算固定到某一份样本旁边,未必能够同时靠近后续全部输入。分离部署后,任务可以依据可用 GPU 安排,利用多条链路并行取回输入;调度仍要检查热点与全局负载。

网络容量可以粗略按需求相加。例如 100 台计算节点各需持续 2 GB/s,聚合输入需求约为 200 GB/s。链路、交换路径和存储端都需要提供对应能力。full bisection 的设计目标还要由具体拓扑与实际负载验证。

访问文件

内核管理设备和进程资源,用户态运行普通应用与文件服务程序。FUSE 把内核收到的文件操作送给用户态程序。共享队列由多个线程访问时,需要同步安排每次取放请求;共享内存缓冲区让应用和客户端直接访问同一块数据,减少复制。

FUSE 是 Filesystem in Userspace。普通应用继续使用文件接口,需要更短 I/O 数据路径的应用可以接入 native client。

思考题:普通应用怎样通过 FUSE 访问 3FS?一次 read 从应用到存储服务,要经过哪些队列、程序和数据复制?

解答:

普通应用调用 read,进入 VFS,再由 FUSE 模块放入共享请求队列。用户态 daemon 取出请求并访问存储服务,数据沿返回路径交给应用。图中队列的多线程取放需要同步,数据在 daemon、内核和应用之间传递时存在复制,因而占用 CPU 与内存带宽。

普通 FUSE 请求经过 VFS、内核共享队列与用户态 daemon

在 3FS 设计说明的测试环境中,FUSE 路径约处理每秒 40 万次 4 KiB 读取。

思考题:高速存储和网络下,FUSE 路径可能在哪些环节等待?native client 的 Ior、Iov 分别传什么,怎样减少每次 I/O 的队列与复制工作?

解答:

FUSE 可能等待共享队列的锁、请求调度及内核与用户态之间的数据复制。Ior 保存读写请求,Iov 保存待写内容或读取结果。应用先打开文件并完成所需注册,随后通过共享内存提交请求,由 native client 批量处理并访问存储,减少每次经过 FUSE 队列和复制数据的工作;共享结构仍按接口同步使用。

native 数据路径采用共享缓冲区与请求环

结构 作用
Iov 共享数据缓冲区,读取结果进入其中,写入数据先放入其中
Ior 应用与 native client 间的请求环,用于异步提交与处理

应用先通过 FUSE 打开文件并注册 fd,随后把读写请求放入 Ior,把数据放入 Iov。native client 批量取出请求,通过 RDMA 访问存储。图中的绿色路径减少了每次进入 FUSE 队列和复制数据的开销。初始化和缓冲区注册仍由操作系统参与,多线程共用请求环时也要安排同步。

开发者参考了 io_uring 的异步提交方式,用共享请求环在应用和客户端程序之间传递请求。普通工具通过 FUSE 访问文件,频繁进行小块读写的程序可以接入 native I/O。

共享缓冲

共享内存让两个进程的虚拟地址映射到同一份物理页面。例如应用用地址 0x1000 访问页面 P,客户端程序用地址 0x8000 访问同一页面 P,二者地址不同,但读写的字节由同一份页面提供。

因此应用把内容写进 Iov 后,客户端可以通过自己的映射使用这些字节,减少把内容复制到另一份进程缓冲区的工作。RDMA 注册使网卡能在指定范围与权限条件下访问缓冲区,注册、映射和控制操作仍需要系统参与。

一次异步写可以按下面的逻辑流程理解:

  1. 打开文件并完成 fd 与有关缓冲区的注册。
  2. 应用把内容放入 Iov,填写偏移、长度和缓冲区位置等请求信息。
  3. 将请求发布到 Ior,客户端取出并按接口批量执行。
  4. 网络与存储操作完成后,返回状态与实际结果。
  5. 应用取得完成结果,再按接口规则复用缓冲区或处理错误。

提交请求和 I/O 完成分成两个时刻。数据尚在被访问时,要保留缓冲区及其内容;多个线程使用共享环时,需要协调发布与消费顺序。

native 数据路径减少每次读写经过 FUSE 队列的工作,open、close、stat 等元数据路径继续使用对应文件接口。接入这条数据路径需要应用使用 native API,并检查异步完成与缓冲区管理。

三种系统

读懂一套文件系统时,可以跟踪一次读取:路径怎样得到文件标识,偏移怎样得到块编号,块编号怎样得到存储节点,节点怎样返回内容。再跟踪一次写入:谁规定顺序,哪些节点要确认,失败后保存了哪些记录。

维度 经典 NFS GFS 3FS
主要负载 工作站文件共享 巨型文件、顺序读与追加 多文件、随机样本读和突发写
接口 挂载后的文件 API 用户态库 FUSE 与 native
元数据 文件服务器 单 master 与恢复材料 多服务实例与事务 KV
数据定位 fh 与偏移 查询并缓存 chunk 信息 根据缓存布局计算链
数据拓扑 客户端与服务器 常结合数据本地调度 计算、存储分离

NFS 用客户端保存的读写位置处理远端文件共享。GFS 为大文件批量处理安排分块和副本写入顺序。3FS 面向随机样本读取,使用共享的元数据存储和较短的 I/O 路径。这些设计都与文件访问方式和硬件条件有关。

参考资料