PHP 大文件上传解决方案:分片上传完整思路
普通
move_uploaded_file()只能应付小文件。文件一大,就会撞上
upload_max_filesize、post_max_size、执行超时、内存占用、网络中断重传等问题。大文件上传的正确答案不是"调大配置",而是:分片上传 + 断点续传 + 服务端合并校验。
一、为什么普通上传撑不住大文件
一次请求把整个文件传完,会遇到这些坑:
- PHP 限制:
upload_max_filesize、post_max_size - Web 服务器限制:Nginx
client_max_body_size、Apache 请求体限制 - 执行时间:
max_execution_time、max_input_time - 内存压力:
memory_limit - 网络一抖:失败就要从头重传
- 没进度:用户不知道是卡了还是死了
所以大文件上传的核心思路是:
把"一个大请求"拆成"很多个小请求"。
二、分片上传的整体架构
浏览器 服务端
------ ------
File.slice() 接收分片
↓ ↓
chunk_0.bin uploads/tmp/{fileId}/0.part
chunk_1.bin uploads/tmp/{fileId}/1.part
chunk_2.bin uploads/tmp/{fileId}/2.part
... ...
全部上传完成 → 发起 merge 请求 → 按序号合并 → 校验 → 生成最终文件
核心概念有三个:
- fileId / fileHash:标识"同一个文件"
- chunkIndex:第几片
- totalChunks:一共多少片
三、前端:切片、续传、进度、重试
1. 切片
const file = input.files[0];
const chunkSize = 5 * 1024 * 1024; // 5MB
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 fd = new FormData();
fd.append('file', chunk);
fd.append('fileId', fileId);
fd.append('chunkIndex', i);
fd.append('totalChunks', totalChunks);
fd.append('fileName', file.name);
fd.append('fileSize', file.size);
await uploadChunk(fd);
}
切片用
Blob.slice(),不会把整个文件读进内存。
2. 断点续传怎么做
上传前先问后端:
GET /upload/status?fileId=xxx
后端返回:
{
"uploaded": [0, 1, 2, 4]
}
前端就只传 3, 5, 6...,跳过已上传分片。
断点续传不是"浏览器自动帮你续",而是:
- 前端有"已上传到哪了"的状态
- 后端有"哪些分片已经落盘"的记录
- 两边一比对,缺哪片传哪片
3. 进度 & 暂停 & 重试
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
console.log(e.loaded / e.total);
};
xhr.send(fd);
暂停:
xhr.abort();
重试策略:
- 单个分片失败:延迟 1~3 秒重试
- 最多重试 3 次
- 还失败就整体暂停,提示用户
- 同一分片重复上传必须是幂等的
四、后端 PHP:接收分片
1. 目录设计
不要用原始文件名直接存。
推荐结构:
storage/
chunks/
{fileId}/
0.part
1.part
2.part
files/
{uuid}.mp4
2. 接收分片接口
<?php
// upload_chunk.php
$fileId = $_POST['fileId'] ?? '';
$chunkIndex = $_POST['chunkIndex'] ?? '';
$totalChunks= $_POST['totalChunks']?? '';
if (!ctype_alnum($fileId)) {
http_response_code(400);
exit(json_encode(['error' => 'invalid fileId']));
}
if (!is_numeric($chunkIndex) || !is_numeric($totalChunks)) {
http_response_code(400);
exit(json_encode(['error' => 'invalid chunk']));
}
$chunkIndex = (int) $chunkIndex;
$totalChunks = (int) $totalChunks;
if ($chunkIndex < 0 || $chunkIndex >= $totalChunks) {
http_response_code(400);
exit(json_encode(['error' => 'chunk out of range']));
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit(json_encode(['error' => 'upload error']));
}
$baseDir = __DIR__ . '/storage/chunks/' . $fileId;
if (!is_dir($baseDir)) {
mkdir($baseDir, 0755, true);
}
$dest = $baseDir . '/' . $chunkIndex . '.part';
// 幂等:同分片重复上传直接覆盖
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) {
http_response_code(500);
exit(json_encode(['error' => 'move failed']));
}
echo json_encode([
'ok' => true,
'chunkIndex' => $chunkIndex,
]);
关键点:
fileId必须白名单校验- 分片名用数字:
0.part / 1.part / 2.part - 合并时不要按字符串排序,
10.part会排在2.part前面 - 同一分片可重复上传,覆盖即可
五、状态接口:支持断点续传
<?php
// upload_status.php
$fileId = $_GET['fileId'] ?? '';
if (!ctype_alnum($fileId)) {
exit(json_encode(['uploaded' => []]));
}
$dir = __DIR__ . '/storage/chunks/' . $fileId;
$uploaded = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.part') as $file) {
$name = basename($file, '.part');
if (is_numeric($name)) {
$uploaded[] = (int) $name;
}
}
sort($uploaded, SORT_NUMERIC);
}
echo json_encode(['uploaded' => $uploaded]);
生产环境可以把状态放 Redis / 数据库:
upload:task:{fileId} => {
fileName,
fileSize,
totalChunks,
uploaded: [0,1,2,4]
}
六、合并文件:最容易写出 bug 的一步
1. 错误写法
$content = '';
foreach ($chunks as $c) {
$content .= file_get_contents($c); // 大文件直接内存爆炸
}
file_put_contents($finalFile, $content);
2. 正确写法:流式合并
<?php
// upload_merge.php
$fileId = $_POST['fileId'] ?? '';
$fileName = $_POST['fileName'] ?? '';
$totalChunks= (int) ($_POST['totalChunks'] ?? 0);
if (!ctype_alnum($fileId)) {
http_response_code(400);
exit(json_encode(['error' => 'invalid fileId']));
}
$chunkDir = __DIR__ . '/storage/chunks/' . $fileId;
$finalDir = __DIR__ . '/storage/files';
if (!is_dir($finalDir)) {
mkdir($finalDir, 0755, true);
}
// 1. 校验分片是否齐全
for ($i = 0; $i < $totalChunks; $i++) {
if (!file_exists($chunkDir . "/$i.part")) {
http_response_code(400);
exit(json_encode([
'error' => 'missing chunk',
'missing' => $i,
]));
}
}
// 2. 生成安全文件名
$ext = pathinfo($fileName, PATHINFO_EXTENSION);
$ext = preg_replace('/[^a-z0-9]/i', '', $ext);
$finalName = bin2hex(random_bytes(16)) . ($ext ? ".$ext" : '');
$finalPath = $finalDir . '/' . $finalName;
// 3. 按数字顺序流式合并
$out = fopen($finalPath, 'wb');
if (!$out) {
http_response_code(500);
exit(json_encode(['error' => 'cannot open final file']));
}
for ($i = 0; $i < $totalChunks; $i++) {
$part = $chunkDir . "/$i.part";
$in = fopen($part, 'rb');
if (!$in) {
fclose($out);
unlink($finalPath);
http_response_code(500);
exit(json_encode(['error' => "cannot read chunk $i"]));
}
stream_copy_to_stream($in, $out);
fclose($in);
}
fclose($out);
// 4. 校验大小
$expectedSize = (int) ($_POST['fileSize'] ?? 0);
if ($expectedSize > 0 && filesize($finalPath) !== $expectedSize) {
unlink($finalPath);
http_response_code(400);
exit(json_encode(['error' => 'size mismatch']));
}
// 5. 清理分片
array_map('unlink', glob($chunkDir . '/*.part'));
rmdir($chunkDir);
echo json_encode([
'ok' => true,
'path' => 'storage/files/' . $finalName,
'size' => filesize($finalPath),
]);
七、完整性校验:别省
至少做三层:
1. 分片级
前端算每个分片 MD5,后端比对(可选,但推荐)
2. 文件级
前端算整个文件 hash:
fileHash = spark-md5 / Web Crypto SHA-256
后端合并后:
hash_file('sha256', $finalPath) === $clientFileHash
不一致 → 文件损坏,删除重传。
3. 业务级
- 文件大小上限
- MIME 类型:
finfo_open(FILEINFO_MIME_TYPE) - 扩展名白名单
- 禁止执行权限
- 存到 web 目录外,或用 Nginx 做下载代理
八、PHP / Nginx 该改什么
php.ini
upload_max_filesize = 20M
post_max_size = 24M
max_execution_time = 300
max_input_time = 300
memory_limit = 256M
分片上传不是为了让"单文件 2GB 通过 PHP 限制",而是让每个请求只有 5MB。
Nginx
client_max_body_size 20m;
client_body_timeout 300s;
fastcgi_read_timeout 300s;
proxy_read_timeout 300s;
很多人卡在这里:PHP 配好了,Nginx 返回 413 Request Entity Too Large。
九、并发上传怎么处理
前端可以并发传分片,比如 3~5 个:
const pool = 3;
但要注意:
-
合并必须单线程
-
用 Redis 锁 / 文件锁防止重复合并
-
分片本身可以乱序到,但合并必须按序号
fp = fopen(lockFile, 'w');
if (!flock(fp, LOCK_EX | LOCK_NB)) { exit(json_encode(['error' => 'merging'])); } // 合并... flock(fp, LOCK_UN);
fclose($fp);
十、完整流程图
选择文件
↓
计算 fileHash / fileId
↓
查询后端已上传分片
↓
前端切片
↓
跳过已上传分片
↓
逐片/并发上传
↓
失败重试
↓
全部完成 → 请求 merge
↓
后端校验分片齐全
↓
流式合并
↓
hash / size 校验
↓
落库、返回文件地址
↓
清理临时分片
十一、生产环境进阶方案
如果项目重要,不建议从零手搓,可以用:
- 前端:Uppy、Flow.js、Resumable.js、tus-js-client
- 协议:tus 断点续传协议
- 对象存储:阿里云 / 腾讯云 / 七牛 / S3 分片上传
- 后端:PHP 只做"签名 + 状态管理",真正存文件交给对象存储
典型架构:
浏览器 → 后端拿上传凭证 → 直传对象存储 → 回调通知 PHP
这样 PHP 不背文件 IO,扩展性最好。
十二、一句话总结
大文件上传 = 前端切片 + 分片幂等上传 + 后端按序暂存 + 状态可查询 + 合并时流式写 + 哈希/大小校验 + 安全落盘。
调大 upload_max_filesize 只是权宜之计;
分片上传才是工程上能扛生产的方案。