各位同学,大家好!
今天咱们不聊虚的,咱们来聊一个PHP开发者,尤其是做Web后端开发的哥们儿,午夜梦回时最容易被吓醒的话题——大文件上传。
还记得你当年第一次写PHP上传代码,信心满满地写完,结果文件只有5MB,服务器直接卡死,或者PHP抛出一个凄惨的post_max_size exceeded错误,那一刻,你的心是不是比你的硬盘还凉?
想象一下这样一个场景:你辛辛苦苦写了三个月的毕设视频(或者公司的高清产品手册),上传到服务器,网络突然断了,或者你妈突然喊你吃晚饭。你挂断电话,回到电脑前,发现之前的进度没了,你必须从头开始!那一刻,你的眼泪掉下来,比文件里的像素点还多。
别哭!今天,我就要带着大家,用一种极其优雅、极其“PHP”的方式——分片上传加上断点续传,来彻底粉碎这个噩梦。
我们要把那个巨大的“汉堡”切成一口能吃下的“小肉饼”。至于断点续传?那就像是给肉饼装了个GPS定位,下次吃的时候,从没吃完的那一块接着吃。
准备好了吗?让我们开始这场代码的“手术刀”之旅。
第一部分:为什么PHP处理大文件是个坑?
在深入代码之前,我们先得搞清楚PHP这个“粘合剂”为什么会在这个问题上掉链子。
默认情况下,PHP处理文件上传是同步阻塞的。一旦浏览器发来一个POST请求,PHP进程就会暂停所有其他工作,去拼命读取那个临时文件。如果文件是500MB,PHP进程就会在这个状态上傻傻地等上几分钟甚至更久。
这时候,Web服务器(比如Nginx)会认为PHP挂了,直接把连接切断。于是,你的大文件上传宣告失败,或者只上传了一半。
解决方案很简单:不要让PHP一次性吃完整个汉堡。 我们要把它切成1MB、2MB的小块,一个接一个地喂给PHP。这就引出了我们的核心架构:前端切片 + 后端聚合。
第二部分:前端——手起刀落
首先要说的是,PHP不能决定前端切多少。切多大的“肉饼”,得由你的前端JS大哥来决定。
这里有一个关键的API:Blob.slice()。注意,是slice,不是splice!slice是切蛋糕,splice是把蛋糕扔掉。别搞错了,搞错了会报错。
我们的策略是:前端计算文件总大小,然后设定一个切片大小(比如1MB)。然后,用一个循环,把文件切成无数个Blob对象。每个Blob对象里,还得带上当前是第几片(index),以及总共有多少片(total),最重要的是——整个文件的唯一指纹(MD5)。
为什么要有MD5?为了防止用户把A文件的MD5填到了B文件的切片请求里。这叫幂等性,听起来很高大上,其实就是为了防止服务器存储了一堆垃圾碎片。
前端代码示例(原生JavaScript):
async function uploadLargeFile(file) {
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB per chunk
const fileHash = await calculateFileHash(file); // 假设你有一个计算MD5的函数
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
console.log(`开始切片!总大小: ${file.size}, 片数: ${totalChunks}, 文件指纹: ${fileHash}`);
// 并发上传,提高速度,当然如果网速太慢,你也可以改成串行
const uploadTasks = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunk = file.slice(start, end);
// 构造FormData
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkIndex', i);
formData.append('chunkSize', CHUNK_SIZE);
formData.append('totalChunks', totalChunks);
formData.append('fileHash', fileHash); // 关键:文件指纹,用来判断是不是同一个文件
formData.append('fileName', file.name);
uploadTasks.push(uploadChunk(formData));
}
// 等待所有切片上传完成
const results = await Promise.all(uploadTasks);
// 如果所有切片都返回200,那我们就可以通知后端合并了
const allSuccess = results.every(r => r === 'success');
if(allSuccess) {
mergeFile(fileHash, file.name, totalChunks);
}
}
async function uploadChunk(formData) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'upload.php', true);
xhr.onload = function() {
if (xhr.status === 200) {
resolve('success');
} else {
reject('error');
}
};
xhr.onerror = function() {
reject('error');
};
xhr.send(formData);
});
}
// 注意:这里只是个演示,真实的MD5计算在浏览器端可能会卡顿大文件,生产环境通常在Web Worker中跑
function calculateFileHash(file) {
// ...省略MD5计算逻辑...
return Promise.resolve('mock_hash_123456');
}
这段代码的逻辑很简单:切蛋糕 -> 装盒 -> 发货。注意看,我们给每个切片都打了标签(chunkIndex, fileHash),这就像给每个小肉饼贴了个二维码,方便后端接收。
第三部分:后端——那个只会捡破烂的PHP
现在前端把小肉饼发过来了。PHP接收到这个multipart/form-data请求。注意,我们不需要修改PHP.ini里的upload_max_filesize,因为我们从来不吃大块头,我们只吃一口!
我们的PHP逻辑分三步走:
- 验证身份: 检查
fileHash是不是合法的,检查是不是同一个文件在重复上传。 - 保存碎片: 把接收到的
chunk移动到临时目录,文件名可以是temp/{fileHash}_index.tmp。 - 反馈: 返回成功。
PHP后端核心代码:
<?php
// upload.php
// 1. 获取前端传来的参数
$fileHash = $_POST['fileHash'] ?? '';
$chunkIndex = $_POST['chunkIndex'] ?? '';
$fileName = $_POST['fileName'] ?? '';
if (!$fileHash || $chunkIndex === '') {
die(json_encode(['code' => 400, 'msg' => '参数错误']));
}
// 2. 设置临时存储目录(注意权限!PHP进程需要有写入权限)
$uploadDir = './uploads/' . $fileHash; // 以文件指纹建目录,防止冲突
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// 3. 接收上传的文件
// 注意:$_FILES['file']['tmp_name'] 是PHP自动分配的临时文件路径
$chunkFile = $uploadDir . '/' . $chunkIndex;
// 4. 将临时文件移动到我们指定的目录(安全!)
if (move_uploaded_file($_FILES['file']['tmp_name'], $chunkFile)) {
// 这里可以记录日志,或者更新数据库状态
echo json_encode(['code' => 200, 'msg' => '分片上传成功']);
} else {
echo json_encode(['code' => 500, 'msg' => '保存失败']);
}
瞧,PHP干完活了。它并没有把文件存成一个巨大的文件,而是存成了成百上千个微小的文件。这大大降低了PHP内存溢出(OOM)的风险,因为PHP不需要把整个文件加载到内存里。
第四部分:灵魂——断点续传与状态管理
这是本节课的重头戏。如果只是上传,那是小学生水平。我们要的是断点续传。
什么是断点续传?就是网络断了,过一会儿我重连,你告诉我:“嘿,哥们儿,上回切到第3片了,剩下的4、5、6片你赶紧补上。”
怎么实现?我们需要一个大脑,来记录上传的状态。这个大脑可以是MySQL数据库,也可以是Redis。为了通俗易懂,我们这里用MySQL。
我们需要一个表来记录每个文件的进度:
CREATE TABLE `upload_progress` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`file_hash` varchar(32) NOT NULL COMMENT '文件MD5',
`file_name` varchar(255) NOT NULL,
`total_chunks` int(11) NOT NULL COMMENT '总片数',
`uploaded_chunks` int(11) NOT NULL DEFAULT '0' COMMENT '已上传片数',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-进行中,1-完成',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_hash` (`file_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
有了这个表,流程就变成了:
- 初始化: 前端发送文件信息(文件名、MD5、总片数)给后端。
- 后端检查: 查库。如果表里没有这条记录,插入一条(状态:进行中)。如果有,说明是断点续传,读取
uploaded_chunks。 - 前端重试: 前端根据
uploaded_chunks的值,跳过前面已经上传过的片,只上传剩下的。 - 后端接收: 收到切片,移动文件,同时更新数据库:
UPDATE upload_progress SET uploaded_chunks = uploaded_chunks + 1 WHERE file_hash = 'xxx'。 - 合并: 当
uploaded_chunks == total_chunks时,触发合并。
断点续传逻辑增强版:
// check.php - 前端上传前先问问后端,我需要传哪些片?
$hash = $_GET['hash'];
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', 'password');
$stmt = $pdo->prepare("SELECT uploaded_chunks, total_chunks FROM upload_progress WHERE file_hash = ?");
$stmt->execute([$hash]);
$progress = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$progress) {
// 没记录,从头开始
echo json_encode(['needUpload' => []]);
} else {
// 有记录,计算缺失的片
$uploadedCount = $progress['uploaded_chunks'];
$totalCount = $progress['total_chunks'];
$missing = [];
for ($i = $uploadedCount; $i < $totalCount; $i++) {
$missing[] = $i;
}
echo json_encode(['needUpload' => $missing]);
}
这就像是服务员问你:“先生,刚才的龙虾已经上来了三只,还需要上三只吗?”
第五部分:合体——分片合并
现在,所有的碎片都已经在服务器的./uploads/{hash}/目录里躺好了。它们是散沙,现在我们要把它们聚成一座塔。
合并文件在PHP中也有讲究。千万不能用file_get_contents把它们全读进内存,然后file_put_contents写进去。如果文件是2GB,内存瞬间就炸了。
我们要用流式写入。就像我们用吸管喝水,而不是把一桶水倒进嘴里一样。我们用fopen一个个打开分片,用fread读出数据,再用fwrite写进目标文件。
合并核心代码:
<?php
// merge.php
$hash = $_POST['hash'] ?? '';
$fileName = $_POST['fileName'] ?? '';
$uploadDir = './uploads/' . $hash;
$targetFile = './uploads/finished/' . $fileName;
// 1. 检查目录里是不是有所有分片
// 2. 确保所有分片都存在,然后开始合并
$files = glob($uploadDir . '/*'); // 获取所有分片
// glob会返回数组,我们可以按数字排序,确保顺序正确
sort($files, SORT_NUMERIC);
$fp = fopen($targetFile, 'wb');
foreach ($files as $file) {
$chunkContent = file_get_contents($file);
fwrite($fp, $chunkContent);
// 清理一下内存,虽然PHP会自动GC,但手动清理个好习惯
unset($chunkContent);
}
fclose($fp);
// 3. 验证完整性(可选,但强烈建议)
if (md5_file($targetFile) === $hash) {
// 合并成功!清理临时文件夹
array_map('unlink', glob($uploadDir . '/*'));
rmdir($uploadDir);
// 更新数据库状态为完成
$pdo->prepare("UPDATE upload_progress SET status = 1 WHERE file_hash = ?")->execute([$hash]);
echo json_encode(['code' => 200, 'msg' => '合并成功']);
} else {
// 哎呀,合体失败了,MD5对不上
unlink($targetFile);
echo json_encode(['code' => 500, 'msg' => '合并失败,MD5校验不通过']);
}
注意看代码里的fwrite循环。这是处理大文件的神器。它就像一个精密的传送带,把碎片一个个输送到最终成品里,而不会让系统崩溃。
第六部分:那些年我们踩过的坑与Nginx配置
虽然我们有了PHP代码,但如果你直接这么干,大概率还是会遇到问题。这就像你有了一辆法拉利(代码),但没有红绿灯(Nginx配置),照样会撞车。
坑一:Nginx的超时时间
默认情况下,Nginx处理单个请求的时间是60秒。如果你切了1000片,每片1MB,上传每片可能需要1秒,60秒根本传不完。
解药: 在Nginx配置里加上这几行:
client_max_body_size 0; # 允许上传无限大
client_body_timeout 300s; # 超时时间延长到5分钟
proxy_read_timeout 300s;
proxy_send_timeout 300s;
坑二:前端并发太高导致服务器崩溃
我们在前端用了Promise.all,这意味着1000个请求会瞬间并发打过来。如果你的PHP代码里用了sleep或者循环里有死锁,服务器瞬间CPU 100%。
解药: 前端控制并发数,或者后端使用消息队列(如Redis List + Supervisor守护进程)来异步处理切片。
坑三:文件权限
你写了代码,文件却无法写入。在Linux下,PHP进程(通常是www-data)没有权限在/uploads目录下创建子目录。
解药: chmod 777 ./uploads(测试环境),或者更好的办法,使用chown和chgrp指定正确的用户组。
坑四:前端MD5计算太慢
在上面的代码里,我为了演示,直接返回了假的MD5。但在真实场景下,计算一个1GB文件的MD5可能需要几秒钟,这期间用户看着进度条不动,会以为浏览器死机了。
解药: 使用Web Worker在后台线程计算MD5,或者使用一个专门的Web服务(如FastDFS自带的校验)。
第七部分:高级玩法——秒传与秒撤回
讲到这里,基础的断点续传已经完成了。作为一个“资深专家”,我觉得这还不够。让我们来点更高级的。
秒传:
如果文件已经存在了怎么办?比如用户第一次上传了《哈利波特》,过了两天又想传一遍。
我们的前端在切片前,计算MD5。前端先发一个请求给后端:“老板,我手里有个文件,MD5是abc123,你查查库里有没有。”
后端查数据库:“有啊!代码全一样!直接返回成功!前端直接显示上传完成!”
这样,用户不需要上传,后端不需要存储,双方都开心。这叫节省带宽。
秒撤回:
如果用户上传到一半觉得文件不对,想删掉。后端只需在数据库里把status改成0,或者把那个目录删掉就行。
结语:拥抱分片
好了,同学们,今天的讲座就到这里。
我们回顾一下:
- 大文件是PHP的噩梦,分片是唯一的出路。
- 前端负责切,后端负责存,数据库负责记。
- MD5指纹是身份的象征,必须要有。
- 合并时要用流式写入,别把内存吃撑了。
- 别忘了配置Nginx的超时时间,否则一切白搭。
实现大文件分片上传,不仅仅是修改几行代码,它考验的是你对HTTP协议的理解、对文件系统的掌控以及对用户体验的细腻思考。
当你看到那个进度条稳步前行,不管网络怎么波动,都能稳稳当当地恢复,直到最后一个大功告成的那一刻,你会感到一种前所未有的成就感。那种感觉,就像是你亲手把散落在地上的积木,一块一块拼成了宏伟的城堡。
所以,别再忍受那个红色的post_max_size exceeded报错了。拿起你的键盘,去实现你的分片上传系统吧!记得,代码要优雅,逻辑要清晰,最重要的是——善待你的服务器CPU。
谢谢大家!下课!