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() 加锁合并 |
七、总结
这套断点续传方案的核心优势:
-
可靠性:任意中断都能恢复,不丢失数据
-
高效性:并行上传充分利用带宽
-
可扩展:支持TB级文件上传
-
低成本:纯PHP实现,无需额外组件
适用场景:
-
视频平台的大文件上传
-
云盘系统的文件同步
-
企业内部的资料归档
-
日志收集系统的批量导入
通过分片、哈希校验和状态追踪三个核心机制,我们让PHP也能优雅地处理GB级别的文件上传。关键在于把大问题拆解成小问题,再逐个解决。