资讯详情

资讯详情

多平台大文件上传进度条统一方案:PHP切片聚合实战

多平台上传进度条这件事我踩过不少坑。以前做过的项目里桌面浏览器、移动端 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.onprogressfetch直接没有上传进度回调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);这段代码在小文件、局域网环境下很流畅。文件一大问题就来了浏览器发出loaded不等于服务器收到loaded进度会先到 100%。服务器接收期间代理或反代如果有缓冲客户端无法知道服务端是否已经处理完。如果请求失败要重新传整个文件不能从某一片继续。小程序、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 的的内部传输数据只要服务端记录的数字一致所有端显示的进度就一致。实现这个目标需要三步文件切片客户端把文件按固定大小切成多个分片例如每个 5 MB。分片上报客户端逐片上传服务端收一片、校验一片、登记一片。进度查询客户端定时请求进度接口拿到服务端汇总后的完成度。这样做还有一个额外好处服务端知道每个分片是否已经完整接收断点续传、进度上报、合成都建立在这个可靠基础上。3.2 完整流程初始化、分片、查进度、合并我用的是下面这套时序前后端统一按这个约定来做前端发起初始化请求把文件名、文件大小、分片大小告诉 PHP 后端后端生成一个唯一的upload_id返回给前端。前端拿到upload_id后用file.slice()或对应平台的文件分片能力把文件切成 N 片依次或并发上传。每个分片请求都携带upload_id和分片序号index请求体是分片的二进制内容。PHP 后端每收到一个分片校验大小是否正确写入临时分片文件并更新任务记录中的finished_bytes。前端在分片传输过程中以固定间隔请求进度接口展示服务端返回的百分比。所有分片传完后前端请求合并接口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_sizeNginx 默认 1M至少要大于单片大小。post_max_sizePHP 中限制整个 POST 请求体要大于单片大小。upload_max_filesizePHP 中限制单个文件上传大小也要大于单片大小。我习惯把这三个值都直接设为 20M分片固定 5M留足了余量。如果配置都没问题还要注意浏览器本地代理、公司安全软件对 5M 以上 POST 请求的限制。这类问题现象是开发环境正常、某个用户环境上传失败经验做法是让失败现场把响应 body 打出来看一眼通常错误信息比猜测管用。5.2 分片大小对不上分片大小不一致最容易出现在大文件的最后一个切片。file.size不一定正好被chunkSize整除所以我在 PHP 端用了expectedChunkSize()函数非最后一片必须是固定chunk_size。最后一片大小等于file_size - index * chunk_size。如果前端和后端对“最后一片”的判断不一致就会出现某些分片校验失败。解决方法是前后端用同一个公式计算且分片序号从 0 开始这一点在接口文档里一定要写清楚。5.3 进度条跳变与回退进度条跳变通常有两个原因轮询间隔太长比如 3 秒一次后端进度涨上去了UI 却像卡住一样突然跳一大截。把轮询间隔调到 800 到 1000 毫秒比较合理。轮询接口返回了缓存数据。PHP 端默认不会给动态接口加缓存但如果你自己设置了响应头要保证Cache-Control: no-store否则某些浏览器会把进度接口当成静态资源缓存。我之前还遇到过“进度回退”的现象后来发现是服务端某个分片校验失败后返回了错误前端没有标记该分片失败仍然当成成功并显示了进度。核心做法是前端仅在 HTTP 200 且响应code0时才认为该分片完成否则进入重试队列这样才能保证 UI 进度与服务端一致。5.4 断点续传、临时文件清理与身份校验断点续传是这套统一进度方案的额外红利。前端把upload_id存到localStorage、小程序 Storage 或 App 本地数据库中。用户刷新页面后先请求进度接口发现任务还在且未完成就可以继续从缺失分片开始上传不用重新切文件、重新获取任务。更精细一点的方案可以让接口单独返回“未完成分片索引列表”前端只补传缺失部分。临时文件清理容易被忽视。如果用户传了一半就放弃upload_temp目录里会留下大量过期任务。挂一个定时任务删除创建时间超过 24 小时的未完成任务目录是每个生产环境都必须做的事。还有身份校验。上面给的示例代码里没有登录态校验实际项目中一定要确认当前用户是否有权创建任务、续传任务和获取进度。否则别人拿一个upload_id就能遍历你的临时目录这可能成为安全隐患。最后说一个我在实操中的体会这套方案真正提升的不是“单次上传速度”而是“大文件上传的可控性”。分片上传配合后端统一进度后即使某个用户网速极不稳也能一直接着传而不是从头再来。遇到产品经理要求“所有端进度条一模一样”别再纠结各平台上传 API 的差异了直接去做切片上传和统一进度接口这才是靠谱的落地路线。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →