PHP 大文件上传、断点续传完整实现思路
侧边栏壁纸
    love love love

  • 累计撰写 13 篇文章
  • 累计收到 0 条评论
  • 今日撰写 0 篇文章
php

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

admin
2026-08-07 / 0 评论 / 2 阅读 / 耗时: 20 ms /

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

传统 move_uploaded_file 一把梭只能应付小文件。真上了 GB 级视频 / 安装包 / 日志归档,必须上 分片 + 断点续传。本文给一套可落地的纯 PHP 实现。

一、为什么必须做断点续传?

假设上传一个 2GB 视频,传统方式的坑:

痛点表现后果
网络中断传到 80% WiFi 断了从头再来,前功尽弃
超时限制max_execution_time 默认 30s大文件必超时
内存/大小限制upload_max_filesize 默认 2M直接被拒
体验差进度条卡死、不能暂停用户流失

断点续传核心价值

  • ✅ 失败恢复:从中断处继续,不重头传
  • ✅ 分片并行:吃满带宽
  • ✅ 可暂停 / 继续
  • ✅ 哈希校验:保证文件完整性

二、整体架构

客户端                              服务端
┌─────────────────┐                ┌─────────────────────┐
│ 文件分片         │ ── 分片1 ──▶   │ 临时目录存分片        │
│ 计算 MD5 指纹    │ ── 分片2 ──▶   │ 记录上传状态          │
│ 本地记录进度     │ ── 分片3 ──▶   │ 合并 + 校验           │
│ 断点恢复         │ ◀─ 响应 ───   │ 返回已传分片列表      │
└─────────────────┘                └─────────────────────┘

三条主线:分片化 · 哈希标识 · 状态追踪


三、前端实现

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;
    }

    // 1. 计算文件哈希(Web Worker 中做,不卡 UI)
    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 === 'complete') {
                    this.fileHash = e.data.hash;
                    resolve(this.fileHash);
                }
            };
            worker.onerror = reject;
        });
    }

    // 2. 询问服务端已传分片
    async checkUploadStatus() {
        const res = 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 res.json();
        if (data.status === 'completed') return { needUpload: false, url: data.file_url };
        this.uploadedChunks = new Set(data.uploaded_chunks || []);
        return { needUpload: true };
    }

    // 3. 上传单分片
    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 fd = new FormData();
        fd.append('chunk', blob);
        fd.append('index', index);
        fd.append('total_chunks', this.chunks);
        fd.append('file_hash', this.fileHash);
        fd.append('file_name', this.file.name);

        const res = await fetch('/upload-chunk', {
            method: 'POST',
            body: fd,
            signal: this.abortController?.signal
        });
        if (!res.ok) throw new Error(`分片 ${index} 失败`);
        this.uploadedChunks.add(index);
        this.updateProgress();
    }

    // 4. 主控流程
    async startUpload() {
        this.isPaused = false;
        this.abortController = new AbortController();

        await this.calculateHash();
        const status = await this.checkUploadStatus();
        if (!status.needUpload) return alert('文件已存在: ' + status.url);

        for (let i = 0; i < this.chunks; i++) {
            if (this.isPaused) break;
            if (this.uploadedChunks.has(i)) continue;
            await this.uploadChunk(i);
        }
        if (!this.isPaused) await this.mergeFile();
    }

    async mergeFile() {
        const res = 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 r = await res.json();
        if (r.success) alert('上传完成:' + r.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 f = document.getElementById('fileInput').files[0];
    if (!f) return alert('请选择文件');
    window.uploader = new ChunkUploader(f, 2 * 1024 * 1024); // 2MB 分片
    await window.uploader.startUpload();
}
function pauseUpload() { window.uploader?.pauseUpload(); }

3.3 Web Worker 算 MD5(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;
    const chunks = Math.ceil(file.size / chunkSize);
    let current = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();

    reader.onload = function (ev) {
        spark.append(ev.target.result);
        if (++current < chunks) loadNext();
        else self.postMessage({ type: 'complete', hash: spark.end() });
    };
    function loadNext() {
        const s = current * chunkSize;
        reader.readAsArrayBuffer(file.slice(s, Math.min(s + chunkSize, file.size)));
    }
    loadNext();
};

{hint|关键点:哈希放 Worker 里算,2GB 文件也不会冻住主线程}


四、后端(PHP)

4.1 目录结构

project/
├── uploads/
│   ├── chunks/{file_hash}/0.part, 1.part ...
│   └── 最终文件
├── config.php
├── check_status.php
├── upload_chunk.php
└── merge_chunks.php

4.2 配置 config.php

<?php
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('CHUNK_DIR', UPLOAD_DIR . 'chunks/');
define('MAX_FILE_SIZE', 10 * 1024 * 1024 * 1024);
define('ALLOWED_EXTENSIONS', ['mp4', 'avi', 'zip', 'pdf', 'docx']);
define('CHUNK_EXPIRE_HOURS', 48);

is_dir(UPLOAD_DIR) || mkdir(UPLOAD_DIR, 0755, true);
is_dir(CHUNK_DIR) || mkdir(CHUNK_DIR, 0755, true);

4.3 检查已传状态 check_status.php

<?php
require_once 'config.php';
header('Content-Type: application/json');

$data = json_decode(file_get_contents('php://input'), true);
$hash = $data['file_hash'] ?? '';
$name = $data['file_name'] ?? '';
if (!$hash || !$name) http_response_code(400), exit(json_encode(['error' => '参数缺失']));

$final = UPLOAD_DIR . $name;
if (file_exists($final)) {
    exit(json_encode(['status' => 'completed', 'file_url' => '/uploads/' . $name]));
}

$dir = CHUNK_DIR . $hash . '/';
$uploaded = [];
if (is_dir($dir)) {
    foreach (scandir($dir) as $f) {
        if (preg_match('/^(\d+)\.part$/', $f, $m)) $uploaded[] = (int)$m[1];
    }
    sort($uploaded);
}
echo json_encode(['status' => 'in_progress', 'uploaded_chunks' => $uploaded]);

4.4 接收分片 upload_chunk.php

<?php
require_once 'config.php';
header('Content-Type: application/json');

foreach (['index','total_chunks','file_hash','file_name'] as $k) {
    if (!isset($_POST[$k])) http_response_code(400), exit(json_encode(['error' => "缺 {$k}"]));
}

$idx  = (int)$_POST['index'];
$total = (int)$_POST['total_chunks'];
$hash = $_POST['file_hash'];
$name = basename($_POST['file_name']); // 防路径穿越

$ext = strtolower(pathinfo($name, 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' => '分片接收失败']));

$dir = CHUNK_DIR . $hash . '/';
is_dir($dir) || mkdir($dir, 0755, true);

$path = $dir . $idx . '.part';
if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $path))
    http_response_code(500), exit(json_encode(['error' => '保存失败']));

echo json_encode(['success' => true, 'index' => $idx, 'size' => filesize($path)]);

4.5 合并分片 merge_chunks.php(流式,不爆内存)

<?php
require_once 'config.php';
header('Content-Type: application/json');

$d = json_decode(file_get_contents('php://input'), true);
$hash = $d['file_hash'] ?? '';
$name = basename($d['file_name'] ?? '');
$total = (int)($d['total_chunks'] ?? 0);
if (!$hash || !$name || !$total) http_response_code(400), exit(json_encode(['error' => '参数缺失']));

$dir = CHUNK_DIR . $hash . '/';
for ($i = 0; $i < $total; $i++) {
    if (!file_exists($dir . $i . '.part'))
        http_response_code(400), exit(json_encode(['error' => "缺分片 {$i}", 'missing_index' => $i]));
}

$final = fopen(UPLOAD_DIR . $name, 'wb');
for ($i = 0; $i < $total; $i++) {
    $part = fopen($dir . $i . '.part', 'rb');
    while (!feof($part)) fwrite($final, fread($part, 8192));
    fclose($part);
    unlink($dir . $i . '.part');
}
fclose($final);
rmdir($dir);

echo json_encode(['success' => true, 'file_url' => '/uploads/' . $name, 'size' => filesize(UPLOAD_DIR . $name)]);

4.6 统一入口 index.php

<?php
match ($_GET['action'] ?? '') {
    'check'  => require 'check_status.php',
    'upload' => require 'upload_chunk.php',
    'merge'  => require 'merge_chunks.php',
    default  => http_response_code(404),
};

五、高级优化

5.1 前端并发上传(并发度 5)

startUpload 里的串行 for 换成:

async uploadAllChunks(concurrency = 5) {
    const queue = [];
    for (let i = 0; i < this.chunks; i++)
        if (!this.uploadedChunks.has(i)) queue.push(() => this.uploadChunk(i));

    const exec = new Set();
    for (const task of queue) {
        if (this.isPaused) break;
        const p = task().then(() => exec.delete(p));
        exec.add(p);
        if (exec.size >= concurrency) await Promise.race(exec);
    }
}

5.2 服务端限速 / 防滥用(Redis 频率 + 磁盘检查)

$ip = $_SERVER['REMOTE_ADDR'];
$cnt = $redis->incr("upload_rate:$ip");
$redis->expire("upload_rate:$ip", 60);
if ($cnt > 600) http_response_code(429), exit(json_encode(['error' => '请求过快']));

if (disk_free_space(UPLOAD_DIR) < 500 * 1024 * 1024)
    http_response_code(507), exit(json_encode(['error' => '磁盘不足']));

5.3 定时清理孤儿分片(cron 调 cleanup.php)

<?php
require 'config.php';
$expire = time() - CHUNK_EXPIRE_HOURS * 3600;
foreach (glob(CHUNK_DIR . '*', GLOB_ONLYDIR) as $dir) {
    if (filemtime($dir) < $expire) {
        array_map('unlink', glob($dir . '/*'));
        rmdir($dir);
    }
}
echo 'cleanup ok ', date('Y-m-d H:i:s');

六、安全清单

大文件入口是攻击面,下面几条必须做。
风险防护
路径遍历basename() 过滤文件名
覆盖已有文件file_hash 隔离分片目录
磁盘被打满disk_free_space() 前置检查
分片灌水单 IP 频率限制(Redis)
文件类型伪造服务端校验文件头魔数
合并并发冲突flock() 加锁合并区

七、小结

{note|这套方案的特点}

  • 🧩 可靠:任意中断可恢复,不丢数据
  • 高效:分片并行,吃满带宽
  • 📈 可扩展:TB 级也能跑
  • 🪶 零依赖:纯 PHP + 原生 JS,无额外组件

适用:视频平台上传、网盘同步、企业归档、日志批量导入。

核心就三件事:把大文件拆小、用哈希做身份、用状态表做记忆。


附:完整项目文件清单

index.html(前端页面)

将第三章 3.1 的 HTML 结构保存为 index.html 即可。

uploader.js(前端核心逻辑)

将第三章 3.2 的 JavaScript 代码保存为 uploader.js

hash-worker.js(Web Worker 哈希计算)

将第三章 3.3 的代码保存为 hash-worker.js

config.php(PHP 配置)

将第四章 4.2 的代码保存为 config.php

check_status.php(检查上传状态)

将第四章 4.3 的代码保存为 check_status.php

upload_chunk.php(接收分片)

将第四章 4.4 的代码保存为 upload_chunk.php

merge_chunks.php(合并分片)

将第四章 4.5 的代码保存为 merge_chunks.php

index.php(统一入口路由)

将第四章 4.6 的代码保存为 index.php

cleanup.php(定时清理)

将第五章 5.3 的代码保存为 cleanup.php


💡 部署提示:将所有 .php 文件放在同一目录,确保 uploads/ 目录可写,配置 cron 定时执行 cleanup.php 即可。
你认为这篇文章怎么样?
  • 0
    点赞
  • 0
  • 0
  • 0
    滑稽
  • 0
    尴尬
  • 0
    睡觉

评论 (0)

取消
头像
邮箱:
I P:
互动: