PHP如何实现大文件分片上传并支持断点续传功能开发

各位同学,大家好!

今天咱们不聊虚的,咱们来聊一个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,不是spliceslice是切蛋糕,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逻辑分三步走:

  1. 验证身份: 检查fileHash是不是合法的,检查是不是同一个文件在重复上传。
  2. 保存碎片: 把接收到的chunk移动到临时目录,文件名可以是 temp/{fileHash}_index.tmp
  3. 反馈: 返回成功。

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;

有了这个表,流程就变成了:

  1. 初始化: 前端发送文件信息(文件名、MD5、总片数)给后端。
  2. 后端检查: 查库。如果表里没有这条记录,插入一条(状态:进行中)。如果有,说明是断点续传,读取uploaded_chunks
  3. 前端重试: 前端根据uploaded_chunks的值,跳过前面已经上传过的片,只上传剩下的。
  4. 后端接收: 收到切片,移动文件,同时更新数据库:UPDATE upload_progress SET uploaded_chunks = uploaded_chunks + 1 WHERE file_hash = 'xxx'
  5. 合并: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(测试环境),或者更好的办法,使用chownchgrp指定正确的用户组。

坑四:前端MD5计算太慢
在上面的代码里,我为了演示,直接返回了假的MD5。但在真实场景下,计算一个1GB文件的MD5可能需要几秒钟,这期间用户看着进度条不动,会以为浏览器死机了。
解药: 使用Web Worker在后台线程计算MD5,或者使用一个专门的Web服务(如FastDFS自带的校验)。


第七部分:高级玩法——秒传与秒撤回

讲到这里,基础的断点续传已经完成了。作为一个“资深专家”,我觉得这还不够。让我们来点更高级的。

秒传:
如果文件已经存在了怎么办?比如用户第一次上传了《哈利波特》,过了两天又想传一遍。
我们的前端在切片前,计算MD5。前端先发一个请求给后端:“老板,我手里有个文件,MD5是abc123,你查查库里有没有。”
后端查数据库:“有啊!代码全一样!直接返回成功!前端直接显示上传完成!”
这样,用户不需要上传,后端不需要存储,双方都开心。这叫节省带宽

秒撤回:
如果用户上传到一半觉得文件不对,想删掉。后端只需在数据库里把status改成0,或者把那个目录删掉就行。


结语:拥抱分片

好了,同学们,今天的讲座就到这里。

我们回顾一下:

  1. 大文件是PHP的噩梦,分片是唯一的出路。
  2. 前端负责切,后端负责存,数据库负责记。
  3. MD5指纹是身份的象征,必须要有。
  4. 合并时要用流式写入,别把内存吃撑了。
  5. 别忘了配置Nginx的超时时间,否则一切白搭。

实现大文件分片上传,不仅仅是修改几行代码,它考验的是你对HTTP协议的理解、对文件系统的掌控以及对用户体验的细腻思考。

当你看到那个进度条稳步前行,不管网络怎么波动,都能稳稳当当地恢复,直到最后一个大功告成的那一刻,你会感到一种前所未有的成就感。那种感觉,就像是你亲手把散落在地上的积木,一块一块拼成了宏伟的城堡。

所以,别再忍受那个红色的post_max_size exceeded报错了。拿起你的键盘,去实现你的分片上传系统吧!记得,代码要优雅,逻辑要清晰,最重要的是——善待你的服务器CPU

谢谢大家!下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注