PHP多端大文件上传进度条统一方案:分片+服务端权威记录
发布时间:2026/10/17 14:09:05 锦皓数字建站

做PHP前后端的人最怕业务方风轻云淡地丢过来一句“功能不复杂就是个大文件上传要进度条Web、小程序、App三端都要能用后端就PHP一套。”这话听着轻松真做起来全是细节。我最近就接了这么个活儿一套PHP接口同时喂给三个端进度条从刚开始的各自为政折腾到后来三端表现完全一致中间踩了不少坑也沉淀了一些通用经验。如果你也面临“多平台大文件上传进度条统一”的问题这篇就是给你准备的。先说结论想靠各个前端自己计算进度来达成统一是走不通的。真正稳的办法是——所有端只负责把文件切成块往上传由PHP服务端权威记录每个分片的接收情况前端再用统一规则把进度拉回来。进度条长什么样、走多快由服务端数据决定而不是由浏览器或小程序环境说了算。1. 同一套PHP后端为什么进度条却各自为政1.1 三端上传的底层机制差异Web端、小程序、App表面上都是“POST一个文件到服务器”但底下完全不是一回事。Web端浏览器里XMLHttpRequest自带upload.onprogress事件能拿到当前请求传了多少字节、总共多少字节。但fetchAPI 不支持上传进度很多人一换fetch就发现进度条没了只能退回去用XHR。小程序端走的是 uploadFile 这一套它有 onProgressUpdate 回调但这个回调的“总进度”是单个上传请求维度的。如果你的方案是把大文件拆成上百个分片、并行发请求那每个分片都有各自的progress前端还得自己想办法汇总。至于App端情况更散原生HTTP库的进度回调、WebView里套Web上传逻辑、某些跨端框架封装的进度事件细节都不一样。每个端都在用“自己语言”描述进度天然就统一不起来。1.2 传统PHP进度条方案的瓶颈很多人第一时间想到PHP自带的 uploadprogress也就是session.upload_progress机制。它确实能让你在session里拿到上传进度但有个致命前提客户端要能维护同一个会话也就是要带cookie session。Web端没问题cookie天然存在。但小程序和App通常走的是Token/Bearer认证不维护PHP session即便往上传接口里硬塞一个 session_id也会被各种跨域、同源策略和请求头限制卡住。更不用说session是锁机制的写进度的时候会把整个会话锁住并发上传稍微一多后面请求全都排队等着页面转圈转到怀疑人生。所以如果你想“一套进度方案三端通用”直接把老方案抄过来是不现实的。必须换一个所有端、所有PHP环境都能稳定支持的统一协议。2. 统一进度的核心思路分片上传加统一进度协议2.1 先定协议再谈统一我最后采用的方案听起来很简单但每一步都是被现实逼出来的第一大文件不再一次POST整个文件。按固定大小切成若干分片比如每片2MB或者5MB一片一片传。单次请求体很小Nginx、PHP的各种上传上限都容易避开。第二服务端每收到一个分片就持久化记录一次哪个上传任务、第几片、已经累计收到多少字节。这个记录是全局权威不依赖任何端自己的统计。第三所有端通过同一个查询接口GET /api/upload/progress?upload_idxxx拿进度。Web、小程序、App轮询的都是同一个数据源。这样做的本质是把“进度”从前端网络事件里剥离出来变成后端的一个业务状态。进度条看到的是“服务端已经收了多少数据”而不是“本机这次请求发出去多少”。三端面对同一个状态想不一致都难。2.2 分片大小怎么选不是越大越好也不是越小越好分片大小是第一个要拍板的参数。我建议普通场景选2MB到5MB弱网场景可以降到1MB。为什么分片太大比如10MB以上单个请求在网络抖动时重传成本高而且对服务端内存和Nginx缓冲区配置更苛刻。分片太小比如256KB请求次数动辄上千PHP每处理一个请求都有开销整体吞吐量反而下降进度查询接口还要被海量上传请求挤占。计算方式很直接总分片数 ceil(文件大小 / 分片大小)。一个500MB的文件用5MB分片正好100片既不多也不少。如果你们服务端部署了Redis并发能力更强可以选稍大一点的分片如果客户端经常在弱网环境就选小一点。这里有个细节分片大小必须同时写在客户端配置和服务端配置里。服务端在初始化上传时要拿到客户端选定的分片大小校验并落库合并分片时严格按照记录来。别让两边各算各的否则合并时风马牛不相及。2.3 为什么不能完全依赖前端上报有人会问既然每个端都能拿到自己上传了多少字节直接让前端把进度POST给服务端存起来不行吗省去轮询服务端还能实时推送。理论上可行但实践里全是坑。前端在上传过程中还会遇到重试、断网、app切后台、小程序被系统回收等情况前端上报的进度是不连续的甚至可能丢了。一旦某个端上报逻辑写错服务端进度就是错的而且无法自校验。更关键的是服务端自己统计收到的字节是有业务约束的比如磁盘真实写入量、分片是否完整这些数据前端根本得不到。让服务端掌握真实进度是最不容易说谎的方案。前端只负责“干活和拉取”不负责“报数”。2.4 三种方案选型对比我把话说透我给当时的项目做了个小对比方向基本就三个这里直接放出来供参考方案实现思路三端统一程度隐患与成本session_upload_progressPHP内置基于session仅Web可用小程序/App基本废session锁并发、跨域认证困难分片上传服务端进度记录自己实现分片、记录、查询高协议统一即可需要建表、写合并逻辑工程量中等公有云对象存储直传前端直传云存储自带进度高但PHP失去控制权依赖外部服务成本高私有化部署不适用我最后选了第二种。因为项目是私有化部署第三方云存储不适用而session方案根本喂不饱三端。你如果也有私有化、内网部署的约束大概率也要选第二种。3. 服务端PHP落地从建表到接口一次讲完3.1 数据表设计一个上传任务一张记录建表是整个方案的地基。我当时用的MySQL表结构做了精简核心字段大概长这样CREATE TABLE upload_task ( upload_id VARCHAR(64) NOT NULL COMMENT 唯一上传ID, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名, file_size BIGINT NOT NULL COMMENT 文件总字节数, chunk_size INT NOT NULL COMMENT 分片字节数, total_chunks INT NOT NULL COMMENT 总分片数, received_chunks INT NOT NULL DEFAULT 0 COMMENT 已收分片数, received_bytes BIGINT NOT NULL DEFAULT 0 COMMENT 已收总字节数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0上传中 1已合并 2合并失败, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (upload_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意upload_id是业务层生成的随机字符串别用自增ID。原因很简单分片上传第一步要先“发起任务”拿到ID后续每个分片都要用它标识如果这个ID可被猜测别人就能把你的上传任务填满或伪造合并。received_chunks和received_bytes虽然是冗余字段理论上能通过统计分片文件算出来但把它们直接放在主表里进度查询就是一条简单UPDATE和SELECT的事不需要每次都扫描分片文件目录性能差别很大。3.2 核心接口一初始化上传上传流程的第一步是前端口令服务端“我准备传一个名叫xxx的文件总共多少字节分片大小多少。”服务端根据这些信息计算总分片数生成upload_id建一条任务记录。// 简化版假设参数已通过POST提交 public function initUpload(array $params): array { $fileName $params[file_name]; $fileSize (int)$params[file_size]; $chunkSize (int)$params[chunk_size]; // 分片参数校验防止客户端乱传 if ($chunkSize 1024 * 1024 || $chunkSize 10 * 1024 * 1024) { throw new \Exception(chunk_size must be 1MB~10MB); } $uploadId bin2hex(random_bytes(16)); $totalChunks (int)ceil($fileSize / $chunkSize); // 插入 upload_task 表初始状态为0 $this-db-insert(upload_task, [ upload_id $uploadId, file_name $fileName, file_size $fileSize, chunk_size $chunkSize, total_chunks $totalChunks, received_chunks 0, received_bytes 0, status 0, created_at date(Y-m-d H:i:s), updated_at date(Y-m-d H:i:s), ]); return [upload_id $uploadId, total_chunks $totalChunks, chunk_size $chunkSize]; }有个容易忽略的点如果文件只有几百KB会出现total_chunks 1的情况也就是单分片上传接口逻辑要兼容进度条直接走到100%也不算错。3.3 核心接口二接收分片并更新进度这是整套方案的高频路径也是并发压力最大的地方。客户端可能同时传5个分片服务端必须保证进度数据不重复、不丢失。典型实现先把分片文件写到临时目录文件名为{uploadId}_{chunkIndex}.part写成功后再更新upload_task计数。public function receiveChunk(array $params): void { $uploadId $params[upload_id]; $chunkIndex (int)$params[chunk_index]; $content $params[chunk_content]; // 分片二进制内容 // 校验任务存在 $task $this-db-queryOne(SELECT * FROM upload_task WHERE upload_id ?, [$uploadId]); if (!$task) { throw new \Exception(upload task not found); } $tmpDir APP_UPLOAD_TMP_DIR . DIRECTORY_SEPARATOR . $uploadId; if (!is_dir($tmpDir) !mkdir($tmpDir, 0755, true)) { throw new \Exception(cannot create tmp dir); } $partPath $tmpDir . DIRECTORY_SEPARATOR . $chunkIndex . .part; // 幂等判断分片已存在直接返回成功不重复计数 if (!file_exists($partPath)) { file_put_contents($partPath, $content); $this-db-execute( UPDATE upload_task SET received_chunks received_chunks 1, received_bytes received_bytes ?, updated_at ? WHERE upload_id ?, [strlen($content), date(Y-m-d H:i:s), $uploadId] ); } }为什么一定要做幂等判断因为我实际测试中碰到过这个问题客户端在网络抖动时会对同一个分片重试如果服务端每次收到都加计数进度就会虚高甚至合并时因为分片文件本身没问题、但计数超过总量状态错乱。判断分片文件是否存在是成本最低的幂等方案。还有一个更细的坑进度更新用received_bytes ?这种增量更新比“先查总数再更新总数”安全因为并发更新时读改写容易丢更新。MySQL里用一条UPDATE完成原子累加天然躲开了这个坑。3.4 核心接口三查询进度和合并收尾进度查询接口最直接就是从表里把数据读出来组装成统一格式。我返回的结构长这样{ upload_id: abcd1234, file_size: 524288000, received_bytes: 104857600, chunk_size: 5242880, total_chunks: 100, received_chunks: 20, status: 0, percent: 20.0 }percent是服务端算好的前端拿到不需要再做二次换算。合并逻辑我单独放了一个接口前端全部传完后调用。合并时按分片索引从小到大逐个读取拼接边拼边校验总字节数是否等于file_sizepublic function mergeChunks(string $uploadId): void { $task $this-db-queryOne(SELECT * FROM upload_task WHERE upload_id ?, [$uploadId]); if (!$task) || $task[status] ! 0 || $task[received_chunks] ! $task[total_chunks]) { throw new \Exception(cannot merge: task not ready); } $finalPath APP_FINAL_DIR . DIRECTORY_SEPARATOR . $uploadId . _ . $task[file_name]; $dest fopen($finalPath, wb); $tmpDir APP_UPLOAD_TMP_DIR . DIRECTORY_SEPARATOR . $uploadId; for ($i 0; $i $task[total_chunks]; $i) { $partPath $tmpDir . DIRECTORY_SEPARATOR . $i . .part; if (!file_exists($partPath)) { throw new \Exception(chunk {$i} missing); } fwrite($dest, file_get_contents($partPath)); } fclose($dest); // 校验 if (filesize($finalPath) ! $task[file_size]) { $this-db-execute(UPDATE upload_task SET status 2 WHERE upload_id ?, [$uploadId]); throw new \Exception(merged file size mismatch); } $this-db-execute(UPDATE upload_task SET status 1, updated_at ? WHERE upload_id ?, [date(Y-m-d H:i:s), $uploadId]); }合并这一步非常消耗CPU和磁盘I/O尤其是大文件几百MB可能要好几秒甚至几十秒。所以前端在收到“所有分片已传完”之后不要傻等合并接口返回而是把状态切到“合并中”继续轮询进度接口等status变成1再跳转。进度接口在合并期间可以固定返回99%或一个“合并中”标记这样用户看到的是流畅过渡而不是界面卡死。4. 前端三端统一封装实战4.1 Web端放弃fetch用XHR和轮询Web端第一步要做的是文件切片。浏览器里File对象支持slice(start, end)直接可以切出二进制块这是整个方案的基石。上传每个分片我用XHR而不是fetch因为fetch不支持上传进度监听。但注意这里的onprogress只代表“当前这个分片”的进度对整体进度没有直接参考价值。我在结构上把每个分片的上传事件当作“完成任务”进度的推进统一交给轮询解决。function uploadChunk(uploadId, chunkIndex, blob) { return new Promise((resolve, reject) { const form new FormData(); form.append(upload_id, uploadId); form.append(chunk_index, chunkIndex); form.append(chunk, blob); const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk); xhr.onload () { if (xhr.status 200) resolve(); else reject(new Error(chunk upload failed)); }; xhr.onerror () reject(new Error(network error)); xhr.send(form); }); }整体控制流程是初始化拿upload_id然后把分片丢进一个并发池比如同时传3~5片每片完成就把数组里的下标标记为完成继续补新片所有片都完成后调用合并接口。并发度是个值得调的点并发太高小程序和App端容易把TCP连接打满弱网下一堆超时并发太低大文件传得慢。我实测3~5比较平衡。4.2 Web端进度轮询与平滑展示进度条拉取走轮询1秒一次足够。为了不让进度条跳来跳去前端做了一次平滑处理。let displayedPercent 0; async function pollProgress(uploadId, onUpdate) { return new Promise((resolve) { const timer setInterval(async () { const res await fetch(/api/upload/progress?upload_id uploadId); const data await res.json(); const target data.percent; displayedPercent displayedPercent (target - displayedPercent) * 0.4; if (Math.abs(displayedPercent - target) 0.1) { displayedPercent target; } onUpdate(Math.round(displayedPercent)); if (data.status 1 || data.status 2) { clearInterval(timer); resolve(data); } }, 1000); }); }这里0.4是平滑系数系数大跟手、系数小柔和。如果想更专业可以做加权移动平均但实际用下来一个简单的指数平滑已经能让用户体验提升一大截。原始进度条如果刚传完5%突然跳到20%用户会以为出了bug平滑之后观感完全不一样。4.3 小程序端和App端的“同一套”思路小程序端的 uploadFile 接口与Web的XHR不同但我只需要它负责上传分片和通知成功失败不需要它计算整体进度。进度照旧通过GET /api/upload/progress轮询获得。伪代码大概是这样const uploadTask uploadFile({ url: /api/upload/chunk, filePath: tempChunkFilePath, name: chunk, formData: { upload_id, chunk_index }, success: () resolve(), fail: () reject() });至于App如果你用的是原生开发那就在HTTP库的上传回调里做同样的封装更省力的做法是App内嵌WebView直接复用Web端那套代码。如果App里本身就是用跨端框架写的那上传接口的封装方式和Web端基本可以保持同构。核心原则就一条前端永远不计算整体进度只拉服务端的。4.4 统一封装模型把三端差异封死在内部为了让业务代码不被三端差异折腾我在前端做了一个统一的ChunkUploader类对外只暴露最简洁的方法class ChunkUploader { constructor({ file, chunkSize 5 * 1024 * 1024, concurrency 4, api, onProgress }) { this.file file; this.chunkSize chunkSize; this.concurrency concurrency; this.api api; this.onProgress onProgress; } async start() { const initData await this.api.init(this.file.name, this.file.size, this.chunkSize); this.uploadId initData.upload_id; // 创建分片列表 this.chunks []; for (let i 0; i initData.total_chunks; i) { this.chunks.push(i); } // 并发调度上传 await this.runConcurrentUpload(); // 触发合并 const result await this.api.merge(this.uploadId); // 合并期间持续轮询直到最终状态 await this.pollUntilDone(); } }Web、小程序、App在创建ChunkUploader时只需要传入各自的api实现和upload执行函数。进度条组件永远只面对onProgress回调面对同一个数值来源。这一层抽象做好之后后面加新端半天就能接完。5. 常见问题与排查实录5.1 进度条整个卡死不动这个症状出现频率最高。如果前端一直轮询不到进度先查服务端日志里有没有收到分片请求。最常见的原因是Nginx默认的client_max_body_size只有1MB即使你分了2MB的分片请求一到Nginx就被拦截了后端根本没机会写进度。解法很简单在Nginx配置里调大这个值或者单独为上传接口设置location /api/upload/ { client_max_body_size 10m; client_body_timeout 60s; }注意这个值必须比你的分片大小略大留出multipart表单的额外开销。如果是5MB分片就配10m这样能保证单个分片顺利通过。同时检查PHP侧的post_max_size和upload_max_filesize这两个值也要大于分片大小否则PHP直接拒绝请求连错误日志都很容易忽略。5.2 进度条走了但合并后文件损坏分片文件明明全都收到了合并后文件却打不开或者大小对不上。十有八九是分片顺序问题。前端并行上传时分片到达服务端的顺序是乱的但分片文件名里带了 chunk_index合并时按索引顺序读就行。我见过有人在合并时用了scandir后按文件名自然排序结果10.part排在2.part前面顺序全乱。解决方案就是别依赖目录扫描老老实实按数字索引循环读取。另外还有一种情况分片损坏。如果你们用HTTP断点续传服务端判断“分片已存在”时只看了文件名是否存在没校验文件大小或内容某个中间环节写入了一截空数据合并后就会发现中间有一块填充的空字节。稳妥的做法是判断文件存在后再比一下文件大小是否等于初始化时记录的分片大小。注意最后一个分片可能不满所以不能用统一分片大小去校验要用file_size - chunk_size * (total_chunks - 1)计算最后一个分片的期望大小。5.3 进度条到达99%后长时间挂住所有分片都已上传理论上应该马上合并完成前端却卡在99%不动了。排查后发现合并接口是在同步执行文件拼接的几百MB的文件拼接需要好几秒前端在等待合并接口返回前停止轮询界面就僵住了。解决方法是所有分片上传完成后前端先调用合并接口但不等它返回立刻继续轮询进度接口。服务端合并期间把status置为“合并中”或者返回一个标志前端展示“正在合并”状态。如果合并完成status变为1前端再跳转若status变为2说明合并失败可以提示用户重新合并。5.4 断点续传场景下进度条重新从0开始断点续传是一个很加分的功能但如果不设计好进度条会露怯。用户传了一半刷新页面重新上传时所有分片都重传进度条又从头走用户体验很受影响。在分片方案里做断点续传很简单初始化接口增加可选参数last_upload_id服务端查询已有任务返回当前已上传的分片索引列表前端拿到列表后跳过这些分片只传缺失的。进度查询接口在续传时也要兼容从已有字节数继续累计。这样断点续传和进度显示才能自洽。5.5 查询进度接口被请求打爆轮询1秒一次如果同时在线100个人传文件每秒就有100次进度请求这还不算分片上传本身带来的压力。初期我用MySQL直接查表高峰期数据库连接打满。后面做了两个优化一是进度接口加了Redis缓存设置30秒过期服务端更新计数时顺便写缓存查询走缓存二是前端加了轮询退避策略当进度连续两次没有变化时自动把轮询间隔从1秒拉长到2秒、3秒直到有变化再缩短。这招特别管用既保证了进度条实时性又明显降低了接口压力。如果把进度更新也扛到Redis上用INCRBY原子累加字节数并发能力能再上一个台阶。Redis宕机的风险也存在所以我在项目里是Redis为主、MySQL为兜底每隔一小段时间把Redis数据回写到MySQL表两路数据都能自校验。结尾一点实在体会整套方案上线之后我最大的体会是多端上传这个问题真正的复杂度根本不在某个端的API怎么调而在于你愿不愿意多做一层“服务端权威状态”。把进度条从一种“前端网络事件”变成“后端业务状态”之后三端统一水到渠成。最后再分享一个不起眼但很有用的小技巧进度条组件里把数字进度做成“离散化”展示比如只显示偶数整数或者按5%、10%这样跳视觉上更稳。用户眼睛对忽闪忽闪的进度条非常敏感先定义好协议、再做服务端统计、最后加一层前端平滑这一套组合下来才能做到“三端进度条像是复制粘贴出来的”。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。