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

PHP 大文件上传、断点续传完整实现思路 PHP 大文件上传、断点续传完整实现思路一、为什么需要断点续传传统上传的痛点假设你要上传一个 2GB 的视频文件问题表现后果网络中断上传到80%时WiFi断了重新开始前功尽弃超时限制PHPmax_execution_time默认30秒大文件必然超时内存溢出upload_max_filesize限制超过2M直接被拒用户体验差进度条卡死、无法暂停用户流失断点续传的核心价值失败恢复从中断处继续而非从头开始分片加速多片并行上传充分利用带宽暂停/继续用户可随时控制上传节奏校验完整性确保文件传输无损坏二、整体架构设计客户端 服务端 ┌─────────────┐ ┌──────────────────┐ │ 文件分片 │ ──分片1──▶ │ 临时目录存储分片 │ │ 计算MD5 │ ──分片2──▶ │ 记录上传状态 │ │ 记录进度 │ ──分片3──▶ │ 合并验证完整性 │ │ 断点恢复 │ ◀──响应── │ 返回已上传分片 │ └─────────────┘ └──────────────────┘三、前端实现HTML JavaScript3.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 typefile idfileInput / button onclickstartUpload()开始上传/button button onclickpauseUpload()暂停/button div classprogress-bar div classprogress-fill idprogressFill stylewidth: 0%/div /div div classchunk-status idstatus/div script srcuploader.js/script /body /html3.2 核心上传逻辑uploader.jsclass 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(); };四、后端实现PHP4.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级别的文件上传。关键在于把大问题拆解成小问题再逐个解决。