网站大文件上传实战:413 全链路排查、Nginx 上传限速与分片断点续传落地

413 Request Entity Too Large:每个站长迟早撞上的墙

网站上线一段时间后,几乎都会遇到同一个报错:用户上传一张稍微大点的图,或者后台要导入一个几十兆的 CSV,页面直接返回「413 Request Entity Too Large」。这是 Nginx 的默认限制在拦你——client_max_body_size 默认只有 1M。对做内容站的人来说,不能传大文件意味着投稿、素材、备份导入全被卡住。

但把这个值简单调大只是第一步,真正麻烦的是 413 往往牵连四五个环节:Nginx 前端、后端 PHP/应用、反向代理链路、CDN 边缘节点,还有系统层的超时。任何一层没放开,用户看到的还是失败。本文按个人站长上传大文件的完整链路,把 413 的定位与解决、上传限速、分片上传和断点续传讲清楚,命令在 Nginx 1.24 + PHP-FPM + Debian 12 上实测过。

先定位:413 到底是谁返回的

不要一上来就改配置。先用 curl 看清响应头里的 Server 字段,确认是哪一层在拦:

curl -I -X POST --data-binary @bigfile.zip \
  https://www.example.com/upload.php

如果 Server 是 nginx,说明是 Nginx 层的 client_max_body_size 在拦;如果 Server 是 cloudflare 或返回带有 CF-Ray 头,那可能是 CDN 边缘的请求体限制(Cloudflare 免费版单请求上限 100M),改后端根本没用。分清责任层,才不会白改一通。

Nginx 层:正确设置 client_max_body_size

确定是 Nginx 拦的,就在站点配置里放开。注意这个指令可以放在 http、server、location 三个层级,就近覆盖:

server {
    listen 443 ssl;
    server_name www.example.com;

    # 全局放开到 64M
    client_max_body_size 64M;

    location /upload.php {
        # 只对上传接口单独放宽,避免全站被大包拖累
        client_max_body_size 256M;
    }
}

一个常被忽略的细节:如果你前面还有一层 Nginx 做反向代理,那么代理层的 client_max_body_size 和 proxy_request_buffering 都要一起看。默认 proxy_request_buffering on 会把整个请求体先缓冲到磁盘再转发,大文件会先占满代理层的 /var/cache 空间。文件特别大时,改成 proxy_request_buffering off 让 Nginx 边收边转,能显著降低代理层的磁盘压力:

location / {
    proxy_pass http://127.0.0.1:9000;
    proxy_request_buffering off;
    client_max_body_size 256M;
}

改完务必 nginx -t 检查语法再 reload,语法错会导致 reload 直接失败、老进程继续跑旧配置,你以为生效了其实没有。

PHP 层:三处限制一个都不能漏

Nginx 放开了,PHP 还可能拦。PHP 有三个和上传相关的限制,必须同时大于你要传的大小,取最小值生效:

; /etc/php/8.2/fpm/php.ini
upload_max_filesize = 256M
post_max_size = 256M
max_execution_time = 300
memory_limit = 512M
  • upload_max_filesize:单个文件的上限。
  • post_max_size:整个 POST 请求体的上限,必须 ≥ upload_max_filesize,否则多文件上传会莫名其妙失败。
  • max_execution_time:大文件写入慢,默认 30 秒常被超时中断,调到 300 秒更稳。

改 php.ini 后要重启 php-fpm(reload 有时不重读 php.ini):systemctl restart php8.2-fpm。验证是否生效,别靠猜,写个 phpinfo 页面或命令行查:

php -i | grep -E "upload_max_filesize|post_max_size|memory_limit"

上传限速:放开大小的同时别把自己拖垮

无限制的大文件上传是带宽杀手。个人 VPS 带宽本来就窄,几个用户同时传几百兆,整站访问都会被拖慢。Nginx 提供了两个限速指令,可以既允许大文件又能管住带宽:

# 定义一个限速区,按 IP 限速,10M 内存约能记 16 万个 IP
limit_req_zone $binary_remote_addr zone=upload:10m rate=2r/s;

location /upload.php {
    client_max_body_size 256M;
    limit_rate 2m;            # 每个连接限速 2MB/s
    limit_rate_after 5m;      # 前 5MB 不限速,快速开始,之后限速
    limit_req zone=upload burst=5 nodelay;
}

limit_rate_after 是个好用的折中:小文件几乎不受影响,只有当上传超过阈值才开始限速,既保证体验又防滥用。若被限速的请求超时,还要同步调大 client_body_timeout,否则慢速上传到一半就被 Nginx 掐断:

client_body_timeout 120s;
send_timeout 120s;

大文件上传的正解:分片 + 断点续传

对真正的大文件(几百兆到几个 G),把限制一路调大并不是好办法——单个请求越久越容易断,一旦断掉就得从头再来。更工程化的是前端分片上传:把文件切成固定大小的块(比如 5MB),逐块上传,服务端按文件唯一标识(通常是文件 MD5)把块拼起来。这样有三大好处:单请求小、不触发各种大小限制;断网后只重传失败的块;还能并发上传提速。

服务端拼接前必须做校验,否则会产生损坏文件。最稳妥的顺序是:所有分片到齐后,先按 MD5 拼出完整文件,再和客户端上报的整体 MD5 比对,一致才算成功。合并命令很简单:

# 分片按序号命名 part_000, part_001 ...
cat part_* > upload_full.zip
md5sum upload_full.zip

这里有个坑:分片的顺序必须严格按数字序号,而不能按文件系统的字母序——part_10 排在 part_2 前面会把文件拼坏。正确做法是在服务端按索引排序后再拼,而不是靠 shell 通配符的自然展开。

别忘了 CDN 和代理的超时

如果你的站套了 CDN,还要检查两件事。一是 CDN 对单请求体积的上限(很多免费套餐是 100M),超过就只能走分片;二是 CDN 的回源超时,大文件慢速上传很容易触发 524 之类的超时错误。这类问题的通用解法还是分片——把大请求拆小,让每个请求都能在超时窗口内完成。定位时可以在源站 Nginx 日志里看 $request_time 和 $upstream_response_time,判断是卡在源站还是边缘。

进度条与超时重试:体验层也要跟上

放开限制只是让上传「能成功」,但大文件上传最劝退用户的其实是「卡着不动,不知道还要多久」。前端用 XMLHttpRequest 的 upload.onprogress 事件就能拿到实时进度,配合分片还能显示「第几块/共几块」:

const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) {
    const pct = (e.loaded / e.total * 100).toFixed(1);
    console.log('已上传 ' + pct + '%');
  }
};
xhr.open('POST', '/upload_chunk');
xhr.send(chunkBlob);

xhr.upload 上的进度事件只反映「数据发出去」的进度,不代表服务端已经写完磁盘。真正可靠的完成信号要看响应——服务端在分片文件落盘并做完整性校验后返回成功,前端才更新「这一片完成」。对慢速网络,单个分片的超时时间也要设够,建议片上超时 60 秒、整文件总超时另设,避免用户传了几十兆因为一片超时而全部重来。

最后是重试策略。断点续传的核心是「上传前先问服务端哪些分片已经收过了」。流程是:开始上传时先带文件 MD5 询问进度,服务端返回已存在的分片序号数组,前端只传缺失的部分。这样无论中途断多少次,重新进入页面都能从断点继续,而不是从头再来。分片大小也有讲究:太小会让请求数暴增、合并开销大,太大会让单片失去抗断的意义,实践中 5M 到 10M 是比较舒服的区间。

临时目录与磁盘:别让分片填满 / 分区

分片上传会把所有临时分片先落到磁盘上再合并,这块空间往往被忽略。如果临时目录和系统盘是同一块,几 G 的分片很可能会把根分区写满,直接把站点拖挂。稳妥做法是把上传临时目录指到独立的、容量充足的数据盘,并定期清理超过一定时间的孤儿分片——那些上传到一半就再没回来的残留:

# 清理 1 天前的临时分片
find /data/upload_tmp -type f -mtime +1 -delete

把这个清理任务放进 cron 每天跑一次,比等到磁盘告警再手忙脚乱强得多。合并完成后也要及时删掉对应的分片目录,否则每个成功上传都会留下一份冗余副本,日积月累就是空间黑洞。

另外值得单独提一句的是「上传目录的可执行权限」。很多站点把所有上传文件放在网站根目录下,一旦有人上传了伪装成图片的脚本文件,就可能被直接访问执行,这是最经典的上传漏洞。稳妥的做法是上传目录禁止脚本执行,在 Nginx 里关掉该目录的 PHP 解析,并强制附件以下载方式返回而不是内联渲染:

location ~* ^/uploads/.*\.(php|phtml|php5)$ {
    deny all;
}
location /uploads/ {
    add_header Content-Disposition "attachment";
    add_header X-Content-Type-Options nosniff;
}

nosniff 头能阻止浏览器把内容「猜」成可执行类型,配合禁止解析,双保险堵住上传型攻击。放开上传大小是为了让用户方便,但方便不能以牺牲安全为代价——两者完全可以兼得。

收尾清单

  • 先用 curl -I 看清 413 是 Nginx、CDN 还是应用层返回的。
  • Nginx 放开 client_max_body_size,代理层加 proxy_request_buffering off。
  • PHP 的 upload_max_filesize / post_max_size / max_execution_time 三处一起调,改完 restart php-fpm。
  • 用 limit_rate + limit_rate_after 在放开大小的同时管住带宽。
  • 真正的大文件走分片 + 断点续传,拼接时按数字序号排序并校验整体 MD5。
  • 套 CDN 的站点同时核对边缘体积上限与回源超时。

413 从来不是一个参数的问题,而是一条链路的问题。理解了请求从浏览器到磁盘经过了哪些层、每层在哪拦、怎么放,你就能既让用户顺利传上大文件,又不被大文件拖垮整站。

Last modification:October 10th, 2026 at 08:26 pm

Leave a Comment