大文件上传失败:从Nginx到分片续传全链路

为什么大文件上传总是失败在 90%

给客户做的一个视频素材站,用户上传文件到一半就报错,重试三次都在差不多同一个位置断掉。看一眼 Nginx 日志:client intended to send too large body,再往下翻是 upstream prematurely closed connection while reading response header,最后是 PHP 的 POST Content-Length exceeds the limit。三个报错,三个不同的层,都跟同一个上限有关。

大文件上传这件事,从浏览器到磁盘要穿过六七道关卡,每一道都有自己的默认值,而且默认值都很小。这篇文章按数据流的顺序把每一道关卡过一遍,然后给出一个真正可用的方案:分片上传怎么做、断点续传怎么记、合并时怎么保证不丢字节、以及为什么直接调大 client_max_body_size 往往不是好主意。

一、先数清楚有几道关卡

一个 500MB 的文件从浏览器发出,要经过下面这些限制,任何一道不合格都会失败,而且失败的方式各不相同:

浏览器/Fetch 层      无硬限制,但内存与超时是隐性限制
CDN / 反向代理层     单请求大小、超时、缓冲区
Nginx                client_max_body_size / client_body_timeout
                     client_body_buffer_size / proxy_read_timeout
PHP                  upload_max_filesize / post_max_size
                     max_execution_time / memory_limit
应用框架             multipart 解析上限、临时目录
磁盘                 剩余空间、inode、单文件大小上限

排查顺序建议从后往前:先确认 PHP 和磁盘,再看 Nginx,最后看 CDN。因为前者改起来最快,也最常是真正原因。

二、Nginx 层两个参数必须一起改

第一个是 client_max_body_size,默认 1m。改它不是问题,问题是改在哪儿。要改在 http 或 server 块里,而不是 location 里的某个静态目录——很多模板配置在 location ~ \.php$ 里单独写了参数,导致你的全局修改不生效。

client_max_body_size 512m;
client_body_timeout 300s;
client_body_buffer_size 256k;

第二个必须改的是 client_body_timeout,默认 60 秒。这个参数不是「整个上传的总时长」,而是「两次成功读取之间的最大间隔」。很多人误解了它,以为调大就会让慢速上传成功。实际上对于稳定的宽带上传,60 秒内一定会有数据到达,所以真正卡住的是下面几个。

第三个容易被忽略的是 client_body_buffer_size。请求体超过这个值(默认是 8k 或 16k)就会被写到临时文件 /var/lib/nginx/body/。这个目录所在的文件系统如果空间不够,上传会以「500 Internal Server Error」的形式失败,日志里只有一个 open() "/var/lib/nginx/body/0000000xxx" failed (28: No space left on device)。查一下 df -h /var/lib/nginx。

还有一个经常被漏掉的:如果是代理到后端,proxy_request_buffering 的默认行为是「先把整个请求体收完再转发」。这意味着用户上传 500MB,Nginx 先写到磁盘 500MB,再发给后端 500MB,双倍耗时。对大文件上传,应当关掉它,让数据流式转发:

location /upload {
    proxy_request_buffering off;
    proxy_pass http://backend;
}

关掉之后有个副作用:后端要自己去读慢速连接,PHP-FPM 的进程会被占住更久。所以 worker 数量要够。

三、PHP 层四个参数是一组

PHP 这边最常见的错误是只改了 upload_max_filesize,忘了 post_max_size。后者管的是整个 POST 请求体,必须大于前者,否则上传照样失败,而且报错信息很误导——$_FILES 是空的,$_POST 也是空的,页面上什么都不显示。

upload_max_filesize = 512M
post_max_size = 512M
max_execution_time = 300
max_input_time = 300
memory_limit = 512M

memory_limit 这里有个陷阱:很多人以为上传大文件会把整个文件读进内存。实际上 PHP 接收文件时是流式写入临时目录的,不会全读进内存。但如果你在代码里 file_get_contents 之后再处理,就会爆内存。所以正确写法永远是流式处理:

$in = fopen($_FILES['chunk']['tmp_name'], 'rb');
$out = fopen($targetPath, 'ab');
stream_copy_to_stream($in, $out);
fclose($in); fclose($out);

'ab' 模式是追加写,这一行就是分片合并的核心。不要用 file_get_contents 加 file_put_contents($path, $data, FILE_APPEND)——后者每次都要把已有的整个文件读进内存再写回,第 10 个分片时内存占用会变成文件大小的 10 倍。

还有 $_FILES['chunk']['error'] 必须检查。它是 0 才代表成功,常见的值:1 表示超过 upload_max_filesize,3 表示只收到一部分,4 表示没有文件,7 表示临时目录不可写或磁盘满。只判断 is_uploaded_file 是不够的,很多框架封装把这个 error 丢了,导致「上传成功但文件是空的」。

四、分片上传:为什么这是唯一靠谱的方案

把参数调大能让单个大请求通过,但解决不了根本问题:网络抖动、用户切后台、浏览器崩溃,任何一个都会让 500MB 的前功尽弃。分片的本质是把「一次不可重试的大事务」变成「多次可重试的小事务」。

前端用 Blob.slice 切割,每片 5MB,逐个上传:

async function uploadFile(file, chunkSize = 5 * 1024 * 1024) {
  const total = Math.ceil(file.size / chunkSize);
  const fileId = await createFileId(file);  // 服务端返回,用于续传
  let index = await queryUploaded(fileId, total);  // 已成功的分片数
  for (; index < total; index++) {
    const blob = file.slice(index * chunkSize, (index + 1) * chunkSize);
    const fd = new FormData();
    fd.append('fileId', fileId);
    fd.append('index', index);
    fd.append('total', total);
    fd.append('chunk', blob);
    await retry(() => fetch('/api/upload', { method: 'POST', body: fd }), 3);
  }
  await fetch('/api/upload/merge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ fileId, total, name: file.name })
  });
}

几个关键设计:

fileId 必须由服务端生成,而且要和「文件内容」关联。常用做法是前端算一个轻量指纹:文件名 + 大小 + 最后修改时间 + 首尾各 1MB 的哈希。不要用整个文件的 MD5——500MB 算一次要十几秒,用户体验很差,而且大文件算哈希本身就可能因为内存问题失败。用首尾采样已经能避免 99% 的碰撞。

分片大小要权衡。5MB 是比较好的默认值:太小则请求数过多,500MB 要发 100 个请求,HTTP 头开销和 TCP 握手成本加起来很可观;太大则单次失败重传的代价高。移动网络下建议降到 1MB,因为弱网时 5MB 一片的成功率明显更低。

并发数控制在 3 到 4。并发太高会抢占带宽,反而让单片超时;而且服务端要同时接收多个大请求,磁盘 I/O 会成为瓶颈。Promise.all 一次性发 100 个请求是最常见的错误写法。

五、断点续传的状态记在哪里

分片上传真正难的部分不是切分,而是「刷新页面之后还能接着传」。状态存储有三种做法,各有取舍:

方案一:文件系统。每个 fileId 一个目录,收到第 N 片就 touch 一个 N.part。优点是零依赖、崩溃恢复天然可靠;缺点是海量小文件,inode 消耗大,清理要靠定时任务。

方案二:数据库表。建一张 upload_chunks(file_id, idx, size, created_at),唯一索引 (file_id, idx),重复上传同一片时用 INSERT IGNORE 天然幂等。查询进度就是 SELECT idx FROM ... WHERE file_id=?。清理按 created_at 删过期记录。推荐这个。

方案三:Redis 位图。SETBIT upload:{fileId} N 1,一个 500MB / 5MB = 100 片只需要 13 字节。判断是否收齐用 BITCOUNT。速度最快,但 Redis 重启可能丢(取决于持久化配置),而且过期清理要靠 TTL。适合对可靠性要求不那么高的场景。

不管哪种方案,都要有一个过期清理机制。用户上传一半就关掉页面的情况非常普遍,如果不清理,临时目录一个月就能堆出几百 GB。做法是每天跑一次:找出 created_at 超过 3 天且未合并的记录,连同分片文件一起删掉。

六、合并:最容易丢字节的一步

合并的朴素写法是按 index 顺序把分片追加到一个文件里。这段代码必须做三件事,缺一件就可能出问题:

<?php
$dir = "/data/tmp/{$fileId}";
$fp = fopen($final, 'wb');
for ($i = 0; $i < $total; $i++) {
    $part = "{$dir}/{$i}.part";
    if (!is_file($part)) {
        http_response_code(409);
        exit(json_encode(['error' => "missing chunk $i"]));
    }
    $in = fopen($part, 'rb');
    stream_copy_to_stream($in, $fp);
    fclose($in);
}
fclose($fp);
// 关键:合并完成后校验总大小
$expect = (int)$_POST['totalSize'];
if (filesize($final) !== $expect) {
    unlink($final);
    exit(json_encode(['error' => 'size mismatch']));
}

第一件事是缺片必须报错而不是跳过。跳过会导致文件「看起来合并成功」但内容是错的,这种错误在生产环境极难排查——用户拿到一个能打开但播放到一半卡住的视频。

第二件事是校验总大小。前端在上传开始时就把 file.size 一起提交,合并后比对。不一致说明有片损坏或被并发覆盖。

第三件事是合并要幂等,而且要防并发。用户网络不好时,merge 请求可能重发两次,两个请求同时合并同一个 fileId 会互相覆盖。用一个文件锁或者数据库的 GET_LOCK 挡一下:

$lock = fopen("/data/tmp/{$fileId}.lock", 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) {
    exit(json_encode(['error' => 'merging']));
}

另外,合并后的文件不要直接放在最终路径上,而是先写到同目录的 .tmp 文件,校验通过之后 rename 过去。rename 在同一文件系统内是原子操作,这样下游任何读取方都不会读到「合并了一半」的文件。

七、几个真实踩过的坑

坑一:CDN 会缓存或拦截大请求。大多数 CDN 对单个 POST 请求体有上限(常见 100MB),而且有些会直接拒绝 multipart/form-data 的大请求。正确做法是让上传请求绕开 CDN,直接打到源站的独立域名(比如 upload.example.com 单独解析到源站 IP,不套 CDN)。同时这个域名要单独配置 CORS 白名单。

坑二:时间戳文件名冲突。用 time() 做文件名,两个用户同时上传就会覆盖。用 bin2hex(random_bytes(16)),或者 fileId 本身就带随机部分。

坑三:move_uploaded_file 跨文件系统变慢。如果 PHP 的 upload_tmp_dir 在 /tmp(tmpfs,内存盘)而目标目录在数据盘,跨文件系统的移动会变成「完整复制一遍」。500MB 的文件就是一次额外的 500MB 读写。解决办法是把 upload_tmp_dir 设到数据盘上同一个文件系统。

坑四:临时目录权限。PHP-FPM 跑在 www-data 下,如果 upload_tmp_dir 是 root 建的 755 目录,上传会静默失败为 error=7 或 error=6。检查 ls -ld 的属主。

坑五:只调大了 PHP 没调 Nginx。反过来的情况也很多:Nginx 报 413,而 PHP 日志里什么都没有,因为请求根本没到 PHP。

坑六:没有限速导致带宽打满。一个用户传 2GB 文件,占满上行带宽,其他用户全部超时。要用 limit_rate 或者在前端按网络状况动态限制,上传并发数也要限制在每个用户 2 个以内。

八、什么时候不该用分片

分片不是万能的。文件小于 20MB 时,分片带来的复杂度(状态管理、清理任务、合并逻辑)远大于收益,直接单请求上传 + 调大参数更简单。

如果对象存储可用(S3 兼容接口),不要自己实现分片,直接用 multipart upload。它的 CreateMultipartUpload / UploadPart / CompleteMultipartUpload 就是标准的分片协议,服务端会帮你管理状态、校验 ETag(每个分片返回的 MD5,可在 Complete 时整体校验),而且支持预签名 URL 让浏览器直传,完全绕开你的服务器——带宽和磁盘都不用你出。这是目前最省事的方案。

最后一点经验:上传成功之后一定要给明确的反馈和可复制的链接。大文件上传用户等了十分钟,如果结束时只弹一个「完成」,他大概率会再点一次上传。把文件大小、耗时、访问地址都显示出来,能省掉大量重复上传和客服问题。

Last modification:October 27th, 2026 at 10:25 pm

Leave a Comment