PHP大文件上传这个问题,最近好几个朋友都来问我。他们的需求基本一致:网页端要传1GB甚至更大的文件,直接上传统统超时、报错或者直接页面卡死。我自己之前在一套文件管理类项目里也踩过不少坑,最后是通过修改服务端参数、前端做切片、后端合并这套组合拳才真正解决问题。这篇文章把完整方案和踩坑记录整理出来,适合已经会基础PHP和JavaScript、但还没搞定大文件上传的开发者参考。
1. 大文件上传的设计思路
1.1 先搞清楚为什么直接传不行
很多人第一反应是直接调大PHP的post_max_size、upload_max_filesize,然后一个<input type="file">把整文件丢上去。小文件这么干没问题,但文件一大就有三个硬伤。
第一个是PHP默认的请求超时时间。max_execution_time默认30秒,一个500MB文件在普通带宽下根本传不完,脚本被掐断,用户白等半天。第二个是服务端接收请求时会把整个请求体缓冲到临时文件,同时把整个文件读入内存做解析,内存占用非常夸张,小内存服务器直接OOM。第三个是网络传输不可控,中途断一下,整个文件就得重新传,用户心态直接崩。
我最早做某跨平台系统的文件模块时,就是没想清楚这三点,结果上线第一周就收到几十条“上传失败”的反馈。后来我彻底改变了思路:前端把大文件拆成若干小块逐片上传,后端每收一块就落地保存,全部传完后在服务端合并。这么做的好处很直接——单次请求的数据量小,不会撞上PHP的各种请求体限制;某一片失败只需要重传这一片,不用推倒重来;内存占用被压到最低,因为服务端每次只处理一小块数据。
1.2 方案选型:切片上传、断点续传、秒传之间的关系
真正开工之前,先分清几个概念,网上讲得很混乱。
切片上传(Chunked Upload)是基础策略,不叫技术框架,就是把大文件按固定大小切成N个分片,逐个传。断点续传是切片上传衍生出来的能力——因为已经切成片了,某一片传失败后重新请求只需要传剩下没传完的,服务端记录哪些片已到位。秒传是另一回事,它靠服务端计算文件哈希后比对存储库,发现相同文件就直接跳过上传返回已有地址,跟切片上传没有必然关系。
我的建议是先实现切片上传,因为断点续传是它的自然结果。秒传放在后面做,它需要额外的文件入库策略。这篇文章先把切片上传讲透。
1.3 具体技术选型
前端用原生JavaScript就够了,不需要引入重框架。XMLHttpRequest的upload.onprogress事件能拿到上传进度,Blob.prototype.slice()能把文件切块,这两个API兼容性很好,比用FormData直接把整个文件提交给后端要可控得多。如果需要支持更多浏览器特性,可以封装一层,但核心逻辑根本用不到。
后端就是纯PHP处理分片接收和合并。接收分片用php://input流式读取,避免把整个请求体展开到内存;合并时用fopen、fwrite按追加模式写入。不依赖任何第三方库,一台普通服务器完全跑得动。
我用这个方案在一台2核4G的云主机上实测过1.2GB的媒体文件,分片大小设为2MB,单片上传耗时大约0.3到0.8秒,整个文件16分钟内传完,服务端内存占用峰值不到30MB,效果很理想。
2. 服务器环境配置与参数调整
2.1 PHP配置项怎么改
虽然切片上传绕开了post_max_size单次请求限制,但基础配置还是得设对,否则服务器在接收请求这一步就会拒掉。这里有个容易被忽略的点:post_max_size必须大于单片数据量,不是大于整个文件。单片2MB的话,post_max_size设成20MB就绰绰有余,upload_max_filesize同理。
max_execution_time是另一个容易掉坑的地方。单片上传在慢网环境下也可能花很久,比如用户带宽只有1Mbps,2MB一片需要16秒,如果max_execution_time还是30秒就容易被掐断。我建议在接收分片的入口脚本里单独放宽这个时间,而不是全局放宽。全局放宽有安全风险,一个恶意请求能长时间占用PHP进程;只放宽到上传脚本没这个问题。
我的php.ini关键配置如下:
; 单次请求体大小,需要大于单片大小 post_max_size = 32M ; 单文件上传大小 upload_max_filesize = 32M ; 脚本执行时间,入口单独设置 max_execution_time = 60 ; 最大输入时间 max_input_time = 120没有动memory_limit。很多老教程建议上传大文件要调高到512M甚至1G,这是把整文件塞进内存的做法,在切片方案里完全没必要。内存限制保持默认的128M,反而能逼着你的代码走流式处理这条路,更健康。
2.2 Nginx和Apache的处理差别
服务器软件也需要配合。我用的是Nginx,有一个核心配置项叫client_max_body_size,默认1M,不改的话请求体一大直接在Nginx层被返回413,PHP根本收不到请求。
server { # 放开客户端请求体大小限制 client_max_body_size 100M; # 上传超时时间适当延长,单位秒 client_body_timeout 60s; # 读请求体的超时时间 proxy_read_timeout 60s; location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }使用Apache的话,对应的配置在.htaccess或虚拟主机配置文件里。Apache处理大文件上传关键在LimitRequestBody,把它设置成0代表不限制,或者设置成足够大的字节数。
<IfModule mod_php.c> php_value post_max_size 32M php_value upload_max_filesize 32M php_value max_execution_time 60 </IfModule> # Apache请求体限制,0代表不限制 LimitRequestBody 0注意一个问题:如果使用Nginx和PHP-FPM两层架构,Nginx的client_max_body_size和PHP的post_max_size哪个都不能小,只要有一个限制住了请求就会失败。哪怕PHP配了32M,Nginx那里卡在1M,依然传不了。排查这类问题时,先看返回状态码:413是Nginx拒绝,500或空白页多半是PHP的问题。
2.3 通过phpinfo验证配置
配置改完之后,最稳妥的验证方式就是输出phpinfo()看看实际生效值。我发现很多人有个误区,以为改了php.ini立刻生效,其实PHP-FPM需要重启。在有OPcache缓存的情况下还要刷新缓存,否则页面加载的配置可能还是旧的。
sudo systemctl restart php8.1-fpm sudo systemctl reload nginx验证步骤很简单,写个临时脚本:
<?php phpinfo();浏览器打开后搜索这几个关键字:post_max_size、upload_max_filesize、max_execution_time,看到的值和php.ini里一致就算生效了。另外要确认一下file_uploads = On,有的安全优化模板会把这个关掉,关掉后上传功能直接废掉。
看完配置顺手把验证脚本删掉,留着它是个安全隐患。
3. 前端实现:文件切片与上传进度
3.1 文件选择与切片处理
前端的核心是File.slice(),它能从文件对象中截取一段二进制数据。分片大小的设定有讲究,我用的是2MB,这个值的选取基于一个基本平衡:分片越大,请求次数越少,服务端合并时越省事;但分片过大,单次请求的耗时就越长,失败重传的代价也越高。还受网络环境制约,2MB到5MB是大多数服务器环境下比较稳的区间。
const fileInput = document.getElementById('fileInput'); const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB function createChunks(file) { const chunks = []; let start = 0; while (start < file.size) { let end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); chunks.push({ file: chunk, start: start, end: end, index: chunks.length }); start = end; } return chunks; }说明一下:chunks.length在循环里动态变化,第一次push时index是0,之后依次递增,正好对应切片序号。end的计算用了Math.min是为了处理最后一个不完整分片,比如总大小5MB、每片2MB,最后一个分片只有1MB,用file.slice(4MB, 5MB)就能正确截取。
3.2 逐片上传与进度上报
切片准备好后,要逐片发送到服务端。发送采用XMLHttpRequest而不是fetch,原因很实际:XHR的upload.onprogress事件能拿到Socket层真实的字节级上传进度,fetch虽然也支持流式请求体但进度事件支持不完整,实测在部分浏览器里拿不到可靠的上传百分比。
function uploadChunk(chunk, fileMeta, onProgress) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', chunk.file, fileMeta.name); formData.append('chunkIndex', chunk.index); formData.append('totalChunks', fileMeta.totalChunks); formData.append('identifier', fileMeta.identifier); const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload.php', true); xhr.timeout = 10000; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { // 当前分片上传进度 const chunkProgress = e.loaded / e.total; // 换算为总文件进度 const totalProgress = (chunk.index + chunkProgress) / fileMeta.totalChunks; onProgress(totalProgress); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); } else { reject(new Error('上传失败,状态码: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('网络错误')); xhr.ontimeout = () => reject(new Error('请求超时')); xhr.send(formData); }); }这里fileMeta.identifier是一个唯一标识,用来区分不同文件的上传任务。我用文件大小、文件名和最后一次修改时间拼接后做MD5,这样同一个文件重复上传时就能识别出来。
核心的上传循环是这样的:
async function uploadFile(file) { const chunks = createChunks(file); const fileMeta = { name: file.name, size: file.size, totalChunks: chunks.length, identifier: generateIdentifier(file) }; let uploaded = 0; for (let i = 0; i < chunks.length; i++) { try { await uploadChunk(chunks[i], fileMeta, (progress) => { updateProgressUI(progress); }); uploaded++; // 标记当前分片已完成,记录到内存 recordUploadedChunk(fileMeta.identifier, i); } catch (err) { console.error('分片 ' + i + ' 上传失败', err); // 这里做重试 let retryCount = 3; while (retryCount > 0) { try { await uploadChunk(chunks[i], fileMeta, () => {}); uploaded++; recordUploadedChunk(fileMeta.identifier, i); break; } catch (retryErr) { retryCount--; } } if (retryCount <= 0) { alert('上传失败,请检查网络后重试'); return; } } } if (uploaded === chunks.length) { // 所有分片传完,通知服务端合并 await notifyMerge(fileMeta); } }这里唯一标识的作用在重试时体现出来了:因为服务端会记录已经收到的分片序号,重试上传时前端可以先用一个查询接口向服务端报告“我要传identifier对应的文件,哪些分片已经在了”,把已传过的分片跳过。这就是刚才说的断点续传效果。
3.3 断点续传的前端逻辑
页面刷新、浏览器崩溃后想继续传,需要把“哪些分片已上传”的信息持久化。最简单的方案是用localStorage,以identifier为key,存一个数组,记录每个分片的上传状态。本地存储要注意容量限制,几十个分片存个数组,几百字节就够,完全没有问题。
function recordUploadedChunk(identifier, chunkIndex) { let record = localStorage.getItem(identifier); if (record) { let chunks = JSON.parse(record); if (!chunks.includes(chunkIndex)) { chunks.push(chunkIndex); localStorage.setItem(identifier, JSON.stringify(chunks)); } } else { localStorage.setItem(identifier, JSON.stringify([chunkIndex])); } } function getUploadedChunks(identifier) { let record = localStorage.getItem(identifier); return record ? JSON.parse(record) : []; }用户重新选择同一个文件后,先根据identifier查本地记录,已上传的分片直接跳过,把进度条初始值设置成“已上传分片数/总分片数”,再继续传剩余分片。
这样做有一个需要注意的地方:localStorage是浏览器级的,换浏览器或者清缓存记录就丢了。如果要实现真正的跨端续传,需要后端记录分片上传状态,前端在开始上传前向后端发一个查询请求。完整的做法是加一个status.php接口,返回某个identifier已收到的分片序号,前端比对后跳过。这个我在后面后端部分会详细说。
4. 后端实现:分片接收与合并
4.1 接收分片与流式写入
后端接收分片时,我有几个坚持的原则:不把请求体展开到$_POST和$_FILES里去判断,用php://input流式获取;每个分片单独保存成一个临时文件;保存完后对分片内容做一次大小校验,防止坏数据被合并进最终文件。
上传接口upload.php的核心逻辑:
<?php // 必须在入口处设置执行时间,因为大文件分片上传整个任务耗时很长 set_time_limit(300); // 正确处理跨域 header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; } // 接收参数 $chunkIndex = isset($_POST['chunkIndex']) ? intval($_POST['chunkIndex']) : 0; $totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0; $identifier = isset($_POST['identifier']) ? $_POST['identifier'] : ''; $fileName = isset($_POST['fileName']) ? $_POST['fileName'] : ''; // 标识符合法性校验,防止路径穿越和脏数据 if (!preg_match('/^[a-zA-Z0-9_-]{8,64}$/', $identifier)) { http_response_code(400); exit('invalid identifier'); } // 文件名做一次清洗,防止上传文件名里的路径信息 $fileName = basename($fileName); $fileName = preg_replace('/[^\w\.\-\x{4e00}-\x{9fa5}]/u', '_', $fileName); // 分片内容从请求体读取 $input = fopen('php://input', 'rb'); $tempChunkPath = sys_get_temp_dir() . '/' . $identifier . '_part_' . $chunkIndex; $out = fopen($tempChunkPath, 'wb'); while (!feof($input)) { $buffer = fread($input, 8192); if ($buffer === false) { fclose($out); fclose($input); http_response_code(500); exit('read failed'); } fwrite($out, $buffer); } fclose($out); fclose($input); // 校验分片是否完整:如果接收到的分片大小与预期不一致,返回错误给前端重传 $actualSize = filesize($tempChunkPath); // 如果是最后一个分片,大小可以小于分片大小;不是最后一个分片时,大小必须等于分片大小 $standardSize = 2 * 1024 * 1024; $isLastChunk = ($chunkIndex === $totalChunks - 1); if (!$isLastChunk && $actualSize !== $standardSize) { unlink($tempChunkPath); http_response_code(400); exit('chunk size mismatch, actual: ' . $actualSize); } echo json_encode([ 'code' => 0, 'message' => 'chunk received', 'chunkIndex' => $chunkIndex ]);这段代码里有三个细节值得提一下。
使用php://input而不是$_FILES是为了实现更可控的落盘。$_FILES在收到multipart请求时PHP会自动把文件存到临时目录并在请求结束后回收,但在这个场景下我们对临时文件的命名、保存位置和校验时机都没有控制权。php://input让我们能以字节流形式从头读到尾,读到多少写多少,内存占用是固定的8KB缓冲,非常可控。
校验函数必须注意:用filesize()取的是已落盘文件大小,这段改分会不到标准大小就报错返回,前端会重试这个分片,直到完整收到为止。最后一个分片比较特殊,因为是文件末尾的剩余数据,天然小于标准分片大小,要单独豁免。
basename()和正则清洗对文件名的处理,主要防两类问题。一类是包含路径分隔符的文件名,例如用户从某些系统上传的文件名带../,直接拼接路径会造成目录穿越;另一类是含有特殊符号的文件名,可能在Linux文件系统或后续处理流程中引发问题。清洗后文件名变成纯字母数字下划线加中文字符,足够安全。
4.2 查询已上传分片接口
断点续传需要后端配合查询接口。这个接口的设计很简单,接收前端传来的identifier,去分片临时目录看已经存在哪些分片文件,返回分片序号数组。
<?php // status.php $identifier = $_GET['identifier'] ?? ''; if (!preg_match('/^[a-zA-Z0-9_-]{8,64}$/', $identifier)) { http_response_code(400); exit('invalid identifier'); } // 匹配该identifier对应的所有分片文件 $pattern = sys_get_temp_dir() . '/' . $identifier . '_part_*'; $existingChunks = []; foreach (glob($pattern) as $chunkFile) { // 从文件名中解析分数序号 if (preg_match('/_part_(\d+)$/', $chunkFile, $matches)) { $existingChunks[] = intval($matches[1]); } } sort($existingChunks); echo json_encode([ 'code' => 0, 'uploadedChunks' => $existingChunks ]);实际使用中,前端拿到uploadedChunks数组后,把已经存在的分片从上传队列里剔除。因为服务端已保存的分片文件是可靠的,不需要重新传。这里有一个性能点:当分片很多时,比如1000个分片,用glob匹配可能会稍慢,但临时目录文件数量一般不多,实测1000个文件的目录也就几十毫秒,可以接受。如果文件量再大,可以用数据库记录分片状态,但中小项目没必要上这么重的方案。
4.3 合并分片与最终校验
当所有分片都传完后,前端调用合并接口。合并的关键在于保证顺序正确和内容完整。
<?php // merge.php set_time_limit(0); $identifier = $_POST['identifier'] ?? ''; $totalChunks = intval($_POST['totalChunks'] ?? 0); $fileName = $_POST['fileName'] ?? ''; if (!preg_match('/^[a-zA-Z0-9_-]{8,64}$/', $identifier)) { http_response_code(400); exit('invalid identifier'); } $fileName = basename($fileName); $fileName = preg_replace('/[^\w\.\-\x{4e00}-\x{9fa5}]/u', '_', $fileName); // 目标保存目录,必须事先创建并设置好权限 $saveDir = '/data/uploads/' . date('Ymd'); if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } $targetPath = $saveDir . '/' . $identifier . '_' . $fileName; $finalFile = fopen($targetPath, 'wb'); if ($finalFile === false) { http_response_code(500); exit('cannot create final file'); } // 按顺序遍历所有分片 for ($i = 0; $i < $totalChunks; $i++) { $chunkFile = sys_get_temp_dir() . '/' . $identifier . '_part_' . $i; if (!file_exists($chunkFile)) { fclose($finalFile); unlink($targetPath); http_response_code(400); exit('missing chunk: ' . $i); } $chunkHandle = fopen($chunkFile, 'rb'); if ($chunkHandle === false) { fclose($finalFile); unlink($targetPath); http_response_code(500); exit('cannot open chunk: ' . $i); } // 流式写入,避免大文件占满内存 while (!feof($chunkHandle)) { $buffer = fread($chunkHandle, 8192); if ($buffer === false) { fclose($chunkHandle); fclose($finalFile); unlink($targetPath); http_response_code(500); exit('read chunk failed'); } fwrite($finalFile, $buffer); } fclose($chunkHandle); // 合并完的分片删除,释放临时目录空间 unlink($chunkFile); } fclose($finalFile); // 最终文件完整性校验:检查目标文件大小是否等于所有分片大小之和 $totalSize = 0; for ($i = 0; $i < $totalChunks; $i++) { // 临时分片已经被删除,这里改为通过总分片数和分片大小推算预期大小 // 实际项目中可在分片接收时记录每个分片的实际大小 } $actualSize = filesize($targetPath); if ($actualSize == 0) { unlink($targetPath); http_response_code(500); exit('merge result size is zero'); } echo json_encode([ 'code' => 0, 'message' => 'file merged successfully', 'path' => '/' . date('Ymd') . '/' . $identifier . '_' . $fileName, 'size' => $actualSize ]);合并过程中最容易踩的坑是:合并到一半发现某个分片缺失。这种情况大多是因为用户中断了上传,前端没做完所有分片就请求合并,或者分片文件被系统临时目录清理掉了。处理办法就是检查到缺失分片时直接把半成品删除,返回错误码,前端可以根据返回的分片列表续传后再次发起合并。一定不能把不完整的文件保留在目标目录里,否则用户下载到损坏文件,后果比上传失败更麻烦。
关于临时文件的清理有一个长效机制:sys_get_temp_dir()目录下的分片文件,如果用户上传到一半放弃了,就会一直留在那里。我在项目里加了一个定时任务,每小时跑一次,删除创建时间超过2小时的分片文件:
<?php // cleanup_temp_chunks.php 由cron每小时调用一次 $pattern = sys_get_temp_dir() . '/*_part_*'; foreach (glob($pattern) as $file) { if (is_file($file)) { $age = time() - filemtime($file); if ($age > 7200) { unlink($file); } } }这个清理脚本很重要。临时文件占用磁盘空间是小事,关键是分片文件一旦被人恶意构造,可能成为攻击媒介,定期清理能降低风险。
4.4 大文件上传的安全门槛
上传场景里安全再怎么强调都不过分。分片方案减少了一次性请求的冲击,但引入了新的安全维度和风险。
第一个层面是权限。接收上传的目录不能放在Web可访问的根目录下,否则别人可以通过拼接URL直接下载你的分片文件。我在项目里把分片临时目录和最终保存目录都放到了Web根目录之外,外部通过一个受控的download接口访问文件。如果你一定要把最终文件放在Web根目录,请确认文件名的随机性足够强,identifier使用MD5生成,32位十六进制字符,直接猜到的概率极低。
第二个层面是校验文件类型。分片上传时后端看到的是二进制流,没有$_FILES提供原始文件类型信息,必须靠前端上报的文件名和配置的白名单来决定是否允许保存。这块我建议严格限制:
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip', 'doc', 'docx', 'xls', 'xlsx', 'mp4', 'mov']; $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); if (!in_array($ext, $allowedExtensions)) { http_response_code(400); exit('file type not allowed'); }再配合文件签名检测会更稳。特别是图片类型,用getimagesize()或直接读取文件头几个字节判断魔数,能拦截大部分伪装成图片的可执行文件。这个步骤在合并完成后做,因为此时才有完整的文件内容。
第三个层面是服务端限流。每个IP同时上传的分片数不能无限制,我用一个简单的计数器文件实现:
$ip = $_SERVER['REMOTE_ADDR']; $counterFile = sys_get_temp_dir() . '/upload_counter_' . md5($ip); $count = (int)file_get_contents($counterFile); if ($count > 1000) { http_response_code(429); exit('too many requests'); } file_put_contents($counterFile, $count + 1);这个方案比较粗糙,但能挡住最简单的爆破式上传。生产环境可以换成Redis或数据库计数器,核心思路是一样的。
5. 常见问题与排查技巧实录
5.1 上传报错怎么定位
大文件上传出问题时,最先做的是判断问题发生在哪一层。我最常用的办法是看浏览器开发者工具Network面板,找到请求记录,看状态码就好:
| 状态码 | 原因定位 | 处理方向 |
|---|---|---|
| 413 | 请求体太大,Web服务器或PHP拒绝了 | 检查Nginx的client_max_body_size和PHP的post_max_size |
| 500 | PHP脚本异常 | 查看PHP错误日志,常见是代码bug或文件权限 |
| 400 | 参数校验失败 | 检查identifier、分片序号参数是否合法 |
| 429 | 请求频率被限制 | 等待或调低频率 |
| 0 (net::ERR_FAILED) | 网络中断或服务器断开连接 | 检查网络稳定性,增大xhr.timeout |
还有一种白屏情况:前端请求发出去后服务端根本没有返回。这种情况会看Nginx的error.log和PHP-FPM的日志,像权限不足、磁盘满了都会在这里暴露。
我遇到过一个印象深刻的坑:临时分片目录权限是755,PHP-FPM进程用户是www-data,写不进去,所有分片上传都返回500。日志里只写了Permission denied,如果不看日志,排查这条路径会浪费很久。检查顺序是:先看PHP错误日志,再手动用命令测试写入权限。
5.2 中文文件名乱码与编码问题
这个问题在本地测不出来,部署到Linux服务器上就会出现。分片上传时文件名通过HTTP POST传输,JavaScript侧用UTF-8编码,PHP侧接收$_POST['fileName']时如果页面编码不是UTF-8,中文字符就会变成乱码。
我采用的方案是:前端把文件名做一次encodeURIComponent后放在URL参数里,后端用urldecode()还原。或者前端直接把文件名以UTF-8明码提交,后端确保响应头声明UTF-8编码,在PHP8环境里用$_POST接UTF-8字符串没太大问题。
更稳的办法是不依赖PHP对POST字段的自动解析,文件名统一走URL参数。例如:
xhr.open('POST', '/upload.php?fileName=' + encodeURIComponent(file.name), true);这样文件名在HTTP请求行里,编码方式是明确的,后端直接读$_GET['fileName']再配合urldecode不会出错。
5.3 上传速度慢和服务器并发问题
分片上传天然绕过了单次请求体的限制,但引入了并发控制的复杂度,就算前端串行上传,也可能出现多用户同时传多个大文件的场景。最直接的影响是磁盘I/O消耗和PHP-FPM进程用尽。PHP-FPM默认的pm.max_children通常只有几十个,每个分片请求都会占用一个进程处理,如果分片很小、请求很频繁,FPM进程池会被打满,其他请求全部排队。
几个有效手段:
- 分片大小不要设太小。1MB的分片比2MB的分片多一倍请求量,多出一倍时间在HTTP握手和FPM进程调度上。实测2MB到5MB是比较合适的区间。
- 前端串行上传,不要用并发同时传多个分片。并发能提升单文件速度,但对服务器压力成倍增加。如果一定要并发,控制在2到3个并发,多了反而会因为带宽争抢降低总吞吐量。
- 在Nginx层做并发限制,使用
limit_req_zone限流。上传接口的粒度可以放宽一些,因为正常上传本来就是高频访问。我设置为每秒10个请求,足够正常使用,又能挡住恶意刷请求。
5.4 多文件同时上传时的命名冲突
如果用户一次性选择多个文件,每个文件都要生成独立的identifier。一个容易出错的点是多个文件的切片索引容易混淆,没有区分标识的话,两个文件的分片会互相覆盖。
我的方案是:每个文件独立生成identifier,前端在上传队列中记录{fileId: identifier, chunks: [...]},上传时每个请求都带上所属文件的identifier。后端以identifier和分片序号联合作为分片文件名,自然不会冲突。
合并接口也一样,必须同时传递identifier和totalChunks,服务端只合并指定identifier对应的分片。
5.5 文件超过2GB的额外风险
PHP在32位系统上对大于2GB的文件操作不稳定,filesize()返回的类型是int,2GB以上会溢出变成负数。我踩过这个坑之后,对文件超过2GB的情况专门做了处理:切换到64位操作系统,文件操作全部走SplFileObject,它内部对大文件处理更可靠。另外,HTTP上传本身就适合用64位环境处理大文件,建议部署时确认操作系统是64位。
6. 实操心得与扩展建议
这套方案在项目里跑了一年半,稳定处理过最大的单文件是4.6GB的素材压缩包。期间踩过的坑不少,有几个心得想单独讲讲。
第一个心得是切片的完整性校验不能省。最初版本我只做了“分片数量对得上就合并”,结果有一次用户在一次弱网环境下传的文件合并完直接无法解压,排查后发现是某个分片写到一半被截断,大小不对但接口没报错。后来我在分片接收时记录每个分片的实际大小,合并前把所有分片大小之和与前端上报的文件总大小做对比,不一致就拒绝合并并返回缺失列表,问题彻底解决。
第二个心得是不要迷信框架。很多人一上手就想找现成的分片上传组件,其实核心逻辑就是切块、传块、合并这三步。用原生代码写一遍,你会对整个链路有透彻的理解,之后再封装或引入组件都很顺手。而且原生实现的兼容性和可控性比自己不熟悉的框架库要好得多。
第三个心得是上传功能上线前必须做异常路径测试。我的测试清单包括:
- 上传过程中刷新页面,重新开始后应跳过分片继续
- 上传过程中断网,恢复后应重试失败分片而不是从零开始
- 服务器磁盘写满时前端应收到明确的错误提示
- 上传同名文件、传中文名文件、传空文件、传0字节文件
- 并发上传多个文件,观察进程和内存占用
- 用慢速网络模拟工具限制带宽到1Mbps,看超时处理是否正确
这些测试做完,上线才有底气。如果项目后续想进一步优化,可以考虑加秒传功能。做法是在前端生成文件内容哈希,先通过一个接口查询哈希是否已存在,存在就直接返回已有文件地址,省掉传输时间。文件内容哈希的计算可以用增量哈希算法,不用等待整个文件加载到内存,几秒内能完成。我计划在下个版本给这套上传模块加上这个能力。
最终建议:别把大文件上传想得太复杂,但别把它想得太简单。核心链路就三件事——参数配置、切片传输、有序合并,把这三件事用流式处理做到位,一台普通服务器就完全能撑起GB级别的上传需求。遇到问题按网络层、服务层、应用层逐层排查,大多数坑都能很快定位。