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.php4.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)