多个工作者可以重做同一份计算,保存计算结果的文件系统却必须回答另一组问题:数据放在哪些机器上,谁决定并发写入的先后,一次写入只有部分副本完成时能否读取,以及客户端收到成功究竟意味着什么。

上一篇:MapReduce 任务重试与输出提交区分了执行与发布。GFS 把这种区分推进到存储层:网络已经传完数据、某个副本已经改动文件、整个请求已经得到成功响应,是三个不同的时刻。混淆它们,会把失败后的残留内容当成已提交结果。

讨论对象是 Ghemawat、Gobioff 和 Leung 在 SOSP 2003 发表的 GFS。MIT 6.5840 Spring 2026 把它放在 MapReduce、RPC 之后,用它连接分片、复制与故障恢复。本篇的 Go 程序只执行有限内存时间线,不提供 GFS API,也没有真实的磁盘复制、租约时钟或网络传输。

从工作负载确定系统模型

2003 年 GFS 面向大型数据处理。文件较大,追加和顺序读取常见,系统需要聚合带宽,并把机器故障视为日常条件。这与任意程序都能直接使用的通用文件系统有距离。应用可以配合存储接口,接受校验记录、过滤重复等额外责任。原论文 §1–2

一个文件切成若干 chunk,每个 chunk 有多个副本。分片让不同文件区域可以利用不同机器的容量与带宽,副本则保存同一区域的多份内容。假设三个副本 A、B、C 存放同一 chunk,将写请求分别交给三台机器,并不会自动得到一个统一的文件。

设两个客户端都写偏移 0,一个写 x,另一个写 y。A 按 x,y 执行,B 按 y,x 执行,最终读取分别得到 yx。两台机器都没有丢请求,错误来自不同执行顺序。反过来,即使它们采用相同顺序,B 在执行第二个请求前故障,也会暂时留下不同内容。排序解决并发顺序,失败处理决定这种差异何时可以暴露。

这里的故障模型包括进程停止、机器或磁盘故障、网络不可达。磁盘还能发生内容损坏,因此系统使用校验和检测一部分错误;这不等于容忍任意拜占庭行为。MIT 讲义明确提醒,错误计算和不满足租约要求的时间行为也可能破坏预期保证。MIT 2026 讲义

元数据与文件内容分开传输

master 管理命名空间、文件到 chunk 的映射和副本位置。需要持久保存的元数据进入日志;副本位置可以在启动时询问 chunkserver 重建。文件内容直接在客户端与 chunkserver 之间传输。原论文 §2.3、§2.6

客户端读取文件某个偏移时,先把偏移转换为 chunk 索引,取得 chunk 标识与位置,再向一个副本请求字节。客户端缓存位置信息,减少对 master 的重复访问。大文件中的连续读取因而不必每次经过 master。

这种结构降低了 master 的数据带宽压力,但没有消除元数据成本。大量小文件仍会增加映射、名字和管理操作;一个热点 chunk 也不能靠增加其他 chunk 的机器数量来直接加速。评估系统能否扩展,需要分别观察文件数、chunk 数、单个热点的读写速率,以及整个集群的总流量。

记录位置与保存内容还有不同的恢复含义。位置表记录“应向哪台机器请求”,机器自己的磁盘才决定“实际还存着什么”。如果磁盘损坏,master 上一份持久化的位置列表不能恢复丢失内容。重新盘点副本可以修正目录,却仍需要可用副本提供数据。

primary 决定修改顺序

同一个 chunk 在一段租约有效期内由一个 primary 编排修改,其余副本作为 secondary。master 负责租约授予;primary 给修改请求确定先后顺序。这个职责按 chunk 分配,不表示整个集群只能同时写一个文件。原论文 §3.1

租约的作用是限制谁可以继续充当 primary。master 与旧 primary 失联后,不能仅凭超时怀疑就立即任命一个仍会与旧 primary 重叠的新 primary。有效设计要求旧任期的权限在新任期开始前失效;节点判断到期所用的时间规则属于安全假设。MIT 2026:What is a lease?

假设租约失效处理正确,且参与副本都遵守 primary 的序号,对一个 chunk 内的修改就可以作归纳论证:初始内容相同;每一步都对相同内容执行相同修改;完成同一前缀后内容仍相同。这只证明相同执行前缀的副本相同,不能推出落后副本已经完成该前缀,也不能推出多个 chunk 上的操作具有整体原子性。

大的应用写入可能被拆成多个存储操作。若客户端一写两段,另一个客户端也写两段,存储层可能按段交错处理。每段在各副本上的结果一致,与整个应用请求完整地排在另一个请求之前,是不同的保证。需要跨区域原子更新的应用必须另找协议依据。

数据 push 与修改命令

写入路径有两类传输:数据流负责把字节送到副本,控制流负责指定怎样修改文件。数据可以按网络位置沿链条传送;primary 不必位于这条链的第一站。收到的数据先进入缓冲区,收到修改命令后才用于指定位置的写入。原论文 §3.1–3.2

1
2
3
4
5
控制:客户端 → master:查询 primary 与副本位置
数据:客户端 → 邻近副本 → 其余副本:缓冲待写字节
控制:客户端 → primary:引用已传数据,请求修改
控制:primary → secondaries:偏移、数据标识、修改顺序
响应:secondaries → primary → 客户端

数据流在所有副本完成接收之后,客户端才发出修改请求。由此可以识别一个重要故障窗口:客户端刚传完数据便退出,服务器上存在缓冲字节,却没有相应文件修改。读取接口不能把“缓冲区里有数据”解释为文件已经更新。

控制流又有另一个窗口。primary 已更新自己的文件,一个 secondary 也完成,另一个却失败。客户端收到错误,并不意味着前两份修改会自动撤销。GFS 将这类失败区域交给其较宽松的一致性语义处理;应用不能使用一次失败来证明所有副本都没执行。MIT 2026:What data may a client read after a failed write?

这与 01 的 RPC 重试问题相连:失败响应描述请求没有取得所需成功条件,无法倒推出所有副作用为零。若应用把错误当成没有写入,再用新的业务编号重试,存储层与应用层都可能失去识别重复的依据。

record append 的成功范围

普通写由客户端指定偏移;record append 让 primary 选择偏移,以完整记录为单位追加。它提供至少一次语义,失败重试允许重复。若记录放不进当前 chunk,则填充剩余空间并要求在下一个 chunk 重试。成功记录所在区域有保证,失败留下的中间区域则可能不同。原论文 §2.7、§3.3

用两个字节的记录 RR 可以直接观察这个边界。第一次尝试在偏移 0 写入:A、B 成功,C 未执行,请求失败。重试时 primary 在偏移 2 写入,三个副本都成功。A、B 现在有两个 RR;C 的前两个字节是空洞,后两个字节才是 RR。偏移 2 上的成功记录完整相同,整个 chunk 并不逐字节相同。

这条时间线给出两种不同的计数。物理字节中可能有多份相同记录,逻辑业务上却只有一个事件。记录携带稳定 ID 后,读取端可以在应用层去重;记录再携带长度、类型和校验信息,读取端才能区分有效记录、残片与填充。去重状态的保存范围和保留时间又决定了重复是否会在重启后重新出现。

成功保证也不等于无需重试就最终完成。若一个必要副本持续不可写,操作可能持续失败;如果所有保存有效内容的副本永久丢失,复制不能凭空恢复数据。安全性描述哪些成功结果允许出现,活性还需要可用资源、故障修复与通信恢复等条件。

GFS 的这些接口选择降低了部分存储层协调负担,却把记录识别和重复处理交给应用。2009 年原工程师 Sean Quinlan 的回顾指出,这种宽松语义实际带来的负担超过预期,不同读取路径还可能让记录以不同顺序出现。因而不能把“上层可以处理”当成无成本的理由。GFS: Evolution on Fast-forward,第 10 页

chunk version 与陈旧读取

新租约授予时推进并持久化 chunk version,能够识别没有参与新版本的副本。master 不把这些落后副本提供给新的位置查询。但持有旧位置缓存的客户端仍可能读取旧副本;原论文明确保留了这个窗口。原论文 §4.5、§2.7.1

版本号在这里区分副本所属的更新阶段,不能代替逐条写入日志。两个副本具有相同版本号,不表示本任期内每个失败请求都在两者上留下了相同字节。要判断写入完成,仍须检查具体操作的完成条件。

目录新鲜与读取新鲜也不是同一个检查。master 可以已经知道 C 落后,并在新目录中排除 C;某个客户端却仍保留 C 的地址,直接发起读取。检查一次 master 的输出只能证明这次查询返回了哪些位置,不能证明全部既有客户端已经刷新。

因此,分析读写历史时,应明确读取是否查询了 master、是否使用缓存、访问哪个副本、该副本完成到哪个阶段。只画主副本与备份之间的复制箭头,会遗漏客户端缓存形成的独立路径。

本地实验:五条可重复时间线

代码位于仓库 examples/distributed-systems/gfs03/,只依赖 Go 标准库。三个 replica 各自保存字节数组、待写缓冲区和版本号;脚本直接安排事件先后。数组内容用于模拟文件字节,不是磁盘持久化证据。

在仓库根目录执行:

1
2
3
4
cd examples/distributed-systems
go run -race ./gfs03
go run ./gfs03 -scenario retry
go run ./gfs03 -help

程序内置断言,检查不通过会非零退出。默认运行全部五个场景,单独运行可以选择 dataretrypaddingversionsorder

场景 注入的条件 断言观察对象
data 全部收到数据,尚未发送修改 文件内容仍为空;修改完成后才可读
retry 第一次写入跳过 C,第二次全部执行 第一次失败仍有副作用;重试成功区域一致,旧区域允许不同
padding 16 字节 chunk 已用 14 字节,追加 4 字节记录 旧块补齐 2 字节;完整记录进入新块
versions A/B 版本 8,C 版本 7 新目录排除 C;旧缓存仍能取得旧内容
order 数据到达顺序不同,修改命令带序号 按序号执行收敛;负对照按到达顺序执行出现分歧

order 在执行前收齐有限命令,再按序号排序。真实系统需要在线处理缺失、延迟与重复命令;这个有限模型没有实现那些机制。retry 固定 primary,没有模拟换主,空洞使用零字节表示;padding 的 16 字节容量纯粹为了输出容易核对。实验也没有故障检测器、后台复制、校验恢复或租约计时器。

一次实际本地运行的核心输出如下;完整环境与命令保存在附件:

1
2
3
4
5
6
7
8
push: A/B/C buffered=DATA readable=empty
apply: offset=0 A/B/C=DATA success=true
attempt1: success=false A=RR B=RR C=empty
attempt2: success=true offset=2 A="RRRR" B="RRRR" C="\x00\x00RR"
padding: old-length=16 padding-bytes=2 retry-next=true new-offset=0 record=ABCD
versions: master=8 replicas=[8 8 7] fresh-directory=[A B] cached-C=old
order: primary=[x:1 y:2] A/B/C=y arrival-only=[y x]
CHECK scenarios=5 passed=true

这些断言可以揭示模型中的语义变化。例如,把 push 改成同时写入文件,会触发 data 的断言;把部分失败错误地返回成功,会触发 retry 的断言。通过检查只说明这五个有限场景符合设定,不证明任意故障组合下的完整协议正确。

本地验证记录论断、资料与核验状态

两道推导题

成功记录与重复业务事件

某客户端追加事件 ID 为 e7 的记录。第一次操作返回错误,第二次返回成功偏移 20。读取者在偏移 8 与 20 都找到带有效校验信息的 e7。能否只保留偏移最大的记录,并据此证明业务只执行过一次?

不能。选择一个物理副本记录,最多解决本次扫描如何输出;业务是否早已根据偏移 8 执行过转账、发信等副作用,需要另一份业务完成记录。读取端在崩溃后重扫,也可能再次处理偏移 20。要维持一次业务效果,必须把事件 ID 与业务状态更新放进合适的原子边界,或者让业务动作本身幂等。存储层至少一次追加并不提供这个边界。

排除陈旧副本后能否立即保证新读

master 的新目录返回 A、B,C 因版本过低被排除。某读请求仍然返回 C 中的旧内容。这是否足以证明 master 的版本过滤有 bug?

不足以。先检查该读请求是否真的使用了这次新目录。若它使用此前缓存的地址,master 的过滤可能完全正确,而整个读取路径仍允许旧结果。只有确认客户端采用新目录、实际请求仍被路由到 C,才能把调查推进到目录消费、缓存更新或代理路由。这个区分也适用于负载均衡、服务发现和成员变更。

从 GFS 到下一篇 KV

GFS 将大块数据传输与元数据协调分开,也展示了失败区域暴露给应用后的代价。2009 年访谈讨论了文件数压力和恢复延迟;2021 年 Google 官方对 Colossus 的说明则采用了分布式元数据服务。后继系统继续保留数据直达存储节点的结构,但不能因此推断它与 GFS 2003 具有相同接口语义。访谈Colossus 官方说明

已有的 HDFS 写入路径文章可以用于比较流水线;具体产品差异留给选修 E02。主线下一篇 04 从单机 KV 开始,把“写入内存”“持久保存”“向客户端确认”放到真正可重启的进程中观察,为复制状态机建立更严格的本地基础。

参考资料