关键问题:如何构建分布式文件系统
如何构建一个分布式文件系统?需要考虑哪些关键方面?哪些地方容易出错?能从现有系统中学到什么?
原文: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 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 称之为分布式状态)。它让崩溃恢复变得复杂:
- 服务器崩溃:第一次 read 完成后、第二次 read 之前服务器崩溃。重启后客户端发来第二次 read,服务器根本不知道 fd 指的是哪个文件——那个信息只在内存里,崩溃时丢了。要处理这种情况,客户端和服务器必须设计某种恢复协议:客户端在内存里保留足够的信息,告诉服务器"fd 指的是 foo"。
- 客户端崩溃更糟:客户端打开文件后崩溃了,服务器上的描述符被占着。正常情况下客户端最终会调用 close 通知服务器;可客户端崩溃时服务器永远收不到 close,它必须想办法察觉客户端已经崩溃,才能关闭文件。
因此 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):它唯一地描述一个操作要作用的文件或目录,因此许多协议请求都包含文件句柄。
协议中的重要消息(原书 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(用来取后续目录项) | 是(只读) |
- LOOKUP 用来取得文件句柄:客户端传入目录的句柄和要查找的名字,服务器返回该文件(或目录)的句柄及其属性。根目录
/的句柄通过 挂载协议(mount protocol)获得(本章不展开)。例如打开/foo.txt,客户端发送 LOOKUP(根目录句柄, "foo.txt")。 - 属性(attributes)就是文件系统为每个文件记录的元数据:创建时间、最后修改时间、大小、属主和权限等——和
stat()得到的信息是同一类。 - READ 带着句柄、偏移和字节数:句柄告诉服务器是哪个卷、哪个 inode,偏移和字节数告诉它读哪些字节。WRITE 类似,只是数据从客户端传给服务器,返回成功码和最新属性。
- GETATTR 给定句柄,取回文件属性,包括最后修改时间。为什么它很重要?看完后面的缓存一节就明白了。
从协议到文件系统:读一个文件的全过程
客户端文件系统跟踪打开的文件,把应用请求翻译成相应的协议消息;服务器只是响应协议消息,每条消息都包含完成请求所需的全部信息。下面的模拟器复刻原书 Figure 49.5:一个应用打开文件、读三次、关闭。注意三个细节:
- 状态全在客户端:从整数 fd 到 NFS 文件句柄的映射、当前文件位置,都保存在客户端的打开文件表里。所以应用的 read(不带偏移)能被翻译成带确切偏移的 READ 消息;读成功后客户端更新文件位置。
- 与服务器交互的时机:第一次 open 时发 LOOKUP;路径长的话要逐级查找,例如
/home/remzi/foo.txt要发三次 LOOKUP。close 完全不用联系服务器。 - 每个请求都自包含:这是能从服务器故障中优雅恢复的关键。试试勾选"服务器崩溃",再切换到假想的有状态协议对比。
open / read / close → NFS 协议消息
用幂等操作处理服务器故障
客户端发出请求后,有时收不到回复。原因可能是请求在网络中丢了,可能是回复丢了,也可能是服务器崩溃了(过一会儿会重启,但这期间的请求全都丢失)。客户端该怎么办?
NFSv2 的客户端用一种统一而优雅的方式处理所有这些失败:重试。发请求时设一个定时器;定时器到期前收到回复,就取消定时器,一切正常;到期了还没回复,客户端就假设请求没被处理,重发一次。
能这样无脑重试,是因为大多数 NFS 请求都是幂等(idempotent)的:执行多次的效果等同于执行一次。往一个内存地址存一个值三次,和存一次一样,所以"存值"是幂等的;而把计数器加一三次,结果和加一次不同,所以"递增"不是幂等的。一般来说,只读数据的操作显然幂等;更新数据的操作则要仔细分析。
- LOOKUP 和 READ 平凡地幂等:它们只从服务器读信息。
- WRITE 也幂等,这更有意思:WRITE 消息包含数据、字节数,以及(关键地)要写入的确切偏移。重复写,结果和写一次一样。
于是客户端可以用统一的方式处理所有超时:WRITE 请求丢了(情况 1),客户端重试,服务器执行写入,一切正常;请求发出时服务器宕机、第二次请求时已恢复(情况 2),同样正常;服务器收到 WRITE、写了盘、发了回复,但回复丢了(情况 3),客户端重发,服务器做完全相同的事:把同样的数据写到同样的位置并回复。消息丢失和服务器故障被统一处理了。
一个小插曲:有些操作很难做成幂等。比如创建一个已经存在的目录会失败。于是如果服务器收到 MKDIR 并成功执行,但回复丢了,客户端重发时就会得到"失败"——而实际上操作第一次已经成功了。生活并不完美。
服务器崩溃与幂等性
当一个操作可以被执行多次时,处理它的失败就容易得多:直接重试即可;不幂等的操作让生活困难得多。
即使设计得很漂亮,也总有角落情况处理得不尽如人意。可以重新设计 mkdir 的语义让它幂等(想想怎么做?),但何必呢?NFS 的设计哲学覆盖了大多数重要情况,让整个系统在故障处理上简洁清晰。接受生活不完美、依然把系统造出来,是好工程的标志。据说这句智慧出自伏尔泰:"……一位聪明的意大利人说,最好是好的敌人",所以称之为伏尔泰定律(Voltaire's Law)。
提升性能:客户端缓存
把所有读写请求都发到网络上会带来严重的性能问题:网络一般没那么快,尤其和本地内存或磁盘比。答案是客户端缓存(client-side caching):NFS 客户端文件系统把从服务器读到的文件数据(和元数据)缓存在客户端内存里。第一次访问昂贵(要走网络),之后的访问由客户端内存快速服务。
缓存还充当写操作的临时缓冲:应用第一次写文件时,客户端先把数据放进内存(与读缓存同一个缓存),稍后再写到服务器。这种写缓冲(write buffering)让应用 write() 的延迟与实际写入性能解耦:write() 立刻成功(只是把数据放进客户端缓存),之后才真正写到文件服务器。
缓存让性能大增,问题解决了?并不。在有多个客户端缓存的系统中引入缓存,会带来一个有趣的大难题:缓存一致性问题(cache consistency problem)。
缓存一致性问题
这里其实有两个子问题:
- 更新可见性(update visibility):C2 可能把写缓冲在自己的缓存里一段时间才传给服务器。F[v2] 还在 C2 内存里时,其他客户端(比如 C3)访问 F 拿到的是旧版本 F[v1]。想象你在 C2 上更新了 F,再登录到 C3 读它,却得到旧的副本——令人沮丧。问题是:一个客户端的更新,什么时候对其他客户端可见?
- 陈旧缓存(stale cache):C2 终于把写刷到了服务器,服务器有了最新版本 F[v2]。但 C1 的缓存里还是 F[v1];C1 上的程序读 F,得到的是陈旧版本。
NFSv2 的实现用两招应对:
- 关闭时刷新(flush-on-close,又称 close-to-open 一致性语义):应用写文件并关闭时,客户端把所有更新(缓存中的脏页)刷到服务器。这保证之后在另一个节点上的 open 能看到最新版本。
- 使用缓存前先检查:客户端文件系统在使用缓存块之前,先发 GETATTR 请求取回文件属性,其中包括服务器上的最后修改时间。如果修改时间比文件被取进客户端缓存的时间更新,客户端就作废(invalidate)缓存中的这个文件,后续读会去服务器取最新版本;否则放心使用缓存。
Sun 的团队实现后发现了新问题:服务器被 GETATTR 请求淹没了。好的工程原则是为常见情况设计并让它高效:常见情况是一个文件只被一个客户端访问(也许反复访问),可客户端每次都得发 GETATTR 确认没人改过——不停地问"有人改过这个文件吗?",而绝大多数时候答案是"没有"。
为此每个客户端加了属性缓存(attribute cache):客户端仍然在访问前验证文件,但多数时候只查属性缓存。文件的属性在第一次访问时放进缓存,经过一段时间(比如 3 秒)后超时。在这 3 秒内,所有文件访问都会认定可以使用缓存文件,完全不与服务器通信。
三个客户端的缓存一致性时间线
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 块
服务器写缓冲 + 崩溃
在错误的实现里,客户端收到了三个"成功",文件却变成 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 不再重要,它底下这些基础也会延续下去。
先预测再验证
/home/remzi/foo.txt,read 3 次。预测:一共会发出多少条 LOOKUP、多少条 READ?close 会发消息吗?看答案
看答案
看答案
xxabcxxx,与无故障时完全一样——这就是幂等。客户端不需要、也无法知道是哪种情况,一律重试即可。看答案
看答案
看答案
动手实验
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。
自测题
read(fd, buf, MAX) 时,NFS 客户端发出的 READ 消息中的偏移从哪里来?close(fd) 关闭一个只读打开的 NFS 文件,客户端会?常见误区与小结
- "NFS 协议里有 OPEN/CLOSE 消息":NFSv2 没有。open 被翻译成 LOOKUP,close 只在本地清理(有脏数据时先 WRITE 刷写)。(NFSv4 才加入了服务器状态,如 open 消息。)
- "无状态 = 服务器什么都不存":服务器当然存着文件数据和元数据(磁盘上的持久状态);无状态指的是不保存关于客户端的状态,如谁打开了什么、文件指针在哪、谁缓存了什么。
- "所有 NFS 操作都幂等":MKDIR、REMOVE、RMDIR 等在"回复丢失 + 重试"时会返回错误。NFS 接受了这点不完美。
- "NFS 保证读到最新数据":属性缓存超时之前,客户端可能直接使用陈旧缓存;别的客户端还没关闭文件时,它的写也不可见。
- "服务器用内存做写缓冲可以提升性能,没有风险":回复 WRITE 成功前必须落到稳定存储;想要快,得用带电池的内存等手段。
本章小结(原书"关键 NFS 术语")
- 实现"简单快速的崩溃恢复"的关键是无状态协议:服务器崩溃后迅速重启继续服务,客户端只需重试直到成功。
- 幂等是 NFS 协议的核心:执行多次等同于执行一次。它让客户端可以放心重试,并把"丢失消息的重传"与"服务器崩溃的处理"统一起来。
- 性能需要客户端缓存与写缓冲,但带来了缓存一致性问题(更新可见性、陈旧缓存)。
- NFS 用多种工程手段应对:flush-on-close(close-to-open)让文件关闭时内容被强制写到服务器;GETATTR + 属性缓存在"检查文件是否变化"和"减少询问次数"之间折中——代价是偶尔出现怪异行为。
- NFS 服务器必须把写提交到持久介质后才能返回成功,否则会丢数据。
- 为了把 NFS 集成进操作系统,Sun 引入了 VFS/vnode 接口,让多种文件系统实现共存。
- 早期 NFS 的安全性非常宽松,客户端上的用户很容易冒充他人;后来与 Kerberos 等认证服务集成才弥补了这一点。
- 接下来:第 50 章的 AFS 以"可扩展性"为首要目标,用整文件缓存 + 服务器回调(callback)取代 NFS 的反复询问,提供了更容易理解的一致性语义——代价是服务器必须保存状态,崩溃恢复更复杂。