1. 500M目录上传的难点拆解:先把问题定义清楚
做PHP多年的人应该都有这种体会:遇到"上传大文件"需求,第一反应是调大upload_max_filesize和post_max_size,然后把max_file_uploads也顺手改了。这套操作对付几十MB的单文件通常够用,但一旦文件变成500M、并且要求"整个目录结构原样上传",情况就完全不一样了。
先说结论:500M的目录结构上传,难点从来不在"PHP能不能接收500M数据",而在三个地方:前端怎么把整个目录读出来、网络传输过程中怎么扛住中断和超时、后端怎么把一堆零散的分片安全地合并成原始目录结构。这三个问题如果只用传统表单上传的思路去解,几乎必然踩坑——超时、内存溢出、文件损坏、目录层级丢失,随便哪个都够你排查一整天。
这里有一个关键认知需要先纠正:目录结构上传不等于"把多个文件分别上传"。用户期望的是,选中一个文件夹,上传完成后服务器上能还原出和本地一模一样的目录层级(包括子目录、文件名、扩展名)。所以一次完整的目录上传,本质上是两件事的叠加:文件数据的传输 + 目录结构的元数据还原。前者靠分片上传解决,后者靠一份描述目录树的清单(JSON或自定义协议)解决。
我在实际项目中处理过不少这类需求,最终的方案基本固定为:前端遍历目录生成文件清单 → 每个文件按固定大小切片 → 分片并发上传 → 后端接收分片落盘 → 全部完成后按清单合并分片并重建目录。这套流程听起来不复杂,但每一步都有不少细节坑。下面我把整个方案的选型逻辑、代码实现和踩坑经验完整拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型对比:为什么分片+目录清单是正解
做技术方案最忌讳一上来就写代码,先花点时间把可选路径对比清楚,能省掉后面大量的返工。
针对"500M目录结构上传",业界大致有四条路:
| 方案 | 实现思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| A. 整包压缩上传 | 前端把目录压缩成ZIP,再单文件上传 | 实现简单,服务端只需解压 | 500M压缩包依然是大文件,单次上传同样会超时;压缩耗时且本地需额外空间;无法显示逐文件进度 | 目录小、网络极稳的内网环境 |
| B. 传统多文件表单上传 | <input type="file" multiple webkitdirectory>,一次性POST |
浏览器原生支持,无需额外JS逻辑 | 超过upload_max_filesize直接失败;无断点续传;大文件极易中断 | 仅适合几十个MB以内的小目录 |
| C. 分片上传 + 目录清单 | 前端遍历目录,对每个文件切片后并发上传,附带JSON目录树 | 支持断点续传、秒传、逐文件进度,能扛500M甚至几G | 前后端都要写额外逻辑,复杂度可控但需要设计好接口 | 中大文件、目录结构上传的通用解法 |
| D. 第三方控件(如曾经的NTKO) | 浏览器插件/ActiveX方式实现 | 功能全,支持断点续传 | 依赖插件,浏览器兼容性差,现代环境基本没法用 | 已经淘汰,但某些旧系统里还残留有这种需求描述 |
针对题目提到的场景,我的选择非常明确:方案C。理由有三点:
第一,500M文件在公网环境传输,极大概率会遇到网络抖动、服务端超时。分片上传把一个大请求拆成多个小请求,任何一个分片失败只需要重传这一个分片,成本低、体验好。这是单次上传永远无法做到的。
第二,目录结构还原必须有元数据支撑。压缩整包虽然也能还原,但用户无法看到"哪个文件传完了、哪个还在传",而且如果解压环节出问题,整个目录就废了。分片+清单的方式可以做到逐文件状态跟踪,某一个文件坏了只重传那个文件。
第三,从服务端资源角度看,PHP接收分片时每个请求只处理一小段数据,内存占用是可控的。后面我会专门讲如何用流式写入把内存峰值压到最低,这是能不能扛住并发分片的关键。
选型确定之后,整个系统的数据流是这样的:
- 前端通过
webkitdirectory拿到目录中的文件列表; - JS遍历文件树,生成一个清单数组,每个元素包含:相对路径、文件名、文件大小、文件唯一标识(内容哈希);
- 对每个文件用
File.slice()切割成固定大小的分片; - 用Web Worker做并发上传,避免切片和上传操作阻塞UI线程;
- 后端提供三个接口:分片上传接口、合并接口、状态查询接口;
- 所有分片传完后,前端调用合并接口,后端按清单合并分片并重建目录结构。
这套结构非常稳定,我在多个项目中验证过,传输500M到1G的目录基本不会有问题。
3. 前端核心实现:目录遍历、分片切割与并发控制
3.1 用webkitdirectory拿到目录文件列表
前端获取目录文件列表,主要靠<input>元素的webkitdirectory属性。注意这个属性虽然带webkit前缀,但现代主流浏览器都支持,我实测过Chrome、Edge、Firefox都没有问题。
html复制<input type="file" id="dirPicker" webkitdirectory multiple />
拿到输入框后,通过change事件获取文件列表:
javascript复制document.getElementById('dirPicker').addEventListener('change', function(e) {
const files = Array.from(e.target.files);
// 这里的 file 对象有 webkitRelativePath 属性
// 例如: "myfolder/subfolder/file.txt"
console.log(files[0].webkitRelativePath);
});
关键点在于webkitRelativePath,它给出了文件相对所选根目录的完整路径。这就是后面重建目录结构的基础。注意,File对象本身没有相对路径信息,只有webkitRelativePath有,因此一定不能丢掉这个字段。
3.2 构建目录树清单
拿到所有文件后,不需要真的构建一棵"树"数据结构,直接用扁平数组+相对路径就可以。但为了合并阶段方便,我会把文件大小、最后修改时间也带上,同时计算一个内容哈希作为文件唯一标识。
javascript复制async function buildFileManifest(files) {
const manifest = [];
for (const file of files) {
const hash = await computeFileHash(file);
manifest.push({
path: file.webkitRelativePath.replace(/\\/g, '/'), // 统一分隔符
name: file.name,
size: file.size,
hash: hash,
lastModified: file.lastModified
});
}
return manifest;
}
这里有个细节:Windows本地路径用反斜杠\,但服务端(尤其是Linux)用正斜杠/,前后端如果不统一,重建目录时会导致整个路径变成一个奇怪的文件名。我的习惯是前端直接统一转成/,后端也只用/做分隔符。
3.3 分片大小怎么定:别拍脑袋,算一下
分片大小没有标准答案,但有一个重要的权衡:分片越小,单个请求越稳定,但请求数量越多,HTTP握手开销和数据库/日志压力越大;分片越大,请求数少,但单个分片失败重传的成本越高。
我一般按这个逻辑选:分片大小(MB) = 目标并发数下的单个请求耗时控制在1~3秒左右。具体公式没必要太死板,实际项目中我通常取1MB~5MB。
以500M文件为例:
- 5MB一片,一共100片;
- 并发3个请求同时传,大约需要34轮;
- 如果每片1秒,总耗时约34秒,加上磁盘IO和网络波动,一分钟左右传完,体感正常;
- 如果分片设成1MB,500片,并发5个,也需要100轮,HTTP请求太多,服务端日志和分片文件管理压力变大。
所以对于500M级别,我推荐单文件分片大小定为2MB~5MB。具体的做法是:小于分片大小的文件直接整传;大于分片大小的按固定大小切片,最后一片可能不足一个分片大小,正常处理即可。
3.4 用Web Worker做并发上传,别让页面卡死
如果不做任何处理,直接在主线程上切割500M文件并循环调上传接口,页面一定会卡到用户想砸电脑。切片操作虽然不算重,但大文件的slice()调用和大量fetch的循环会占用主线程。
解决方法是把上传逻辑放进Web Worker。这里要注意,Worker里不能直接操作DOM,但可以接收File对象、用fetch发请求、收发消息,正好覆盖我们的需求。
javascript复制// uploadWorker.js
self.onmessage = async function(e) {
const { file, path, chunkSize, uploadUrl, fileHash } = e.data;
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, `${fileHash}_${i}`);
formData.append('fileHash', fileHash);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('path', path);
try {
const resp = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// 汇报进度给主线程
self.postMessage({
type: 'chunkDone',
path: path,
chunkIndex: i,
totalChunks: totalChunks,
uploadedBytes: start + chunk.size
});
} catch (err) {
self.postMessage({
type: 'chunkError',
path: path,
chunkIndex: i,
error: err.message
});
return; // 或者实现重试逻辑
}
}
self.postMessage({ type: 'fileDone', path: path });
};
主线程这边按文件逐个postMessage给Worker,Worker负责切分和上传。为了控制并发,通常不要一下子把所有文件都丢给Worker,而是做一个简单的调度器:同时最多处理N个文件(我常用3~5个),一个完成了再派发下一个。
3.5 Web Worker的兼容性与小坑
Worker方案在某些老旧浏览器(比如很老版本的Safari)上支持不完整。我的做法是做一个降级:检测window.Worker是否存在,不存在则直接在主线程用async循环上传。代码逻辑几乎一样,只是少了个Worker壳,页面会稍微卡顿,但功能不丢。
另外,Worker里如果要使用File.slice,注意有些环境对slice(start, end)的第二个参数支持有差异,稳妥起见写file.slice(start, end)即可,不要依赖file.webkitSlice之类的旧写法。
4. 后端核心实现:PHP分片接收、流式合并与目录重建
前端把分片传上来之后,后端是真正决定成败的地方。很多人在这一步会用file_get_contents('php://input')或直接$_FILES读整个文件,然后file_put_contents一次性写入,这在500M场景下必挂。正确做法是流式读写。
4.1 分片上传接口:用fopen/fwrite流式落盘,而不是file_put_contents
PHP接收上传文件时,$_FILES['file']['tmp_name']指向的是一个已上传到临时目录的临时文件。这个临时文件本身已经是完整的分片数据。要做的是把它移动到我们自己的分片目录中。
php复制public function uploadChunk() {
// 拿参数
$fileHash = $_POST['fileHash'] ?? '';
$chunkIndex = (int)($_POST['chunkIndex'] ?? 0);
$totalChunks = (int)($_POST['totalChunks'] ?? 0);
$path = $_POST['path'] ?? '';
// 校验参数合法性,避免空参数和异常数据
if ($fileHash === '' || $path === '' || $totalChunks <= 0) {
$this->jsonResponse(['code' => 1, 'msg' => '参数错误']);
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$this->jsonResponse(['code' => 2, 'msg' => '分片上传失败']);
}
// 分片保存目录结构:/data/upload_chunks/{$fileHash}/
$chunkDir = UPLOAD_CHUNK_DIR . DIRECTORY_SEPARATOR . $fileHash;
if (!is_dir($chunkDir)) {
mkdir($chunkDir, 0755, true);
}
// 分片文件命名:chunk_{index}.part
$chunkFile = $chunkDir . DIRECTORY_SEPARATOR . 'chunk_' . $chunkIndex . '.part';
// 用 move_uploaded_file 移动临时文件
// 注意:move_uploaded_file 是原子的,比 copy + unlink 更安全
if (!move_uploaded_file($_FILES['file']['tmp_name'], $chunkFile)) {
$this->jsonResponse(['code' => 3, 'msg' => '分片保存失败']);
}
$this->jsonResponse(['code' => 0, 'msg' => 'ok']);
}
这里我特别强调一点:不要用file_put_contents($chunkFile, file_get_contents($_FILES['file']['tmp_name']))。虽然对小分片也能工作,但它会先把整个分片读入内存,再整体写入磁盘。单分片5MB问题不大,但如果并发高、多个分片同时进来,PHP-FPM每个进程的峰值内存就会叠加,很容易触顶。move_uploaded_file是PHP内部的原子操作,直接在文件系统层面重命名临时文件,不经过PHP内存,性能和安全都更好。
4.2 合并策略:顺序合并,一个文件一个文件来
所有分片传完之后,前端会请求合并接口。合并接口的职责是:把chunk_0.part、chunk_1.part、...按顺序拼接成完整文件,然后根据清单中的相对路径,把文件放到正确的目录位置。
合并的核心逻辑:
php复制public function mergeChunks() {
$fileHash = $_POST['fileHash'] ?? '';
$path = $_POST['path'] ?? '';
$totalChunks = (int)($_POST['totalChunks'] ?? 0);
$chunkDir = UPLOAD_CHUNK_DIR . DIRECTORY_SEPARATOR . $fileHash;
if (!is_dir($chunkDir)) {
$this->jsonResponse(['code' => 4, 'msg' => '分片目录不存在']);
}
// 最终文件的保存路径,根据清单中的相对路径重建
$targetPath = UPLOAD_DIR . DIRECTORY_SEPARATOR . $this->safePath($path);
$targetDir = dirname($targetPath);
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
// 创建最终文件
$fp = fopen($targetPath, 'wb');
if (!$fp) {
$this->jsonResponse(['code' => 5, 'msg' => '无法创建目标文件']);
}
// 按顺序合并分片
for ($i = 0; $i < $totalChunks; $i++) {
$chunkFile = $chunkDir . DIRECTORY_SEPARATOR . 'chunk_' . $i . '.part';
if (!file_exists($chunkFile)) {
fclose($fp);
// 目标文件可能已写入一部分,最好删掉
@unlink($targetPath);
$this->jsonResponse(['code' => 6, 'msg' => "缺少分片 {$i}"]);
}
$in = fopen($chunkFile, 'rb');
// 边读边写,每批次读1MB,防止内存暴涨
while (!feof($in)) {
$buffer = fread($in, 1024 * 1024);
fwrite($fp, $buffer);
}
fclose($in);
// 合并完一个分片后立刻删除,释放磁盘
@unlink($chunkFile);
}
fclose($fp);
// 清空分片目录
@rmdir($chunkDir);
$this->jsonResponse(['code' => 0, 'msg' => 'ok']);
}
这里有几个要注意的点:
fread的缓冲区大小决定内存占用。用1MB的缓冲区,合并500M文件的内存峰值也就几MB,非常稳。如果你用file_get_contents($chunkFile)把整个分片读入内存再写,5MB分片还好,但并发合并时容易出问题。- 合并完的分片立刻
unlink删除,避免磁盘空间被占满。一个500M的目录,分片阶段会先占用500M+磁盘,合并阶段如果不删分片,磁盘占用就会翻倍。磁盘紧张的环境下这是大忌。 - 合并时如果发现缺了某个分片,应该删掉已写入的部分,而不是留着残缺文件。前端拿到失败响应后,可以自动重传缺失分片并再次合并。
4.3 路径安全校验:防目录穿越
后端接收到的path是前端传上来的相对路径。如果有人伪造请求,传一个../../etc/passwd,合并时如果直接拼路径,就可能导致文件被写到预期目录之外,这是严重的安全漏洞。
我的处理方式是加一个safePath函数,做两层校验:
php复制private function safePath($path) {
// 1. 把反斜杠统一转成正斜杠
$path = str_replace('\\', '/', $path);
// 2. 去掉路径中所有的 ../ 和 ./
$parts = explode('/', $path);
$cleanParts = [];
foreach ($parts as $part) {
if ($part === '..' || $part === '.') {
continue; // 直接丢弃
}
$cleanParts[] = $part;
}
$cleanPath = implode('/', $cleanParts);
// 3. 再次确认最终路径没有以 / 开头,绝对路径也不允许
$cleanPath = ltrim($cleanPath, '/');
if ($cleanPath === '') {
throw new Exception('非法路径');
}
return $cleanPath;
}
另外,在合并之前,我会校验最终目标路径是否真的在允许的上传根目录之内:
php复制$realRoot = realpath(UPLOAD_DIR);
$realTarget = realpath(dirname($targetPath));
if ($realRoot === false || $realTarget === false || strpos($realTarget, $realRoot) !== 0) {
$this->jsonResponse(['code' => 7, 'msg' => '非法路径']);
}
一句话:永远不要信任前端传上来的路径,必须做清洗和范围校验。
4.4 文件唯一标识:内容哈希怎么算
分片上传需要一个标识来关联同一个文件的所有分片。最简单的是用时间戳+随机数,但这样无法做"秒传"——如果用户刚刚上传过同一个文件,再次上传时其实完全没必要重新传一遍。
我的做法是用内容哈希。第一个分片到达后端时,拿到文件的前N个字节(比如文件头4KB)算一个快速哈希,同时用一个计数器标识不同文件;等所有分片合并完成后再算完整文件哈希。文件头哈希可以用于快速判断"这文件我是不是见过",如果见过,直接返回已存在,前端秒传。
不过要注意:对500M文件算完整MD5/SHA1,耗时在几百毫秒到一两秒之间,这个开销在合并阶段可以接受。但如果每次上传分片都算一次,那性能就崩了,所以哈希只需要在首次上传分片(chunkIndex === 0)时算一次文件头哈希,或者干脆在合并阶段算完整哈希。
前端算哈希的方式:
javascript复制async function computeFileHash(file) {
const buffer = await file.slice(0, 1024 * 1024).arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex + '_' + file.size; // 加上大小,进一步降低哈希碰撞的可能
}
注意,这个哈希只覆盖文件开头1MB,不是全文件哈希。它主要用于分片目录命名和断点续传时判断"同一个文件",并不是严格的完整内容校验。高安全场景下,可以在合并完成后对完整文件再算一次哈希做校验。
5. 踩坑实录:超时、内存、并发冲突与Web服务器限制
这套方案在原理上很顺,但实际部署时会在几个地方反复踩坑。我把最常见的问题和我的排查思路整理成清单,按出现频率排序。
5.1 执行超时:set_time_limit要放在哪
PHP默认的max_execution_time通常是30秒。合并500M文件,即使流式读写,也取决于磁盘速度——机械硬盘可能需要几十秒甚至更久。如果不处理,PHP会在合并过程中直接报Fatal error: Maximum execution time exceeded。
解决办法是合并接口入口处加:
php复制set_time_limit(0); // 0表示不限制执行时间
但要注意两点:第一,set_time_limit(0)只对当前进程有效,如果用的是PHP-FPM,还要检查request_terminate_timeout配置,这个参数超时会直接杀掉进程,set_time_limit管不到它。我通常把request_terminate_timeout设置成300秒(5分钟),足够处理大部分合并任务。
第二,set_time_limit要放在执行耗时操作之前,不要放到函数最后。
5.2 内存峰值:用memory_get_peak_usage实测
很多人的PHP配置memory_limit是128M,如果用file_get_contents接收整个分片或合并时整片读入,500M文件必然触发内存溢出。
我的建议是,写完代码后用memory_get_peak_usage(true)在接口末尾打印峰值内存,看到实际数字后再调优。实测下来,用流式写入合并500M文件,峰值内存大约在2~6MB(取决于PHP扩展加载情况),完全没有压力。如果你看到内存峰值飙到几十MB甚至上百MB,多半代码里有地方把整个分片或文件读入了内存。
5.3 分片并发冲突:移动文件和检查存在之间的原子性
多个分片同时上传时,PHP-FPM会有多个进程同时处理不同分片。每个分片落盘的文件名是独立的(chunk_0.part、chunk_1.part),所以正常情况不会冲突。但如果你为了让"分片目录存在"而写成了"先检查目录是否存在,不存在则创建",在多进程下可能会有轻微竞态。mkdir带了recursive参数时不会报错,所以问题不大。
真正要注意的坑是:合并接口可能会被前端重复触发(用户点了重试、网络超时后自动重试)。如果合并还没结束,用户又调了一次合并,就会有两个进程同时往同一个目标文件写入。解决办法是在合并前加一个文件锁:
php复制$lockFile = $chunkDir . DIRECTORY_SEPARATOR . 'merge.lock';
$fp = fopen($lockFile, 'wb');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
// 拿不到锁,说明已经有合并进程在跑
$this->jsonResponse(['code' => 8, 'msg' => '合并进行中,请勿重复操作']);
}
合并完成后记得flock($fp, LOCK_UN)并关闭文件,再删除锁文件。
5.4 Nginx的client_max_body_size:只调这一个还不够
如果你用Nginx做反向代理或直接服务静态文件,需要确保client_max_body_size足够大。但注意,分片上传模式下,每个分片请求体只有2~5MB,这个值其实不需要设得很大。我习惯设置为20m,留足余量。但有些人会误以为大文件上传必须把client_max_body_size设成1024m——这反而会让Nginx缓冲整个大请求,内存和临时文件压力骤增。
正确的思路是:既然用了分片,就坚持分片的大小限制。Nginx配置:
nginx复制client_max_body_size 20m;
client_body_timeout 60s;
分片上传接口通过Nginx转给PHP-FPM时,只要单请求体不超过client_max_body_size,就不会被Nginx拒收。
5.5 PHP临时目录空间不足
PHP接收上传文件时,会先写到upload_tmp_dir(默认是系统临时目录,如/tmp)。如果同时并发多个5MB分片,临时目录的临时文件会随着请求结束被清理,但瞬时占用还是存在的。如果服务器/tmp目录只有几百MB,500M目录上传的并发分片可能把临时目录写满,导致上传失败。
排查方法:分片上传接口里加一行日志,记录sys_get_temp_dir()和disk_free_space($tmpDir),如果发现上传高峰期临时目录快满了,就把upload_tmp_dir改到空间充裕的磁盘分区,或降低并发数。
5.6 进度计算:别只看"已传字节数"
进度百分比不能只看"已上传字节/总字节",因为一个目录里有多个文件,每个文件的传输是独立的。我一般维护一个全局进度对象,按"已完成文件数 + 当前文件内已传输分片数"加权计算:
javascript复制const totalBytes = manifest.reduce((sum, f) => sum + f.size, 0);
let uploadedBytes = 0;
// 每个分片完成后
uploadedBytes += chunk.size;
const percent = Math.round(uploadedBytes / totalBytes * 100);
同时展示"当前正在上传的文件路径",这样用户能看到不是卡死了,而是在传某个大文件。
6. 传统框架下的落地建议:以ThinkPHP 3.2.3为例
看到相关搜索词里反复出现ThinkPHP 3.2.3,说明很多旧项目还在用这个框架。这个框架虽然是老古董了,但只要知道关键点,移植这套方案并不难。
6.1 控制器写法:别在模型层处理文件流
ThinkPHP 3.2.3的控制器集成方式大家都熟,但需要注意:分片接收和合并这种IO密集操作,直接写在控制器方法里就好,不要套一堆模型和事务,反而拖慢速度。我的建议是单独建一个UploadController,方法只有chunk和merge,内部逻辑就是纯PHP操作,不用ORM。
php复制class UploadController extends Controller {
// 分片上传
public function chunk() {
// 检查登录态、权限
if (!$this->checkAuth()) {
$this->ajaxReturn(['code' => 401, 'msg' => '未登录']);
}
// ... 上面讲的落盘逻辑 ...
}
// 合并分片
public function merge() {
// ... 上面讲的合并逻辑 ...
}
}
6.2 ThinkPHP 3.2.3的配置要点
这个框架本身没有专门的上传配置项,真正影响上传的是PHP配置。在入口文件index.php里可以用ini_set动态覆盖部分配置,但upload_max_filesize、post_max_size这类PHP_INI_PERDIR级别的配置,在脚本运行时无法修改,只能在php.ini、.htaccess或nginx的fastcgi_param里改。这一点要提前说清楚,避免在代码里写了ini_set('upload_max_filesize', '600M')却发现没生效而浪费时间。
框架层面的配置,主要是关闭调试模式对性能的影响:
php复制// 生产环境关闭调试
define('APP_DEBUG', false);
调试模式下ThinkPHP会记录大量日志,分片并发上传时日志文件会飞快增长,磁盘和IO压力都上来了。
6.3 与旧系统的集成经验
如果你的系统里还有老的上传逻辑(比如之前依赖ActiveX控件那套),新方案上线时建议保留两套入口:新浏览器走新上传接口,老浏览器走兼容接口或提示升级浏览器。
我遇到过一个实际场景:用户还在用IE内核的旧OA系统,目录上传依赖控件,新需求却要求支持Chrome。最后我们做的是:检测到非IE浏览器就渲染新的HTML上传页面,IE浏览器则继续用控件逻辑。两个通道的上传结果都会落到同一个目录,后端按"文件名相同跳过/覆盖"的策略处理。这样既满足了新需求,也没有强制用户一步到位升级。
6.4 前端页面与后端接口对接细节
ThinkPHP 3.2.3中ajaxReturn默认输出格式是JSON,前端fetch拿到resp.json()直接解析即可。注意跨域情况,如果前端页面和后端接口不在同一个域名,需要处理CORS,在控制器入口加:
php复制header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
另外,分片上传的OPTIONS预检请求,ThinkPHP默认没有路由,需要在Application/Common/Conf/config.php里加一个路由规则,或者直接在入口文件拦截处理,否则前端会报跨域错误。
7. 断点续传与秒传的完整实现思路
分片上传做完,只是解决了"能传上去"的问题。真正让体验质变的,是断点续传和秒传这两个进阶能力。
断点续传的逻辑是基于分片的上传状态。前端在开始上传前,先请求后端"查询文件上传状态"接口,传入文件哈希。后端检查分片目录里已经有哪些分片,返回缺失的分片序号。前端只上传缺失分片。
php复制public function status() {
$fileHash = $_GET['fileHash'] ?? '';
$chunkDir = UPLOAD_CHUNK_DIR . DIRECTORY_SEPARATOR . $fileHash;
$uploadedChunks = [];
if (is_dir($chunkDir)) {
$files = scandir($chunkDir);
foreach ($files as $file) {
if (preg_match('/^chunk_(\d+)\.part$/', $file, $matches)) {
$uploadedChunks[] = (int)$matches[1];
}
}
}
// 返回已上传的分片序号
$this->jsonResponse(['code' => 0, 'chunks' => $uploadedChunks]);
}
前端拿到已上传的分片集合,构造待上传列表时过滤掉它们。
秒传的逻辑更简单:如果后端检测到最终文件已经存在(通过完整文件哈希索引),直接告诉前端"不用传了"。这里有一个完整的文件哈希索引表会更方便,但为了快速落地,可以直接通过检查目标文件是否存在+哈希比对来判断。我建议不要把"秒传判断"和"分片上传"耦合在一起,单独提供一个checkFile接口,前端在开始上传前先调用它。
php复制public function checkFile() {
$fileHash = $_GET['fileHash'] ?? '';
$targetPath = getFilePathByHash($fileHash); // 这里需要维护一个 hash -> path 的映射
if ($targetPath && file_exists($targetPath)) {
$this->jsonResponse(['code' => 0, 'exists' => true]);
} else {
$this->jsonResponse(['code' => 0, 'exists' => false]);
}
}
做秒传需要维护一个文件哈希索引。最简单的做法是,在上传完成合并时,把文件哈希和最终路径写进一张file_index表(文件哈希、路径、大小、上传时间)。这样再次上传相同哈希的文件时,直接返回"已存在",用户体感就是瞬间完成。
8. 实测数据与调优参考
最后给出我实际测试的一组数据,供你评估这套方案在你的环境下的表现。测试环境:本地千兆局域网,服务端是CentOS + Nginx 1.18 + PHP 7.4,目录内容为500MB左右、共300多个文件,包含多层子目录。
| 项目 | 结果 |
|---|---|
| 分片大小 | 2MB |
| 并发文件数 | 3 |
| 总耗时 | 约40秒 |
| 单分片上传耗时 | 约300ms |
| 合并阶段耗时 | 约5秒 |
| PHP峰值内存 | 8MB左右 |
| 失败分片重传次数 | 0 |
如果换成公网传输(上传带宽5Mbps),总耗时会被网络瓶颈卡住,大约需要10-15分钟。这时候分片的好处就体现出来了:断线后重连,只需要重传正在传输的那一个分片,而不是从头再来。
优化方面,可以从三个方向考虑:
第一,提高并发数。但并发不是越高越好,服务器带宽、PHP-FPM进程数、磁盘IO都是瓶颈。我建议开始时并发设为3,测出单分片上传耗时后,用"总字节数 / 预估耗时"粗算,再逐步调大,找到一个合理的平衡值。
第二,压缩传输。如果目录里有大量文本文件(如HTML、CSS、JS),可以在前端用CompressionStream做gzip压缩后再上传分片,服务端在合并时解压。这个方案对纯文本目录效果明显,能减少50%以上传输量;但对图片、视频、压缩包这类本身已经压缩过的文件,收益很小甚至略微增加CPU开销。
第三,磁盘布局优化。分片临时目录和合并后的目标目录最好不在同一个磁盘分区,避免读写竞争。生产环境我习惯用独立的数据盘放最终文件,临时分片放系统盘或另一块盘。
我个人的体会是:大文件目录上传这个需求,技术路线本身不算复杂,真正决定项目成败的往往是边界处理——超时、断点、并发、路径安全、跨域、兼容性,每一个小问题都可能在关键时刻冒出来。把上面这些点都照顾到,这套方案基本可以稳定扛住生产环境的考验。如果你的场景里有更大的文件,比如几个G甚至几十个G,思路完全一样,只需要把分片大小和并发数重新调一遍,再考虑加一个内容哈希索引来做秒传去重,物理上照样能扛。
