关键问题:如何构建分布式文件系统

如何构建一个分布式文件系统?需要考虑哪些关键方面?哪些地方容易出错?能从现有系统中学到什么?

原文:HOW TO BUILD A DISTRIBUTED FILE SYSTEM. How do you build a distributed file system? What are the key aspects to think about? What is easy to get wrong? What can we learn from existing systems?

分布式客户端/服务器计算最早的用途之一就是分布式文件系统:若干客户端机器、一台(或少数几台)服务器;服务器把数据存在自己的磁盘上,客户端通过格式良好的协议消息请求数据。本章研究其中最成功的一个:Sun 的 NFS(Network File System),重点是它的第二版 NFSv2。上一章讲了通信和 RPC;这一章会看到,把协议设计成"无状态 + 幂等",就能用上一章的"超时重试"统一地应对丢包和服务器崩溃。下一章的 AFS 则走了另一条路。

核心概念速览

透明访问

应用照常调用 open/read/write,感觉不到文件其实在远程服务器上(性能除外)。

无状态(stateless)

服务器不记录客户端在干什么;每个请求自带完成它所需的全部信息。

文件句柄(file handle)

卷 ID + inode 号 + 世代号,唯一标识一个文件或目录,几乎每个请求都带着它。

幂等(idempotent)

执行多次与执行一次效果相同。LOOKUP、READ、WRITE 都幂等,所以客户端可以放心重试。

客户端缓存

缓存读到的数据,并把写先缓冲在本地——性能大增,但带来缓存一致性问题。

关闭时刷新(flush-on-close)

文件关闭时把脏数据刷到服务器,让之后在别处打开的人看到新版本。

GETATTR 与属性缓存

用缓存前先问"文件改过没";属性缓存减少询问次数,却让语义变得难以捉摸。

服务器先落盘再回复

WRITE 必须写入稳定存储后才能回复成功,否则崩溃会丢掉客户端以为已写入的数据。

一个基本的分布式文件系统

Client 0 Client 1 Client 2 Client 3 网络(Network) Server RAID
图 49.1:通用的客户端/服务器系统。磁盘都在服务器上。

为什么要这样安排,而不让客户端用自己的本地盘?首要原因是方便共享:在 Client 0 上访问的文件,换到 Client 2 上看到的还是同一个文件系统视图。其次是集中管理:备份只需在少数服务器上做。另外还有安全:把服务器锁在机房里能避免某些问题。

客户端应用 客户端文件系统 网络层 文件服务器 网络层 磁盘 系统调用 open/read/write/close/mkdir… 读磁盘(或自己的内存缓存)
图 49.2:分布式文件系统的架构。两块关键软件:客户端文件系统与文件服务器。

客户端应用通过系统调用访问客户端文件系统。对应用而言,它和本地磁盘文件系统没有区别(性能除外)——这叫透明访问,谁会想用一个需要另一套 API 的文件系统呢?客户端文件系统负责执行系统调用所需的动作:比如 read() 时,向文件服务器发消息读某个块;服务器从磁盘(或自己的内存缓存)读出来,回消息;客户端把数据拷进用户缓冲区。之后再读同一块,可能直接命中客户端内存(甚至客户端磁盘)上的缓存,最好的情况下根本不产生网络流量。

断电(恢复供电后才能重启);服务器由几十万甚至上百万行代码组成,总有 bug 会被触发;内存泄漏,哪怕很小,最终也会耗尽内存;还有网络——如果网络分区了,客户端和服务器都在正常运行却无法通信,在客户端看来就像服务器崩溃了一样。

NFS 的目标:简单快速的崩溃恢复

Sun 定义 NFS 的方式很特别:不做封闭的专有系统,而是发布一个开放协议,只规定客户端和服务器之间的消息格式。不同厂商可以各自开发 NFS 服务器,在保持互操作的前提下竞争(Oracle/Sun、NetApp、EMC、IBM……)。这种"开放市场"很可能就是 NFS 成功的原因。

NFSv2 协议设计的首要目标是简单快速的服务器崩溃恢复。在多客户端、单服务器的环境里这非常合理:服务器宕机的每一分钟,所有客户端和它们的用户都没法干活——服务器怎么样,整个系统就怎么样。

关键:无状态

NFSv2 用无状态(stateless)协议实现这一目标:服务器从设计上就不追踪每个客户端在做什么——不知道谁缓存了哪些块、谁打开了哪些文件、每个文件的当前指针在哪。协议让每个请求都携带完成它所需的全部信息。

反例是有状态的 open():它返回一个文件描述符,之后的 read()/write() 都用这个描述符。

char buffer[MAX];
int fd = open("foo", O_RDONLY);   // 得到描述符 fd
read(fd, buffer, MAX);            // 通过 fd 从 foo 读 MAX 字节
read(fd, buffer, MAX);            // 再读 MAX
...
read(fd, buffer, MAX);            // 再读 MAX
close(fd);                        // 关闭文件

假如客户端把 open 直接变成一条"打开 foo,给我一个描述符"的消息,服务器在本地打开文件并把描述符发回来,之后客户端每次 read 都把描述符传给服务器。这个描述符就是客户端与服务器之间的共享状态(Ousterhout 称之为分布式状态)。它让崩溃恢复变得复杂:

服务器崩溃:描述符在服务器内存里 客户端服务器 OPEN("foo") fd = 3 表:3 → foo READ(fd=3) 崩溃、重启 READ(fd=3) fd 3 是什么?? 客户端崩溃:服务器不知道何时可以关闭 客户端服务器 OPEN("foo") fd = 3 客户端崩溃 永远等不到 CLOSE fd 3 一直占着……
图 49-A:共享状态让两边的崩溃都难以处理。

因此 NFS 的设计者选择了无状态方案:每个客户端操作都包含完成请求所需的全部信息。不需要花哨的崩溃恢复——服务器重新跑起来就行,客户端最坏也只是需要重试一次请求。

NFSv2 协议:文件句柄与协议消息

如何定义一个无状态的文件协议?open() 这类有状态调用显然不能出现在协议中(那需要服务器追踪打开的文件),可客户端应用又要调用 open、read、write、close 等标准 API。怎样让协议既无状态,又能支撑 POSIX 文件系统 API?

原文:HOW TO DEFINE A STATELESS FILE PROTOCOL. How can we define the network protocol to enable stateless operation?

理解 NFS 协议的钥匙是文件句柄(file handle):它唯一地描述一个操作要作用的文件或目录,因此许多协议请求都包含文件句柄。

文件句柄 = 卷标识符(volume ID) inode 号 世代号(generation) 服务器可导出多个文件系统, 指明是哪一个 该分区中的哪个文件 inode 号被重用时 +1 世代号为什么必要: ① 客户端 A 拿到句柄 foo → {1, 37, 3} A 把它存在自己的 打开文件表里 ② B 删除 foo,新建 bar bar → {1, 37, 4} inode 37 被重用, 世代号 3 → 4 ③ A 用旧句柄 READ {1, 37, 3} ≠ 4 服务器发现世代号不符 → 报错,而不是读到 bar
图 49-B:文件句柄的三个组成部分。服务器每次重用 inode 号时递增世代号,保证持有旧句柄的客户端不会意外访问新分配的文件。

协议中的重要消息(原书 Figure 49.4),外加一列本章后面会用到的"是否幂等":

消息参数返回幂等?
NFSPROC_GETATTR文件句柄属性是(只读)
NFSPROC_SETATTR文件句柄、属性属性是(设为给定值)
NFSPROC_LOOKUP目录文件句柄、要查找的文件/目录名文件句柄、属性是(只读)
NFSPROC_READ文件句柄、偏移(offset)、字节数(count)数据、属性是(只读)
NFSPROC_WRITE文件句柄、偏移、字节数、数据属性是(写到确切的偏移)
NFSPROC_CREATE目录文件句柄、文件名、属性文件句柄、属性需仔细考虑
NFSPROC_REMOVE目录文件句柄、要删除的文件名–否(重试时文件已不存在)
NFSPROC_MKDIR目录文件句柄、目录名、属性文件句柄、属性否(原书的例子)
NFSPROC_RMDIR目录文件句柄、要删除的目录名–否
NFSPROC_READDIR目录句柄、要读的字节数、cookie目录项、cookie(用来取后续目录项)是(只读)

从协议到文件系统:读一个文件的全过程

客户端文件系统跟踪打开的文件,把应用请求翻译成相应的协议消息;服务器只是响应协议消息,每条消息都包含完成请求所需的全部信息。下面的模拟器复刻原书 Figure 49.5:一个应用打开文件、读三次、关闭。注意三个细节:

  1. 状态全在客户端:从整数 fd 到 NFS 文件句柄的映射、当前文件位置,都保存在客户端的打开文件表里。所以应用的 read(不带偏移)能被翻译成带确切偏移的 READ 消息;读成功后客户端更新文件位置。
  2. 与服务器交互的时机:第一次 open 时发 LOOKUP;路径长的话要逐级查找,例如 /home/remzi/foo.txt 要发三次 LOOKUP。close 完全不用联系服务器。
  3. 每个请求都自包含:这是能从服务器故障中优雅恢复的关键。试试勾选"服务器崩溃",再切换到假想的有状态协议对比。
模拟器

open / read / close → NFS 协议消息

逐步看应用的每个系统调用在客户端文件系统和服务器上分别引发了什么动作、发出了哪条消息。右侧面板显示客户端打开文件表和服务器保存的状态(无状态协议下永远是空的)。

用幂等操作处理服务器故障

客户端发出请求后,有时收不到回复。原因可能是请求在网络中丢了,可能是回复丢了,也可能是服务器崩溃了(过一会儿会重启,但这期间的请求全都丢失)。客户端该怎么办?

NFSv2 的客户端用一种统一而优雅的方式处理所有这些失败:重试。发请求时设一个定时器;定时器到期前收到回复,就取消定时器,一切正常;到期了还没回复,客户端就假设请求没被处理,重发一次。

能这样无脑重试,是因为大多数 NFS 请求都是幂等(idempotent)的:执行多次的效果等同于执行一次。往一个内存地址存一个值三次,和存一次一样,所以"存值"是幂等的;而把计数器加一三次,结果和加一次不同,所以"递增"不是幂等的。一般来说,只读数据的操作显然幂等;更新数据的操作则要仔细分析。

情况 1:请求丢失 客户端服务器 [send request](没收到消息) 情况 2:服务器宕机 客户端服务器 (down) [send request] 情况 3:回复在返回途中丢失 客户端服务器 [recv] [handle] [send reply] [send request]
图 49.6:三种丢失。客户端看到的都一样——没有回复——处理方式也都一样:超时后重试。

于是客户端可以用统一的方式处理所有超时:WRITE 请求丢了(情况 1),客户端重试,服务器执行写入,一切正常;请求发出时服务器宕机、第二次请求时已恢复(情况 2),同样正常;服务器收到 WRITE、写了盘、发了回复,但回复丢了(情况 3),客户端重发,服务器做完全相同的事:把同样的数据写到同样的位置并回复。消息丢失和服务器故障被统一处理了。

一个小插曲:有些操作很难做成幂等。比如创建一个已经存在的目录会失败。于是如果服务器收到 MKDIR 并成功执行,但回复丢了,客户端重发时就会得到"失败"——而实际上操作第一次已经成功了。生活并不完美。

模拟器

服务器崩溃与幂等性

选一个操作和一种失败情况,单步观察客户端超时重试的全过程,以及服务器上的数据被执行了几次。比较 WRITE(幂等)与 MKDIR、REMOVE、假想的 APPEND(不幂等)在"回复丢失"时的结局。

当一个操作可以被执行多次时,处理它的失败就容易得多:直接重试即可;不幂等的操作让生活困难得多。

即使设计得很漂亮,也总有角落情况处理得不尽如人意。可以重新设计 mkdir 的语义让它幂等(想想怎么做?),但何必呢?NFS 的设计哲学覆盖了大多数重要情况,让整个系统在故障处理上简洁清晰。接受生活不完美、依然把系统造出来,是好工程的标志。据说这句智慧出自伏尔泰:"……一位聪明的意大利人说,最好是好的敌人",所以称之为伏尔泰定律(Voltaire's Law)。

提升性能:客户端缓存

把所有读写请求都发到网络上会带来严重的性能问题:网络一般没那么快,尤其和本地内存或磁盘比。答案是客户端缓存(client-side caching):NFS 客户端文件系统把从服务器读到的文件数据(和元数据)缓存在客户端内存里。第一次访问昂贵(要走网络),之后的访问由客户端内存快速服务。

缓存还充当写操作的临时缓冲:应用第一次写文件时,客户端先把数据放进内存(与读缓存同一个缓存),稍后再写到服务器。这种写缓冲(write buffering)让应用 write() 的延迟与实际写入性能解耦:write() 立刻成功(只是把数据放进客户端缓存),之后才真正写到文件服务器。

缓存让性能大增,问题解决了?并不。在有多个客户端缓存的系统中引入缓存,会带来一个有趣的大难题:缓存一致性问题(cache consistency problem)。

缓存一致性问题

C1cache: F[v1] C2cache: F[v2] C3cache: empty Server S disk: F[v1] at firstF[v2] eventually
图 49.7:缓存一致性问题。C1 读过 F 并缓存了 v1;C2 覆写了 F(v2);C3 还没访问过 F。

这里其实有两个子问题:

NFSv2 的实现用两招应对:

  1. 关闭时刷新(flush-on-close,又称 close-to-open 一致性语义):应用写文件并关闭时,客户端把所有更新(缓存中的脏页)刷到服务器。这保证之后在另一个节点上的 open 能看到最新版本。
  2. 使用缓存前先检查:客户端文件系统在使用缓存块之前,先发 GETATTR 请求取回文件属性,其中包括服务器上的最后修改时间。如果修改时间比文件被取进客户端缓存的时间更新,客户端就作废(invalidate)缓存中的这个文件,后续读会去服务器取最新版本;否则放心使用缓存。

Sun 的团队实现后发现了新问题:服务器被 GETATTR 请求淹没了。好的工程原则是为常见情况设计并让它高效:常见情况是一个文件只被一个客户端访问(也许反复访问),可客户端每次都得发 GETATTR 确认没人改过——不停地问"有人改过这个文件吗?",而绝大多数时候答案是"没有"。

为此每个客户端加了属性缓存(attribute cache):客户端仍然在访问前验证文件,但多数时候只查属性缓存。文件的属性在第一次访问时放进缓存,经过一段时间(比如 3 秒)后超时。在这 3 秒内,所有文件访问都会认定可以使用缓存文件,完全不与服务器通信。

模拟器

三个客户端的缓存一致性时间线

在左边写操作序列(每行一个操作,每个操作耗时 1 秒),右边逐步观察 C1/C2/C3 的数据缓存、属性缓存、脏数据,以及服务器上的文件版本。统计栏会标出"读到陈旧数据"和"看不到别人未刷写的更新"。试着改属性缓存超时、关掉 flush-on-close,看问题何时出现、何时消失。
预设:
语法:C1 open · C1 read · C2 write v2 · C2 close · C2 flush(手动刷写)· wait 3(空等 3 秒)· # 开头为注释。服务器上 F 初始为 v1。

评价 NFS 的缓存一致性

flush-on-close 是为了"讲得通"而加的,却带来了性能问题:如果客户端创建了一个临时的、很快就删除的文件,它仍会被强制写到服务器。更理想的实现可以把这种短命文件留在内存里直到被删除,完全省掉与服务器的交互。

更重要的是,属性缓存让人很难理解或推断自己到底拿到的是哪个版本的文件:有时是最新的,有时只是因为属性缓存还没超时,客户端就乐呵呵地把内存里的旧版本给了你。虽然大多数时候这没问题,但它(至今!)偶尔会导致奇怪的行为。NFS 客户端缓存就是这样一个有趣的例子:实现细节定义了用户可见的语义,而不是反过来。

服务端写缓冲的陷阱

NFS 服务器通常是内存充足的高配机器,也有缓存问题。读的时候,服务器把从磁盘读到的数据(和元数据)留在内存里,后续读就不用再访问磁盘,性能有(小)提升。更微妙的是写缓冲:NFS 服务器在把写强制写入稳定存储(比如磁盘或其他持久设备)之前,绝对不能对 WRITE 请求返回成功。它可以在内存里放一份副本,但提前回复成功会导致错误行为。为什么?答案在于我们对客户端如何处理服务器故障的假设。

write(fd, a_buffer, size);   // 用 a 填满第 1 块
write(fd, b_buffer, size);   // 用 b 填满第 2 块
write(fd, c_buffer, size);   // 用 c 填满第 3 块
模拟器

服务器写缓冲 + 崩溃

文件原来是 x/y/z 三块。客户端依次发出三个 WRITE(a、b、c),服务器在处理第 2 个 WRITE 时崩溃。比较"先回复、后落盘"和"先落盘、再回复"两种服务器的结局。

在错误的实现里,客户端收到了三个"成功",文件却变成 aaaa / yyyy / cccc:服务器在把第二块写盘之前就告诉客户端成功了,崩溃后旧数据留在了文件中间——视应用而定,这可能是灾难性的。

为避免这个问题,NFS 服务器必须先把每个写提交到稳定(持久)存储,再告知客户端成功。这样客户端就能在写操作期间察觉服务器故障,并一直重试直到成功,永远不会出现上面那种新旧内容混杂的文件。

这个要求让写性能(如果不花大功夫)成为 NFS 服务器的主要瓶颈。有些公司(例如 Network Appliance)就是为了"造一台写得快的 NFS 服务器"而成立的:一招是先把写放进带电池的内存(battery-backed memory),既能快速回复 WRITE,又不怕丢数据,也不用马上写盘;另一招是使用专门为快速写盘设计的文件系统(受第 43 章日志结构文件系统影响)。

把 NFS 带到世界上还需要其他基础创新,其中最持久的是 Sun 引入的虚拟文件系统(Virtual File System,VFS)/ 虚拟节点(vnode)接口,让不同的文件系统能方便地插入操作系统。VFS 层包括对整个文件系统的操作(挂载/卸载、获取全局统计、把所有脏数据刷盘等),vnode 层包括对文件的所有操作(open、close、read、write 等)。要实现一个新文件系统,只需定义这些"方法",框架负责其余:把系统调用接到具体实现上、以集中方式完成所有文件系统通用的功能(如缓存),并让多种文件系统实现同时运行。今天的 Linux、BSD、macOS,甚至 Windows(可安装文件系统)都有某种形式的 VFS/vnode 层。即使 NFS 不再重要,它底下这些基础也会延续下去。

先预测再验证

在"open/read/close"模拟器里选 /home/remzi/foo.txt,read 3 次。预测:一共会发出多少条 LOOKUP、多少条 READ?close 会发消息吗?
看答案
3 条 LOOKUP(在 / 中查 home、在 home 中查 remzi、在 remzi 中查 foo.txt),3 条 READ(offset 分别为 0、MAX、2·MAX),close 不发任何消息,只释放本地打开文件表中的 fd。共 6 条请求。
保持"第 1 次 read 之后服务器崩溃",分别选择 NFSv2 与假想的有状态协议。预测:第 2 次 read 在两种协议下各会怎样?
看答案
NFSv2:第 2 次 READ 带着文件句柄和 offset=MAX,重启后的服务器用句柄直接定位卷和 inode,照常返回数据——它本来就没保存任何客户端状态。有状态协议:READ(sfd=7) 到达时,服务器内存中的打开文件表已随崩溃消失,它不知道 7 指什么,只能报错;要恢复就需要额外的恢复协议。
在"服务器崩溃与幂等性"模拟器里,对 WRITE 依次试三种失败情况。预测:服务器执行 WRITE 的次数分别是多少?最终文件内容一样吗?
看答案
请求丢失:1 次;服务器宕机:1 次(第一次请求到达时服务器宕机,没有执行);回复丢失:2 次。但三种情况的最终内容都是 xxabcxxx,与无故障时完全一样——这就是幂等。客户端不需要、也无法知道是哪种情况,一律重试即可。
改选 MKDIR,失败情况为"回复丢失"。预测:服务器上最终有没有 new 目录?客户端看到的结果是成功还是失败?换成 REMOVE 呢?
看答案
MKDIR:目录已经建好(第一次执行成功),但重试得到"目录已存在"错误,客户端被告知失败。REMOVE 同理:文件已被删除,重试得到"文件不存在"。服务器状态是对的,客户端看到的结果却是错的——这就是原书说的"有些操作很难做成幂等"。
在缓存一致性模拟器里点"属性缓存导致陈旧读"。预测:C1 在 t=5 和 t=6 的两次 read 各读到哪个版本?然后把 T 改为 0 再看。
看答案
C1 在 t=3 的 READ 顺带刷新了属性缓存。t=4 C2 关闭并刷写 v2。t=5 时属性缓存才过了 2 秒(< 3),C1 不联系服务器,直接读缓存里的 v1——陈旧读!t=6 时属性缓存过期,GETATTR 发现 mtime 变了,作废缓存并重新 READ,得到 v2。T=0 时每次读前都会 GETATTR,t=5 就能读到 v2,但 GETATTR 次数增加。
点"GETATTR 风暴",分别把 T 设为 0、3、10。预测 GETATTR 数量的变化。T 越大越好吗?
看答案
T=0 时每次 open/read 都要 GETATTR,共 9 次(单个客户端反复读一个没人改的文件,却不停问服务器"改过没");T=3 时降到 3 次;T=10 时只剩 1 次。但 T 越大,别的客户端修改后你读到旧数据的时间窗就越长。这是性能与一致性的权衡,也正是 NFS 语义难以捉摸的原因。

动手实验

NFS 需要服务器和挂载权限,不方便随手实验。这里用一个单进程 C 模拟器 nfs_sim.c 把本章的四个论点"跑一遍":无状态读、幂等重试、世代号、服务器写缓冲。

cd labs/49-nfs && make && ./nfs_sim

示例输出(本机真实运行结果;该程序是确定性的,你的输出应当相同):

=== 1. Stateless READ survives a server crash (Figure 49.5) ===
app: fd = open("/foo")
  client -> LOOKUP(rootdir FH{vol=1,ino=2,gen=1}, "foo")
  server <- returns FH{vol=1,ino=3,gen=1} + attributes (size=40)
  client: open file table[fd=3] = { fh, pos=0 }
app: read(fd=3, buf, 16)
  client -> READ(FH{vol=1,ino=3,gen=1}, offset=0, count=16)
  server <- 16 bytes "0123456789ABCDEF"
  client: pos = 16
  *** server crashes and reboots here (all server memory lost) ***
app: read(fd=3, buf, 16)
  client -> READ(FH{vol=1,ino=3,gen=1}, offset=16, count=16)
  server <- 16 bytes "GHIJ0123456789ab"
  client: pos = 32
...
--- same program over a hypothetical STATEFUL protocol ---
  client -> OPEN("/foo")
  server <- sfd=7 (server remembers: sfd 7 = foo, pos 0)
  client -> READ(sfd=7, count=16)
  server <- 16 bytes, server pos now 16
  *** server crashes and reboots here ***
  client -> READ(sfd=7, count=16)
  server <- ERROR: unknown descriptor 7 (state lost in crash!)

=== 2. Retrying after a LOST REPLY (Figure 49.6, case 3) ===
WRITE(fh, offset=2, count=3, "abc")
    before: size=8 data="xxxxxxxx"
    try 1: server executes, result=3, reply LOST -> client times out, retries
    try 2: server executes again, result=3 -> client sees this result
    after : size=8 data="xxabcxxx"
    => idempotent: same as executing once
APPEND(fh, "abc")  [hypothetical]
    ...
    after : size=14 data="xxxxxxxxabcabc"
    => NOT idempotent: data appended twice
MKDIR(rootdir, "newdir")
    try 1: server executes, result=0, reply LOST -> client times out, retries
    try 2: server executes again, result=-17 (EEXIST) -> client sees this result
    => NOT idempotent: op succeeded, but client is told it failed
...
=== 3. Generation number in the file handle ===
[server CHECKS generation]
  client A caches handle for foo: FH{vol=1,ino=3,gen=1}
  client B: rm foo; create secret -> reuses inode 3, new handle FH{vol=1,ino=3,gen=2}
  client A: READ(old handle) -> ESTALE (stale file handle), good
[server IGNORES generation]
  ...
  client A: READ(old handle) -> "TOP-SECRET"  <-- read someone else's file!

=== 4. Server-side write buffering + crash ===
[reply BEFORE commit (wrong)]
  WRITE block 0 = aaaaaaaa: server buffers in memory, replies OK; later flushed to disk
  WRITE block 1 = bbbbbbbb: server buffers in memory, replies OK
  *** server crashes: memory lost (block 1 never reached disk) ***
  WRITE block 2 = cccccccc: server buffers in memory, replies OK; later flushed to disk
  client believes all 3 writes succeeded. Final file on server disk:
    aaaaaaaa
    yyyyyyyy   <--- oops
    cccccccc

对照本页的模拟器阅读每一段输出;可以试着修改 nfs_sim.c:例如给 APPEND 加上"请求编号去重",或者把 MKDIR 改成"目录已存在且是我刚建的就返回成功",想想它们各需要服务器保存什么状态。详见 labs/49-nfs/README.md。

自测题

NFSv2 协议设计的首要目标是什么?
原书:"the main goal in the design of the protocol was simple and fast server crash recovery"。追求可扩展性(支持更多客户端)是下一章 AFS 的目标。
应用调用 read(fd, buf, MAX) 时,NFS 客户端发出的 READ 消息中的偏移从哪里来?
服务器是无状态的,不记录文件位置。客户端的打开文件表保存 fd → (文件句柄, 当前位置),把不带偏移的 read 翻译成带确切偏移的 READ,成功后更新位置。
文件句柄中为什么要有世代号(generation number)?
标识文件系统的是卷 ID;判断缓存是否过期用的是 GETATTR 返回的修改时间。世代号只在 inode 号被重用时递增。
为什么 NFS 客户端在超时后可以不加区分地重试 WRITE?
幂等让客户端无需知道失败原因(请求丢失、服务器宕机、回复丢失),一律重试即可;NFSv2 服务器无状态,不做去重。
服务器执行了 MKDIR 但回复丢失,客户端重试。结果是?
这正是原书"some operations are hard to make idempotent"的例子:操作第一次其实成功了,只在重试时失败。
flush-on-close(close-to-open)一致性主要解决哪个子问题?
陈旧缓存靠"使用前 GETATTR 检查修改时间"解决;GETATTR 过多靠属性缓存缓解;服务器写缓冲问题靠"先落盘再回复"解决。
关于属性缓存(attribute cache),下列哪项正确?
属性缓存是客户端的;超时(如 3 秒)内的访问不联系服务器,于是"有时你拿到旧版本只是因为属性缓存还没超时"。NFS 服务器保持无状态,不跟踪谁缓存了什么——那是 AFS 的回调。
NFS 服务器为什么必须在 WRITE 数据写入稳定存储后才回复成功?
客户端靠"没收到回复就重试"来应对故障;服务器提前回复成功就破坏了这一前提。原书示例的结果是 a/y/c。
应用调用 close(fd) 关闭一个只读打开的 NFS 文件,客户端会?
原书 Figure 49.5:close 只需清理本地结构,No need to talk to server。若文件被写过,flush-on-close 会先把脏数据 WRITE 到服务器,但也没有"CLOSE"消息。

常见误区与小结

本章小结(原书"关键 NFS 术语")