☰
多平台大文件上传进度条统一方案:PHP切片聚合实战
2026/10/17 14:09:00 网站建设 项目流程

多平台上传进度条这件事,我踩过不少坑。以前做过的项目里,桌面浏览器、移动端 H5、小程序、App 都有上传需求,文件动不动就是几百 MB 甚至几个 G。最开始以为用 XMLHttpRequest 的 progress 事件就万事大吉,结果换到小程序就成了另一套回调,App 端又要自己算流量,进度条口径根本对不上。后来我彻底换了一种思路:把“传输层进度”改成“服务端实际接收进度”,用切片上传 + PHP 后端聚合同一个进度数据源,才真正把各路端统一起来。

这篇文章会把这套方案完整拆开讲,包括为什么不能直接依赖各端自带的进度事件、切片进来的整体时序、PHP 端接口怎么设计、浏览器和小程序端怎么适配,以及我在生产环境里真实遇到过的几个坑。适合正在做 PC/移动端大文件上传、想搞定进度条和断点续传的开发者,下文所有代码基于常见的 PHP-FPM + Nginx 环境,照着落地基本能跑通。

1. 先看懂:上传进度条为什么不能“一把梭”

1.1 Progress 事件的真实含义

很多人觉得浏览器的上传进度条就是“服务器收到了百分之多少”,其实不是。XMLHttpRequest 里的upload.onprogress事件,拿到的loaded是“浏览器已经把多少字节交给操作系统网络栈”的数量,并不是服务器真正落盘的数量。中间隔着一层 TCP 传输、可能的代理缓冲、TLS 加密、服务端反向代理缓冲,客户端这里显示 100%,服务器可能还在接收一半的数据。

进度事件是分块触发的,浏览器会把 File 对象按块读出并交给网络层,触发频率由浏览器控制。对几十 MB 的小文件,这个差异感觉不明显,但对几个 GB 的大文件,网络稍微抖动一下,进度条看起来就会“虚高”甚至卡住。换句话说,浏览器的 progress 事件只适合作为“这个请求发送状态的参考值”,不适合作为业务层面“文件是否真正上传成功”的最终依据。

1.2 多平台差异到底差在哪

如果你同时开发过 Web、小程序、App,一定见过这些不一致的 API:

  • 浏览器:XMLHttpRequest 有upload.onprogress,fetch直接没有上传进度回调,axios 底层也是 XHR。
  • 小程序:wx.uploadFile有onProgressUpdate,但它只管当前这次上传请求本身的传输情况。
  • App:原生网络库或 Flutter 的 dio 都有自己的上传进度回调,和浏览器、小程序拿到的数据模型也不一样。

就算每个端都把“已上传字节数 / 总字节数”算出来,口径还是对不齐。浏览器算的是客户端发出量,小程序算的是小程序上传组件内部量,App 算的是 socket 写入量。一旦用户中途切换网络、刷新页面、上传中断,这些进度值全部作废,没有一端能回答“这个文件现在传到哪了”。

1.3 技术路线怎么选才靠谱

针对“统一进度条”这个目标,我对比过几种方案:

方案跨端进度统一断点续传服务端压力开发成本
单请求整文件 + XHR 原生进度差不支持高低
切片上传 + 后端聚合进度好支持中中
对象存储分片直传 + 后端协调好支持低中(依赖云环境)

最终我选择的是第二行:前端把文件切成分片,一片一片传,后端每收到一个分片就登记一个“已完成状态”,前端定时去查后端提供的统一进度接口。所有端看到的是同一个数字,这个数字来自服务端实际接收并校验通过的字节数,不再依赖各端自己的传输层 API。

2. 基础方案能走多远:XHR 进度事件与 PHP 限制

2.1 XHR 的 upload.onprogress 其实只知道“发出去多少”

先不急着切分片,看看如果坚持用传统单请求上传,会碰到的实际问题。下面是一段很常见的浏览器端代码:

const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload.php'); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); updateProgress(percent); } }); xhr.send(formData);

这段代码在小文件、局域网环境下很流畅。文件一大,问题就来了:

  1. 浏览器发出loaded不等于服务器收到loaded,进度会先到 100%。
  2. 服务器接收期间,代理或反代如果有缓冲,客户端无法知道服务端是否已经处理完。
  3. 如果请求失败,要重新传整个文件,不能从某一片继续。
  4. 小程序、App 端根本没有完全一致的xhr.upload,代码复用不了。

所以传统方案不适合“大文件 + 多平台”组合,这是我在项目里吃过亏后下的结论。

2.2 PHP 整文件接收的几个坑

PHP 接收传统大文件上传,还有三个必须面对的配置问题。第一个是upload_max_filesize,它限制单个上传文件大小;第二个是post_max_size,它限制整个 POST 请求体大小;第三个是 Nginx 的client_max_body_size,默认通常只有 1M。如果三者不一起调大,上传到一半可能会直接收到 413 或“文件超出大小限制”。

整文件上传时还有一个容易被忽视的问题:PHP-FPM 进程在等待完整请求体到达之间会一直占用 worker,大文件上传期间这个 worker 没法处理其他请求。并发一高,FPM 进程池很容易被打满,其他人访问页面都跟着变慢。分片的第一个好处就是每个请求体都很小,worker 被占用的时间很短,不会出现“一个人传大文件,整个站点卡死”的情况。

2.3 为什么我最终放弃纯 XHR 方案

单纯从“显示进度条”来说,XHR 方案确实最简单。但我们的核心需求是“多平台进度统一 + 大文件稳定传输”,这两个需求已经超出了纯 XHR 的能力范围。换成切片上传后,我才发现以下几个问题被顺带解决了:

  • 进度口径统一:前端不再需要关心各端 progress 事件,只需要定时查询后端进度。
  • 断点续传成为可能:哪些分片传完了,服务端有记录,下次继续补传缺失分片即可。
  • 并发可控:不用一个请求传完整个文件,而是按需求控制同时上传 3 到 6 个分片。
  • 故障恢复快:某个分片失败了,只需要重传那个分片,而不是整个文件。

这就是“统一进度”方案底层逻辑:各端只负责把分片安全可靠地送上来,服务器负责记录进度,前端负责用统一接口展示进度。

3. 核心思路:切片上传 + 后端统一进度

3.1 把进度对齐到“服务端实际收到多少”

先明确统一进度的标准。我用的是:

进度 = 服务端实际接收并校验通过的分片字节数 / 文件总字节数

这个定义不依赖浏览器、小程序或 App 的的内部传输数据,只要服务端记录的数字一致,所有端显示的进度就一致。实现这个目标需要三步:

  1. 文件切片:客户端把文件按固定大小切成多个分片,例如每个 5 MB。
  2. 分片上报:客户端逐片上传,服务端收一片、校验一片、登记一片。
  3. 进度查询:客户端定时请求进度接口,拿到服务端汇总后的完成度。

这样做还有一个额外好处:服务端知道每个分片是否已经完整接收,断点续传、进度上报、合成都建立在这个可靠基础上。

3.2 完整流程:初始化、分片、查进度、合并

我用的是下面这套时序,前后端统一按这个约定来做:

  1. 前端发起初始化请求,把文件名、文件大小、分片大小告诉 PHP 后端,后端生成一个唯一的upload_id,返回给前端。
  2. 前端拿到upload_id后,用file.slice()或对应平台的文件分片能力,把文件切成 N 片,依次或并发上传。
  3. 每个分片请求都携带upload_id和分片序号index,请求体是分片的二进制内容。
  4. PHP 后端每收到一个分片,校验大小是否正确,写入临时分片文件,并更新任务记录中的finished_bytes。
  5. 前端在分片传输过程中,以固定间隔请求进度接口,展示服务端返回的百分比。
  6. 所有分片传完后,前端请求合并接口,PHP 后端按分片序号依次合并还原完整文件。

这个流程把“上传”和“统计”完全分开了。统计由服务端统一负责,各端不需要关心对方是怎么传的。

3.3 分片大小与并发数怎么定

分片大小一开始很容易拍脑袋。我实际用下来发现几个约束:

  • 分片太大,比如 50 MB,遇到一次网络抖动,重传成本高,Nginxclient_max_body_size也要调得很大。
  • 分片太小,比如 512 KB,分片数量会非常多,一个 10 GB 文件要切 2 万片,请求往返和校验开销太大。
  • PHP-FPM 的post_max_size/upload_max_filesize如果能设到 20 MB,分片控制在 5 MB 到 10 MB 比较稳。

我常用默认值是 5 MB。1 GB 文件大约 200 片,断点续传粒度适中,请求数量也不会太多。

并发数方面,桌面端可以同时传 4 到 6 个分片,手机端和弱网环境建议降到 2 到 3 个。不要无限并发,否则 FPM worker 全被占住,进度接口也跟着变慢。

4. 完整落地:PHP 接口 + 多端前端适配

4.1 任务初始化接口实现

先定义临时目录结构。我一般在项目里建立一个upload_temp目录,每个上传任务在目录下新建一个以upload_id命名的子目录,里面放分片文件和task.json状态文件。生产环境可以把状态存到 Redis,但用文件做示例最容易复现。

初始化接口的 PHP 代码大致如下:

<?php // api/init.php $fileName = isset($_POST['file_name']) ? basename($_POST['file_name']) : ''; $fileSize = (int)($_POST['file_size'] ?? 0); $chunkSize = (int)($_POST['chunk_size'] ?? 0); if ($fileSize <= 0 || $chunkSize <= 0 || $chunkSize > 50 * 1024 * 1024) { exit(json_encode(['code' => 1, 'message' => '参数异常'])); } $chunkCount = (int)ceil($fileSize / $chunkSize); $uploadId = bin2hex(random_bytes(16)); $taskDir = UPLOAD_TMP_DIR . '/' . $uploadId; mkdir($taskDir, 0755, true); $task = [ 'upload_id' => $uploadId, 'file_name' => $fileName, 'file_size' => $fileSize, 'chunk_size' => $chunkSize, 'chunk_count' => $chunkCount, 'finished_chunks' => [], 'finished_bytes' => 0, 'created_at' => time(), ]; file_put_contents($taskDir . '/task.json', json_encode($task)); echo json_encode([ 'code' => 0, 'data' => [ 'upload_id' => $uploadId, 'chunk_count' => $chunkCount, ], ]);

注意几个细节。file_name一定用basename()做一次净化,防止用户传../../xxx.php这种路径。upload_id必须由服务端生成,不能信任客户端传入。异步任务如果没有用户体系约束,建议后续由业务逻辑判断权限,防止别人拿到upload_id就能往目录里写分片。

4.2 分片接收与幂等设计

分片接口是整套方案里最容易出错的部分。我采用两种接收方式并存:一是浏览器用 FormData 传文件字段,二是小程序/App 直接传原始二进制 body,元数据放 HTTP Header。这样各端都有适合自己的通道,后端统一处理出同一个结果。

核心接收函数如下:

<?php // api/chunk.php $uploadId = $_SERVER['HTTP_X_UPLOAD_ID'] ?? ''; $index = $_SERVER['HTTP_X_CHUNK_INDEX'] ?? ''; if (!preg_match('/^[0-9a-f]{32}$/', $uploadId)) { exit(json_encode(['code' => 1, 'message' => 'upload_id 不合法'])); } if (!is_numeric($index) || $index < 0) { exit(json_encode(['code' => 1, 'message' => 'index 不合法'])); } $taskDir = UPLOAD_TMP_DIR . '/' . $uploadId; $taskFile = $taskDir . '/task.json'; if (!is_file($taskFile)) { exit(json_encode(['code' => 1, 'message' => '任务不存在'])); } $task = json_decode(file_get_contents($taskFile), true); $index = (int)$index; if ($index >= $task['chunk_count']) { exit(json_encode(['code' => 1, 'message' => '分片序号越界'])); } $partFinal = $taskDir . '/' . $index . '.part'; // 如果分片已经存在,直接返回成功,实现幂等 if (file_exists($partFinal)) { echo json_encode(['code' => 0, 'message' => 'ok']); exit; } $tmpFile = $taskDir . '/' . $index . '.part.tmp'; $expectedSize = expectedChunkSize($task, $index); // 真正把请求体保存为临时分片文件 $written = saveUploadedBinary($tmpFile, $expectedSize); // 原子操作:最终分片文件不存在才改名,避免并发覆盖 if (!file_exists($partFinal)) { rename($tmpFile, $partFinal); } else { unlink($tmpFile); } // 更新任务状态 $fp = fopen($taskFile, 'r+'); flock($fp, LOCK_EX); $task = json_decode(fread($fp, filesize($taskFile)), true); $task['finished_chunks'][] = $index; $task['finished_bytes'] += $written; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($task)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); echo json_encode(['code' => 0, 'message' => 'ok']); function expectedChunkSize($task, $index) { if ($index < $task['chunk_count'] - 1) { return (int)$task['chunk_size']; } return (int)($task['file_size'] - $index * $task['chunk_size']); } function saveUploadedBinary($tmpFile, $expectedSize) { $written = 0; if (!empty($_FILES['data'])) { if ($_FILES['data']['error'] !== UPLOAD_ERR_OK) { throw new RuntimeException('upload error: ' . $_FILES['data']['error']); } $written = (int)filesize($_FILES['data']['tmp_name']); if ($written !== $expectedSize) { throw new RuntimeException('size mismatch'); } move_uploaded_file($_FILES['data']['tmp_name'], $tmpFile); return $written; } $in = fopen('php://input', 'rb'); $out = fopen($tmpFile, 'wb'); while (!feof($in)) { $buf = fread($in, 8192); if ($buf === false) break; $written += fwrite($out, $buf); } fclose($in); fclose($out); if ($written !== $expectedSize) { @unlink($tmpFile); throw new RuntimeException('size mismatch'); } return $written; }

这里有两个非常关键的细节。

第一是幂等。网络请求随时可能重发,同一分片可能被前端传两次。如果后端每次都重新写入,可能出现“A 请求正在写,B 请求已经完成”,最后文件被覆盖成不完整内容。所以我在保存时先写.part.tmp,最后用rename()原子替换成.part。已经存在的直接返回成功,不重复写。

第二是状态更新要加锁。多分片并发上传时,多个 PHP 进程可能同时更新task.json,不加锁会互相覆盖。用flock做文件锁足够简单,高并发生产环境建议换 Redis 原子操作。

4.3 进度查询与文件合并

进度查询接口非常简单,读一下task.json里的累计字节数:

<?php // api/progress.php $uploadId = $_GET['upload_id'] ?? ''; if (!preg_match('/^[0-9a-f]{32}$/', $uploadId)) { exit(json_encode(['code' => 1, 'message' => 'upload_id 不合法'])); } $taskDir = UPLOAD_TMP_DIR . '/' . $uploadId; $taskFile = $taskDir . '/task.json'; if (!is_file($taskFile)) { exit(json_encode(['code' => 1, 'message' => '任务不存在'])); } $task = json_decode(file_get_contents($taskFile), true); $percent = 0; if ($task['file_size'] > 0) { $percent = round($task['finished_bytes'] / $task['file_size'] * 100, 2); } echo json_encode([ 'code' => 0, 'data' => [ 'upload_id' => $uploadId, 'finished_bytes' => $task['finished_bytes'], 'file_size' => $task['file_size'], 'finished_count' => count($task['finished_chunks']), 'chunk_count' => $task['chunk_count'], 'percent' => $percent, ], ]);

合并接口在所有分片都完成后触发。合并时要注意:不要直接打开几千个文件句柄,而是一个一个按顺序追加到最终文件里。先写.tmp再重命名成最终文件,可以避免合并到一半时留下半个残缺文件。

<?php // api/complete.php $uploadId = $_GET['upload_id'] ?? ''; // 校验同之前 $task = json_decode(file_get_contents($taskFile), true); if (count($task['finished_chunks']) !== $task['chunk_count']) { exit(json_encode(['code' => 1, 'message' => '还有分片未上传'])); } set_time_limit(0); $finalTmp = UPLOAD_DIR . '/' . $uploadId . '.merge.tmp'; $finalPath = UPLOAD_DIR . '/' . $uploadId . '_' . $task['file_name']; $out = fopen($finalTmp, 'wb'); for ($i = 0; $i < $task['chunk_count']; $i++) { $part = $taskDir . '/' . $i . '.part'; if (!is_file($part)) { exit(json_encode(['code' => 1, 'message' => '分片丢失'])); } $in = fopen($part, 'rb'); stream_copy_to_stream($in, $out); fclose($in); } fclose($out); rename($finalTmp, $finalPath); echo json_encode([ 'code' => 0, 'data' => [ 'url' => '/download.php?file=' . urlencode($finalPath), ], ]);

合并大文件很吃磁盘占用,因为临时分片和合并后的文件会同时存在一段时间,磁盘剩余空间最好有最终文件大小的两倍以上。

4.4 浏览器端封装

浏览器端我建议用 fetch 直接传原始二进制分片,这样元数据走 Header,后端走php://input分支,逻辑最统一。前端封装大致长这样:

async function initUpload(file, chunkSize = 5 * 1024 * 1024, concurrency = 4) { const form = new FormData(); form.append('file_name', file.name); form.append('file_size', file.size); form.append('chunk_size', chunkSize); const initResp = await fetch('/api/init.php', { method: 'POST', body: form, }); const initJson = await initResp.json(); const { upload_id: uploadId, chunk_count: chunkCount } = initJson.data; let currentIndex = 0; async function worker() { while (currentIndex < chunkCount) { const index = currentIndex++; const start = index * chunkSize; const end = Math.min(file.size, start + chunkSize); const blob = file.slice(start, end); const resp = await fetch('/api/chunk.php', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-Upload-Id': uploadId, 'X-Chunk-Index': index, }, body: blob, }); if (!resp.ok) { throw new Error('chunk upload failed, index=' + index); } } } await Promise.all(Array.from({ length: concurrency }, worker)); const completeResp = await fetch('/api/complete.php?upload_id=' + uploadId); return completeResp.json(); }

进度轮询独立启动,因为上传和轮询互不干扰。有一个很好的小技巧:轮询拿到的服务端进度是真实值,但每 800 毫秒刷新一次会导致 UI 突然跳变,可以加一个平滑函数,让进度条向真实值靠近而不是直接等于真实值:

let displayPercent = 0; async function pollProgress(uploadId) { while (displayPercent < 100) { const resp = await fetch('/api/progress.php?upload_id=' + uploadId); const json = await resp.json(); const serverPercent = json.data.percent; if (serverPercent === 100) { displayPercent = 100; } else { displayPercent += (serverPercent - displayPercent) * 0.25; if (displayPercent >= 99 && serverPercent < 100) { displayPercent = 99; } } renderProgress(Math.floor(displayPercent)); await new Promise((resolve) => setTimeout(resolve, 800)); } }

这样进度条既不会卡住不动,也不会突然跳得比服务端真实进度还高。

4.5 小程序与App端的差异处理

小程序和其他 App 与浏览器最大的差异是:没有统一的 File 对象,不能直接用file.slice()。在微信小程序里,可以用FileSystemManager.readFile的position和length参数按偏移量读取分片:

const fs = wx.getFileSystemManager(); function uploadChunk(filePath, uploadId, index, chunkSize, fileSize) { return new Promise((resolve, reject) => { const start = index * chunkSize; const length = Math.min(chunkSize, fileSize - start); fs.readFile({ filePath, position: start, length, success(res) { wx.request({ url: 'https://your-domain.com/api/chunk.php', method: 'POST', data: res.data, // ArrayBuffer header: { 'Content-Type': 'application/octet-stream', 'X-Upload-Id': uploadId, 'X-Chunk-Index': index, }, success(response) { if (response.statusCode === 200) { resolve(); } else { reject(new Error('chunk failed')); } }, fail: reject, }); }, fail: reject, }); }); }

小程序里要注意并发不能太高,我一般控制在 2 到 3 个。因为小程序的网络库和系统资源本身就比桌面浏览器紧,并发太高会导致请求排队甚至超时。App 端思路类似,原生层只要能按偏移量读取文件数据,就可以走同一套二进制分片协议。React Native、Flutter 都可以做到这一点。

如果你遇到某个端实在不支持“按偏移量读取文件分片”的能力,例如一些老旧的小程序基础库,那只能退回整文件上传。但整文件上传的进度只能靠该端自己的 progress 事件展示,和后端统一进度对不齐。这类场景我会直接在业务层提示用户升级客户端版本,而不是迁就旧端。

5. 问题排查与实操经验

5.1 Nginx 和 PHP 配置导致分片被拒

排查顺序很重要。第一次在线上传分片时,如果所有分片都失败或者返回 413,先去检查三个配置:

  • client_max_body_size:Nginx 默认 1M,至少要大于单片大小。
  • post_max_size:PHP 中限制整个 POST 请求体,要大于单片大小。
  • upload_max_filesize:PHP 中限制单个文件上传大小,也要大于单片大小。

我习惯把这三个值都直接设为 20M,分片固定 5M,留足了余量。

如果配置都没问题,还要注意浏览器本地代理、公司安全软件对 5M 以上 POST 请求的限制。这类问题现象是开发环境正常、某个用户环境上传失败,经验做法是让失败现场把响应 body 打出来看一眼,通常错误信息比猜测管用。

5.2 分片大小对不上

分片大小不一致最容易出现在大文件的最后一个切片。file.size不一定正好被chunkSize整除,所以我在 PHP 端用了expectedChunkSize()函数:

  • 非最后一片:必须是固定chunk_size。
  • 最后一片:大小等于file_size - index * chunk_size。

如果前端和后端对“最后一片”的判断不一致,就会出现某些分片校验失败。解决方法是前后端用同一个公式计算,且分片序号从 0 开始,这一点在接口文档里一定要写清楚。

5.3 进度条跳变与回退

进度条跳变通常有两个原因:

  1. 轮询间隔太长,比如 3 秒一次,后端进度涨上去了,UI 却像卡住一样突然跳一大截。把轮询间隔调到 800 到 1000 毫秒比较合理。
  2. 轮询接口返回了缓存数据。PHP 端默认不会给动态接口加缓存,但如果你自己设置了响应头,要保证Cache-Control: no-store,否则某些浏览器会把进度接口当成静态资源缓存。

我之前还遇到过“进度回退”的现象,后来发现是服务端某个分片校验失败后返回了错误,前端没有标记该分片失败,仍然当成成功并显示了进度。核心做法是:前端仅在 HTTP 200 且响应code=0时才认为该分片完成,否则进入重试队列,这样才能保证 UI 进度与服务端一致。

5.4 断点续传、临时文件清理与身份校验

断点续传是这套统一进度方案的额外红利。前端把upload_id存到localStorage、小程序 Storage 或 App 本地数据库中。用户刷新页面后,先请求进度接口,发现任务还在且未完成,就可以继续从缺失分片开始上传,不用重新切文件、重新获取任务。更精细一点的方案,可以让接口单独返回“未完成分片索引列表”,前端只补传缺失部分。

临时文件清理容易被忽视。如果用户传了一半就放弃,upload_temp目录里会留下大量过期任务。挂一个定时任务,删除创建时间超过 24 小时的未完成任务目录,是每个生产环境都必须做的事。

还有身份校验。上面给的示例代码里没有登录态校验,实际项目中一定要确认当前用户是否有权创建任务、续传任务和获取进度。否则别人拿一个upload_id就能遍历你的临时目录,这可能成为安全隐患。

最后说一个我在实操中的体会:这套方案真正提升的不是“单次上传速度”,而是“大文件上传的可控性”。分片上传配合后端统一进度后,即使某个用户网速极不稳,也能一直接着传,而不是从头再来。遇到产品经理要求“所有端进度条一模一样”,别再纠结各平台上传 API 的差异了,直接去做切片上传和统一进度接口,这才是靠谱的落地路线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询