小Cの已经记不起来的博客

深入理解 Linux 文件上传 的工作机制

前几天同事跟我吐槽,说往测试服务器传个 800 多 MB 的日志包,传到一半就断了,页面报 413。我第一反应是去改 PHP 的 upload_max_filesize,改完没效果,又去翻 nginx 的配置,才发现 client_max_body_size 压根没设,这玩意儿默认只有 1M,白折腾了快一个钟头。趁着这次排查,干脆把一个文件从浏览器走到磁盘的完整链路捋了一遍,发现中间要过的关卡比想象中多得多,随便哪一环卡住,最后表现出来的都是"上传失败"这四个字。

先从浏览器说起:multipart/form-data 到底长什么样

平时写个 <input type="file"> 配个表单就完事了,很多人(包括之前的我)其实没细想过浏览器到底发了什么。

表单设置 enctype="multipart/form-data" 之后,浏览器会把文件用一串随机字符串(boundary)隔开,拼成一个大的请求体发出去,大概长这样:

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="logs.tar.gz"
Content-Type: application/gzip

<这里是文件的二进制内容>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

服务器就是靠这串 boundary 来切分字段的。所以自己手动拼请求体的人要小心,如果文件内容里恰好出现了和 boundary 一样的字符串,包就解析不出来了。浏览器自己生成时会避开这个坑,手动拼的可没这待遇。

另外整个请求是流式发出去的,浏览器并不会等整个文件读完才开始发,而是边读边传,这也是为什么大文件上传时你能看到进度条在慢慢走。

数据进内核:socket 缓冲区和 TCP 背压

请求从网卡进来之后,先到的是内核。网卡把数据包丢给协议栈,TCP 层按序组装,放进对应 socket 的接收缓冲区,这一步全程不经过任何应用。缓冲区大小受这几个参数控制:

cat /proc/sys/net/ipv4/tcp_rmem
cat /proc/sys/net/core/rmem_max

tcp_rmem 的三个值分别是最小、默认、最大,现代内核一般都开了自动调节,问题不大。

关键在于:应用读得慢,缓冲区就会满,满了内核就通过 TCP 窗口通知对端"先别发了",这就是所谓的背压。上传大文件时速度上不来,有时候恰恰说明链路在正常协商,谁也不会把内存撑爆。反倒是如果速度远低于带宽预期还伴随卡顿,先看看是不是丢包了:

ss -tin | grep -A1 ':443'

看 retrans(重传数)和 cwnd(拥塞窗口)这两个指标,基本就能判断是链路问题还是程序问题。

nginx 这一层:大部分人栽在这里

前面挂了 nginx 的话,请求体是它先收的,收完才转给后端。几个值得逐个认识的参数:

server {
    client_max_body_size 1024m;     # 允许的最大请求体,超了直接 413
    client_body_buffer_size 1m;     # 内存里放多少,超了就落临时文件
    client_body_temp_path /var/cache/nginx/client_temp;  # 临时文件放哪
    proxy_request_buffering on;     # 默认开启:整个请求体收完再转发
}

这里面的门道:

到了后端:临时文件与 move_uploaded_file

以 PHP 为例(其他语言套路类似),请求到了之后,上传的文件会先被写进临时目录,受这几个配置影响:

upload_max_filesize = 1024M    ; 单个文件上限
post_max_size = 1024M          ; 整个 POST 请求体上限,要 >= 上一个
upload_tmp_dir = /tmp          ; 临时文件目录

注意 post_max_size 必须大于等于 upload_max_filesize,因为请求体里除了文件还有别的字段。超了之后 $_FILES 里会是空的,很多人在这儿排查半天,其实是配置直接把请求拦了,代码根本没执行到。

应用拿到的只是一个临时文件路径,请求结束它就会被删掉,所以必须用 move_uploaded_file() 挪走。这里有个经常被忽略的点:挪到哪儿。如果目标目录和临时目录在同一个文件系统上,底层就是一次 rename,瞬间完成;如果跨文件系统(比如临时目录在 /tmp,目标在挂载的 NFS 上),那就是一次实打实的读加写,1G 的文件就要再读一遍写一遍,接口的超时时间得按这个算。

写盘那点事:page cache 与 fsync

数据落到目标目录,也不等于就在磁盘上了。Linux 写文件走的是 page cache:write() 只是写到内存里的缓存页,内核攒着,攒到一定量(由 vm.dirty_ratio、vm.dirty_background_ratio 控制)或者过一段时间才批量刷盘。

这意味着两件事:

  1. 写完立刻读,速度会快得离谱,因为读的是缓存不是盘。做磁盘性能测试的时候别被这个骗了。
  2. 机器突然断电,缓存里没刷下去的数据就没了。对可靠性有要求的场景,落盘后记得调 fsync(),数据库就是这么干的。代价是慢,但数据是真在盘上了。

看看脏页堆积情况:

grep -E 'Dirty|Writeback' /proc/meminfo

如果 Dirty 长期很大,说明磁盘写入跟不上了,上传高峰期接口变慢,可以从这里查起。

一条完整的排查路径

把上面串起来,上传失败的时候按这个顺序查,实测基本不会漏:

# 1. 浏览器端:F12 看请求体和响应码,413 就是 nginx 拦了,5xx 找后端
# 2. nginx:error log 里的临时目录相关报错,检查磁盘空间和权限
tail -f /var/log/nginx/error.log
# 3. 内核与网络:重传、拥塞窗口
ss -tin
# 4. 后端:确认配置真的生效了
php -i | grep -E 'upload|post_max'
# 5. 磁盘:空间、inode、脏页
df -h; df -i
grep -E 'Dirty|Writeback' /proc/meminfo

最后说两句

一个"文件上传"功能,中间要过浏览器打包、TCP 传输、内核缓冲、nginx 暂存、后端临时文件、page cache 回写这么多道关卡,每一层都有自己的一套配置和默认值,而且默认值基本都是按小文件设计的。平时传个几百 KB 的图片啥事没有,一到几百 MB 的大文件,这些隐藏的墙就一堵一堵冒出来了。

这次排查完我最大的感受是:报错信息只会告诉你结果,不会告诉你卡在哪一层。把整条链路记在脑子里,一层一层往下剥,比到处搜报错信息快多了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放