
做PHP做过几年的人多半都会在某一天被一个问题问住“系统里要往服务器传1GB多的视频前端页面能不能放一个进度条让线上用户看着也安心一点”我前阵子帮某单位做一套内部视频归档系统需求就是这样的业务人员在内网上传监控录像单个文件经常超过500MB偶尔还有上GB的压缩包。平台是PHP要求浏览器里必须实时展示上传进度条、显示速率和剩余时间传一半断了还得能看出来不能让人干等。这类需求看着简单真正做起来之后才发现难点根本不在“画一个进度条”而在整条上传链路上的每一环。浏览器把文件推给Web服务器Web服务器再交给PHPPHP再把临时文件挪到业务目录中途还有Nginx缓冲区、PHP配置、超时时间、反代转发、磁盘IO这些因素在捣乱。任何一个环节被卡住进度条要么不动要么到一半直接报错。这篇文章就把我在类似项目里踩过的坑和最终落地的方案写清楚主要包括进度条的技术路线怎么选、XHR和分片上传的PHP怎么做、以及上线之前那些配置文件到底该调成什么值。1. 做进度条之前先摸清“进度”到底在哪一段1.1 一条上传请求要过的五道闸口很多人以为浏览器发出请求之后文件就一路直达PHP脚本。实际不是一次大文件上传至少要过五个环节每道环节都可能成为进度条的绊脚石。第一道是浏览器本身。浏览器发送请求体时并不会一次性推完而是按网络情况和系统内存分成很多数据块发出去XHR的upload进度事件就是在这里产生的。浏览器到服务器之间的传输进度是前端能拿到的第一个真实数据来源也是绝大多数进度条的基础。第二道是反向代理或负载均衡层。在国企内网里前端一般不会直接把请求打到PHP-FPM而是先过一层Nginx。Nginx默认会缓冲请求体也就是说它会先把文件全部接收下来存到自己的临时目录再转交给后端PHP-FPM。有这一层在浏览器到Nginx的进度条可能已经完全跑到100%但PHP那边可能还没收到任何数据连文件临时文件都没创建。第三道是PHP-FPM进程。PHP处理上传请求时会把接收到的请求体存到临时目录这个动作受php.ini的upload_max_filesize、post_max_size、max_execution_time等参数限制。一旦文件大小超过post_max_sizePHP会直接认定请求无效把$_FILES清空进度条就算传完了也会在最后一步报错。第四道是磁盘写入。文件从网络层到临时目录、再从临时目录复制到业务存储这两次写盘的速度直接取决于磁盘类型和系统负载。如果目标是机械硬盘内网千兆传输速度可能比磁盘写入还快会出现传输进度到99%但后端迟迟不返回响应的情况。第五道是业务处理。文件落盘不等于业务完成很多国企项目后面还跟着压缩、转码、解析、入库这些动作。如果进度条展示的是上传进度用户在100%之后还会看到页面在转圈容易造成“有没有执行成功”的困惑。1.2 国企内网场景的特殊约束在公网上随便找个对象存储SDK上传进度条分分钟就能集成好。但在国企内网很多条件是受限的这也是这类需求看着简单、做起来别扭的核心原因。一是带宽不一定大。千兆内网看起来很快但实际中间隔着路由器、防火墙、病毒扫描网关之后实际吞吐可能只有几十MB每秒。遇到2GB的大文件也要传几十秒甚至几分钟进度条必须考虑长时间传输的稳定性。二是浏览器环境偏保守。内部系统对外网资源有限制前端框架经常是老版本甚至还有单位要求兼容旧版内核的浏览器。那么一些比较新的上传组件比如基于WebSocket或者流式读取的方案可能就不好用。最稳妥的还是原生XMLHttpRequest只要浏览器支持XHR Level 2就有upload.onprogress事件可用。三是部署架构不一致。有的系统是单机部署有的是两台机器挂了负载均衡。如果是后者PHP上传的临时文件目录和最终存储目录必须两机共享否则请求打到A机、进度查询打到B机就会查不到数据。这属于很基础的分布式常识但实践中就是容易漏。这些条件叠加在一起决定了方案选型不能只看代码写得漂不漂亮还要看它适不适合这套内网环境。2. 三种实现路线从老法到分片别一上来就选最重的2.1 iframe轮询的老方案还能干嘛早期做上传进度条流行的是隐藏iframe表单提交加后端轮询大概是这样的套路前端把文件放进一个隐藏iframe里的form提交后在页面上定时请求PHP的一个进度接口后端通过session.upload_progress记录上传字节数前端循环查询回来填充进度条。这套方案在IE时代算经典但放到今天问题特别多。首先是体验差iframe提交之后页面没有标准的传输回调前端只能靠猜轮询间隔短了后台压力大间隔长了进度条又像跳帧。其次是session.upload_progress本身有硬伤它依赖PHP session机制上传完成之后进度数据默认会被清理经常出现进度条走到100%的前一刻数据被清空前端反而查不到最终结果。更关键的是从PHP 7.2开始官方就标记这组功能为废弃到了PHP 8.4直接把session.upload_progress相关配置移除了。现在新项目如果再选这个方案就是往一个正在退场的旧时代里搭地基不值得。2.2 XHR onprogress当前最划算的主流方案现代浏览器支持的XMLHttpRequest Level 2提供了一个专门的上传进度事件xhr.upload.onprogress。浏览器每往服务器发送一块数据就会触发一次事件对象里带两个关键字段loaded表示已经发送的字节数total表示总字节数二者一除就能得到百分比。这个方案和真实传输过程强绑定前端拿到的是一个实时且真实的数据源误差只在浏览器网络栈层面基本可以忽略。配合FormData可以做到无刷新上传、携带业务参数、附带取消请求的能力。PHP后端也不需要额外的进度查询接口只要正常处理上传请求就行进度完全由浏览器自己驱动这对一家企业的现有PHP系统来说是最友好的方案。兼容性方面所有现代浏览器都能用就连手机端的微信内置浏览器都没问题。旧IE倒是不支持但2025年还在维护的内部系统如果非要兼容IE那这个方案就得做降级处理。我的建议是如果没有硬性的IE兼容要求直接用XHR就是性价比最高的起点。2.3 分片上传断点续传超大文件的终极解如果单文件超过1GB或者业务上还要求断点续传和秒传XHR整包上传就有点吃力了。这时候主流做法是分片上传前端把文件按固定大小切成很多块每一块单独通过XHR上传全部传完后由PHP合并成原文件。分片带来的好处是明显的一个1GB文件切成100个10MB的分片每一片请求都很短哪怕中间断网也只要重传没成功的分片不用从头再来。前端还可以根据分片完成情况精确计算整体进度甚至并行上传多个分片来提升吞吐量。后端也能对每个分片做大小校验某一片出错单独重试即可不会像整包上传失败那样必须全部重来。代价是前后端代码量明显增加还要设计分片合并的机制。因此我的判断标准很简单文件大概率小于200MB用XHR整包上传就行超过500MB或者断网重传刚需才值得上分片。3. 从零实现一个“能上生产”的上传进度条3.1 前端封装带实时速率和剩余时间的XHR进度条先写一个最实用的前端封装不依赖任何第三方组件原生JavaScript就能用。功能包含上传进度、实时速率、剩余时间估计三个指标。这样在页面上展示的就不是孤零零一个百分比而是“43.8% 12.6MB/s 剩余18秒”这种完整信息业务方看着心里有底。function uploadFileWithProgress({ file, url /upload.php, formData {}, chunkSize 0, onProgress () {}, onDone () {}, onError () {}, signal null } {}) { const fd new FormData(); for (const key in formData) { fd.append(key, formData[key]); } if (chunkSize 0) { // 分片场景只上传一个分片 fd.append(chunkIndex, formData.chunkIndex); fd.append(totalChunks, formData.totalChunks); fd.append(fileName, file.name); fd.append(chunk, file, file.name); } else { fd.append(file, file, file.name); } const xhr new XMLHttpRequest(); xhr.open(POST, url, true); if (signal) { signal.addEventListener(abort, () xhr.abort()); } if (xhr.upload) { xhr.upload.onprogress (e) { if (e.lengthComputable) { const percent Math.min(99, Math.round((e.loaded / e.total) * 100)); onProgress(percent, e.loaded, e.total); } }; } xhr.onload () { if (xhr.status 200 xhr.status 300) { try { onDone(JSON.parse(xhr.responseText)); } catch (err) { onDone(xhr.responseText); } } else { onError(xhr.status, xhr.responseText); } }; xhr.onerror () onError(0, network error); xhr.onabort () onError(-1, aborted); xhr.send(fd); return xhr; }上面这段的onProgress回调里拿到的loaded和total是本次请求的字节数很多人在整包上传时直接拿来算百分比没问题。但注意一点total在个别情况下可能为0或不可计算这时候e.lengthComputable会是false所以一定要先做判断否则百分比变成NaN。页面上拿到回调之后要计算实时速率和剩余时间需要自己维护一个时间窗口class SpeedTracker { constructor() { this.windowSize 5; this.samples []; } push(loadedNow, timestamp) { this.samples.push({ loadedNow, timestamp }); if (this.samples.length this.windowSize) { this.samples.shift(); } if (this.samples.length 2) return 0; const first this.samples[0]; const last this.samples[this.samples.length - 1]; const bytes last.loadedNow - first.loadedNow; const seconds (last.timestamp - first.timestamp) / 1000; if (seconds 0) return 0; return bytes / seconds; } }进度条组件的渲染我一般不用第三方UI库一个外层容器放一个div当进度条底色里面一个div设置宽度百分比再加一个文本节点显示数字和速率就足够稳定。如果公司前端已经在用类似Element之类的组件库直接用它的Progress控件也行但别为了一个上传进度条强制引入整套组件框架。3.2 PHP后端接收文件并返回状态XHR上传到了PHP这边普通处理方式和一个表单提交没有区别区别在于要做好几类校验避免大文件上传把服务器拖垮。?php header(Content-Type: application/json; charsetutf-8); const MAX_SIZE 2 * 1024 * 1024 * 1024; // 2GB const TARGET_DIR /data/upload_server/; if ($_SERVER[REQUEST_METHOD] ! POST) { echo json_encode([code 405, msg method not allowed]); exit; } $bizId trim((string)($_POST[bizId] ?? )); if ($bizId ) { echo json_encode([code 400, msg 缺少业务号]); exit; } if (!isset($_FILES[file])) { echo json_encode([code 400, msg 未收到文件]); exit; } $file $_FILES[file]; if ($file[error] ! UPLOAD_ERR_OK) { echo json_encode([code 400, msg 上传错误码 . $file[error]]); exit; } if ($file[size] MAX_SIZE) { echo json_encode([code 413, msg 文件超过2GB限制]); exit; } // 按业务号建目录避免一个目录塞几万个文件 $targetDir TARGET_DIR . $bizId . /; if (!is_dir($targetDir) !mkdir($targetDir, 0750, true) !is_dir($targetDir)) { echo json_encode([code 500, msg 目录创建失败]); exit; } // 文件名做一次处理防止路径注入 $fileName preg_replace(/[^\w\.\-\x{4e00}-\x{9fa5}]/u, _, basename($file[name])); $targetPath $targetDir . date(YmdHis) . _ . $fileName; if (!move_uploaded_file($file[tmp_name], $targetPath)) { echo json_encode([code 500, msg 文件保存失败, targetDir ]); exit; } echo json_encode([code 0, msg ok, path $targetPath, size $file[size]]);这段代码里有几个坑值得展开说。move_uploaded_file失败的原因多半不是PHP权限而是临时目录本身没有被PHP-FPM进程读取或写入的权限。排查时先看sys_get_temp_dir()返回的路径再确认php-fpm的运行用户对该目录有写权限。文件名校验不能省。内部系统也一样basename可以干掉路径分隔符但文件名里可能有特殊字符比如分号、引号一旦后续被其他脚本引用就容易出问题。我习惯先把非预期字符替换成下划线再拼接宁可文件名变得不好看也不能给系统留风险。返回的path字段一般不要原样返回到前端页面展示因为它包含了服务端目录结构。这里我为了排查方便返回了相对路径生产环境建议只返回一个文件编号让前端后续通过编号再请求文件元数据。3.3 分片合并的PHP实现要点当业务确实需要分片上传时后端逻辑比XHR整包复杂一些但核心就两个动作接收分片、合并分片。前端把文件切成10MB或20MB的分片后每片请求都会带分片序号和总分片数。PHP接收单片与接收完整文件类似只是落盘到专门的临时分片目录?php // upload_chunk.php header(Content-Type: application/json; charsetutf-8); $chunkIndex (int)($_POST[chunkIndex] ?? -1); $totalChunks (int)($_POST[totalChunks] ?? 0); $fileName (string)($_POST[fileName] ?? ); $bizId (string)($_POST[bizId] ?? ); if ($chunkIndex 0 || $totalChunks 1 || $fileName ) { echo json_encode([code 400, msg 分片参数错误]); exit; } $safeName preg_replace(/[^\w\.\-\x{4e00}-\x{9fa5}]/u, _, basename($fileName)); $chunkDir /data/upload_chunks/ . md5($bizId . _ . $safeName) . /; if (!is_dir($chunkDir) !mkdir($chunkDir, 0750, true) !is_dir($chunkDir)) { echo json_encode([code 500, msg 分片目录创建失败]); exit; } if (!isset($_FILES[chunk]) || $_FILES[chunk][error] ! UPLOAD_ERR_OK) { echo json_encode([code 400, msg 分片数据异常]); exit; } $chunkPath $chunkDir . $chunkIndex . .part; if (!move_uploaded_file($_FILES[chunk][tmp_name], $chunkPath)) { echo json_encode([code 500, msg 分片保存失败]); exit; } // 如果这是最后一片则触发合并 if ($chunkIndex $totalChunks - 1) { mergeChunks($chunkDir, $safeName, $totalChunks, $bizId); } echo json_encode([code 0, msg ok]); function mergeChunks($chunkDir, $fileName, $totalChunks, $bizId) { $targetDir /data/upload_server/ . $bizId . /; if (!is_dir($targetDir) !mkdir($targetDir, 0750, true)) { return; } $finalPath $targetDir . $fileName; $out fopen($finalPath, wb); if (!$out) { return; } for ($i 0; $i $totalChunks; $i) { $partPath $chunkDir . $i . .part; if (!is_file($partPath)) { fclose($out); throw new RuntimeException(missing chunk: . $i); } $in fopen($partPath, rb); while (!feof($in)) { fwrite($out, fread($in, 1024 * 1024)); } fclose($in); unlink($partPath); } fclose($out); }这里有一个很典型的错误写法直接用file_get_contents把整个分片读进内存再fwrite。如果分片是50MB理论上PHP内存配置够高也能跑但并发一上来就把内存打爆。上面的代码用fread每次读1MB循环写入内存占用恒定在1MB左右这才是生产环境该有的写法。分片合并时还要注意“最后一片触发合并”的时机。如果前端用并发上传最后一片可能先到但前面的分片还没传完此时直接合并会报“missing chunk”。稳妥的做法是后端不主动合并而是每个分片存完都检查目录里分片数量是否达到totalChunks全到了再合并。或者更简单一点让前端在所有分片都上传成功之后显式请求一个“合并接口”。两种方式各有取舍并发场景我更推荐前端显式触发合并逻辑更清晰也方便做“如果合并失败前端重试合并”的操作。4. 上线前必须做好的配置、排查与边界4.1 一套能扛住大文件的配置清单代码写得再好配置文件不配合上传一样会失败。给内部系统调优过的同仁都知道大部分“上传到一半断开”的问题根源就是Web服务器或PHP某个默认限制被撞上了。下面这份清单是按2GB上限整理的实际根据业务最大值等比调整。层级配置项参考值说明php.iniupload_max_filesize2048M单文件上限和业务对齐php.inipost_max_size2048M必须配得比upload_max_filesize更大否则小文件也会被误伤php.inimax_execution_time300脚本执行上限分片场景可设600内网大文件够用php.inimax_input_time-1请求体解析不限制时间php-fpmrequest_terminate_timeout600超过会被强杀默认0表示不限制但建议设有限值防异常请求占死进程nginxclient_max_body_size2048M和PHP配置保持一致nginxclient_body_timeout600s防止上传过程中空闲时间过长被断开nginxproxy_request_bufferingoff如果有反向代理层关掉缓冲才能让后端实时收到数据系统/tmp分区空间预留上传文件大小的2倍临时文件和业务文件同时落盘时的空间余量特别注意post_max_size和upload_max_filesize的关系post_max_size不仅要大而且要大于用户表单里所有字段加上文件大小的总和。有些人把upload_max_filesize调成2GBpost_max_size还是默认8M结果传200MB的文件都报错就是这个原因。Nginx作为反向代理转发给PHP-FPM的时候proxy_request_buffering on默认开启。它会让Nginx先把整个请求体缓冲完再转发给PHP于是PHP侧接收数据的时间点被延后。如果在进度条方案里依赖PHP查进度就会出现“前端已经100%后端还没开始”的错位。关掉缓冲能改善但代价是高并发大文件上传时PHP-FPM会直接面对所有请求体建议根据并发量测试再决定。4.2 常见问题排查速查表实际运行中我把遇到过的典型问题整理成一张速查表每个月都能靠它定位七八成的问题。现象原因解决方案上传到30%直接报403/413Nginx的client_max_body_size太小调大Nginx配置并reload后端打印$_FILES为空post_max_size小于实际上传大小调大post_max_size前端进度条100%PHP还没落盘反向代理开了缓冲关proxy_request_buffering或调整缓冲策略上传过程被中断日志无错误client_body_timeout或php-fpm超时延长相关超时配置上传大文件时内存警报file_get_contents读整个分片或合并逻辑处理不当改用流式读写分片传完但合并文件打不开某个分片缺失或顺序错乱校验分片序号缺失时提示前端补传该分片所有分片都存在合并却找不到文件多实例部署时临时目录不共享把分片目录放到共享存储或同一台机器IE浏览器点击上传没反应IE不支持XHR Level 2降级为整包表单提交或引导使用现代浏览器关于session.upload_progress这里再啰嗦一句如果你的系统还在用老代码它确实能查上传进度但PHP 8.4已经移除了而且它要求上传过程中保持同一个session在多台机器负载均衡、session不共享的环境里会莫名其妙查不到数据。新代码不要写这条路。4.3 容易漏掉但很重要的项目级细节大文件上传在国企场景里还经常牵扯到一些代码之外的事。一个是审计日志哪个账号、什么时间、上传了哪个文件、传了多大这些记录在合规检查时经常要用。建议上传接口落盘后同步写一条上传记录字段至少包含业务号、文件名、大小、来源IP、账号、上传结果。另一个是磁盘空间预检查。大文件上传最怕传到一半磁盘满了PHP会报写入失败用户只看到“上传失败”却没有原因。后端可以在接收文件之前先检查目标磁盘可用空间如果小于文件大小的1.2倍直接拒绝上传并给出提示。还有文件类型的二次校验。前端就算限制只能选视频文件请求照样可以被手工构造所以后端不能只看扩展名。内部系统如果没有解析库至少做一个文件头魔数校验对视频类和压缩包类要分别校验。最后是上传进度与业务处理进度要分开。上传进度是浏览器到服务器之间的传输进度业务处理进度是文件落盘之后后端在执行的压缩、转码、入库进度。如果业务处理时间很长应该在上传完成后再请求一个任务查询接口返回类似“转码中30%”的状态或者用接口轮询而不是把“转圈”和“进度条”混为一谈。写了很多最后分享一点个人体会。进度条这件事真正决定成败的往往不是前端那几十行动画逻辑而是你提前把链路配置、分片策略、共享存储细节想清楚了。我先劝一句别一看到大文件就直接上分片先评估业务最大文件大小和网络环境200MB以内用XHR整包上传就挺好等文件真的超过500MB或者断点续传变成硬需求再动手做分片。架构越简单后期越省心。还有个小技巧想留给大家所有上传接口的响应不管成功还是失败尽量统一返回JSON格式并且带上统一的code字段。前端判断的时候只看code不要用HTTP状态码做业务分支。因为在反向代理中间还有可能插入一层统一认证网关某些非200状态会被网关提前拦掉前端拿到的根本不是接口本来返回的内容。统一code约定能少踩很多莫名其妙的坑。