PHP 大文件上传解决方案:分片上传完整思路

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 请求 → 按序号合并 → 校验 → 生成最终文件

核心概念有三个:

  1. fileId / fileHash:标识"同一个文件"
  2. chunkIndex:第几片
  3. 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 只是权宜之计;

分片上传才是工程上能扛生产的方案。

相关推荐
Wang's Blog5 小时前
Java 项目实战:交友项目 - 我的访客:需求说明
java·开发语言·交友
niucloud-admin5 小时前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:订单回访
php
布莱克6056 小时前
C++ 中堆和栈的区别详解
开发语言·c++·栈·堆
像风一样自由20206 小时前
3.Dockerfile如何构建一个Python算法镜像
开发语言·人工智能·python·大模型
大黄说说7 小时前
Composer 常见报错汇总:镜像、依赖冲突、环境坑一站式排查
php·composer
安易算力7 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
BuHuaX7 小时前
Lua 与 ToLua:运行机制及热更新原理
开发语言·unity·junit·c#·lua
xys_6787 小时前
深挖C++:C++入门基础
开发语言·c++