客户端超时了,服务端却可能已经创建文件;WRITE 成功了,内容又可能尚未稳定。NFS 需要分别追踪请求身份、缓存验证和持久确认,再决定怎样重试。
| 要解释的现象 | 阅读位置 |
| 多个操作放进一条 RPC,是否一起回滚? | 第 1~2 节:版本与 COMPOUND |
| 另一个客户端何时看到修改? | 第 3 节:缓存与 delegation |
| 崩溃后是重发数据还是重新确认? | 第 4 节:WRITE/COMMIT 推演 |
| 超时、恢复与安全如何定位? | 第 5~7 节:等待、布局与只读观测 |
1. VFS、RPC 与后端文件系统
Linux 客户端 fs/nfs/ 接入 VFS;net/sunrpc/ 提供 ONC RPC 相关设施;内核服务端 fs/nfsd/ 处理远程操作并访问导出的后端。ONC RPC 是远程调用框架,XDR 定义数据编码;TCP、UDP 或 RPC-over-RDMA 是其承载方式,不能把 SUNRPC 与 TCP 并列成同一层传输协议。
NFS filehandle 标识服务端文件对象,不是客户端 fd,也不只是绝对路径字符串。rename 可以改变名字而保留对象身份;删除重建、导出变化等可能使旧 handle 失效并返回 ESTALE。遇到 ESTALE 应分析对象和导出生命周期,不应只增加重试次数。
| 版本 | 主要机制 | 边界 |
| v3 | 读写、属性等基本操作;通常通过辅助 MOUNT 协议取得初始 handle | 核心协议相对无状态,不表示服务端无缓存、无锁服务或无恢复需求 |
| v4.0 | OPEN/LOCK、租约与 delegation、COMPOUND、统一导出视图 | 有状态,需要处理恢复与撤销 |
| v4.1 | session、slot/sequence、pNFS 等 | 重放保护有具体会话与缓存边界 |
| v4.2 | 增加服务器端复制、稀疏文件等能力 | 协商版本不代表所有可选操作都可用 |
v4 通常直接从协议导出根遍历,不需要 v3 的辅助 MOUNT 协议;客户端看到的路径是服务端导出视图,未必等于服务端本地路径。新配置使用 -t nfs 并按需要指定 vers=,旧 nfs4 文件系统类型写法已不推荐。见 Linux nfs(5)。
2. COMPOUND 减少往返,不提供数据库事务
把“定位对象、打开、读取属性”等多个操作放进 COMPOUND,可以减少网络往返。服务端按协议执行并报告结果,后面失败不意味着前面已产生的效果自动回滚。因此不能把一条 RPC 中的多个操作当作全有或全无业务事务。
v4.1 的 session 使用 slot 与 sequence 等信息识别重试并复用结果,降低重复执行风险;它不是应用在新会话里任意重发请求都自动去重,更不能替代文件系统之外的业务去重。重启、状态丢失及应答缓存限制仍要按协议处理。v4.1 的更新规格见 RFC 8881,它取代了 RFC 5661。
一次应答丢失如何由 session 区分重试与新请求
假设客户端在一个有效 v4.1 session 的 slot 3 中发送 sequence 8,请求 R 要创建文件,并按协议要求缓存应答。服务端执行成功,记录这个 slot 对应的结果,但应答在途中丢失。客户端面对的现象是超时,实际文件却已经创建。
| 客户端后续动作 | 服务端应如何区分 |
| 同一 session、slot 3、sequence 8 重传相同 R | 识别重试,按应答缓存条件返回此前结果 |
| 接受结果后,在 slot 3 推进到 sequence 9 | 该 slot 的下一项工作 |
| 另一个 slot 发请求 | 可并发占用另一个请求位置 |
slot 提供并发位置,sequence 区分各位置上的前后请求。它们不是永久保存全部业务 ID 的无限去重数据库。
如果客户端重新建立会话,或原应答没有按所需条件保留,就不能凭同一文件名假定仍享有上述重放保障。相关错误必须进入协议规定的恢复流程。这解释了为什么“RPC 有重试机制”与“任意业务操作可安全重发”之间仍有距离。
局部成功:创建完成后,改属性仍可能失败
COMPOUND 的另一个边界可以用创建后改属性来理解:前面的 OPEN 创建成功,后面的 SETATTR 因权限失败,返回值需要逐项解释。文件可能已经存在,不能把整个 COMPOUND 的最后错误简单翻译为“什么都没做”。重放缓存保存这次执行的结果,与回滚前面已完成操作是不同能力。见 RFC 8881 的 Session 与 COMPOUND 语义。
3. 缓存一致性:谁在什么时候验证了什么
客户端可以缓存文件内容、属性以及名字查找结果。属性缓存期限控制何时重新验证,数据是否失效还会受变化属性等判断影响。close-to-open 的典型意图是:写者关闭前推进修改,之后另一客户端打开时检查变化。它不等于所有已打开 fd 和 mmap 映射实时共享同一份内存。
两个客户端:重新打开与持续持有 fd 不同
| 顺序 | 可据此分析什么 |
| A 写入,完成所需同步/关闭 | A 推进了修改 |
| B 此后重新打开,按正常策略验证 | B 有机会通过变化检查看到更新 |
| B 一直持有 fd、反复读取缓存 | 不能仅凭 A 的 write 成功推断 B 下次必见更新 |
多个写者重叠写入仍需共同遵守锁或应用协议。NFS 客户端还可能模拟 O_APPEND,并发追加时存在定位与写入之间的竞争,不能直接套用本地追加直觉。
“每个 open 必发一条 GETATTR”也过于绝对:缓存、delegation、协议组合和挂载选项都会影响实际请求。noac 不是关闭所有页缓存的统一开关;减小属性缓存期限也不会凭空创建多操作事务。
Delegation 是可收回的权限
服务端可在合适条件下授予读或写 delegation,使客户端在许可范围内本地处理更多操作。读 delegation 不必意味着全局只有一个读者;写 delegation 也不等于所有内容都已缓存、本地所有访问永远零 RPC。
发生冲突时,服务端通常先 recall,客户端完成所需回写并归还;不能正常归还时才涉及撤销和恢复。租约、回调通道和服务端状态共同参与此过程,不能简化为“另一客户端访问就立即 revoke,旧客户端自然同步”。
4. WRITE 成功还要看稳定级别
WRITE 可报告不同稳定级别。UNSTABLE 表示服务端尚未作出所需稳定存储承诺,客户端通常仍需保留用于重传的数据,并随后请求 COMMIT;若 WRITE 已按要求稳定完成,未必还要额外发一条 COMMIT。因此 fsync 不是固定“只执行 COMMIT”的别名,它还可能需要发送脏数据、等待结果并处理状态。
write verifier 用于判断服务端相关写入状态是否跨重启变化。若服务端丢失未稳定数据,客户端不能仅再次提交一个没有数据的 COMMIT 就认为内容恢复,应依协议重新发送数据。稳定写入定义见 RFC 1813 的 WRITE/COMMIT及 v4.1 规格。
客户端得到成功仍依赖服务端遵守承诺。导出 async、后端同步策略、设备易失缓存等可能改变实际故障保障;客户端挂载的同步选项与服务端 exports 的同步选项是不同控制点。NFS 不会自动修复后端对持久性作出的错误承诺。
两次崩溃推演:为什么客户端必须保留未稳定数据
设服务端磁盘中的文件内容为旧版本 O,客户端准备把某一范围写成 N。先忽略其他写者,假设服务端严格遵守稳定存储承诺,且底层设备没有损坏。
| 时刻 | 客户端知道什么 | 服务端状态 | 此刻恢复需要什么 |
| t0:WRITE 发出 | N 尚未被确认 | 可能尚未收到请求 | 客户端保留 N |
| t1:收到 UNSTABLE、verifier V₁ | 服务端收到了 N,但未承诺稳定 | N 可能只在易失缓冲,磁盘仍为 O | 客户端仍须能重传 N |
| t2:服务端在 COMMIT 前崩溃 | 客户端可能只看到暂时无响应 | 重启后只有 O,写入状态 verifier 改为 V₂ | 检出 V₂ ≠ V₁,重新写入 N |
| t3:重传并完成稳定确认 | N 的恢复承诺终于成立 | N 已稳定 | 才能结束对应的稳定化责任 |
关键在 t2:COMMIT 没有携带完整新内容。若客户端在 t1 就丢掉唯一的 N,再补发 COMMIT 也无法让服务端从 O 猜回 N。verifier 把“仍是上次那个易失写入时期吗”变成可检查条件;客户端保留数据则提供真正的恢复材料。
把服务端重启和应答丢失分别施加到这次写入:前者可能需要重传内容,后者可能只是客户端还不知道稳定化已完成。图中客户端会一直保留尚需承担责任的数据。
正在呈现知识画面
COMMIT 没带数据,怎样找回丢失的写入?UNSTABLE 应答后客户端仍需保留重传材料。verifier 改变时,仅重发 COMMIT 不能恢复已丢失内容,需要重新发送数据。
{"id":"nfs-verifier-and-retry","title":"COMMIT 没带数据,怎样找回丢失的写入?","summary":"UNSTABLE 应答后客户端仍需保留重传材料。verifier 改变时,仅重发 COMMIT 不能恢复已丢失内容,需要重新发送数据。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"write\" data-i18n=\"write\"></button><button id=\"commit\" data-i18n=\"commit\"></button></div><label class=\"check\"><input type=\"checkbox\" id=\"lost\"><span data-i18n=\"lost\"></span></label><div class=\"track-row\"><span data-i18n=\"client\"></span><div id=\"client\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"buffer\"></span><div id=\"buffer\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"disk\"></span><div id=\"disk\" class=\"track\"></div></div><div class=\"actions\"><button id=\"restart\" data-i18n=\"restart\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"initial\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"unstable\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"mismatch\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"timeout\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"done\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"restarted\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let retained=true,known=null,epoch=1,buffer=null,disk='O',message='initial',needsWrite=true;function draw(){cells('client',[{text:(retained?'N':'—')+' / '+(known===null?'—':'V'+known),cls:retained?'on':'empty'}]);cells('buffer',[{text:(buffer??'—')+' / V'+epoch,cls:buffer?'on':'empty'}]);cells('disk',[{text:disk,cls:'gold'}]);$('#write').disabled=!retained;$('#commit').disabled=known===null||!retained||needsWrite;set('explanation',t(message));}$('#write').onclick=()=>{if(!retained)return;buffer='N';known=epoch;needsWrite=false;message='unstable';draw();};$('#commit').onclick=()=>{if(buffer!==null)disk=buffer;if($('#lost').checked)message='timeout';else if(known!==epoch){needsWrite=true;message='mismatch';}else{retained=false;message='done';}draw();};$('#restart').onclick=()=>{epoch++;buffer=null;message='restarted';draw();};$('#reset').onclick=()=>{retained=true;known=null;epoch=1;buffer=null;disk='O';needsWrite=true;message='initial';$('#lost').checked=false;draw();};draw();","audio":false,"strings":{"buffer":"服务端:易失内容 / 当前 verifier","client":"客户端:保留内容 / 已知 verifier","commit":"发送 COMMIT","disk":"服务端:稳定内容","done":"匹配 verifier 的稳定确认已收到,客户端终于可以释放重传材料。","heading":"COMMIT 没带数据,怎样找回丢失的写入?","initial":"N 只在客户端,还未得到服务端确认。","intro":"先发送 UNSTABLE 写入,再选择服务端重启或 COMMIT。比较客户端保留的数据、verifier 和服务端稳定内容。","lost":"丢弃本次 COMMIT 应答","mismatch":"verifier 不匹配:客户端保留 N,必须重新发送数据。空 COMMIT 无法从 O 重建 N。","reset":"重置","restart":"服务端重启","restarted":"服务端易失内容消失,稳定内容仍在。客户端尚未从新应答中验证当前 verifier。","scope":"单客户端、单范围、稳定存储正确。重启改变写入 verifier 并清除易失缓冲;同 verifier 的成功 COMMIT 才结束本次责任。丢应答选项只丢 COMMIT 回复,不撤销服务端已完成的稳定化。省略会话恢复和其他写者。","scopeLabel":"模型范围与约定","timeout":"服务端已稳定 N,但应答丢失。客户端仍不能结束责任;超时不证明写入没有发生。","unstable":"收到 UNSTABLE,仍保留 N。现在可以比较重启前后 COMMIT 的结果。","write":"WRITE / 重新发送 N"}}
对照:数据已稳定,只丢了 COMMIT 应答
换一个崩溃点:服务端已经把 N 稳定化,但 COMMIT 应答丢失。此时重新询问/按协议恢复是在消除客户端的不确定性,磁盘并未退回 O。两种场景对客户端都可能表现为超时,处理却不能依据“没收到成功”就认定写入从未发生。这个差异也是 fsync 返回错误后应用需要考虑结果不确定的原因。以上是协议推演,未包含服务器故意使用不兑现稳定承诺的配置。
5. 超时、hard/soft 与任务等待
请求超时只说明客户端没有及时拿到可用应答,不证明服务端没执行。TCP 连接可靠传输也不能消除服务端执行后应答丢失或重启的窗口。
| 设置 | 控制什么 | 不应怎样理解 |
| hard | 通常持续重试可恢复请求 | retrans 不是所有请求的总重试上限 |
| soft 等策略 | 可能更早向应用返回错误 | 返回错误不证明服务端未部分执行 |
| timeo | 以十分之一秒计的超时参数 | 不一定是整个系统调用的总截止时间 |
缩短等待会改变应用遇到的失败方式,不能把它当作无条件消除卡顿的优化。
有些等待可以被致命信号打断,有些仍受不可中断资源等待约束。TASK_KILLABLE 早于 5.x 就存在;不能从进程显示 D 就断言“永远不能杀,只能重启”,也不能保证 SIGKILL 立即完成所有 NFS 清理。应结合内核栈、具体等待点与服务端恢复状态分析。
服务端恢复:租约与锁需要重新协调
v4 服务端重启还可能进入 grace period,让客户端重申之前的打开与锁。持久客户端跟踪数据帮助恢复决策,不等于把所有运行时锁对象原样写到一个固定路径。nfsd worker 数量可配置,也不是强制每个 CPU 恰好一个线程。
6. pNFS 与安全模型
pNFS 由元数据服务提供 layout 等信息,客户端按所用布局访问数据服务器或相关存储目标,从而分散数据路径。它不是取消元数据协调,也不保证任意目录访问都绕开 MDS;布局类型、撤回、失败回退及设备能力决定实际路径。
| 安全方式 | 提供什么 | 不提供什么 |
AUTH_SYS / sec=sys | 传递 UID/GID 等身份信息供权限判断 | 不加密,也不凭自身验证远端主机用户声明真实性 |
sec=krb5 | RPCSEC_GSS 认证 | 不提供与 krb5p 相同的内容隐私 |
sec=krb5i | 认证与完整性保护 | 不加密操作内容 |
sec=krb5p | 认证、完整性与隐私 | 不代替导出授权、文件权限和密钥管理 |
root_squash 映射客户端 root 身份,不是对任意不可信客户端身份伪造的完整防护。NFSv4 也不意味着当前挂载一定使用 Kerberos,应检查实际协商的 sec=;规范的实现要求不能直接当成运行时配置事实。
7. 只读定位当前 NFS 视图
from pathlib import Path
found = False
for line in Path("/proc/self/mountinfo").read_text().splitlines():
left, separator, right = line.partition(" - ")
fields = right.split()
if separator and fields and fields[0] in {"nfs", "nfs4"}:
found = True
print("挂载记录:", left)
print("类型、来源与超级块选项:", right)
if not found:
print("当前 mount namespace 未发现 NFS 挂载")
输出保留 mountinfo 的转义形式,避免把包含空格的路径误拆成普通字符串。进一步读 /proc/self/mountstats、nfsstat -c 或 nfsstat -s,区分 RPC 数量、重传和服务时间;这些指标应取相同时间窗口差分。全量 rpcdebug 会修改调试状态并可能生成大量日志,不应作为默认只读观测步骤。