PHP 大文件上传、断点续传完整实现思路

PHP 大文件上传、断点续传完整实现思路

一、为什么需要断点续传?

传统上传的痛点

假设你要上传一个 2GB 的视频文件:

问题 表现 后果
网络中断 上传到80%时WiFi断了 重新开始,前功尽弃
超时限制 PHP max_execution_time 默认30秒 大文件必然超时
内存溢出 upload_max_filesize 限制 超过2M直接被拒
用户体验差 进度条卡死、无法暂停 用户流失

断点续传的核心价值

  • 失败恢复:从中断处继续,而非从头开始

  • 分片加速:多片并行上传,充分利用带宽

  • 暂停/继续:用户可随时控制上传节奏

  • 校验完整性:确保文件传输无损坏

二、整体架构设计

复制代码
客户端                         服务端
┌─────────────┐              ┌──────────────────┐
│ 文件分片     │ ──分片1──▶  │ 临时目录存储分片  │
│ 计算MD5     │ ──分片2──▶  │ 记录上传状态     │
│ 记录进度     │ ──分片3──▶  │ 合并验证完整性   │
│ 断点恢复     │ ◀──响应──  │ 返回已上传分片   │
└─────────────┘              └──────────────────┘

三、前端实现(HTML + JavaScript)

3.1 HTML 基础结构

复制代码
<!DOCTYPE html>
<html>
<head>
    <title>断点续传上传</title>
    <style>
        .progress-bar { width: 300px; height: 24px; background: #eee; border-radius: 12px; overflow: hidden; }
        .progress-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #45a049); transition: width 0.3s; }
        .chunk-status { margin-top: 10px; font-size: 13px; color: #666; }
    </style>
</head>
<body>
    <input type="file" id="fileInput" />
    <button onclick="startUpload()">开始上传</button>
    <button onclick="pauseUpload()">暂停</button>
    <div class="progress-bar">
        <div class="progress-fill" id="progressFill" style="width: 0%"></div>
    </div>
    <div class="chunk-status" id="status"></div>
    
    <script src="uploader.js"></script>
</body>
</html>

3.2 核心上传逻辑(uploader.js)

复制代码
class ChunkUploader {
    constructor(file, chunkSize = 1024 * 1024) {
        this.file = file;
        this.chunkSize = chunkSize;          // 每片大小(默认1MB)
        this.chunks = Math.ceil(file.size / chunkSize);
        this.uploadedChunks = new Set();      // 已上传的分片索引
        this.isPaused = false;
        this.fileHash = '';                  // 文件唯一标识
        this.abortController = null;          // 用于取消请求
    }

    // 第一步:计算文件哈希(唯一标识)
    async calculateHash() {
        return new Promise((resolve, reject) => {
            const worker = new Worker('hash-worker.js');
            worker.postMessage({ file: this.file });
            
            worker.onmessage = (e) => {
                if (e.data.type === 'progress') {
                    // 可选:显示哈希计算进度
                } else if (e.data.type === 'complete') {
                    this.fileHash = e.data.hash;
                    resolve(this.fileHash);
                }
            };
            worker.onerror = reject;
        });
    }

    // 第二步:检查已上传状态
    async checkUploadStatus() {
        const response = await fetch('/check-upload', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                file_hash: this.fileHash,
                file_name: this.file.name,
                total_size: this.file.size
            })
        });
        const data = await response.json();
        
        if (data.status === 'completed') {
            return { needUpload: false, url: data.file_url };
        }
        
        // 返回已上传的分片列表
        this.uploadedChunks = new Set(data.uploaded_chunks || []);
        return { needUpload: true, uploadedChunks: this.uploadedChunks };
    }

    // 第三步:上传单个分片
    async uploadChunk(index) {
        const start = index * this.chunkSize;
        const end = Math.min(start + this.chunkSize, this.file.size);
        const blob = this.file.slice(start, end);
        
        const formData = new FormData();
        formData.append('chunk', blob);
        formData.append('index', index);
        formData.append('total_chunks', this.chunks);
        formData.append('file_hash', this.fileHash);
        formData.append('file_name', this.file.name);

        try {
            const response = await fetch('/upload-chunk', {
                method: 'POST',
                body: formData,
                signal: this.abortController?.signal
            });
            
            if (!response.ok) throw new Error(`分片${index}上传失败`);
            
            this.uploadedChunks.add(index);
            this.updateProgress();
            
            return true;
        } catch (error) {
            if (error.name === 'AbortError') {
                console.log('上传被暂停');
                return false;
            }
            throw error;
        }
    }

    // 第四步:控制上传流程
    async startUpload() {
        this.isPaused = false;
        this.abortController = new AbortController();
        
        // 1. 计算文件哈希
        document.getElementById('status').textContent = '正在计算文件指纹...';
        await this.calculateHash();
        
        // 2. 检查已有进度
        document.getElementById('status').textContent = '检查已上传部分...';
        const status = await this.checkUploadStatus();
        
        if (!status.needUpload) {
            alert('文件已存在: ' + status.url);
            return;
        }
        
        // 3. 逐片上传(可改为并行上传)
        document.getElementById('status').textContent = '开始上传...';
        for (let i = 0; i < this.chunks; i++) {
            if (this.isPaused) break;
            if (this.uploadedChunks.has(i)) continue;  // 跳过已上传
            
            await this.uploadChunk(i);
        }
        
        // 4. 通知服务端合并
        if (!this.isPaused) {
            await this.mergeFile();
        }
    }

    // 第五步:通知合并
    async mergeFile() {
        const response = await fetch('/merge-chunks', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                file_hash: this.fileHash,
                file_name: this.file.name,
                total_chunks: this.chunks
            })
        });
        
        const result = await response.json();
        if (result.success) {
            alert('上传完成!文件地址:' + result.url);
        }
    }

    // 暂停上传
    pauseUpload() {
        this.isPaused = true;
        this.abortController?.abort();
    }

    // 更新进度条
    updateProgress() {
        const percent = (this.uploadedChunks.size / this.chunks) * 100;
        document.getElementById('progressFill').style.width = percent + '%';
        document.getElementById('status').textContent = 
            `已上传 ${this.uploadedChunks.size}/${this.chunks} 分片`;
    }
}

// 启动上传
async function startUpload() {
    const fileInput = document.getElementById('fileInput');
    if (!fileInput.files[0]) {
        alert('请选择文件');
        return;
    }
    
    window.uploader = new ChunkUploader(fileInput.files[0], 2 * 1024 * 1024); // 2MB分片
    await window.uploader.startUpload();
}

function pauseUpload() {
    window.uploader?.pauseUpload();
}

3.3 Web Worker 计算文件哈希

hash-worker.js:

复制代码
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.min.js');

self.onmessage = function(e) {
    const file = e.data.file;
    const chunkSize = 2 * 1024 * 1024;  // 2MB读取块
    const chunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();

    reader.onload = function(event) {
        spark.append(event.target.result);
        currentChunk++;
        
        self.postMessage({
            type: 'progress',
            progress: (currentChunk / chunks) * 100
        });

        if (currentChunk < chunks) {
            loadNext();
        } else {
            self.postMessage({
                type: 'complete',
                hash: spark.end()
            });
        }
    };

    reader.onerror = function() {
        self.postMessage({ type: 'error', message: '文件读取失败' });
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext();
};

四、后端实现(PHP)

4.1 目录结构

复制代码
project/
├── uploads/               # 最终文件存储目录
│   └── chunks/            # 临时分片目录
│       └── {file_hash}/   # 每个文件一个子目录
│           ├── 0.part
│           ├── 1.part
│           └── ...
├── upload_handler.php     # 上传处理入口
├── check_status.php       # 检查上传状态
└── merge_chunks.php       # 合并分片

4.2 配置文件

复制代码
<?php
// config.php
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('CHUNK_DIR', UPLOAD_DIR . 'chunks/');
define('MAX_FILE_SIZE', 10 * 1024 * 1024 * 1024);  // 10GB
define('ALLOWED_EXTENSIONS', ['mp4', 'avi', 'zip', 'pdf', 'docx']);
define('CHUNK_EXPIRE_HOURS', 48);  // 未完成的分片保留时间

// 确保目录存在
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
if (!is_dir(CHUNK_DIR)) mkdir(CHUNK_DIR, 0755, true);

4.3 检查上传状态

复制代码
<?php
// check_status.php
require_once 'config.php';

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit(json_encode(['error' => 'Method not allowed']));
}

$data = json_decode(file_get_contents('php://input'), true);
$fileHash = $data['file_hash'] ?? '';
$fileName = $data['file_name'] ?? '';

if (!$fileHash || !$fileName) {
    http_response_code(400);
    exit(json_encode(['error' => '参数缺失']));
}

$chunkDir = CHUNK_DIR . $fileHash . '/';
$finalPath = UPLOAD_DIR . $fileName;

// 情况1:文件已经完整上传
if (file_exists($finalPath)) {
    echo json_encode([
        'status' => 'completed',
        'file_url' => '/uploads/' . $fileName,
        'uploaded_chunks' => []
    ]);
    exit;
}

// 情况2:有部分分片
$uploadedChunks = [];
if (is_dir($chunkDir)) {
    $files = scandir($chunkDir);
    foreach ($files as $file) {
        if (preg_match('/^(\d+)\.part$/', $file, $matches)) {
            $uploadedChunks[] = (int)$matches[1];
        }
    }
    sort($uploadedChunks);
}

echo json_encode([
    'status' => 'in_progress',
    'uploaded_chunks' => $uploadedChunks,
    'file_url' => null
]);

4.4 接收分片上传

复制代码
<?php
// upload_chunk.php
require_once 'config.php';

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit(json_encode(['error' => 'Method not allowed']));
}

// 验证参数
$requiredFields = ['index', 'total_chunks', 'file_hash', 'file_name'];
foreach ($requiredFields as $field) {
    if (!isset($_POST[$field])) {
        http_response_code(400);
        exit(json_encode(['error' => "缺少参数: {$field}"]));
    }
}

$chunkIndex = (int)$_POST['index'];
$totalChunks = (int)$_POST['total_chunks'];
$fileHash = $_POST['file_hash'];
$fileName = basename($_POST['file_name']);  // 防止路径穿越

// 验证文件扩展名
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if (!in_array($ext, ALLOWED_EXTENSIONS)) {
    http_response_code(403);
    exit(json_encode(['error' => '不支持的文件类型']));
}

// 验证文件大小
if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit(json_encode(['error' => '分片上传失败']));
}

// 创建分片目录
$chunkDir = CHUNK_DIR . $fileHash . '/';
if (!is_dir($chunkDir)) {
    mkdir($chunkDir, 0755, true);
}

// 保存分片文件
$chunkPath = $chunkDir . $chunkIndex . '.part';
if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $chunkPath)) {
    http_response_code(500);
    exit(json_encode(['error' => '保存分片失败']));
}

// 可选:校验分片完整性
$expectedSize = $chunkIndex < $totalChunks - 1 
    ? CHUNK_SIZE 
    : filesize($_FILES['chunk']['tmp_name']); // 最后一片可能较小

echo json_encode([
    'success' => true,
    'index' => $chunkIndex,
    'received_size' => filesize($chunkPath)
]);

4.5 合并分片

复制代码
<?php
// merge_chunks.php
require_once 'config.php';

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit(json_encode(['error' => 'Method not allowed']));
}

$data = json_decode(file_get_contents('php://input'), true);
$fileHash = $data['file_hash'] ?? '';
$fileName = basename($data['file_name'] ?? '');
$totalChunks = (int)($data['total_chunks'] ?? 0);

if (!$fileHash || !$fileName || !$totalChunks) {
    http_response_code(400);
    exit(json_encode(['error' => '参数缺失']));
}

$chunkDir = CHUNK_DIR . $fileHash . '/';
$finalPath = UPLOAD_DIR . $fileName;

// 检查是否所有分片都存在
for ($i = 0; $i < $totalChunks; $i++) {
    $chunkPath = $chunkDir . $i . '.part';
    if (!file_exists($chunkPath)) {
        http_response_code(400);
        exit(json_encode([
            'error' => "缺少分片 {$i}",
            'missing_index' => $i
        ]));
    }
}

// 合并文件(流式写入,避免内存爆炸)
$finalHandle = fopen($finalPath, 'wb');
if (!$finalHandle) {
    http_response_code(500);
    exit(json_encode(['error' => '无法创建目标文件']));
}

for ($i = 0; $i < $totalChunks; $i++) {
    $chunkPath = $chunkDir . $i . '.part';
    $chunkHandle = fopen($chunkPath, 'rb');
    
    // 流式复制,每次读取8KB
    while (!feof($chunkHandle)) {
        fwrite($finalHandle, fread($chunkHandle, 8192));
    }
    
    fclose($chunkHandle);
    unlink($chunkPath);  // 删除分片
}

fclose($finalHandle);

// 清理空目录
rmdir($chunkDir);

// 验证最终文件
$actualSize = filesize($finalPath);
$expectedSize = array_sum(array_map('filesize', glob($chunkDir . '*.part')));

echo json_encode([
    'success' => true,
    'file_url' => '/uploads/' . $fileName,
    'size' => $actualSize
]);

4.6 统一入口路由

复制代码
<?php
// index.php - 简单路由
$action = $_GET['action'] ?? '';

switch ($action) {
    case 'check':
        require 'check_status.php';
        break;
    case 'upload':
        require 'upload_chunk.php';
        break;
    case 'merge':
        require 'merge_chunks.php';
        break;
    default:
        http_response_code(404);
        echo json_encode(['error' => '未知操作']);
}

五、高级优化

5.1 并行上传(浏览器端)

复制代码
// 在 startUpload 方法中替换循环为并行
async uploadAllChunks() {
    const CONCURRENCY = 5;  // 并发数
    const tasks = [];
    
    for (let i = 0; i < this.chunks; i++) {
        if (this.uploadedChunks.has(i)) continue;
        tasks.push(() => this.uploadChunk(i));
    }
    
    // 使用并发控制
    const results = [];
    const executing = new Set();
    
    for (const task of tasks) {
        if (this.isPaused) break;
        
        const promise = task().then(result => {
            executing.delete(promise);
            return result;
        });
        
        executing.add(promise);
        results.push(promise);
        
        if (executing.size >= CONCURRENCY) {
            await Promise.race(executing);
        }
    }
    
    await Promise.all(results);
}

5.2 服务端限速与防滥用

复制代码
// 在 upload_chunk.php 中添加
// IP 频率限制
$ip = $_SERVER['REMOTE_ADDR'];
$rateKey = "upload_rate:{$ip}";
$currentCount = $redis->incr($rateKey);
$redis->expire($rateKey, 60);

if ($currentCount > 600) {  // 每分钟最多600次请求
    http_response_code(429);
    exit(json_encode(['error' => '请求过于频繁']));
}

// 磁盘空间检查
$freeSpace = disk_free_space(UPLOAD_DIR);
if ($freeSpace < 500 * 1024 * 1024) {  // 剩余不足500MB
    http_response_code(507);
    exit(json_encode(['error' => '服务器存储空间不足']));
}

5.3 定时清理过期分片

复制代码
<?php
// cleanup.php - 由cron定期执行
require_once 'config.php';

$expireTime = time() - (CHUNK_EXPIRE_HOURS * 3600);
$dirs = glob(CHUNK_DIR . '*', GLOB_ONLYDIR);

foreach ($dirs as $dir) {
    $mtime = filemtime($dir);
    if ($mtime < $expireTime) {
        // 递归删除目录
        $files = glob($dir . '/*');
        foreach ($files as $file) {
            unlink($file);
        }
        rmdir($dir);
    }
}

echo "Cleanup completed at " . date('Y-m-d H:i:s');

六、安全性考虑

风险 防护措施
路径遍历攻击 使用 basename() 过滤文件名
任意文件覆盖 基于文件哈希隔离存储
超大文件耗尽磁盘 检查 disk_free_space()
恶意分片填充 限制单个IP的上传速率
文件类型欺骗 服务端验证文件头魔数
并发写入冲突 使用 flock() 加锁合并

七、总结

这套断点续传方案的核心优势:

  1. 可靠性:任意中断都能恢复,不丢失数据

  2. 高效性:并行上传充分利用带宽

  3. 可扩展:支持TB级文件上传

  4. 低成本:纯PHP实现,无需额外组件

适用场景:

  • 视频平台的大文件上传

  • 云盘系统的文件同步

  • 企业内部的资料归档

  • 日志收集系统的批量导入

通过分片、哈希校验和状态追踪三个核心机制,我们让PHP也能优雅地处理GB级别的文件上传。关键在于把大问题拆解成小问题,再逐个解决。

相关推荐
l1t1 小时前
PostgreSQL 11–18 中的 SQL 改进:个人精选-1
数据库·sql·postgresql
逃跑的浣熊2 小时前
MySQL 性能分析报告:Page Cache 与 fsync 对读写性能的影响
数据库
路由侠内网穿透.2 小时前
本地部署开源日志收集系统 Log Bull 并实现外部访问
运维·服务器·网络·数据库·开源
sevenll073 小时前
SqlKit - 覆盖 50+ 数据库的 AI 智能体 SQL 桌面客户端
数据库·人工智能·sql·智能体
数智化管理手记5 小时前
手工统计指标误差大、效率低?指标管理系统如何告别人工算数痛点?
大数据·运维·数据库·人工智能·云计算
ShiXZ21311 小时前
Redis 常用指令全集:redis-cli 实战速查手册
数据库·redis·缓存
晓子文集12 小时前
Tushare接口文档:期货日线行情(fut_daily)
大数据·数据库·金融数据·量化投资·tushare
WA内核拾荒者12 小时前
WhatsApp 账号异常检测的自动化告警系统设计
数据库·python·自动化
龙仔72516 小时前
人大金仓OS_Core数据库自动备份实施笔记(银河麒麟Linux)
linux·数据库·笔记·备份·人大金仓