做 PHP 上传功能的人,应该都经历过这么一段:功能上线没问题,然后产品过来补一刀“大附件上传加个进度条吧”。你搜一圈,复制了一段 XHR 代码,发现小文件挺好用,到了几百 MB 的视频就开始露馅——进度条要么卡在 99%,要么瞬间 100%,但页面死活不跳下一步。
这不是你的代码有问题,而是“上传进度条”这件事在 HTTP 协议层面本来就分好几个层次。尤其是 PHP 环境,前端把字节发出去了、Nginx 缓冲完了、PHP-FPM 开始接收了、文件落盘了、业务处理完了,这些时刻完全不是一回事。如果你没有意识到这一点,做的进度条只能是“看起来在动”,不是真正反映超大附件的上传状态。
这篇文章我会把 PHP 场景下做超大附件上传进度条的三条路线都拆开讲透:XHR 本地发送进度、PHP 的 session.upload_progress 方案、分片上传方案。每条线都会给可以直接抄的代码和实际会踩的坑,适合正在给 Web 系统加文件上传功能、或者已经在为“超大附件进度不准”挠头的开发者。
1. HTTP 上传进度条的底层逻辑:为什么在 PHP 里格外绕
1.1 multipart/form-data 的“一次性投入”决定了进度条的样子
先说清楚 HTTP 上传的本质。浏览器上传文件时,发的是一个 POST 请求,请求体是 multipart/form-data 格式。在这个格式里,文件内容会作为请求体的一个字段,被完整地组装进一个数据流里,然后通过 TCP 连接源源不断地发给服务器。
对 HTTP 协议本身来说,上传没有“传一半停下来”这种说法。从客户端视角看,一次上传就是一个完整的请求体发送动作;从服务端视角看,服务器必须收到完整的请求之后,才能正常解析出 $_FILES、触发业务逻辑、返回响应。也就是说,浏览器发完了、服务器响不响应,中间还隔着一大截“服务器接收和处理”的时间。
这也是上传进度条比下载进度条难做的根本原因。下载时服务器往浏览器持续吐数据,响应头里的 Content-Length 会告诉浏览器总长度,浏览器可以边收边渲染进度。上传时方向反了,是浏览器往服务器吐数据,服务器并不天然有义务在中途告诉浏览器“我收到了多少”。所以如果你想做“真实的”上传进度,必须额外想办法从服务端拿数据。
1.2 三种“上传进度”不要混淆
很多开发者对进度条的理解是单一的,实际上一个完整的上传动作里,存在三种完全不同的进度。这里用一张表格帮你理清:
| 进度类型 | 谁能获取 | 含义 | 典型局限 |
|---|---|---|---|
| 浏览器发送进度 | 前端 JS | 浏览器已经向网络发送了多少字节 | 只代表“发出去了”,不代表服务器收到,更不代表处理完成 |
| 服务端接收进度 | 后端脚本 | PHP 进程实际收到了多少字节 | 需要轮询或长连接获取;Nginx 缓冲会把它藏起来 |
| 业务处理进度 | 后端脚本 | 文件是否完成落盘、转码、入库等 | 往往是上传完成后才开始的,前端无论如何都感知不到 |
这三者最大的区别,在看超大附件时尤其明显。比如用户上传一个 2GB 的视频,如果本地带宽够快,浏览器可能在 5 秒内就把数据全塞给了 Nginx,前端进度条唰地到了 100%。但 Nginx 可能还在往 PHP-FPM 转发,PHP-FPM 可能还在写临时文件;写完之后你的业务还可能要做转码、压缩、移动文件到对象存储。这些全都没结束,你告诉用户“上传完成”,用户大概率会反馈:“传完了怎么界面没反应?”
所以我建议你先想清楚一个问题:你的进度条到底要表达哪个阶段。如果只是给用户一个“网络发送中”的感知,浏览器端的 xhr.upload.onprogress 就够了。如果是要表达“服务器确实收下了文件”,那必须做服务端进度统计。如果是“文件能用了”,那还要把后续处理环节纳入进度体系。
1.3 PHP 加大附件还要过三层配置门槛
除了进度条的展示问题,PHP 本身对超大附件还有天然的硬限制。最常见的几个配置项,很多新手改了一个就以为完事了:
upload_max_filesize:限制单个文件最大体积,默认只有 2MB。post_max_size:限制整个 POST 请求体体积,默认 8MB。这个值必须大于upload_max_filesize,因为请求体里除了文件,还有文件名、其他表单字段、multipart 分隔符等额外内容。memory_limit:PHP 处理数组、字符串时的内存上限。虽然文件不会整体读进内存,但某些封装库或操作不当也会触雷。max_execution_time和max_input_time:上传大文件时,处理时间可能很长,默认 30 秒或 60 秒很容易触发超时。
一个典型的大附件配置可以写成这样:
ini复制upload_max_filesize = 2048M
post_max_size = 2560M
max_execution_time = 0
max_input_time = -1
memory_limit = 512M
注意我特意把 post_max_size 留出了大约 500MB 的余量。因为在表单里如果还有别的字段、或者需要把分片外的元数据一起提交,这个空间是必要的。很多人只改了 upload_max_filesize 忘了 post_max_size,结果传几十 MB 文件就报 “POST Content-Length exceeds the limit”,就是这个原因。
另外,如果 PHP 跑在 Nginx 后面,Nginx 层还有一个 client_max_body_size 限制,默认 1MB。所以你在浏览器里可能看到的是 413 Request Entity Too Large,而这个错误跟 PHP 完全没关系,是 Nginx 先拒绝了。加上反向代理、负载均衡、CDN,每一层都可能有体积限制。排查超时或失败问题,不能只看 PHP 日志,要看完整的链路配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案一:XHR + FormData 的本地发送进度(100MB 内够用)
2.1 前端完整实现
如果文件不算特别大,比如 100MB 以内,最快能落地的方案就是浏览器原生 XHR。核心是使用 XMLHttpRequest 的 upload 对象上的 progress 事件。
下面是一个完整的示例页面逻辑,HTML 部分只有一个文件选择框和一个进度条容器。
html复制<input type="file" id="fileInput" />
<div style="width: 320px; height: 20px; background: #eee; border-radius: 4px;">
<div id="bar" style="width: 0%; height: 100%; background: #1a7f37; border-radius: 4px;"></div>
</div>
<span id="status"></span>
javascript复制const input = document.getElementById('fileInput');
const bar = document.getElementById('bar');
const status = document.getElementById('status');
input.addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload.php', true);
// 上传过程中的进度事件,注意是 xhr.upload
xhr.upload.addEventListener('progress', function (e) {
if (!e.lengthComputable) return;
const percent = Math.round((e.loaded / e.total) * 100);
bar.style.width = percent + '%';
status.textContent = percent + '%';
});
xhr.upload.addEventListener('load', function () {
// 这个事件表示浏览器已经把数据发出去了,但不代表后端处理完成
status.textContent = '等待服务器处理...';
});
xhr.onload = function () {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
status.textContent = result.message || '上传成功';
} else {
status.textContent = '上传失败: HTTP ' + xhr.status;
}
};
xhr.onerror = function () {
status.textContent = '网络异常';
};
xhr.send(formData);
});
如果你项目里用的是 axios,它也封装了同样的能力,配置 onUploadProgress 回调即可:
javascript复制axios.post('/upload.php', formData, {
onUploadProgress: function (progressEvent) {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
// 更新 UI
}
});
注意 status.textContent = '等待服务器处理...' 这行,我在“发送完成”事件里故意把文案切到了等待处理状态。这是为了不让用户误以为已经成功——因为这时候 PHP 可能还在收数据。
2.2 PHP 后端要做的配合
PHP 这边其实没有太多特殊逻辑,正常接收文件即可。重点在于:第一,必须提前把 upload_max_filesize 和 post_max_size 改到位;第二,接口返回一定要是干净合法的 JSON,不能有任何 warning 或额外输出。因为前端读的是 xhr.responseText,一旦 PHP 前面输出了 PHP Notice,JSON.parse 就会炸。
php复制<?php
// upload.php
header('Content-Type: application/json');
if (empty($_FILES['file'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => '没有收到文件字段']);
exit;
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => '文件上传错误码: ' . $file['error']]);
exit;
}
$savePath = __DIR__ . '/storage/' . uniqid('upload_', true) . '_' . basename($file['name']);
if (!is_dir(dirname($savePath))) {
mkdir(dirname($savePath), 0755, true);
}
if (move_uploaded_file($file['tmp_name'], $savePath)) {
echo json_encode(['ok' => true, 'message' => '上传成功', 'path' => $savePath]);
} else {
http_response_code(500);
echo json_encode(['ok' => false, 'message' => '文件保存失败,请检查目录权限']);
}
如果你把 PHP 跑在 Nginx 后面,别忘了调整 Nginx 配置:
nginx复制server {
# ...
client_max_body_size 512M;
# 上传大头容易超时,酌情调大
proxy_read_timeout 300s;
fastcgi_read_timeout 300s;
}
2.3 这套方案的短板与适用边界
XHR 进度事件在中小文件场景下很直观,但它有一个天然问题:进度只代表浏览器“把字节塞进了系统 socket 缓冲区”,服务器不一定真的收完了。在 Nginx 默认配置下,Nginx 会把请求体缓冲到本地再转给 PHP-FPM,这段时间内你的前台上传早就显示 100% 了,后端可能还在转发大文件数据。
所以这套方案的真实定位是:需求不苛刻、文件体积中等、用户能接受“界面先到 100%,服务端再处理几秒”的场景。比如后台管理系统的图片批量上传、几十 MB 的文档、课程封面视频等。
如果文件动辄几百 MB、几个 GB,还要求进度条能稳定反映服务器真实状态,那么方案一的短板会非常明显。这时候就要看下面两种方案了。
3. 方案二:PHP session.upload_progress 读服务端真实接收进度
3.1 原理:PHP 解析 multipart 时顺手记账
PHP 从 5.4 开始内置了一个上传进度功能,叫 session.upload_progress。它的思路是:当请求是 POST 且 Content-Type 是 multipart/form-data,并且请求体里包含一个字段名等于 session.upload_progress.name(默认是 PHP_SESSION_UPLOAD_PROGRESS)的隐藏字段时,PHP 会在解析请求体的过程中,持续把“已接收字节数、总字节数、文件列表”这些信息写进当前 session。
也就是说,PHP 在接收上传请求时,把进度账本记到了 session 里。前端再用另一个请求去读 session,就能看到服务端真实的接收进度。
相关配置项主要有这几个:
ini复制session.upload_progress.enabled = On
session.upload_progress.cleanup = Off
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"
其中 cleanup 默认是 On,含义是“上传结束后立即清理进度记录”。如果你希望进度接口在最后几次轮询里还能读到“done=true”的状态,建议开发阶段把它设为 Off,否则进度数据可能在上传完成的一瞬间就消失了。
3.2 落地:表单加隐藏字段 + 轮询进度接口
使用这套方案时,前端页面上要有一个隐藏字段,名字必须是 PHP_SESSION_UPLOAD_PROGRESS 或你自定义的其他值,并且这个字段必须出现在文件字段之前。如果你用传统表单提交,写法是这样:
html复制<form action="/upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden"
name="<?php echo ini_get('session.upload_progress.name'); ?>"
value="demo_upload_001" />
<input type="file" name="file" />
<button type="submit">开始上传</button>
</form>
如果你使用 XHR 手动拼 FormData,也要注意 append 顺序:
javascript复制const key = 'upload_' + Date.now();
const formData = new FormData();
// 这一行必须放在 append file 之前!
formData.append('PHP_SESSION_UPLOAD_PROGRESS', key);
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload.php', true);
xhr.send(formData);
// 同时启动轮询,每秒去 progress.php 读进度
const timer = setInterval(async () => {
const res = await fetch('/progress.php?key=' + key, { credentials: 'include' });
const data = await res.json();
updateProgressBar(data.percent);
if (data.done) clearInterval(timer);
}, 1000);
服务端接收文件的上传接口本身不需要特殊代码:
php复制<?php
// upload.php
session_start();
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$saveDir = __DIR__ . '/storage/';
if (!is_dir($saveDir)) mkdir($saveDir, 0755, true);
$savePath = $saveDir . uniqid() . '_' . basename($_FILES['file']['name']);
move_uploaded_file($_FILES['file']['tmp_name'], $savePath);
echo json_encode(['ok' => true]);
} else {
http_response_code(400);
echo json_encode(['ok' => false]);
}
关键是进度读取接口。它的逻辑很简单:根据 key 取出 session 里的进度数组,计算百分比后返回 JSON。但我这里有一个非常重要的操作,就是尽早调用 session_write_close(),否则很容易被 session 文件锁卡死。
php复制<?php
// progress.php
$key = $_GET['key'] ?? '';
if ($key === '') {
http_response_code(400);
exit;
}
session_start();
$prefix = ini_get('session.upload_progress.prefix');
$fullKey = $prefix . $key;
$data = $_SESSION[$fullKey] ?? null;
if (!$data) {
echo json_encode(['percent' => 0, 'done' => false]);
session_write_close();
exit;
}
$total = (int)($data['content_length'] ?? 0);
$processed = (int)($data['bytes_processed'] ?? 0);
$percent = $total > 0 ? min(100, (int)round($processed / $total * 100)) : 0;
echo json_encode([
'percent' => $percent,
'done' => (bool)($data['done'] ?? false),
'bytes_processed' => $processed,
'content_length' => $total,
'file_name' => $data['files'][0]['name'] ?? '',
]);
// 读完立刻释放 session 锁,避免阻塞其他请求
session_write_close();
PHP 存入 session 的进度数据大概长这样,你可以把 file_name、files[0]['bytes_processed'] 都打出来看:
json复制{
"start_time": 1700000000,
"content_length": 123456789,
"bytes_processed": 23456789,
"done": false,
"files": {
"0": {
"field_name": "file",
"name": "demo.mp4",
"tmp_name": "/tmp/phpXXXXXX",
"error": 0,
"done": false,
"start_time": 1700000000,
"bytes_processed": 23400000
}
}
}
3.3 为什么很多人在 Nginx + PHP-FPM 下试不成功
这是方案二最容易被忽视的坑。很多人照着 PHP 文档写了半天,发现进度接口永远返回 0 或者干脆在文件传完后突然跳到 100%,完全看不到中间状态。
原因往往不在 PHP 代码,而在 Nginx 的请求缓冲。Nginx 作为反向代理转发请求给 PHP-FPM 时,默认对 FastCGI 请求有 fastcgi_request_buffering on; 的行为。也就是说,Nginx 会先把整个 HTTP 请求体完整接收并缓冲下来,比如存到临时文件里,然后再把整个请求体转发给 PHP-FPM。PHP 接收到完整请求体时,客户端那边的上传其实早就结束了。你自然读不到“服务端接收中”的进度。
要验证这个原因很简单:在 Nginx 站点配置里关掉 FastCGI 请求缓冲,再测试一次:
nginx复制location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_request_buffering off;
}
如果你前面还有一层 Nginx 做负载均衡或者静态转发,可能还需要在对应 location 里关闭 proxy_request_buffering。但请注意,关闭缓冲意味着网络数据会直接流式进入 PHP-FPM 进程。大附件上传期间,这个 PHP-FPM worker 会被长时间占用,连接数会迅速被打满。你需要在性能与进度精确度之间做权衡。
另外还有一种老环境很实用:如果你用的是 Apache 的 mod_php 模式,就不存在这层 FastCGI 缓冲问题,session.upload_progress 跑起来会顺畅很多。
3.4 方案 B 的问题自查
如果你已经决定用 session.upload_progress,我把最常见的三个坑列一下:
- 隐藏字段顺序不对。
PHP_SESSION_UPLOAD_PROGRESS对应的字段必须出现在文件字段之前。如果放后面,PHP 解析到它时文件数据已经开始接收,进度信息就不会被登记。 - 进度查询请求没带 Session Cookie。前端轮询进度接口时要用同一个浏览器会话,否则拿不到同一个 session 文件里的数据。跨域情况下要处理 CORS 与 Cookie 携带。
- Session 锁导致查询卡住。PHP 的 session 默认有文件锁,同一时间同一个 session 只有一个脚本能写。如果 upload.php 里没有处理好锁的释放,progress.php 的
session_start()会一直等着,进度条就不再更新。这就是我为什么在进度接口里立刻写session_write_close()。
方案二有一个比较微妙的问题:你也看到了,它能反映的只是“传到 PHP 进程”的进度,而且 PHP 在 FastCGI 模式下是否能看到中间状态,还取决于 Nginx 的缓冲策略。到了这一步,很多项目会选择更可控的路:分片上传。
4. 方案三:分片上传,超大附件的最终解法(推荐方案)
4.1 为什么最终要回到“分片”这条路上
分片上传的思路其实跟 ASP 时代讲的无组件分块上传殊途同归:前端把一个大文件用 File.slice() 切成若干个小块,然后逐块通过独立的上传请求提交给后端。后端每收到一块就保存一块,全部收齐之后再合并成完整文件。
分片方案解决的不只是进度条:
- 绕开了 PHP 对单个 POST 请求体积的限制。每个请求只传 5MB 或 10MB,PHP 的
upload_max_filesize只要大于分片大小即可,不用调到 2G。 - 进度天然真实。每一片上传完成后,后端确实落盘了该片,前端记录“已成功上传的字节数/总字节数”,这个数字不会像方案一那样虚高。
- 天然支持断点续传。如果某一片上传失败,重传这一片就行,不用整个文件重来。
- 方便做并发控制和取消。用户想暂停,前端只要不发后面的分片即可。
缺点是需要前后端一起配合,服务端要负责分片文件管理、顺序合并、完整性校验,复杂度比方案一高不少。
架构上通常设计三个接口:
| 接口 | 作用 | 关键参数 |
|---|---|---|
| POST /upload/init | 通知后端准备开始上传,返回文件 ID | filename, fileSize, md5(可选) |
| POST /upload/chunk | 上传单个分片 | fileId, chunkIndex, totalChunks, chunk 文件流 |
| POST /upload/complete | 告知全部完成,触发后端合并 | fileId, totalChunks |
如果你的需求只是“传完能合并成一个文件就行”,不追求断点续传,也可以
