1. 大文件上传为什么会翻车先从几个真实失败现场说起做Web开发的朋友应该都有过这种经历明明就是一个简单的表单上传功能在本地测试一切正常一部署到线上用户上传一个一两百MB的视频或者压缩包进度条转了半天最后等来的不是上传成功而是网络错误或者是白屏运气好点还能看到PHP抛出的内存耗尽警告。我最初接手这类问题时也走了不少弯路。一个在某高校信息中心的同学拉着我帮忙看一个课件上传模块老师传一个500MB的录屏传了十几分钟最后直接报错文件在服务器上只留下一个0字节的残缺文件。我们当时第一反应是改PHP配置把upload_max_filesize调大、post_max_size调大甚至把max_execution_time改成0结果还是时灵时不灵有的文件能上去有的文件一到某个固定的位置就断。后来才意识到问题不是配置不够大这么简单大文件上传的技术难点本质上是一个传输链路的可靠性问题而不是一个单纯的容量问题。这篇文章就是想把我在这类场景里折腾过的东西完整地梳理一遍核心话题就是PHP如何实现网页大文件上传。如果你正在做一个涉及视频、压缩包、Photoshop源文件这类动辄几百MB甚至几个GB上传功能的后端开发者或者你只是被用户传不上大文件这个Bug折磨得焦头烂额的前端同学这篇文章应该能直接帮到你。我会从为什么普通上传方案会翻车讲起然后给出分片上传的完整思路前端后端都附上可以直接跑的代码最后把断点续传、秒传这些进阶用法和我在实战中踩过的坑一并交代清楚。这样你拿到的不只是一个能跑的Demo而是知道每一步为什么要这么做出问题了该怎么排查。2. 三个罪魁祸首服务器配置、PHP脚本限制、网络波动2.1 服务器配置不只是改一个参数的事普通网页上传走的是HTTP POST请求文件内容放在请求体里。那服务器怎么判断这个请求能有多大第一步是Web服务器自己有一道闸门。如果你用的是Nginxclient_max_body_size默认是1MB。你没看错默认就1MB。很多文件传不上去第一步就死在Nginx这里。用户一传超过1MB的文件Nginx直接返回413。这个错误其实很诚实它根本不给你PHP处理的机会。如果你用的是Apache对应的是LimitRequestBody如果没显式配置通常默认值比较宽松但也要查一下。所以我个人的习惯是改PHP配置之前先确认Web服务器那一层没有卡脖子。Nginx里常见的调整是这样server { listen 80; server_name example.com; client_max_body_size 2048M; # 其他 location 配置... }这里有个容易忽略的细节client_max_body_size放在server块或者location块里都行但如果你有反向代理层比如前面还挂了CDN或者其他代理服务那每一层的这个值都要检查。我有一次排查了半天Web服务器和PHP都放开了结果文件一上传还是失败最后才发现是网关那层把请求体大小限制了。2.2 PHP配置upload_max_filesize 与 post_max_size 的关系PHP这边的配置主要牵扯三个参数。; php.ini 或 php-fpm 配置 upload_max_filesize 2048M post_max_size 2048M max_execution_time 0 max_input_time -1 memory_limit 512M这三个参数各管一段upload_max_filesize限制单个上传文件的大小。字面意思很直接但它只对multipart/form-data请求里的单个文件生效。post_max_size限制整个POST请求体的大小。注意一次上传多个文件时总大小不能超过这个值而且这个值一般建议设置成比upload_max_filesize大一些因为请求体里还要塞一些额外的表单字段。max_execution_time脚本最大执行时间。长时间上传和合并文件时PHP脚本很容易超时所以大文件场景下直接设成0或者一个非常大的值。post_max_size必须大于upload_max_filesize这本是很基础的知识但我在实际项目中看到过好几次有人把两者设成相同大小导致传一个刚好接近上限的文件时PHP把整个请求连同额外字段一起拒绝报一个Post Too Large之类的错误用户完全摸不着头脑。改完配置之后一定要重启PHP-FPM或者Apache然后去phpinfo页面确认改动生效这些工作看似琐碎但能省掉后面很多不必要的猜测。2.3 内存的坑不是上传的文件才占内存还有一个很多人忽略的点PHP接收上传文件时文件首先会写入服务器上的临时目录$_FILES里的tmp_name指向的就是这个临时文件。所以理论上PHP读取文件内容时才占用内存上传本身不直接吃掉大量内存。但你如果写了类似file_get_contents($_FILES[file][tmp_name])这样的代码想一次性把整个文件内容拿来做校验或处理那内存就直接爆掉了几百MB的文件会让一个只有128M内存限制的PHP进程瞬间牺牲。我见过一些所谓大文件上传实现其实就是把上传逻辑搞成if ($_FILES[file][size] 100 * 1024 * 1024) { // 逻辑处理... }这种只是把门禁扩大并没有解决传输可靠性的问题。文件越大中途断网、超时、服务器重启的概率就越高而普通POST上传是一次性把所有数据传完任何一个字节没传完整个文件就是废的需要重新传一遍。所以调大配置永远是第一步但绝对不应该成为唯一一步。2.4 网络波动的启示为什么大文件容易断在半路假设用户要上传一个1GB的文件他的上行带宽是10Mbps理想状态下也需要十几分钟。在这十几分钟里网络稍有波动、路由器重启、Wi-Fi信号闪断TCP连接就断了。普通方案下断了就断了已传的数据全部作废用户得从头再来。这个问题在小文件场景下完全感知不到因为小文件可以在极短时间内完成传输网络波动还来不及搅局。可一旦文件变大传输时间拉长网络断开的概率就不可忽视了。这也引出了大文件上传的核心出路**不要一次性传一个大的把一个大的拆成很多个小的逐个传即使中间断掉也只重传失败的那一小块。**这就是分片上传Chunked Upload的基本思想。3. 分片上传把一个大文件拆成小块再拼回去3.1 为什么分片上传是主流方案分片上传的大致流程可以这样理解你面前有一块巨大的蛋糕让你一口吞下去很难但如果把蛋糕切成20块每一块都小到可以轻松咽下那就没问题了。服务器端拿到这20块蛋糕后按顺序拼回原来的样子整个过程就完成了。这个方案解决了很多实际问题传输失败的成本可控。20块里只有第13块传失败了重传第13块就行不用把另外19块也重来一遍。可以做到真正的断点续传。前端知道哪些块已经传上去了只要把剩下的块传完就行不需要用户重新选择文件。支持并发上传。小文件块之间互不依赖可以同时多个块一起传大幅提升大文件的上传效率。可做秒传。在上传之前先把文件的唯一标识发给服务器比对如果服务器上已经有这个文件直接返回上传完成用户瞬间看到100%进度条。相比调整各种配置上限分片上传才是从根本上解决问题的手段。配置上限解决了能不能装下的问题分片上传解决的是能不能稳定传完的问题。3.2 分片大小怎么选分片大小是第一个需要拍板的参数。太大会失去分片的意义太小又会导致请求数过多、HTTP开销太大。我常用的分片大小标准大致是这样场景建议分片大小说明普通业务文件几十MB~几百MB2MB~5MB在可靠性和请求数量之间比较均衡超大文件1GB以上5MB~10MB减少分片总数降低合并时的I/O压力弱网环境1MB~2MB分片更小单块传完耗时短中断后重传成本低内网环境10MB~20MB内网带宽充足分片大可减少请求次数分片太小还有一个潜在问题HTTP请求本身有开销每一个分片都要经过建连→上传→响应的过程如果分片只有几百KB而你的服务器处理请求又要几十毫秒那总时间反而会被拖慢。前端在切片时还要考虑一个问题文件名在不同浏览器下对中文、特殊字符的处理不一致所以强烈建议后端不要直接用前端传来的文件名做存储文件名而是自己生成一个安全的标识符。3.3 前端怎么计算文件的唯一标识在分片上传里识别这是哪个文件不能靠文件名两个不同用户可能上传同名文件同一个文件可能被改了名字再传。所以通常给文件算一个哈希值最常用的是MD5或者SHA-1。浏览器端计算整个大文件的MD5不能把整个文件读进内存否则浏览器会卡死。这里我推荐用SparkMD5这个库它支持流式计算可以分块读取文件内容逐块累加哈希值内存占用很小。// 计算文件MD5分块方式 function calculateFileHash(file) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const fileSize file.size; const chunkSize 2 * 1024 * 1024; // 每次读取2MB let currentPos 0; const fileReader new FileReader(); fileReader.onerror function() { reject(new Error(文件读取失败)); }; fileReader.onload function(e) { spark.append(e.target.result); currentPos chunkSize; if (currentPos fileSize) { loadNext(); } else { resolve(spark.end()); } }; function loadNext() { const slice file.slice(currentPos, currentPos chunkSize); fileReader.readAsArrayBuffer(slice); } loadNext(); }); }这里有个细节要注意文件越大计算MD5的时间越长。一个1GB的文件在普通电脑上计算MD5可能需要几十秒。这个等待时间对用户体验是有影响的所以很多产品会把计算哈希和上传并行起来先切分片边传边算Hash最后服务器校验总文件的完整性时再统一处理。如果你的产品对秒传功能不是强需求建议直接跳过前端Hash计算让后端在合并完成后统一算减少用户等待时间。4. 前端切分与上传原生JavaScript的实现4.1 核心APIFile.slice()前端分片的核心API是File.prototype.slice()这是HTML5的标准方法主流浏览器都支持。用法很简单const file inputElement.files[0]; const chunkSize 2 * 1024 * 1024; // 2MB const totalChunks Math.ceil(file.size / chunkSize); const chunkList []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); chunkList.push({ blob, index: i }); }切片完成后每个分片单独发起一个AJAX请求。我见过两种做法做法一循环上传串行async function uploadChunksSerial(file, chunkSize, totalChunks, fileHash) { for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(file, blob, file.name); formData.append(file_hash, fileHash); formData.append(part_index, i); formData.append(total_parts, totalChunks); await fetch(/api/upload-chunk.php, { method: POST, body: formData }); } }做法二并发上传并行async function uploadChunksParallel(file, chunkSize, totalChunks, fileHash) { const tasks []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(file, blob, file.name); formData.append(file_hash, fileHash); formData.append(part_index, i); formData.append(total_parts, totalChunks); tasks.push(fetch(/api/upload-chunk.php, { method: POST, body: formData })); } await Promise.all(tasks); }串行上传的优点是逻辑简单、不容易出错服务器压力小。并发上传的优点是速度快但服务器同时要接收大量请求如果合并逻辑处理不好容易出问题。我个人的建议是第一次实现先用串行把流程跑通了再升级到可控的并发。并发数控制在3~5个就够了不要一上来就Promise.all上一百个并发很多小型服务器根本扛不住而且并发太多会导致分片到达的顺序完全乱掉合并时排序逻辑虽然能处理但不必要的复杂度能少则少。4.2 带进度条的上传上传进度对用户体验太重要了。试想用户传一个800MB的视频没有进度条他不知道还要等多久大概率以为页面卡死了直接关掉。进度显示至少应该包含两部分当前分片的传输进度用XMLHttpRequest的upload.onprogress事件实现function uploadChunk(formData, onProgress) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload-chunk.php); xhr.upload.onprogress function(e) { if (e.lengthComputable onProgress) { const percent Math.round((e.loaded / e.total) * 100); onProgress(percent); } }; xhr.onload function() { if (xhr.status 200) { resolve(xhr.responseText); } else { reject(new Error(上传失败HTTP xhr.status)); } }; xhr.onerror function() { reject(new Error(网络错误)); }; xhr.send(formData); }); }整体进度的计算则基于已成功上传的分片数 / 总分片数function calculateOverallProgress(completedChunks, totalChunks) { return Math.floor((completedChunks / totalChunks) * 100); }需要注意的是整体进度不要等到所有分片全部成功才算100%建议把完成的分片数作为主要依据这样就算单个分片内部进度条不精确也不影响用户对整体进度的判断。4.3 前端的分片状态管理分片多了以后前端的记忆负担就大了。哪些分片传成功了哪些失败了失败了要不要重试重试多少次这些状态建议维护在一个数组或Map里const chunkStates new Array(totalChunks).fill(pending); // 可能的状态pending / uploading / success / failed chunkStates[i] success; chunkStates[i] failed;失败的分片应该触发重试机制。我常用的策略是重试3次每次重试间隔1秒、2秒、4秒递增如果3次都失败就提示用户检查网络后手动点击继续上传。这里还牵扯一个是否支持断点续传的问题。普通的分片上传只要刷新页面或者关闭浏览器所有分片状态就丢失了下次打开页面还得从头传。要做到真正的断点续传必须让服务器记住哪些分片已经存在前端上传前先问服务器。这个在后面单独用一节讲。5. 后端PHP分片接收、存储与合并5.1 分片接收接口按文件哈希创建临时目录前端每个分片都带上了file_hash、part_index、total_parts等参数后端要做的就是把这些分片保存到服务器的临时目录等待所有分片到齐后再合并。我的习惯是给每个文件创建一个以其哈希值命名的临时目录分片按序号存放在里面?php // /api/upload-chunk.php $fileHash $_POST[file_hash] ?? ; $partIndex $_POST[part_index] ?? ; $totalParts $_POST[total_parts] ?? ; $uploadFile $_FILES[file] ?? null; if (!$fileHash || !is_numeric($partIndex) || !$uploadFile) { http_response_code(400); echo json_encode([error 缺少必要参数]); exit; } // 对哈希值做白名单校验防止路径穿越攻击 if (!preg_match(/^[a-f0-9]{32}$/i, $fileHash)) { http_response_code(400); echo json_encode([error 非法的文件标识]); exit; } $tmpDir sys_get_temp_dir() . /chunks/ . $fileHash; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0777, true); } $partFile $tmpDir . / . $partIndex; if (!move_uploaded_file($uploadFile[tmp_name], $partFile)) { http_response_code(500); echo json_encode([error 分片保存失败]); exit; } echo json_encode([ success true, file_hash $fileHash, part_index (int)$partIndex, total_parts (int)$totalParts ]);这个接口我做了几层防御第一file_hash必须符合MD5字符串的格式否则直接拒绝。这是为了防止有人故意传入包含../之类的路径特征值造成任意文件写入漏洞。第二用move_uploaded_file而不是直接rename前者只会处理通过POST上传的临时文件安全性更高。第三临时目录放在sys_get_temp_dir()下面避免和业务目录混在一起目录权限控制也更方便。5.2 分片合并接口按顺序把碎片拼回去所有分片上传完后前端调用合并接口。后端的工作是扫描临时目录下的所有分片按序号排序然后按顺序写入目标文件。?php // /api/merge.php $fileHash $_POST[file_hash] ?? ; $totalParts $_POST[total_parts] ?? ; if (!preg_match(/^[a-f0-9]{32}$/i, $fileHash) || !is_numeric($totalParts)) { http_response_code(400); echo json_encode([error 参数非法]); exit; } $tmpDir sys_get_temp_dir() . /chunks/ . $fileHash; if (!is_dir($tmpDir)) { http_response_code(404); echo json_encode([error 分片目录不存在]); exit; } // 建议加一个锁防止并发下多个请求同时合并同一个文件 $lockFile $tmpDir . /.merge.lock; $lockHandle fopen($lockFile, c); if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { http_response_code(409); echo json_encode([error 文件正在合并中]); exit; } // 校验分片是否齐全 for ($i 0; $i $totalParts; $i) { if (!file_exists($tmpDir . / . $i)) { flock($lockHandle, LOCK_UN); http_response_code(400); echo json_encode([error 缺少分片 . $i, missing_part $i]); exit; } } $ext pathinfo($_POST[file_ext] ?? , PATHINFO_EXTENSION); $ext preg_replace(/[^a-zA-Z0-9_\-.]/, , $ext); $dstDir /data/uploads/; $dstPath $dstDir . $fileHash . ($ext ? . . $ext : ); $fpOut fopen($dstPath, wb); for ($i 0; $i $totalParts; $i) { $partPath $tmpDir . / . $i; $fpIn fopen($partPath, rb); stream_copy_to_stream($fpIn, $fpOut); fclose($fpIn); } fclose($fpOut); flock($lockHandle, LOCK_UN); fclose($lockHandle); // 清理临时文件 array_map(unlink, glob($tmpDir . /[0-9]*)); rmdir($tmpDir); echo json_encode([success true, file_path $dstPath]);合并这段有两点比较关键第一用stream_copy_to_stream而不是file_get_contentsfile_put_contents。前者是流式传输内存开销恒定不会因为分片大小而暴涨。后者遇到几十MB的分片就容易内存告急。第二必须等所有分片都到齐再开始合并。如果前端因为网络问题少传了几个分片合并后文件是坏的而且整个过程不可逆。所以合并接口里我做了循环检查哪个分片缺失就直接返回缺失的序号前端可以根据这个信息补传。5.3 合并后的完整性校验MD5对比合并完成只是拼起来了拼出来的文件是不是和用户上传的原始文件完全一致还需要校验。最简单的校验方式是对合并后的文件重新计算MD5然后和前端上传前计算的MD5对比。如果一致说明合并过程没有出错不一致说明某个分片可能在传输过程中损坏了那整批分片只能作废重传。?php // /api/verify.php $fileHash $_POST[file_hash] ?? ; $dstPath /data/uploads/ . $fileHash . .ext; if (!file_exists($dstPath)) { http_response_code(404); echo json_encode([error 文件不存在]); exit; } $actualHash md5_file($dstPath); if ($actualHash $fileHash) { echo json_encode([success true, valid true]); } else { echo json_encode([success false, valid false, actual_hash $actualHash]); }这个校验很值得做。我遇到过合并后文件大小对但内容不完整的情况原因是有个分片被move_uploaded_file成功但写入的字节数和应该写入的字节数不一致——网络层面的这种静默错误虽然罕见但一旦发生用户拿到的是一个无法解压的压缩包比上传失败更让用户恼火。6. 断点续传与秒传让上传体验再上一个台阶6.1 断点续传前端先查询已上传的分片断点续传的完整状态管理是这样的用户选好文件后前端先把文件切片并计算Hash然后向后端发一个查询请求带上file_hash和total_parts后端返回已经存在于服务器上的分片序号列表。前端拿到这个列表只上传缺失的分片跳过已存在的分片。后端查询接口?php // /api/check-chunks.php $fileHash $_POST[file_hash] ?? ; if (!preg_match(/^[a-f0-9]{32}$/i, $fileHash)) { http_response_code(400); echo json_encode([error 参数非法]); exit; } $tmpDir sys_get_temp_dir() . /chunks/ . $fileHash; $uploadedParts []; if (is_dir($tmpDir)) { $files scandir($tmpDir); foreach ($files as $f) { // 分片文件都是数字命名 if (is_numeric($f)) { $uploadedParts[] (int)$f; } } } echo json_encode([ uploaded_parts $uploadedParts, total_parts (int)($_POST[total_parts] ?? 0) ]);这个接口的作用不只是刷新后恢复上传还能帮助处理上次过程被打断的情况。用户点击上传后中途浏览器崩溃重新打开页面选同一个文件系统辨认出这是同一个file_hash直接接着传这个体验流畅度比大文件上传失败后一切从头再来高了不知道多少。前端拿到uploaded_parts后跳过这些序号即可async function uploadWithResume(file, fileHash, chunkSize, uploadedParts) { const totalChunks Math.ceil(file.size / chunkSize); const uploadedSet new Set(uploadedParts); for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) { continue; // 已经传过了跳过 } const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(file, blob, file.name); formData.append(file_hash, fileHash); formData.append(part_index, i); formData.append(total_parts, totalChunks); await fetch(/api/upload-chunk.php, { method: POST, body: formData }); } // 全部传完后调用合并接口 await fetch(/api/merge.php, { method: POST, body: new URLSearchParams({ file_hash: fileHash, total_parts: totalChunks, file_ext: file.name.split(.).pop() }) }); }6.2 秒传服务器已有该文件直接返回成功秒传的思路更简单如果服务器上已经存在相同内容的文件那用户根本不需要再上传直接告诉前端上传完成即可。因为判断依据是文件内容的哈希值不管用户把这个文件改名叫什么只要内容一样MD5就一样。后端秒传判断逻辑可以在合并接口里加一段前置检查?php // /api/upload-chunk.php 或独立秒传检查接口 $fileHash $_POST[file_hash] ?? ; $dstDir /data/uploads/; $ext preg_replace(/[^a-zA-Z0-9_\-.]/, , $_POST[file_ext] ?? ); $dstPath $dstDir . $fileHash . ($ext ? . . $ext : ); if (file_exists($dstPath) filesize($dstPath) 0) { echo json_encode([ success true, exists true, skip_upload true, file_path $dstPath ]); exit; }秒传要求前端在切片和上传之前先发一次探路请求。这个请求返回exists: true就说明文件之前已经传过了前端直接跳过所有上传动作显示100%进度。6.3 秒传的进阶版本按文件大小哈希联合判断MD5并不是绝对安全的存在极低概率的哈希碰撞。对于绝大多数业务场景MD5的碰撞概率可以忽略不计但如果你的平台涉及敏感内容建议上传后保存文件的SHA-256值并联合文件字节大小一起作为唯一标识。联合判断的意义在于两个不同文件MD5碰撞的概率几乎为零但两个不同文件MD5相同且大小相同的概率就更加趋近于不可能了。而且大小本身不影响性能消耗拿来一起参与判断是零成本的。7. 实战中的坑与经验配置、并发、权限一个都不能少7.1 Nginx、PHP-FPM、网关的配置必须串成一条线分片上传虽然绕开了单次请求体过大的限制但合并后的文件可能有几个GB有些环节仍然需要重新审视配置。举个例子如果你只调大了Nginx的client_max_body_size但PHP-FPM的upload_max_filesize还没动分片上传本身是没问题的因为每个分片只有几MB但如果你在合并之后又写了一个下载原文件的接口下载时Nginx的sendfile倒是无所谓但PHP里如果用了readfile()且没有做流式处理大文件下载照样可能超时崩溃。这里的核心原则是凡是经过PHP处理大文件的地方都要把执行时间、内存、传输超时都设置好。我整理了一个配置清单供参考配置位置配置项建议值说明Nginxclient_max_body_size2048M单次请求体大小分片时不需要太大但要给合并接口留余量Nginxclient_body_timeout300s读取请求体的超时时间PHPupload_max_filesize2048M允许单个文件最大值虽然分片了但防止其他入口绕过PHPpost_max_size2048MPOST请求体总大小PHPmax_execution_time0脚本执行时间合并大文件时极容易超时PHPmemory_limit512M视服务器内存而定使用流式处理时不需要很大7.2 并发上传下的锁与脏数据前端用并发上传分片时会有多个请求同时命中PHP这在接收分片阶段没有问题——各写各的文件互不干扰。但在合并阶段如果两个请求同时触发合并就可能出现两边都读到分片目录两边都开始合并写到同一个目标文件的竞态问题。前面合并接口里我用了flock加文件锁这是最直接有效的手段。加了锁之后只有一个请求能真正执行合并另一个会收到HTTP 409并退出。分片接收阶段也建议加一个归属校验某个分片已经成功接收后如果前端又发来了相同part_index的分片比如重试机制导致重复发送后端是直接覆盖还是拒绝我的建议是直接覆盖因为分片内容按理说是一致的覆盖不会造成数据损坏而且实现简单。7.3 临时目录的清理策略分片上传如果中途放弃临时目录里残留的分片就变成了垃圾文件。时间一久/tmp/chunks/下面会堆积大量文件占用磁盘空间。清理策略我做得很简单定期任务扫描临时目录删除超过24小时没有更新的目录。#!/bin/bash # 每天凌晨3点执行 find /tmp/chunks/ -type f -mtime 1 -delete find /tmp/chunks/ -type d -empty -delete这个脚本用的是最后修改时间超过1天作为清理条件。如果某个文件上传到一半卡住了离最后上传分片超过一天基本可以确认用户已经放弃这些分片留着纯属浪费空间。7.4 文件类型校验不要相信扩展名大文件上传功能极其容易被恶意利用因为用户可能上传任何扩展名的文件。我见过不少只校验$_FILES[file][type]的实现但这是完全不可靠的因为type字段是浏览器根据扩展名自动填的可以任意伪造。更可靠的做法是第一限制允许的扩展名列表比如只允许mp4, zip, pdf, jpg, png等。第二上传后检查文件的MIME类型用finfo_file函数读取文件真实类型而不是信任HTTP头里的Content-Type。?php $finfo new finfo(FILEINFO_MIME_TYPE); $mimeType $finfo-file($dstPath); // 然后与允许的MIME类型列表进行比对第三如果确认文件类型不合规立即删除文件并返回错误。7.5 大文件合并时的磁盘占用提醒合并过程中临时分片占用的空间加上目标文件占用的空间同一份数据占了两倍的磁盘空间。如果用户上传一个2GB的文件服务器就必须有4GB以上的可用空间否则合并时很可能磁盘写满。在做上传接口之前先检查一下磁盘剩余空间?php $freeSpace disk_free_space(/data/uploads/); $fileSize $_POST[file_size] ?? 0; if ($freeSpace $fileSize * 2) { http_response_code(507); // Insufficient Storage echo json_encode([error 服务器存储空间不足请联系管理员]); exit; }这个检查虽然粗暴但能避免磁盘写满之后整个服务器瘫痪的严重后果。8. 最后再分享几个我在实际项目中摸索出来的小技巧分片上传这个方案整个体系跑通之后会非常稳定但细节处仍然有一些经验值得分享。第一个是关于进度条的真实感。如果你只是单纯显示已上传分片数/总分片数用户在最后几个分片时会发现进度条走得很慢因为分片的大小相同传输时间却受到网络波动影响。更平滑的做法是记录已上传的字节数总和let uploadedBytes 0; // 每成功一个分片 uploadedBytes chunkSize; const percent Math.min(100, Math.floor((uploadedBytes / file.size) * 100));这样至少进度条是单调递增的而不是忽快忽慢。第二个是引入队列机制。我曾经处理过一个场景用户一下子选中了十几个大文件每个都切成几百个分片如果所有分片同时并发服务端直接被压垮。解决方法是前端做一个上传队列一次只处理一个文件等前一个文件的所有分片都传完再开始下一个。这个策略对服务器友好用户体验也稳定不会出现总数变得忽快忽慢的怪现象。第三个是给合并接口增加返回值里的missing_part。当服务器检查到缺失分片时返回缺失的序号前端可以直接定位到那个分片重新上传。相比让用户从头来这个精确的补丁式修复要高效得多。第四个是关于PHP 8和旧版本兼容的问题。PHP 8.0之后字符串和数字比较的行为有变化分片序号我统一用严格类型比较或者(int)强转后比较。我之前在迁移PHP版本时踩过这种坑if ($partIndex 2)在旧版PHP返回true但新版PHP下行为完全不同的。建议所有从$_POST读出来的分片索引都先转成整数再比较。第五个小技巧是分片上传的幂等性。前端重试同一个分片时后端要保证这个操作不会造成副作用。接收分片接口天然幂等——同一个分片传两次第二次覆盖第一次的结果最终文件不变。这就让失败重试变得非常安全不需要额外的判断。大文件上传的核心不是某个高深的算法而是把一个又大又不可靠的传输任务分解成无数个又小又可靠的任务让失败变得廉价让重试变得简单。任何方案都有取舍分片上传增加了前后端的复杂度换来了可靠性、断点续传和秒传的可能性。对于用户动辄上传几百MB文件的系统这笔投入绝对值得。
阅读完成 · 觉得有帮助?