上周有个做电商的客户找到我,站点跑在 3 台 Web 服务器上,上传目录用 NFS 挂在同一台文件服务器上。头两天一切正常,第三天下午开始有客服反馈:有的商品图打开是完整的一张,有的只显示上半截,下半截是灰白的,还有的干脆是 0 字节。更离谱的是,图片尺寸显示正常,就是像素内容缺了一大半。
客户第一反应是「是不是有人上传到一半就关页面了」。我让他先别改代码,去服务器上把上传目录里的文件拎出来看。结果一看就明白了:那些坏掉的文件,大小都停在某个固定的整数附近,比如 131072 字节、262144 字节这种,而不是随机的。这说明不是随机截断,而是写入过程被打断在某个位置写进去了多少就是多少,剩下的没写上。
最后查出来,是 NFS 上的文件锁在多台客户端并发写入同一个文件时失效了,两台机器的写入交错在一起,把文件内容搅乱了。这篇就按当时的排查顺序完整写一遍,顺带把 NFS 挂载里另外几个容易踩的参数坑一起说清楚,都是我这些年在这类共享存储上实实在在踩过的。
如果你正在给多台 Web 服务器规划一套共享上传目录、或者准备自己搭一台文件服务器,这两家可以先看看:狗云DOGYUN(高性价比云服务器,多机房可选)、RackNerd(全球VPS,特价常推)。文件服务器不需要高配置,但网卡、IO 和延迟要稳,下面讲的那些坑,基本都跟「机器抖动」有关。

一、先分清是「文件坏了」还是「文件没写完」
这类问题最容易走错路的地方,是一上来就去改业务代码、加校验、加重试。我一开始也这么干了两天,白花时间。正确的第一步是做三件事,每件都花不了三分钟,但能把范围缩小一大圈。
第一,看坏文件的尺寸分布。在文件服务器上跑 find /data/uploads -type f -size -100k -printf '%s\n' | sort -n | uniq -c | sort -rn | head -20。如果尺寸成堆地集中在 2 的幂次附近(64K、128K、256K、512K),基本可以断定是写入被固定大小的缓冲区截断了,而不是业务逻辑写到一半退出。后者的话尺寸分布会是随机的。
第二,确认挂载点是不是真的在 NFS 上。df -T /data/uploads 看一下类型,再 mount | grep nfs 把挂载参数抄下来。我见过有人以为自己在挂 NFS,实际上挂的是本地目录的多机软链接同步,锁语义完全是另一回事。
第三,看内核日志。dmesg | grep -i nfs 和 dmesg | grep -i "server not responding\|hard mount\|task .* blocked"。如果是 soft 挂载,客户端遇到 NFS 服务端无响应会直接返回 IO 错误,写一半就断了;如果进程卡在 D 状态,那是 hard 挂载的典型症状。

二、文件锁为什么会失效,POSIX 锁在 NFS 上有多不靠谱
NFS 的文件锁走的是 RPC 锁服务(老版本 NLM,NFSv4 则是集成在协议里的锁机制)。原理上是:客户端 A 要写文件,先向锁服务申请一把锁,锁服务通知其他客户端「这个文件被锁了」,B 拿不到锁就得等着。
问题出在这个「等」上。锁服务本身是有状态的,它可能因为网络抖动、服务重启、锁服务端口被防火墙拦掉而失效。更要命的是锁服务给的是 advisory lock(建议锁)它只对「老实去调 flock 或 fcntl 加锁」的进程生效。你的 PHP 代码如果压根没用文件锁,或者不同机器上的 PHP 版本、挂载参数对锁的处理方式不一致,那锁服务等于不存在,两台机器会同时往同一个文件里写。
而多个进程并发写同一个普通文件,本身就是没有保护的。POSIX 只保证「同一个文件句柄的写入是原子的」,跨进程、跨机器的交错写不受任何保护。于是就出现了我们看到的现象:A 进程写到第 128K,B 进程从第 0K 开始写,两边各自 seek、write,最后拼出来一个「前半段是 B 的、后半段是 A 的」或者干脆是空洞的文件。图片被截成半张,就是这么来的。
还有一个隐蔽的坑:nolock 这个挂载选项。它是显式告诉内核「不要用锁服务」。很多人为了解决「锁服务连不上导致挂载卡死」的问题,搜到的第一个偏方就是加 nolock,加完之后挂载是快了,问题也来了所有客户端彻底失去互斥保护,并发写冲突从此静默发生。这个偏方,等于把报警器拆了。

三、我们改的四个配置,和为什么这么改
当时的挂载参数大概是这样:
mount -t nfs4 -o soft,timeo=600,retrans=2 10.0.0.9:/export/uploads /data/uploads
改完之后是这样:
mount -t nfs4 -o hard,actimeo=1,timeo=600,retrans=2 10.0.0.9:/export/uploads /data/uploads
第一,soft 换成 hard。soft 模式下,服务端无响应超过重试次数,客户端直接返回 EIO 或者 IO 错误,写入被硬生生截断,这正是半张图片的直接来源。hard 模式会一直重试,进程卡在 D 状态直到服务恢复,看起来「卡住了」,但数据是完整的。业务系统里,文件写一半留下一个坏文件,比请求多等几秒要糟糕得多,因为用户看到的往往是 500 或者一张裂图,而坏文件会永久留在磁盘上占着位置。
当然 hard 有它的代价:服务端真挂了,Web 请求会一直挂着直到超时。所以配套必须做的是:Nginx 的 proxy_read_timeout 调小、PHP 的上传超时调小、PHP-FPM 的 request_terminate_timeout 加上,让请求先失败退出,进程被踢掉重连,而不是无限期堆积。
第二,去掉 nolock,保留默认的锁语义。同时确认锁服务端口(statd 走 111 端口,NLM 走 2049/20048)在客户端和服务端之间是通的。如果确实存在锁服务不稳定的情况,NFSv4 的集成锁比 v3 的 NLM 可靠得多,硬要用 v3 那就得把 rpc.statd 跑成 systemd 服务并配上监控。
第三,加 actimeo=1。默认 actimeo 是 60 秒,客户端会把目录项和属性缓存 60 秒。多台机器之间就可能出现「A 机器刚写完,B 机器的目录列表里还看不到这个文件」的不一致,这在上传目录按文件名取用的时候特别容易踩到。调到 1 秒牺牲了一点性能,换来的是可见性基本实时。
第四,timeo 和 retrans 调成 600/2。timeo 是十进制单位下的十分之一秒,600 就是 60 秒;retrans=2 表示重试两次。这个组合让单次操作最长容忍约 3 分钟,配合 hard 挂载,能扛住偶发的网络抖动而不至于误判失败。

四、配置改完了,坏文件还在长:写入方式也得改
参数调对之后,新上传的文件不再出现截断了,但历史坏文件还在,而且只要并发够高,新的坏文件还是会零星冒出来。这说明还有一个根因没解决我们让多个 Web 进程去写同一个文件路径,这在任何存储上都是危险动作,NFS 只是把它放大了。
标准做法是「临时文件 + 原子改名」:
$tmp = $dir . '/.tmp_' . bin2hex(random_bytes(8)) . '.part';
move_uploaded_file($tmp_src, $tmp);
chmod($tmp, 0644);
rename($tmp, $final);
关键在最后那个 rename。POSIX 的 rename 在同一个文件系统内是原子操作,NFS 协议里也有对应的 RENAME 操作,服务端保证「要么还是旧的,要么就是完整的新文件」,不会出现中间状态。写的时候用一个随机命名的临时文件,只有完整写完才改名上线,这样即使客户端在中途挂掉,磁盘上留下的也只是一个 .part 结尾的垃圾文件,业务永远不会读到半成品。
配套的三条纪律:
临时文件必须和目标文件在同一个目录、同一文件系统。跨文件系统的 rename 不是原子的,会退化成「复制 + 删除」,等于白做。
定时清理 .part 文件。用 cron 或者 systemd timer 每小时清一次超过 1 小时的临时文件,别让垃圾堆在上传目录里。
文件名带唯一性。用 uniqid 或者 md5 拼时间戳做文件名,避免两台机器生成同名文件互相覆盖,这本身也是一种并发写冲突。
做完这两步改法之后,连着跑了 7 天,上传目录再没出现过坏文件。我把「文件尺寸分布 + 零字节文件数量」做成了每日巡检项,一旦出现异常立刻告警。
五、另一个方向的坑:soft 挂载导致的 IO 卡死
我另一台机器上栽的是相反方向的坑。那台是 hard 挂载 + 重试参数给得太大(timeo=600、retrans=6),结果是 NFS 服务端重启之后,所有 Web 进程的请求全部堆在 D 状态,Nginx 上游全超时,502 刷屏,PHP-FPM 的 worker 全部被占满,一个都释放不出来,机器直接变成砖。
排查这类问题的信号很明确:ps -eo stat,cmd | grep '^D' 能看到一堆 D 状态进程,dmesg 里有 INFO: task php-fpm blocked for more than 120 seconds。hard 挂载不是免费的,它把「数据不丢」的责任转移给了「你的超时设置够不够狠」。
我的处理方式是三管齐下:把 timeo 调到 300、retrans 保持 2(单次操作最长容忍约 3 分钟);PHP-FPM 配置里加上 request_terminate_timeout=60 和 pm.max_children 的合理上限,保证极端情况下进程不会无限堆积;再给 NFS 服务端做双机热备,用 keepalived 漂一个 VIP,客户端挂 10.0.0.9:/export/uploads 这个固定地址,服务端切换时客户端能自动重连。
另外一条铁律:NFS 服务端绝对不能和 Web 服务器用同一块系统盘、同一台机器。这不是性能问题,是可用性问题机器一挂,文件拿不到,网站跟着一起挂。
六、回头看:这套共享存储到底值不值得上
折腾完这一轮,我跟客户算了一笔账。他们 3 台 Web 服务器共享的是一个上传目录,日均上传量大概 800 个文件、单个文件平均 1.5MB。三年下来的文件量大概 1TB 出头。
备选方案有三个:
方案一,继续用 NFS。好处是程序几乎不用改,PHP 写本地路径和写 NFS 路径代码一样。代价是必须自己扛锁、原子改名、挂载超时、故障切换这一整套运维细节。适合文件量不大、且团队里有人能持续维护的情况。
方案二,本地磁盘 + 同步。每台机器保留一份本地副本,用 inotifywait 监控上传目录变化,通过 rsync 推给另外两台。读性能是本地盘,延迟最低;代价是三份存储占三倍空间,而且同步有延迟,刚上传的文件在别的机器上可能几秒后才可见。对「上传后立刻要从另一台机器处理」的场景不友好,但配合「上传请求带机器亲和」也能凑合。
方案三,对象存储 + CDN。把文件直传对象存储,本地不留文件,读走 CDN。扩展性、可靠性、成本模型都是最省心的那种。代价是程序要改造,图片处理逻辑要挪到别处,且出网流量要算清楚。
我们最后给客户的建议是:热数据留本地,冷数据走对象存储,只有需要多机共享的那一个目录继续用 NFS,并且把上面那套参数和写法固定下来。技术选型从来不是「哪个最先进」,是「你的团队能长期把它维护住」。
七、巡检用的几条命令,存一下
cat /proc/mounts | grep nfs 看实际生效的挂载参数,别只信 fstab。
nfsstat -m 看每个挂载点的统计和当前操作在途情况。
nfsstat -c 看客户端侧的 RPC 调用统计。
rpcinfo -p <服务端IP> 确认 nfs、lockd、statd 这些服务在不在。
dmesg -T | grep -i nfs 排查超时、重传、服务端无响应。
find /data/uploads -type f -size 0 -mtime -1 | wc -l 零字节文件数量,异常飙升就是写失败率上升。
find /data/uploads -type f -size -1M -printf '%s\n' | sort -n | uniq -c | sort -rn | head 尺寸分布,锁定固定缓冲区截断。
ps -eo stat,cmd | grep '^D' 卡在不可中断 IO 的进程,hard 挂载卡死的信号。
顺带说一句,如果你要在 Windows 侧也访问同一份共享文件,Windows 本身就内置了 NFS 客户端(客户端或服务端功能里),控制面板里加一个 NFS 共享即可,不用装第三方软件;而像 HaneWin NFS Server 这类 Windows 版的 NFS 服务端工具,也能让你不装 Windows Server 的情况下把本机目录共享出去,做测试环境或者临时文件服务器很省事。
写在最后
这次故障真正让我记住的就一句话:NFS 上出问题,八成不是 NFS 坏了,是你在上面做了一组 NFS 不保证能保证的事。NFS 保证的是「同一目录下的文件对多机可见」,它不保证跨机的原子写、不保证锁服务永远可用、不保证服务端失联时你的请求能干净地失败。
所以用 NFS 存共享数据,纪律其实就三条:写入一律走「临时文件 + 原子改名」,挂载参数里 hard 和锁语义不要乱动,超时和进程数上限要卡死。每次部署新机器之前,先拿 mount 的输出和 rpcinfo 的输出对一遍,跟线上机器保持一致,别让「这台是我自己配的」成为故障源头。
这三条都做到了,NFS 还是挺好用的东西。


喜欢
顶
难过
囧
围观
无聊



