在Web开发里“上传”可以说是一个永远绕不开的话题。以前大家处理上传无非就是拖一个input框后端接一下存下来完事。但一旦文件体量上来比如视频素材、数据库备份包、安装程序动辄几百MB甚至几个GB那套传统方案立刻就会暴露出各种问题要么超时断连要么内存爆掉要么用户根本不知道传了百分之多少。我也是被这类问题折腾过几次之后才认认真真把“PHP大文件分片上传、断点续传、上传进度条”这套方案完整梳理了一遍并且直接在一个实际项目里落地。今天就把这套方案从设计到实现包括中间踩过的坑完整拆给大家。先说这套方案能解决什么问题一是让大文件上传不再被服务器超时和内存限制卡死二是网络闪断或者用户手动暂停之后可以接着上次的进度继续传不用从头再来三是前端有一个真实的上传进度条用户不至于干等着。适合的场景很明确视频平台、网盘系统、后台管理系统的批量附件上传以及任何需要上传GB级文件的Web应用。无论你是PHP后端开发者还是全栈工程师这套思路都值得收藏。1. 为什么大文件上传必须走分片路线1.1 PHP默认环境的“三座大山”很多人一开始觉得大文件上传不就是把upload_max_filesize调大一点吗真动手改过就会发现事情远没那么简单。PHP默认配置里藏着三个非常要命的限制它们共同构成了大文件上传的第一道防线。第一个是upload_max_filesize它直接限制了单次上传文件的最大体积。第二个是post_max_size它限制的是整个POST请求体的体积。这意味着即使你把上传文件大小调到2GB如果post_max_size还是默认的8MB请求照样会被拒绝。第三个是max_execution_time它限制的是PHP脚本的最大执行时间。上网环境一波动上传一个1GB文件可能耗上十几分钟脚本直接超时被杀掉前端收到的就是一个500错误。除了这三个显性限制还有一个隐形杀手叫memory_limit。就算你把前面三个参数都调大了PHP在处理上传时如果走的是把整个文件读入内存再进行操作的逻辑内存立刻就会爆掉。即使不报错服务器也会因为内存耗尽而变得极其卡顿直接影响同机器上的其他业务。所以单纯调大配置本质上就是“拆东墙补西墙”。上传请求越大PHP进程占用资源越多挂掉的风险越高。真正优雅的解法是把一个大文件拆成很多个小份让每个请求都保持在一个PHP能轻松处理的体积范围内。1.2 分片方案能解决什么分片上传的核心逻辑其实不复杂前端用JS把大文件切成若干个小块每个小块作为一个独立的HTTP请求发给后端后端收到后先把小块存到临时目录等所有小块都传完了再执行一次合并操作把临时文件拼成最终文件。这套流程带来的好处是实实在在的。首先每个请求上传的体积很小PHP处理起来毫无压力内存占用可以稳定控制在几十MB以内再也不用提心吊胆地调大memory_limit。其次断点续传变成了顺理成章的事情——因为文件已经被切成了独立的小块每一块传没传都是可记录、可查询的中断之后只需要找出那些还没传成功的块重新传一次即可。再者前端可以非常精确地计算上传进度因为已经传了多少块是确定的除以总块数就是百分比。1.3 分片流程全貌在开始写代码之前我建议你先对整个交互流程有一个全局认识。前端负责切片和发起请求后端负责接收和合并。中间有一个查询接口用来告诉前端哪些分片已经传过了。流程大概是这样的用户选择文件后前端计算文件指纹一般是MD5先向后端发一个“查询已上传分片”的请求后端根据指纹到临时目录里比对返回一个“已上传分片序号”的数组前端拿到这个数组把所有没传过的分片逐个上传全部传完后前端再发一个“合并”请求后端收到合并请求后按顺序把之前接收到的分片拼成完整文件同时清理临时文件。这个设计有一个额外的好处秒传。如果后端合并完的文件已经存在查询接口直接返回“该文件已经存在”前端连切片都不用做直接提示“上传成功”即可。实际项目里这个功能极其实用尤其在多人重复上传相同文件的场景下能省下大量的带宽和存储资源。2. 系统设计与核心参数选型2.1 整体架构实际项目里我的技术栈是前端Vue axios后端原生PHP。不依赖任何重量级框架一方面是因为上传逻辑本身比较独立另一方面是这套代码放在任何PHP项目里都能直接复用。前端负责三件事切片、上传、展示进度。后端负责三件事接收分片、查询进度、合并文件。两者之间通过一个简单的文件命名规则来交换信息不需要数据库参与。这一点很重要——并非所有场景都需要引入数据库用文件系统来“天然记录”分片状态既能减少依赖又能避免数据库连接耗尽的问题。前端切片的代码核心是用File.prototype.slice方法。这个方法可以把文件切出指定范围的字节块。我用的是blob.slice(start, end)比如一个10MB的文件切成每片2MB那么第0片是0-2MB区间第1片是2-4MB区间以此类推。2.2 分片大小定多少合适分片大小的选择直接影响上传体验和后端压力这里没有一个绝对标准但有一个合适的参考区间。我实际测试下来10MB的分片在绝大多数网络环境下都是比较稳妥的选择。分片太小比如1MB虽然单请求压力极小但请求数量会急剧膨胀。一个1GB文件就要切1000个分片每个分片都要经历一次完整的HTTP握手、上传、服务端写入、响应。请求数的增加会带来大量额外的时间开销和网络开销总体速度反而变慢。分片太大比如100MB后端合并时需要读入内存的量也会变大而且一旦网络有波动整块失败重传的代价太高断点续传的意义就打了折扣。我个人的推荐是普通网络环境用5MB-10MB内部局域网可以用20MB。核心思路是让单分片上传时间控制在1-3秒以内。可以参考一个粗略公式分片大小 期望上传带宽 × 期望单片耗时。比如带宽是5MB/s期望单片耗时2秒那么分片大小取10MB就比较合适。带宽小的地方要相应调低不然单片耗时太长超时边界就危险了。另外要注意并发数的问题。浏览器对同一域名的并发连接数是有限制的HTTP/1.1下一般是6个左右。上传大文件时我建议把并发数压到2-3个避免请求排队等待。并发数太多不仅不会提速反而会因为TCP拥塞控制机制导致整体吞吐量下降。2.3 文件标识与去重策略后端怎么判断上传的分片属于哪个文件最简单可靠的方式是让前端生成一个唯一标识。这个标识不能用纯随机字符串否则断点续传时前后两次请求对不上号。我用的是文件的MD5值作为标识。因为两个不同文件的MD5碰撞概率极低同一文件无论多少次上传MD5都是相同的。这个特征天然支持“断点续传”和“秒传”两个能力。前段部分读取文件计算MD5的方式在浏览器里可以直接用crypto库一次性读完整个文件可能对内存有压力但计算MD5本身是可以流式处理的我用了一个相对简单的方法利用浏览器的crypto.subtle.digest配合FileReader分块读取每读一块更新一次哈希上下文。不过这里需要提醒一下大文件的MD5计算确实会消耗一些时间尤其在低端移动设备上可能几秒钟。但这段时间能让用户看到“校验中”的状态总比让用户误以为卡死了要好。如果追求更快的启动速度也可以用“文件大小 文件名 最后修改时间”拼一个近似标识断点续传能力会稍微弱一些但不影响正常使用。2.4 目录与文件命名规范后端接收分片时强烈建议按文件的MD5值建一个独立目录。目录结构大概是这样的upload_temp/ ├── {md5}/ │ ├── 0.part │ ├── 1.part │ └── 2.part └── {md5}.tmp所有分片都存到{md5}/目录下文件名就是分片序号加.part后缀。这样做的好处非常直接合并时只需要按序号顺序读取这个目录下的文件即可不需要维护任何数据库记录。哪个文件传了哪个没传扫一眼目录文件名就知道了。合并完成之后建议把所有.part文件全部删除只保留最终文件。最终文件可以放到另一个目录比如upload_files/。临时目录需要定期清理否则异常中断的残留碎片会越积越多。我通常用crontab写一个脚本删除所有修改时间超过24小时的临时目录。3. 前端实现切片、断点续传与进度条3.1 前端切片与上传逻辑前端的核心代码并不复杂但有一些细节特别容易忽略。先看切片部分function createFileChunks(file, chunkSize 10 * 1024 * 1024) { const chunks []; let start 0; while (start file.size) { const end Math.min(start chunkSize, file.size); chunks.push(file.slice(start, end)); start end; } return chunks; }这一段逻辑很直观。file.slice()返回一个新的Blob对象它不复制文件数据而是指向原文件的某一段。所以即使文件很大切片这个过程本身不会占用额外内存这点可以放心。然后就是上传逻辑。我用axios发请求FormData里带上分片序号、总片数和文件的MD5值async function uploadChunk(md5, chunk, index, total) { const formData new FormData(); formData.append(file, chunk); formData.append(md5, md5); formData.append(index, index); formData.append(total, total); const response await axios.post(/api/upload.php, formData, { onUploadProgress: (e) { // 更新当前分片的进度 } }); return response.data; }这里有一个容易踩坑的地方file字段的键名必须跟后端$_FILES[file]一致。很多人在前端用各种乱七八糟的字段名后端取不到就报错排查起来还以为是代码问题其实只是拼写不一致。上传顺序上我采用简单的串行上传即一片传完再传下一片。并发虽然快但会让进度条和暂停逻辑变得复杂很多。如果确实需要并发也要控制在2-3个并发而且每个并发上传的分片序号一定要记录清楚否则合并时一旦缺失或重复就会损坏文件。3.2 断点续传怎么知道哪些分片传过了断点续传的实现核心就是一个“查进度”接口。前端在开始上传之前先调用后端接口询问“这个文件的哪些分片已经传过了”。async function getUploadedChunks(md5) { const response await axios.get(/api/status.php, { params: { md5: md5 } }); return response.data.uploaded || []; // 返回已上传的分片序号数组 }拿到这个数组之后前端只需要跳过这些序号的分片直接上传缺失的部分即可。这种设计最优雅的地方在于它把“续传”的语义从“断点处恢复”变成了“补齐缺口”网络中断后之前已经传好的分片永远不用重传。所以真正需要的是一个稳定可靠的“查询已上传分片”的接口。它返回的数组质量直接决定了整个断点续传的体验。再配合前端对返回值过滤一下就能做到精准续传。顺带一提如果后端返回“该文件已存在”前端就可以立刻跳转到“上传完成”状态这就是秒传。3.3 进度条实现不要再用fetch用XHR进度条是用户唯一能感知到上传过程正在推进的反馈。它的实现并不复杂但如果你用的是fetch就会发现一个尴尬的问题标准fetchAPI本身是不支持上传进度事件的。你必须用XMLHttpRequest的upload.onprogress回调或者是基于XHR封装的库比如axios的onUploadProgress配置。axios的写法比较简洁await axios.post(/api/upload.php, formData, { onUploadProgress: (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); // 更新UI上的进度条 } } });注意这里的e.loaded是当前这一个分片的上传进度不是整个文件的进度。整个文件的进度需要你自己计算const overallProgress ((chunkIndex e.loaded / e.total) / totalChunks) * 100;这样才能做到大文件上传的“总进度条”。有些新手直接把onUploadProgress里的百分比当作全局进度传完一个分片进度条就跳到100%然后再回退到很低的值重新开始涨体验非常糟糕。我反复强调过一定要把当前分片内部的进度“折算”进整体进度里这才是一个真正准确的进度条。3.4 暂停、取消与重试上传过程中用户可能想暂停网络断了需要自动重试。这部分我用axios的AbortController来实现暂停。具体做法是给每个分片上传请求挂一个可取消的信号用户点击暂停时调用controller.abort()中断当前正在传输的分片请求。const controller new AbortController(); axios.post(/api/upload.php, formData, { signal: controller.signal, onUploadProgress: ... });暂停之后已经传成功的分片被后端记录在案重新点击“继续”时只需要再次调用getUploadedChunks然后继续上传缺失的分片即可。这个流程和你“刷新页面之后重新上传”是完全一样的这也是断点续传最有价值的地方——它天然兼容刷新、退出、甚至关机重来的场景。自动重试我一般加上指数退避策略失败后等待1秒重试再次失败等2秒、4秒、8秒……最多重试5次就放弃并提示用户。这样可以避免网络瞬间波动时频繁重试导致后端压力增加也能趁重试间隙让网络自行恢复。4. 后端PHP实现接收分片与合并4.1 上传接口接收分片并落盘后端第一个接口负责接收分片。核心逻辑是获取文件MD5值、分片序号然后把上传的临时文件移动到分片目录中。?php // upload.php $md5 $_POST[md5] ?? ; $index $_POST[index] ?? ; $total $_POST[total] ?? ; if (empty($md5) || !isset($_FILES[file])) { http_response_code(400); echo json_encode([code 400, msg 参数错误]); exit; } $uploadDir __DIR__ . /upload_temp/ . $md5 . /; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0777, true); } $targetPath $uploadDir . $index . .part; if (!move_uploaded_file($_FILES[file][tmp_name], $targetPath)) { http_response_code(500); echo json_encode([code 500, msg 分片保存失败]); exit; } echo json_encode([code 0, msg ok]);这段代码已经能正常工作但有几个细节需要注意。第一个是目录权限问题。mkdir创建的目录权限是0777在部分安全策略严格的服务器上可能被限为0755导致移动文件失败。建议创建后检查一下权限并且确保运行PHP的用户一般是www-data或nginx对该目录有写权限。第二个是校验分片大小。因为上传分片固定是10MB接收时可以检查一下$_FILES[file][size]是不是在合理范围内。如果异常直接拒绝。这个能做简单的防滥用。第三个是并发问题。如果用户同时上传多个分片PHP的move_uploaded_file已经是原子操作不会互相干扰。但如果有多个请求同时上传同一个序号的分片后写入的会覆盖先写入的可能导致文件损坏。解决办法是上传分片时先写临时文件再renamerename是原子的或者增加一个简单的锁$lockFile $uploadDir . .lock; $lockHandle fopen($lockFile, c); flock($lockHandle, LOCK_EX); move_uploaded_file($_FILES[file][tmp_name], $targetPath); flock($lockHandle, LOCK_UN);4.2 查询接口返回已上传分片列表查询接口的逻辑非常朴素扫描目录把所有的.part文件的序号提取出来返回给前端。?php // status.php $md5 $_GET[md5] ?? ; if (empty($md5)) { http_response_code(400); echo json_encode([code 400, msg 参数错误]); exit; } $uploadDir __DIR__ . /upload_temp/ . $md5 . /; $uploaded []; if (is_dir($uploadDir)) { $files scandir($uploadDir); foreach ($files as $file) { if (preg_match(/^(\d)\.part$/, $file, $matches)) { $uploaded[] (int)$matches[1]; } } } sort($uploaded); // 检查最终文件是否已经存在 $finalFile __DIR__ . /upload_files/ . $md5 . .dat; if (file_exists($finalFile)) { echo json_encode([code 0, uploaded $uploaded, exists true]); exit; } echo json_encode([code 0, uploaded $uploaded, exists false]);这段代码简单高效不依赖数据库查询速度足够快即使分片上百个也只是扫个目录而已。有一点要提醒你uploaded数组排序一定要做前端拿到的分片序号最好是升序的。虽然前端不需要依赖排序来跳片但排序会让调试的时候肉眼更直观。关于“最终文件是否已存在”的判断我加了一个exists字段。可以利用MD5的一致性实现秒传。如果文件已经存在前端直接提示上传成功不用再传任何分片。这在很多实际场景下能省掉大量时间和流量。4.3 合并接口把分片拼成最终文件合并接口是整个后端逻辑里最关键的一步。需要按照分片序号从小到大把所有.part文件依次追加到一个最终文件里。?php // merge.php $md5 $_POST[md5] ?? ; if (empty($md5)) { http_response_code(400); echo json_encode([code 400, msg 参数错误]); exit; } $uploadDir __DIR__ . /upload_temp/ . $md5 . /; $finalDir __DIR__ . /upload_files/; if (!is_dir($finalDir)) { mkdir($finalDir, 0777, true); } $finalPath $finalDir . $md5 . .dat; if (file_exists($finalPath)) { // 文件已存在直接清理临时分片 array_map(unlink, glob($uploadDir . *.part)); rmdir($uploadDir); echo json_encode([code 0, msg 文件已存在]); exit; } $partFiles glob($uploadDir . *.part); $count count($partFiles); if ($count 0) { http_response_code(400); echo json_encode([code 400, msg 无分片文件]); exit; } usort($partFiles, function ($a, $b) { $indexA intval(pathinfo($a, PATHINFO_FILENAME)); $indexB intval(pathinfo($b, PATHINFO_FILENAME)); return $indexA - $indexB; }); $out fopen($finalPath, wb); if (!$out) { http_response_code(500); echo json_encode([code 500, msg 无法创建最终文件]); exit; } foreach ($partFiles as $partFile) { $in fopen($partFile, rb); if (!$in) { fclose($out); http_response_code(500); echo json_encode([code 500, msg 读取分片失败]); exit; } stream_copy_to_stream($in, $out); fclose($in); } fclose($out); // 合并完成清理临时分片 array_map(unlink, $partFiles); rmdir($uploadDir); echo json_encode([code 0, msg 合并成功, path $finalPath]);这里我使用了stream_copy_to_stream来复制数据而不是file_get_contents加file_put_contents。为什么要特意用这个函数因为stream_copy_to_stream是流式操作数据不会一次性载入PHP内存而是以块为单位搬运。如果分片多、文件大用file_get_contents读一个几百MB的分片到内存里妥妥地撑爆memory_limit而流式操作内存占用始终在几MB以内。合并完成后立刻清理临时分片目录。如果不清理下次上传同一个文件比如用户重新上传时查询接口会发现分片目录还在返回一堆过期的分片序号前端就会把这些分片当成已上传的跳过最终合并出来一个残缺文件。清理分片是整个流程闭环里必不可少的一步很多人做完合并后就没管临时目录最后被各种诡异问题折磨。4.4 安全与性能文件上传接口是整个Web应用里最容易出安全问题的入口之一特别是没有做任何校验的情况下。我这里至少做了三道保险。第一道是文件扩展名与内容的过滤。因为合并后的文件名用的是MD5加.dat前缀本身没有可执行危险。但如果你的业务需要保留原始文件名一定不能用用户传入的文件名直接落到服务器上必须把扩展名做白名单校验比如只允许jpg、png、mp4、zip等并且禁止上传.php、.phtml这类可能被Web服务器解析的文件。第二道是校验上传分片的大小。前端的切片大小是10MB那么后端接收到的分片大小也应该在10MB左右。如果超出一个较大范围直接拒绝。这样能防止有人手动构造上传请求把超大文件拆成多个分片来绕过上传大小限制。第三道是限制上传频率与总量。可以用一个简单的文件记录用户最近一次上传的时间戳如果两次上传间隔小于一定阈值则拒绝。另外在接收分片时检查临时目录的总大小如果超过预设的上限比如2GB直接拒绝写入。性能方面合并操作是I/O密集型的不建议在Web请求的主线程里处理太大文件。如果文件极大可以把合并任务丢到后台队列让前端轮询合并状态。但对于大多数业务场景来说1GB以内的文件直接在请求里合并也能在几秒内完成完全够用。5. 服务器环境配置与踩坑实录5.1 Nginx关键配置很多人在本地测试分片上传一切正常部署到线上之后却发现分片超过一定大小就上传失败问题常常出在Nginx配置而不是PHP代码上。Nginx有两个配置项直接决定了上传请求能不能进到PHP手里。第一个是client_max_body_size它控制的是Nginx接收请求体的最大体积。如果某个分片是10MB这个值最好设成20MB以上留出一定的余量给FormData的额外字段和文件头信息。第二个是proxy_request_buffering这个参数在Nginx作为反向代理时尤其关键。默认情况下Nginx会把整个请求体都缓冲到临时文件后才会转发给后端这意味着PHP看到请求时文件已经完整到达Nginx了不仅浪费磁盘I/O而且当请求体超过Nginx缓冲区时会直接报413。关闭缓冲后Nginx会把请求体以流的方式转发给PHP整个上传过程更平滑。配置里加一行即可location /api/ { client_max_body_size 20m; proxy_request_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_read_timeout和proxy_send_timeout也值得注意。当后端跨服务时这两个超时值太短可能会导致PHP处理合并请求期间连接被Nginx掐断。5.2 PHP配置PHP这边也有需要同步调整的参数。这里给出我一个比较常用的配置组合upload_max_filesize 20M post_max_size 20M max_execution_time 300 memory_limit 128M你可能注意到上传分片是10MB这里为什么把upload_max_filesize设为20M因为FormData里除了文件还有MD5、序号、总片数等字段请求体总大小稍大于文件大小。设大一点避免边界情况误伤请求。max_execution_time设成300是基于我的重试策略和合并逻辑做的一个保守估计。实际上合并1GB文件在服务器本地很快但为了保险起见超时时间还是放宽到5分钟比较稳。最关键的是memory_limit不用调大128M已经足够。这正是分片方案的价值所在——你的服务哪怕只有256MB内存也可以稳定支撑GB级文件上传。5.3 常见问题与排查实录这部分我挑了几个实际项目中最常见、排查起来最费劲的问题直接做成速查表方便你参考。问题现象可能原因解决方案上传分片时收到413错误Nginxclient_max_body_size过小调到分片大小2倍以上分片上传到一半连接中断proxy_read_timeout过短调大超时时间至300s合并后文件下载无法打开前端切片遗漏了某些分片合并时缺少数据检查已传分片列表补传缺失分片断点续传重传后文件大小不对临时目录里有上次的残留分片合并完成后务必清理全部.part文件进度条一直停留在0%请求方式用了fetch不支持上传进度改用XHR或axios的onUploadProgress上传大文件时PHP报内存耗尽合并时用了file_get_contents用stream_copy_to_stream流式合并上传成功但文件大小比原文件小前端切片时文件句柄未释放切片数据不完整用File.slice后单独读取比较大小偶尔出现某个分片写入失败目录权限不足或磁盘已满检查磁盘空间与目录写权限你在排查时一定要遵循一个原则先从最小闭环测起。先用curl手动上传一个分片到后端接口确认接收正常再测查询接口能否返回该分片信息最后测合并接口。三个环节都正常再回到前端逐步排查这样定位问题会快很多。我在实际项目里遇到最隐蔽的问题是Nginx的临时文件目录写满导致上传中断。默认情况下Nginx的proxy_temp_path在/var/lib/nginx/tmp磁盘分区较小的话很容易被大文件上传填满。需要检查df -h确认这个分区有足够空间必要时把临时目录挪到空间充足的分区。5.4 常见问题速查与避坑技巧再补充几个我总结出来的实用避坑心得。第一前端切片的总数一定要提前算好并且固定不变。因为合并时后端按序号读文件假如用户在上传过程中文件被重新选择前端发出去的分片序号和实际文件内容对不上合并出来的文件就是损坏的。所以切片前必须把文件锁定禁止用户同时更换文件。第二不管前端怎么算MD5后端合并完都要再校验一次最终文件大小是否等于前端传来的totalSize。如果不一致直接删掉合并结果返回错误码要求重新上传。这个校验便宜且高效能挡住绝大多数合并丢片的问题。第三临时目录的清理不要只依赖合并成功时的清理逻辑。如果用户传了一半就放弃临时目录里会残留大量.part文件。一定要写一个定期清理的定时任务把超过一定时间未活动的临时目录删除。我用的是find upload_temp -type f -mmin 1440 -delete一小时清理一次。第四上传接口一定要做跨域处理。如果前端页面和后端接口不在同一个域名需要配置CORS允许前端所在域名的跨域请求。这个看起来简单但实际项目里很多人折腾半天发现所有问题都被CORS拦在门外。上面这套方案我从第一次实现到现在已经经过了多个项目的验证稳定性和性能都经受住了考验。最后再分享一个小技巧如果要对这套方案做进一步的扩展可以加一层服务端切片校验。每次前端上传分片时同时带上该分片的MD5值后端接收后重新计算MD5进行比对不一致则拒绝。这对于网络环境恶劣、数据传输容易出错的场景是最后一道保险。就我自己的体验来说分片上传方案最值钱的地方不是某一个技术点而是它把“大文件上传”这个看似无解的问题成功降维成了一堆“小文件上传”的简单问题。理解了这一点后续不管遇到什么上传问题你都会有一个清晰的应对思路。
阅读完成 · 觉得有帮助?