关键问题:如何设计可扩展的文件协议
如何重新设计协议,使服务器交互次数最少?也就是说,怎样减少 TestAuth 消息的数量?进一步,怎样让这些服务器交互变得高效?同时解决这两个问题,就能得到一个可扩展得多的 AFS。
原文:HOW TO DESIGN A SCALABLE FILE PROTOCOL. How should one redesign the protocol to minimize the number of server interactions, i.e., how could they reduce the number of TestAuth messages? Further, how could they design the protocol to make these server interactions efficient?
Andrew 文件系统(AFS)于 20 世纪 80 年代诞生于卡内基梅隆大学(CMU),由 M. Satyanarayanan("Satya")教授领导。它的首要目标很简单:规模(scale)——怎样设计一个分布式文件系统,让一台服务器支持尽可能多的客户端?
影响可扩展性的因素很多,最重要的是客户端与服务器之间的协议。上一章的 NFS 协议迫使客户端定期向服务器确认缓存内容是否改变;每次检查都消耗服务器资源(CPU、网络带宽),频繁检查就限制了一台服务器能服务的客户端数量。AFS 与 NFS 的另一个不同是:它从一开始就把合理的用户可见行为当作一等公民。NFS 的缓存一致性依赖于客户端缓存超时等底层实现细节,很难描述;AFS 的缓存一致性简单易懂:打开文件时,客户端通常会拿到服务器上最新的一致副本。
核心概念速览
整文件缓存
open 时把整个文件取到客户端本地磁盘;之后的 read/write 都在本地完成,close 时若修改过再整个传回。
Venus / Vice
Venus 是 AFS 的客户端代码,Vice 是服务器端。
TestAuth(AFSv1)
每次打开缓存文件都问服务器"文件变了没"——太多了,成为瓶颈。
回调(callback)
服务器承诺:你缓存的文件被修改时我会通知你。把"轮询"变成"中断"。
文件标识符(FID)
卷 ID + 文件 ID + uniquifier,取代完整路径名;由客户端逐级查找路径并缓存。
关闭时更新 + 中断回调
close 把新版本存到服务器,同时服务器"打断"其他客户端的回调,使其缓存失效。
最后写者胜
两台机器同时修改文件,最后 close 的那个整份文件留在服务器上(其实是"最后关闭者胜")。
有状态的代价
回调保存在服务器内存里;服务器或客户端崩溃后,都要把缓存视为可疑并重新验证。
AFS 第 1 版:整文件缓存
我们讨论两个版本:第一版(称为 AFSv1,原名 ITC 分布式文件系统)已有基本设计,但扩展性不达预期,于是重新设计出最终协议(AFSv2,简称 AFS)。
所有版本 AFS 的基本信条之一是在访问文件的客户端的本地磁盘上做整文件缓存(whole-file caching):
应用第一次调用 open() 时,AFS 客户端代码(设计者称之为 Venus)向服务器发 Fetch 协议消息,传递目标文件的完整路径名(如 /home/remzi/notes.txt)。服务器(称为 Vice)遍历路径名,找到文件,把整个文件发回客户端;客户端把它存进本地磁盘上的缓存。之后应用的 read()/write() 被重定向到本地文件系统中的那份副本,完全不需要网络通信,很快。由于这些调用就像访问本地文件系统一样,被访问过的块还可能被缓存在客户端内存里,所以 AFS 也利用客户端内存缓存本地磁盘上的块。最后 close() 时,如果文件被修改过(被以写方式打开),客户端用 Store 消息把整个新版本和路径名发回服务器永久保存。
下次访问该文件时,AFSv1 会更高效:客户端先用 TestAuth 消息问服务器文件是否变了;没变就直接用本地缓存副本,省去网络传输。注意这个早期版本只缓存文件内容,目录只保存在服务器上。
| AFSv1 协议消息 | 作用 |
|---|---|
TestAuth | 测试文件是否改变(用于验证缓存项) |
GetFileStat | 获取文件的 stat 信息 |
Fetch | 获取文件内容 |
Store | 把这个文件存到服务器上 |
SetFileStat | 设置文件的 stat 信息 |
ListDir | 列出目录内容 |
表:原书 Figure 50.1,AFSv1 协议要点。
第 1 版的问题
为了找出问题所在,AFS 的设计者花了大量时间测量他们的原型。这种实验是好事:测量是理解系统如何工作、如何改进的关键。他们发现了两个主要问题:
- 路径遍历开销太高:每次 Fetch 或 Store 都把完整路径名(如
/home/remzi/notes.txt)交给服务器,服务器必须从根目录开始,先找 home,再在 home 中找 remzi……一路走到目标文件。许多客户端同时访问时,服务器的大量 CPU 时间就花在了沿目录路径往下走上。 - 客户端发出太多 TestAuth 消息:就像 NFS 的 GETATTR 过多一样,AFSv1 产生大量检查本地文件(或其 stat 信息)是否有效的流量。服务器把很多时间花在告诉客户端"可以用你缓存的副本"上,而绝大多数时候答案都是"文件没变"。
另外还有两个问题:服务器之间负载不均衡——通过引入卷(volume)解决,管理员可以在服务器之间移动卷来平衡负载;服务器为每个客户端使用一个独立进程,导致上下文切换等开销——AFSv2 改用线程构建服务器来解决。本章聚焦前两个限制可扩展性的协议问题。它们让服务器 CPU 成为系统瓶颈:每台服务器只能服务 20 个客户端,再多就过载。
David Patterson(RISC 和 RAID 的知名学者)总是鼓励学生:在构建新系统解决某个问题之前,先测量现有系统、证明问题确实存在。用实验证据而不是直觉,能把系统构建变成更科学的事业;还有一个附带好处:它迫使你在新系统完成之前就想清楚如何测量。等你真正做出新系统时,你既有证据表明自己在解决真实问题,又已经有了现成的方法证明新系统确实优于现状。
AFS 第 2 版:回调与 FID
AFSv2 引入了回调(callback)来减少客户端与服务器的交互。回调就是服务器对客户端的一个承诺:当客户端缓存的文件被修改时,服务器会通知它。有了这份(保存在服务器上的)状态,客户端不再需要联系服务器确认缓存文件是否有效,而是假设它有效,直到服务器告诉它不是。这正好对应轮询(polling)与中断(interrupt)的区别(回想第 36 章 I/O 设备)。
AFSv2 还引入了文件标识符(file identifier,FID)(类似 NFS 的文件句柄),取代路径名来指定客户端感兴趣的文件。AFS 的 FID 由卷标识符、文件标识符和一个"uniquifier"组成(uniquifier 让文件删除后卷 ID 和文件 ID 可以被重用)。于是,客户端不再把整条路径交给服务器去走,而是自己逐级遍历路径,缓存每一步的结果,从而减轻服务器负担。
例如客户端访问 /home/remzi/notes.txt,其中 home 是挂载在 / 上的 AFS 目录(/ 是本地根目录,home 及其子项在 AFS 中)。客户端先 Fetch home 的目录内容放进本地磁盘缓存并在 home 上建立回调;再 Fetch 目录 remzi,缓存并建立回调;最后 Fetch 普通文件 notes.txt,缓存到本地磁盘、建立回调,然后把文件描述符返回给应用。下面的模拟器复刻原书 Figure 50.2(从 home 已缓存开始),并可切换到 AFSv1 对比:
打开 /home/remzi/notes.txt:AFSv1 vs AFSv2
与 NFS 的关键区别在于:每次取目录或文件时,AFS 客户端都会与服务器建立回调,确保服务器在其缓存状态改变时通知它。第一次访问 /home/remzi/notes.txt 会产生多条消息,但也为所有目录和 notes.txt 建立了回调,之后的访问完全在本地进行,不需要任何服务器交互。在文件已缓存在客户端这一常见情况下,AFS 的表现几乎和本地磁盘文件系统一样:第二次访问一个文件应该和访问本地文件一样快。
缓存一致性
讨论 NFS 时我们考虑过两方面:更新可见性(服务器何时拿到新版本?)和缓存陈旧(服务器有了新版本后,多久客户端才能看到新版本而不是旧的缓存副本?)。有了回调和整文件缓存,AFS 的一致性很容易描述,分两种情况:
- 不同机器上的进程之间:AFS 在同一时刻让更新在服务器上可见并使其他缓存副本失效——那就是被更新的文件被关闭时。客户端打开文件、(也许反复地)写它;最终关闭时,新文件被刷到服务器(于是可见)。此时服务器"打断"所有缓存了该文件的客户端的回调:逐个联系它们,告知其回调不再有效。这确保这些客户端不会再读陈旧副本:它们之后的 open 需要从服务器重新取新版本(并在新版本上重新建立回调)。
- 同一台机器上的进程之间:AFS 在这里做了例外——对文件的写立即对本地其他进程可见(不必等文件关闭)。这让单台机器的行为和典型 UNIX 语义完全一致,只有换到另一台机器上,才会察觉到更一般的 AFS 一致性机制。
还有一个值得讨论的跨机器情况:在罕见的情况下,不同机器上的进程同时修改一个文件,AFS 自然地采用最后写者胜(last writer wins)的方法(也许应该叫"最后关闭者胜"):谁最后调用 close(),谁就最后把整个文件更新到服务器上,成为"胜出"的文件。结果是一个完全由某一个客户端生成的文件。对比 NFS 这样基于块的协议:各个客户端更新文件时,单个块的写可能陆续被刷到服务器,最终服务器上的文件可能是两个客户端更新的混合。很多情况下混合的文件毫无意义——想象一张 JPEG 图片被两个客户端分块修改,混合的结果多半不是合法的 JPEG。
下面的模拟器默认载入原书 Figure 50.3 的完整时间线:Client1 上有进程 P1、P2,Client2 上有进程 P3,所有操作都针对文件 F。服务器一列显示该行操作完成后服务器上的文件内容;被删除线划掉的缓存表示回调已被打断(失效)。你可以编辑操作序列,也可以切换到 NFS 语义,用同一串操作做对比。
AFS 缓存一致性时间线(Figure 50.3)
P1 open · P1 write A · P1 read · P1 close(P1、P2 在 Client1,P3、P4 在 Client2)· C1 down / C1 up · S crash · wait 3。每个操作耗时 1 秒。逐行读 Figure 50.3,理解每次 read 为什么返回那个值:
- 第 5 行 P2 read → A:P1 写 A 并关闭,A 已存到服务器;P2 在同一台机器上打开,直接用本地缓存。
- 第 10 行 P2 read → B:P1 写了 B 但还没关闭,同一台机器上的 P2 立刻看到 B(本地进程遵循 UNIX 语义)。服务器上仍是 A。
- 第 13 行 P3 read → A:Client2 上的 P3 打开时,B 还只在 Client1 上,服务器仍是 A——远端进程看不到还没关闭的写。
- 第 15 行 P1 close:B 被存到服务器,服务器打断 Client2 的回调(缓存中的 A 被划掉)。
- 第 17 行 P3 read → B:P3 重新打开时回调已失效,于是重新 Fetch,得到 B。
- 第 21–26 行:P1 写 D、P3 写 C;P3 先关闭(服务器变为 C,Client1 的回调被打断,但 P1 仍在使用自己修改过的副本),P1 后关闭(服务器变为 D)。"不幸的 P3":最后写者胜,P3 重新打开读到的是 D。
讨论分布式文件系统时,人们常大谈它提供的缓存一致性。但这种基础一致性并不能解决多客户端访问文件的所有问题。比如你在构建一个代码仓库,多个客户端同时签入、签出代码,就不能指望底层文件系统替你做完所有事,而必须使用显式的文件级锁,确保并发访问时发生"正确"的事。事实上,任何真正在意并发更新的应用都会加上额外机制来处理冲突。本章和上一章描述的基础一致性主要适用于日常使用——用户换一台客户端登录时,希望看到自己文件的某个合理版本。对这些协议期望更多,只会让自己失望、沮丧、泪流满面。
交错枚举:两个客户端的所有执行顺序
类似 AFS 模拟器 afs.py 作业的第 4–6 题:两个客户端各执行三步,一共有 C(6,3) = 20 种交错顺序。调度串中 0 表示让 Client1(P1)执行下一步、1 表示让 Client2(P3)执行下一步(与 afs.py -S 的记法相同)。先预测,再点"枚举"。点击任意一行,可以把它载入上面的时间线模拟器逐步查看。
交错枚举器
崩溃恢复
从上面的描述你可能感觉到,AFS 的崩溃恢复比 NFS 复杂。没错。
设想服务器 S 有一小段时间联系不上客户端 C1(比如 C1 正在重启)。这期间 S 可能向 C1 发过一条或多条回调撤销消息:假设 C1 本地磁盘缓存了文件 F,然后 C2 更新了 F,于是 S 向所有缓存了 F 的客户端发消息,让它们从本地缓存中删掉 F。C1 在重启时可能错过这些关键消息,所以重新加入系统时,C1 应把所有缓存内容视为可疑:下次访问 F 时,先用 TestAuth 问服务器缓存的副本是否仍然有效;有效就用,否则从服务器取新版本。
服务器崩溃后的恢复也更复杂:回调保存在内存里,服务器重启后不知道哪台客户端缓存了哪些文件。因此服务器重启后,每个客户端都必须意识到服务器崩溃过,并把缓存内容视为可疑、在使用前重新确认有效性。服务器崩溃是件大事:必须确保每个客户端及时知道这次崩溃,否则就有客户端访问陈旧文件的风险。实现方式很多,比如服务器恢复后给每个客户端发消息("别相信你的缓存内容!"),或者客户端定期检查服务器是否活着(心跳消息)。可以看到,构建更可扩展、更合理的缓存模型是有代价的;而用 NFS 时,客户端几乎注意不到服务器崩溃。在上面的时间线模拟器里,点"客户端宕机错过回调"或"服务器崩溃丢失回调",再取消"恢复后把缓存视为可疑",就能看到后果。
AFSv2 的规模与性能
新协议到位后,测量发现 AFSv2 的可扩展性大大提高:每台服务器可以支持约 50 个客户端(而不是 20 个)。另一个好处是客户端性能常常接近本地性能:常见情况下所有文件访问都是本地的,读文件通常访问本地磁盘缓存(也可能是本地内存)。只有客户端创建新文件或写已有文件时,才需要向服务器发 Store 消息。
原书 Figure 50.4 用简单的定性分析比较了常见访问场景下 AFS 与 NFS 的性能。小文件有 Ns 个块,中等文件 Nm 个块,大文件 NL 个块;假设小、中等文件能放进客户端内存,大文件能放进本地磁盘但放不进内存。通过网络访问远程服务器的一个块需要 Lnet,访问本地内存需要 Lmem,访问本地磁盘需要 Ldisk,一般假设 Lnet > Ldisk > Lmem。第一次访问文件不命中任何缓存;之后的访问("重读")如果相应缓存装得下,就会命中。改改参数,看看比值:
AFS vs NFS 工作负载对比(Figure 50.4)
几点观察(原书):
- 很多情况下两者性能大致相当。例如第一次读文件(工作负载 1、3、5),从远程服务器取文件的时间占主导,两个系统差不多。你可能觉得 AFS 要把文件写到本地磁盘会更慢,但这些写会被本地文件系统缓存吸收;同样,从本地磁盘缓存副本读看似更慢,但 AFS 同样受益于本地文件系统的内存缓存,读很可能命中客户端内存,性能与 NFS 相近。
- 大文件顺序重读(工作负载 6)差异明显:AFS 有很大的本地磁盘缓存,再次访问时从那里读;NFS 只能在客户端内存里缓存块,比内存大的文件重读时必须从服务器重新取整个文件。所以 AFS 快 Lnet/Ldisk 倍(假设远程访问确实比本地磁盘慢)。而且 NFS 在这种情况下增加了服务器负载,也影响扩展性。
- 新文件的顺序写(工作负载 8、9)两者相近:AFS 写到本地缓存副本,关闭时把数据推给服务器;NFS 把写缓冲在客户端内存中,可能因内存压力提前把一些块写到服务器,但关闭时一定会写,以保持 flush-on-close 一致性。AFS 写本地文件系统,同样先进入页缓存、稍后才在后台写盘,从而受益于客户端操作系统的内存缓存。
- 顺序覆写(工作负载 10)AFS 更差:前面的写负载都是创建新文件;这里文件已存在并被覆写。AFS 客户端先把旧文件整个取回来,然后又整个覆盖掉;NFS 只是覆写块,省掉了开头那次(无用的)读。(假设 NFS 的写都是块大小且块对齐的,文件也不是用 O_TRUNC 打开的——否则 AFS 的 open 不会去取即将被截断的文件内容。)
- 只访问大文件中一小部分数据(工作负载 7、11)NFS 好得多:AFS 在打开时取回整个文件,却只做了一次小读或小写;如果文件被修改,还要把整个文件写回服务器,性能影响翻倍。NFS 作为基于块的协议,I/O 量与读写大小成正比。
总的来说,NFS 和 AFS 做了不同的假设,自然得到不同的性能结果。这些差异重不重要,一如既往,取决于工作负载。
评估任何系统的一个难题是选择工作负载。计算机系统的用法千差万别,存储系统设计者该如何判断哪些工作负载重要?AFS 的设计者根据他们测量文件系统使用情况的经验做了一些假设:大多数文件不常被共享,并且会被从头到尾顺序访问。在这些假设下,AFS 的设计非常合理。但假设并不总成立:比如一个应用周期性地往日志里追加少量信息,这种向大文件追加小数据的写对 AFS 很不友好;事务数据库中的随机更新也是难题。
AFS 的其他改进
就像 Berkeley FFS 趁机加入了符号链接等新特性,AFS 的设计者也借机加入了许多让系统更好用、更好管理的特性:
真正的全局命名空间
所有文件在所有客户端上的名字都一样。NFS 允许每个客户端随意挂载服务器,只能靠约定和大量管理工作让各处命名一致。
认真对待安全
内置用户认证机制,能保证文件在用户需要时保持私密。NFS 在很多年里对安全的支持都很原始。
灵活的访问控制
用户可管理的访问控制列表(ACL),精确控制谁能访问哪些文件;NFS 和大多数 UNIX 文件系统对这类共享支持较少。
简化服务器管理
提供让管理员更容易管理服务器的工具(如在服务器间迁移卷)。在系统管理方面,AFS 领先业界好几光年。
先预测再验证
看答案
看答案
看答案
看答案
看答案
看答案
动手实验
afs_sim.c 是一个与本页时间线模拟器逻辑相同的小型 C 模拟器:它逐行执行 Figure 50.3 的操作并打印表格,然后枚举两个作业工作负载的全部 20 种交错。
cd labs/50-afs && make && ./afs_sim
示例输出(本机真实运行结果;程序是确定性的,你的输出应当相同):
=== Figure 50.3: AFS cache consistency timeline (~X = callback broken) ===
# P1 P2 C1 P3 C2 Server
1 open(F) - - -
2 write(A) A - -
3 close() A - A
4 open(F) A - A
5 read() -> A A - A
6 close() A - A
7 open(F) A - A
8 write(B) B - A
9 open(F) B - A
10 read() -> B B - A
11 close() B - A
12 B open(F) A A
13 B read() -> A A A
14 B close() A A
15 close() B ~A B
16 B open(F) B B
17 B read() -> B B B
18 B close() B B
19 B open(F) B B
20 open(F) B B B
21 write(D) D B B
22 D write(C) C B
23 D close() C C
24 close() D ~C D
25 D open(F) D D
26 D read() -> D D D
27 D close() D D
messages: Fetch=4 Store=4 callback-breaks=3 TestAuth=0
=== Workload 1: client0 = open,write 1,close client1 = open,read,close (file starts as 0) ===
schedule AFS NFS(T=0)
000111 1 1
001011 0 1
001101 0 0
001110 0 0
010011 0 1
...
summary (20 interleavings): AFS 0 x19 AFS 1 x1 NFS 0 x16 NFS 1 x4
=== Workload 2: client0 = open,write X,close client1 = open,write Y,close (file starts as 0) ===
schedule AFS NFS(T=0)
000111 Y Y
001011 Y Y
001101 Y Y
001110 X X
010011 Y Y
...
summary (20 interleavings): AFS X x10 AFS Y x10 NFS X x10 NFS Y x10
表中 ~A 表示该缓存的回调已被打断(原书中划掉的 A)。可以尝试修改程序开头的操作数组,或把 SUSPECT_ON_RECOVER 设为 0 并加入 C1 down / C1 up 操作,观察错过回调的后果。详见 labs/50-afs/README.md。
自测题
常见误区与小结
- "AFS 和 NFS 一样是无状态的":AFSv2 服务器要记住每个客户端缓存了哪些文件(回调),是有状态的;这是它扩展性好的原因,也是崩溃恢复复杂的原因。
- "AFS 保证其他机器立刻看到写":不是。远端要等写者 close、并重新 open 才能看到;已经打开的远端进程仍在读自己的副本。
- "最后写者胜 = 最后一次 write 的数据胜出":决定胜负的是 close 的先后(最后关闭者胜),而且胜出的是整个文件。
- "整文件缓存总是更快":大文件的小读小写、覆写已有文件时 AFS 更慢;这取决于工作负载。
- "有了一致性协议就不需要锁":真正在意并发更新的应用仍需要显式的文件级锁。
本章小结
- AFS 展示了与 NFS 截然不同的分布式文件系统设计。其协议设计尤其重要:通过整文件缓存和回调把服务器交互降到最少,每台服务器能支持更多客户端,管理一个站点所需的服务器数量随之减少。
- AFSv2 的两大改进:回调取代 TestAuth 轮询;FID + 客户端逐级查找取代服务器路径遍历。服务器从支持 20 个客户端提升到约 50 个。
- AFS 的一致性模型简单易懂:跨机器的更新在 close 时可见并同时使其他缓存失效;本机进程立即可见;并发修改时最后关闭者胜。它不会像 NFS 那样偶尔出现奇怪行为。
- 代价:服务器有状态,客户端或服务器崩溃后都要把缓存视为可疑并重新验证。
- 全局命名空间、安全、访问控制列表、管理工具等特性让 AFS 很好用。
- AFS 可能正在衰落:NFS 成为开放标准,得到众多厂商支持,和 Windows 的 CIFS 一起主导了市场。AFS 最持久的影响可能来自它的思想——事实上 NFSv4 加入了服务器状态(例如"open"协议消息),越来越像基本的 AFS 协议。
- 分布式系统部分到此结束:从第 48 章的通信与 RPC,到 NFS 的"无状态 + 幂等",再到 AFS 的"回调 + 整文件缓存",核心问题始终是——怎样在部件会出故障的前提下,做出又快又可靠的系统。