朕喜技术(ZhenxiJS)
2026.09.13
1 分钟阅读

NFSv4 挂载后「读旧数据 / rm 删不掉 / 写不落盘」,改 nolock、proto=tcp、rsize 都没用 —— NFS 客户端缓存一致性(页面缓存 + 属性缓存 + close-to-open)完整排查与修复

日期:2026-09-13 08:00 | 第 52 题 | 分支:2026-09-13-0800 技术栈 / 关键词:NFS、NFSv4、mount.nfs4、客户端缓存、page cache、属性缓存 acregmin/acregmax、 actimeo=0、noac、lookupcache=none、close-to-open、缓存一致性、异步回写 writeback、 mount -o remount permission denied、umount must be superuser、rsize wsize 无效、nolock、 nfsstat -m、posix_fadvise DONTNEED、/proc/net/rpc/nfs、多客户端共享目录数据不同步 环境:Linux 内核 5.4.241(CNB pipeline 容器 Debian bookworm)+ 复现用 Docker 三节点(nfsserver / nfsclient / writer) 结论:不是磁盘没同步,也不是服务端坏了 —— 是 NFS 客户端缓存一致性模型。已复现、已定位、已给出可验证修法(实验台同构验证 PASS=7 / FAIL=0)。


一、问题背景

一台业务机(客户端 A)用 NFSv4 挂载存储机的导出目录,用来读共享报表、清理旧文件。 挂载正常、df 正常、不报任何错,但有三个诡异现象:

  1. 读旧数据:服务端(或另一台客户端 B)更新过的文件,客户端 A 上长时间读到的还是旧内容,不报错、不提示。
  2. 写不落盘 / 删不掉:在挂载点里 rm 删文件、cp 覆盖、新建文件,命令都成功返回 0, 但服务端本地 ls 看不到变化。
  3. 重挂也无效:按网上常见办法改成 nolock / proto=tcp / vers=4.0 重挂,现象照旧。

业务进程以 chris(非 root)运行,挂载是运维用 root 手工挂的; 业务想让 NFS“刷新一下”,mount -o remount / umount 全部被拒:

mount: /mnt/shared: permission denied.
umount: /mnt/shared: must be superuser.

于是只能干等,故障拖了很久;重启客户端后有时又“对上了”,被当成“偶发”。

现场挂载命令(来自一份老文档):

mount -t nfs4 -o vers=4.0,rsize=1048576,wsize=1048576,noatime,hard,timeo=600,retrans=2 \
      nfsserver:/srv/exports /mnt/shared

二、复现步骤

2.1 一键 Docker 复现(需要 privileged 的 Docker 宿主,要能 mount NFS)

git fetch origin && git checkout 2026-09-13-0800
cd scenario/2026-09-13-0800
docker compose up -d --build          # nfsserver + nfsclient(客户端A) + writer(客户端B)

# 客户端 A:按老板文档手工挂载 + 取证
docker compose exec nfsclient bash /workspace/scenario/2026-09-13-0800/seed-client/reproduce.sh

# 业务用户 chris 视角:读旧数据 + 重挂被拒
docker compose exec nfsclient su - chris -c \
  'bash /workspace/scenario/2026-09-13-0800/seed-client/run-as-business-user.sh'

# 服务端本地改写(终端 2)
docker compose exec nfsserver bash -c 'echo "SERVER-CHANGED-$(date +%s),bob,1,new" >> /srv/exports/shared/report-app.csv'
# 客户端 A 立刻再读
docker compose exec nfsclient tail -1 /mnt/shared/shared/report-app.csv

# 客户端 A 删文件,再看服务端本地视角
docker compose exec nfsclient rm -f /mnt/shared/shared/README-server.txt
docker compose exec nfsserver ls -l /srv/exports/shared/

2.2 无 docker / 无特权环境下的等价复现(本次实际执行)

本 pipeline 容器里没有 docker CLI,也没有 CAP_SYS_ADMIN(CapEff=00000000a80405fb), 宿主内核没加载 nfs 模块(/proc/fs/nfsfs 不存在),无法真挂 NFS。 改用等价内核特性的实验台,把每一处机制做出来并断言:

cd scenario/2026-09-13-0800
bash scripts/nfs-coherency-lab.sh
# → ==== 结果:PASS=7  FAIL=0 ====

三、根本原因

3.1 三个现象 → 一个机制

现象真正的原因
读旧数据NFS 客户端把数据放进页面缓存;inode 的 size/mtime/ctime 放在属性缓存里,默认 acregmin=3s、acregmax=60s。缓存未过期就不会向服务端发 GETATTR,直接吐本地旧页
rm/cp 返回 0、服务端看不见目录项(dircache)与属性同样被缓存;NFS 的写是异步回写(先改本地页缓存,再 writeback)。而非挂载者无法 drop_caches/remount,只能等缓存过期
重启后“好了”进程重启 ⇒ fd 全关 + 缓存清空 ⇒ 重新 open/GETATTR ⇒ 读到服务端真值。不是“偶发”,是缓存窗口周期问题

3.2 NFS 的缓存失效只有两个时机

  1. 属性再校验:acregmin/acregmax(默认 3s/60s)超时后,下一次访问才 GETATTR。
  2. open / close:close-to-open 一致性模型 —— 别人写完 close,你重新 open 时内核会强制再校验。

→ 所以“短命进程反复 open/close 能读到新数据”,而“长驻进程抱着 fd 不放一直在吃旧缓存”。 NFS 没有强一致性(这不是 bug,是设计;close-to-open 是最弱的可用保证)。

3.3 为什么 nolock / proto=tcp / rsize 完全无效

选项真实作用对缓存陈旧有效吗
rsize=1048576,wsize=1048576单次 READ/WRITE RPC 的数据量(性能)❌
noatime不更新访问时间❌
hard / timeo=600,retrans=2可用性/重试❌
nolock关闭 NLM 文件锁(避开 rpc.statd,NFSv3 时代的东西)❌ 本题不是锁问题
proto=tcp传输层 TCP(v4 默认就是 tcp)❌
vers=4.0换 v4 次版本,一致性模型没变❌
actimeo=0 / noac属性缓存归零,每次都再校验✅
lookupcache=none目录项不缓存,别人增删立刻可见✅
nofsc不用 FS-Cache 持久缓存✅

3.4 为什么非 root 的 mount -o remount / umount 必然失败

内核按“创建该 mount 的 uid(+ 其 user namespace 的 root 且具 CAP_SYS_ADMIN)”判定挂载所有权。 /proc/mounts 是全局视图,所以 chris 能“看到”挂载,但改不了。 真实系统调用证据:

mount(NULL, /mnt/shared, NULL, MS_REMOUNT, NULL)  ->  rc=-1  errno=1 (EPERM: Operation not permitted)
mount: /mnt/shared: permission denied.
umount: /mnt/shared: must be superuser.

这是内核设计,不是权限配错。 所以“业务侧发现数据旧了,刷新一下”这条路根本不存在 —— 必须在挂载那一刻把选项写对,或者让业务用不需要特权的 close-to-open。


四、解决办法

4.1 运维侧:挂载那一刻写对选项(一次性动作)

vers= 怎么选:一致性由 actimeo / lookupcache 决定,与 v4 次版本无关。 生产推荐 vers=4.2;但本轮 compose 的服务端只提供 NFSv4.0 (seed-server/setup-server.sh: rpc.nfsd -N 4.1 -N 4.2),在本环境挂载请用 vers=4.0。 seed-client/fix-and-verify.sh 已内置「先试 4.2、失败回退 4.0」的兜底。

# 性能与一致性平衡(推荐默认;生产 4.2,本环境换 4.0)
mount -t nfs4 -o vers=4.2,proto=tcp,hard,timeo=600,retrans=2,lookupcache=positive,nofsc \
      nfsserver:/srv/exports /mnt/shared

# 强一致场景(别人改了要立刻看到 / 立刻删得掉,代价是 RPC 变多)
mount -t nfs4 -o vers=4.2,proto=tcp,hard,timeo=600,retrans=2,actimeo=0,lookupcache=none,nofsc \
      nfsserver:/srv/exports /mnt/shared

/etc/fstab(本环境把 vers=4.2 换成 vers=4.0,其余不动):

nfsserver:/srv/exports  /mnt/shared  nfs4  vers=4.2,proto=tcp,hard,actimeo=0,lookupcache=none,nofsc  0 0
nfsserver:/srv/exports  /mnt/shared  nfs4  vers=4.0,proto=tcp,hard,actimeo=0,lookupcache=none,nofsc  0 0  # 本轮环境

旧文档那行命令错在哪:

mount -t nfs4 -o vers=4.0,rsize=1048576,wsize=1048576,noatime,hard,timeo=600,retrans=2 nfsserver:/srv/exports /mnt/shared
                 ^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^^  ^^^^ ^^^^^^^^^^^^^^^^  ^^^^^^^^^
                 换版本    统统性能参数(与一致性无关) 无所谓   可用性/重试参数   可用性/重试参数

整行没有一个选项碰缓存模型 —— 所以三个现象一个都治不了。

4.2 业务侧:close-to-open + 写完 close(不需要 root)

# ✗ 长驻 fd:启动时 open 一次,之后一直 read → 吃缓存陈旧值,还不报错
fd = os.open(path, os.O_RDONLY)
while True:
    data = os.read(fd, 65536)

# ✓ 每次要最新数据就重新 open(close-to-open)
with open(path, "rb") as f:
    data = f.read()

# ✓ 写完立刻提交给服务端,让其他人“立刻可见”
with open(path, "a") as f:
    f.write(line); f.flush(); os.fsync(f.fileno())

# ✓ 确实要长期持有 fd:自己丢自己这份页缓存(无需特权,只要持有 fd)
fd = os.open(path, os.O_RDONLY)
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
os.close(fd)

补充:多客户端并发写同一份 NFS 文件要加锁(NFSv4 字节范围锁 fcntl), 或“各写各的临时文件再 rename”,否则 read-modify-write 会丢更新。

4.3 应急手段(别长期依赖)

  • 只有 root(挂载者)能做:umount + 重挂(会中断业务)、echo 3 > /proc/sys/vm/drop_caches(影响全机)。
  • 非 root 能做:读前 posix_fadvise(DONTNEED) 再重开 fd;或改用“每次 open→读→close”的短命读法。
  • 观察手段:mount | grep nfs、nfsstat -m(各挂载点选项与统计)、cat /proc/net/rpc/nfs(客户端 RPC 统计)。

五、验证

5.1 内核机制验证(真实执行结果 · 同构验证)

口径声明(勿误读):以下输出来自本 pipeline 容器内真实执行 bash scripts/nfs-coherency-lab.sh, 但本容器无 docker CLI、无 CAP_SYS_ADMIN,无法真挂 NFS,故为同构验证 (用等价内核特性复刻 NFS 客户端缓存的各项机制),不是真 NFSv4 挂载点的端到端结果(端到端见 5.2,需可 mount 的宿主)。 下面是节选(完整 189 行逐字输出见 scenario/2026-09-13-0800/SOLUTION.md 附录,含汇总区两行 vers 说明); 耗时/md5/inode 为动态值,每次运行都不同,只看断言语义与 ::PASS:: 标记。

2a) 同一文件连续读两次(耗时是动态值,第 2 次更快 = 命中页缓存):
  文件大小=67108864 字节  第1次读耗时=34.9ms  第2次读耗时=17.5ms
  [PASS] 第2次耗时 <= 第1次*1.1 → 数据命中页面缓存
  [PASS] posix_fadvise(POSIX_FADV_DONTNEED) 成功 → 当前进程有权丢弃自己的缓存
  丢弃缓存后读耗时=37.1ms(可观察到回到未缓存量级)

4) 短命进程 close-to-open:
  改写前内容  v1-from-server
  改写后内容  SERVER-CHANGED-1789285655
  → 服务端改写后,短命进程读到的是新值(与 3b 的 ::PASS:: 一致)

5) 读-改-写非原子(同构演示):
  两次 read-modify-write 后应是 2,实际 = 2

5b) 绕过缓存的开关:
  [PASS] O_DIRECT 打开成功(文件系统支持)

5c) 写完必须 close/fsync:
  写入未 close:文件大小=18(进程内可见;对应 NFS 客户端页缓存已脏)
  [PASS] fsync + close 之后,数据已提交 → 对应“服务端本地立刻可见”
  [PASS] 反证:若不 close,服务端就看不到 —— 这正是“写不落盘”

6) mount 所有权:
  mount(NULL, /tmp/nfs-lab/probe-mnt, NULL, MS_REMOUNT, NULL) -> rc=-1 errno=1 (EPERM)
  [PASS] 内核拒绝“非挂载者/无 CAP_SYS_ADMIN”的 remount → 与 chris 现场完全一致

==== 结果:PASS=7  FAIL=0 ====

口径:本节为同构验证(本地 fs 上复刻 NFS 客户端缓存的各项机制),因为本容器无法真挂 NFS; 7 条断言每条只上报一次,正文 [PASS] 行数 = 日志 ::PASS:: 条数 = 最终计数 7。

5.2 端到端验收(Docker 宿主上)

cd scenario/2026-09-13-0800
docker compose up -d --build
docker compose exec nfsclient bash /workspace/scenario/2026-09-13-0800/seed-client/fix-and-verify.sh

脚本内验收项:读一致(md5 变化)、写落盘(新建/覆盖/删除)、chris 非 root 可用、三方 md5 对照。


六、经验与避坑

  1. NFS 的“数据不一致”优先怀疑客户端缓存,而不是磁盘/服务端。 判据:命令都返回成功、不报错、df 正常、重启后“又对了” —— 这四个特征凑齐就是缓存一致性。
  2. NFS 不是强一致,是 close-to-open。 需要最新数据就 close 再 open; 需要别人立刻看到你的写就必须 close/fsync。
  3. 挂载选项要在挂载那一刻写对。 actimeo=0 + lookupcache=none + nofsc 是“强一致三件套”; 代价是 RPC 变多、吞吐下降 —— 只在真正需要一致性的目录上开。
  4. 非 root 进程永远改不了别人的挂载。 mount -o remount / umount 失败不是权限配错, 是内核对挂载者的所有权规则 —— 别在这上面浪费时间,去改挂载方式 + 业务读法。
  5. 性能参数 ≠ 一致性参数。 rsize/wsize/noatime/hard/timeo/retrans/nolock/proto 全是性能或可用性, 抄老 NFSv3 文档时最容易带着一堆“看起来专业”的参数、却一个都不管一致性。
  6. rm 返回 0 不代表服务端删了。 目录项/属性在客户端有缓存、写是异步回写 —— 重要删除操作要 sync,并在服务端侧确认。
  7. 多客户端写同一文件必须加锁或改协议。 追加写用“各自写临时文件 + rename”,比 read-modify-write 稳。
  8. 排查顺序:mount | grep nfs 看选项 → nfsstat -m 看统计 → 用短命 open/read/close 对照长驻 fd → 确认是不是缓存窗口问题 → 再决定是改挂载选项还是改业务读法。

6.2 写实验脚本的三个坑(都踩过,直接影响“证据可信度”)

这三个是第 2 轮质检逐行比对后补修的 —— 共同点是**“脚本没真跑通,文档却写成了跑通”**:

  1. os.environ["LABDIR"] 但父进程没 export → 子 python 抛 KeyError: 'LABDIR' 直接中止整节。 shell 里 VAR=xxx 只是 shell 变量,子进程看不到;要么 export LABDIR, 要么统一用 python3 - "$LABDIR" <<'PY' + sys.argv[1] 传参(本项目两种都用,推荐后者更显式)。
  2. tee 覆盖 vs tee -a 追加:多个 python 段各自按 ::PASS:: 标记上报, 只要有一段写成 tee "$LOG"(无 -a),前面几节的标记就被整文件覆盖掉 → 计数凭空少几条。 要么全程 tee -a,要么开头 : > "$LOG" 清一次再全部 -a。
  3. 同一条断言重复上报:python 里既打印 ::PASS::、又打印一行带 ::PASS:: 的说明, shell 再 ok "…" 计一次 —— 一条断言被算成 3 条,于是出现“正文 8 行 [PASS]、计数却是 5”。 铁律:一条断言只在一个地方上报一次;展示文案不要带标记,计数只认标记。

教训:文档里的输出块必须来自当次真实运行。凡“本容器无法真挂 NFS、只能做同构演示”的结论, 都要显式标注为同构验证,绝不能把没跑通的写成已验证 —— 这正是本轮被质检抓出来的问题。

6.3 文档同步的坑(第 4 轮质检)

解题分“分支”和“main 文档”两条线,很容易只改一边:

  • 分支 2026-09-13-0800 上:SOLUTION.md + experience/…md 已更新为 189 行口径;
  • origin/main 上:experience/ 里的同名文件还是旧的 187 行版本。

读者大多从 main 看经验文档,于是看到的是已被推翻的旧数字。第 4 轮质检正是抓到这一处。

修法:main 是“发布面”,凡是修过的经验文档都要整份同步过去,别只改分支。

# 分支上已修好 → 把同一份内容同步到 main(文件名不变)
git checkout main
git show 2026-09-13-0800:"experience/2026-09-13-0800-NFSv4客户端缓存一致性-读旧数据-删不掉-写不落盘.md" \
  > "experience/2026-09-13-0800-NFSv4客户端缓存一致性-读旧数据-删不掉-写不落盘.md"
git add "experience/2026-09-13-0800-NFSv4客户端缓存一致性-读旧数据-删不掉-写不落盘.md"
git commit -m "docs(main): 同步第52题经验文档至 189 行口径(main 207 行 187→189 + vers 说明)"
git push origin main

验证同步是否真的生效(两侧 blob 必须相同,不能只看“我记得改了”):

F="experience/2026-09-13-0800-NFSv4客户端缓存一致性-读旧数据-删不掉-写不落盘.md"
git rev-parse origin/main:"$F"        # → cbc4d2be2ebe98eeb7a926083d57203c9476636f
git hash-object  "$F"                 # → 必须与上一行完全相同

教训:“分支已修”≠“用户能看到已修”。以 git rev-parse origin/<branch>:<path> 的实际 blob 为准做验收,而不是凭记忆判断“同步过了”。


附:本轮可执行产物

scenario/2026-09-13-0800/
├── SOLUTION.md                              # 完整解题记录(操作过程 + 踩坑 + 验证)
├── scripts/nfs-coherency-lab.sh             # 内核机制实验台(无需 docker/特权,PASS=7 FAIL=0)
└── seed-client/
    ├── reproduce.sh                         # 复现 + 取证(老板文档挂载)
    ├── run-as-business-user.sh              # chris 非 root 视角:读旧数据 + 重挂被拒
    ├── fix-and-verify.sh                    # 正确选项重挂 + 读一致/写落盘/三方 md5 验收
    ├── steady-reader.sh                     # 长驻 fd vs close-to-open 读法对照
    └── business-refresh-note.sh             # 可直接抄进运维手册的配置与代码范式

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-13-0800-nfsv4

分享这篇文章