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服务器自己有一道闸门。
如果你用的是Nginx,client_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~几百MB) | 2MB~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 核心API:File.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_contents+file_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处理大文件的地方,都要把执行时间、内存、传输超时都设置好。
我整理了一个配置清单供参考:
| 配置位置 | 配置项 | 建议值 | 说明 |
|---|---|---|---|
| Nginx | client_max_body_size | 2048M | 单次请求体大小,分片时不需要太大,但要给合并接口留余量 |
| Nginx | client_body_timeout | 300s | 读取请求体的超时时间 |
| PHP | upload_max_filesize | 2048M | 允许单个文件最大值,虽然分片了,但防止其他入口绕过 |
| PHP | post_max_size | 2048M | POST请求体总大小 |
| PHP | max_execution_time | 0 | 脚本执行时间,合并大文件时极容易超时 |
| PHP | memory_limit | 512M | 视服务器内存而定,使用流式处理时不需要很大 |
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文件的系统,这笔投入绝对值得。