新闻详情

新闻详情

首页 / 资讯中心 / 详情

前端大文件上传与断点续传:从分片策略到并发控制的工程实践

发布时间:2026/6/12 11:34:50
前端大文件上传与断点续传:从分片策略到并发控制的工程实践
前端大文件上传与断点续传从分片策略到并发控制的工程实践一、大文件上传的黑洞2GB 文件上传到 90% 后网络断开大文件上传是前端工程中的经典难题。某视频平台用户上传 2GB 视频文件上传到 90% 时网络波动导致失败只能从头开始。更严重的是浏览器对单次 HTTP 请求的内存占用有限制2GB 文件直接读取到内存会导致标签页崩溃。某企业网盘统计超过 500MB 的文件上传失败率 18%其中 70% 的失败发生在上传进度超过 50% 之后。大文件上传的工程解法是分片上传 断点续传将大文件切分为固定大小的分片逐片上传失败后从断点续传而非从头开始。这涉及分片策略、并发控制、进度追踪和文件校验四个核心环节。二、大文件上传的工程架构flowchart TB subgraph 前端[前端上传引擎] direction TB F1[文件选择与校验br/类型/大小/MD5] F2[分片切割br/固定大小分片br/Blob.slice] F3[并发上传池br/最大并发数控制br/失败重试] F4[进度追踪br/已上传分片记录br/断点恢复] end subgraph 后端[后端接收服务] direction TB B1[分片接收br/临时存储] B2[分片校验br/MD5 一致性验证] B3[分片合并br/按序拼接] B4[文件校验br/整体 MD5 验证] end F1 -- F2 -- F3 -- F4 F3 --|分片数据| B1 -- B2 F4 --|查询已上传| B2 B2 --|全部完成| B3 -- B4 style 前端 fill:#eef,stroke:#333 style 后端 fill:#fee,stroke:#333三、大文件上传的代码实现// 核心1文件分片与校验 interface FileChunk { index: number; start: number; end: number; blob: Blob; hash: string; retryCount: number; } interface UploadProgress { fileId: string; fileName: string; fileSize: number; totalChunks: number; uploadedChunks: Setnumber; startTime: number; } interface UploadConfig { chunkSize: number; // 分片大小字节 maxConcurrency: number; // 最大并发数 maxRetries: number; // 最大重试次数 retryDelay: number; // 重试延迟ms hashAlgorithm: string; // 哈希算法 } const DEFAULT_CONFIG: UploadConfig { chunkSize: 5 * 1024 * 1024, // 5MB maxConcurrency: 3, maxRetries: 3, retryDelay: 1000, hashAlgorithm: md5, }; class FileUploader { private config: UploadConfig; private progressMap: Mapstring, UploadProgress new Map(); private abortController: AbortController | null null; constructor(config: PartialUploadConfig {}) { this.config { ...DEFAULT_CONFIG, ...config }; } // 文件分片 private createChunks(file: File): FileChunk[] { const chunks: FileChunk[] []; const chunkSize this.config.chunkSize; const totalChunks Math.ceil(file.size / chunkSize); for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); chunks.push({ index: i, start, end, blob, hash: , // 延迟计算 retryCount: 0, }); } return chunks; } // 文件哈希计算 private async calculateFileHash(file: File): Promisestring { /** * 使用 Web Worker 计算文件哈希 * 避免阻塞主线程 * 采用抽样哈希对大文件只计算部分内容 */ const SAMPLE_SIZE 2 * 1024 * 1024; // 抽样 2MB const SAMPLE_COUNT 3; const samples: ArrayBuffer[] []; if (file.size SAMPLE_SIZE * SAMPLE_COUNT) { // 小文件全量计算 const buffer await file.arrayBuffer(); samples.push(buffer); } else { // 大文件抽样计算头部 中部 尾部 const gap Math.floor(file.size / SAMPLE_COUNT); for (let i 0; i SAMPLE_COUNT; i) { const start i * gap; const end Math.min(start SAMPLE_SIZE, file.size); const chunk file.slice(start, end); const buffer await chunk.arrayBuffer(); samples.push(buffer); } } // 使用 SubtleCrypto 计算哈希 const combined new Uint8Array( samples.reduce((acc, buf) acc buf.byteLength, 0) ); let offset 0; for (const buf of samples) { combined.set(new Uint8Array(buf), offset); offset buf.byteLength; } const hashBuffer await crypto.subtle.digest(SHA-256, combined); const hashArray Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b b.toString(16).padStart(2, 0)).join(); } // 并发上传池 private async uploadWithConcurrency( chunks: FileChunk[], fileId: string, uploadFn: (chunk: FileChunk, fileId: string) Promiseboolean ): PromiseMapnumber, boolean { /** * 并发上传池控制最大并发数 * 使用信号量模式限制同时上传的分片数 */ const results new Mapnumber, boolean(); const queue [...chunks]; let running 0; return new Promise((resolve) { const tryNext () { // 所有任务完成 if (queue.length 0 running 0) { resolve(results); return; } // 填充并发池 while (running this.config.maxConcurrency queue.length 0) { const chunk queue.shift()!; running; uploadFn(chunk, fileId) .then((success) { results.set(chunk.index, success); running--; tryNext(); }) .catch(() { // 重试逻辑 if (chunk.retryCount this.config.maxRetries) { chunk.retryCount; queue.push(chunk); // 重新入队 } else { results.set(chunk.index, false); } running--; tryNext(); }); } }; tryNext(); }); } // 分片上传 private async uploadChunk( chunk: FileChunk, fileId: string ): Promiseboolean { /** * 上传单个分片 * 包含重试和超时机制 */ const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, chunk.index.toString()); formData.append(chunkHash, chunk.hash); formData.append(data, chunk.blob); const controller new AbortController(); const timeoutId setTimeout( () controller.abort(), 30000 // 30 秒超时 ); try { const response await fetch(/api/upload/chunk, { method: POST, body: formData, signal: controller.signal, }); clearTimeout(timeoutId); return response.ok; } catch (error) { clearTimeout(timeoutId); if (error instanceof DOMException error.name AbortError) { console.warn(分片 ${chunk.index} 上传超时); } throw error; } } // 断点续传 private async getUploadedChunks(fileId: string): Promisenumber[] { /** * 查询已上传的分片列表 * 用于断点续传跳过已上传的分片 */ try { const response await fetch(/api/upload/progress?fileId${fileId}); if (response.ok) { const data await response.json(); return data.uploadedChunks || []; } } catch { // 查询失败从头开始 } return []; } // 完整上传流程 async upload(file: File): Promise{ success: boolean; fileId: string } { /** * 完整上传流程 * 1. 计算文件哈希用于唯一标识和校验 * 2. 检查秒传服务器已有相同文件 * 3. 查询已上传分片断点续传 * 4. 分片并发上传 * 5. 通知服务器合并 */ this.abortController new AbortController(); // Step 1: 计算文件哈希 const fileHash await this.calculateFileHash(file); const fileId ${fileHash}-${file.size}; // Step 2: 检查秒传 const exists await this.checkFileExists(fileId); if (exists) { return { success: true, fileId }; } // Step 3: 查询已上传分片 const uploadedIndices await this.getUploadedChunks(fileId); const uploadedSet new Set(uploadedIndices); // Step 4: 创建分片跳过已上传的 const allChunks this.createChunks(file); const pendingChunks allChunks.filter( (chunk) !uploadedSet.has(chunk.index) ); // Step 5: 并发上传 const results await this.uploadWithConcurrency( pendingChunks, fileId, this.uploadChunk.bind(this) ); // Step 6: 检查是否全部成功 const allSuccess allChunks.every( (chunk) uploadedSet.has(chunk.index) || results.get(chunk.index) true ); if (!allSuccess) { return { success: false, fileId }; } // Step 7: 通知服务器合并 const mergeResult await this.mergeChunks(fileId, file.name, allChunks.length); return { success: mergeResult, fileId }; } // 辅助方法 private async checkFileExists(fileId: string): Promiseboolean { try { const response await fetch(/api/upload/check?fileId${fileId}); if (response.ok) { const data await response.json(); return data.exists true; } } catch { // 忽略 } return false; } private async mergeChunks( fileId: string, fileName: string, totalChunks: number ): Promiseboolean { try { const response await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileId, fileName, totalChunks }), }); return response.ok; } catch { return false; } } // 暂停与恢复 pause(): void { if (this.abortController) { this.abortController.abort(); } } async resume(file: File): Promise{ success: boolean; fileId: string } { /** * 恢复上传重新计算 fileId查询已上传分片续传 */ return this.upload(file); } }四、大文件上传的 Trade-offs分片大小与内存占用的权衡。分片越大HTTP 请求数越少但单次请求的内存占用越高。5MB 分片在移动端表现良好但 100MB 分片在低端设备上可能导致内存压力。建议根据设备类型动态调整分片大小桌面端 10MB移动端 2MB。并发数与服务器压力。高并发上传可以加快速度但服务器需要同时处理多个分片写入磁盘 I/O 和连接数压力增大。3-5 并发是常见折中值但需要根据服务器负载动态调整。哈希计算的时间成本。全量 MD5 计算对 2GB 文件需要 5-10 秒阻塞用户操作。抽样哈希头部 中部 尾部可以在 1 秒内完成但存在极小概率的哈希碰撞。建议使用 Web Worker 在后台线程计算避免阻塞主线程。秒传的隐私风险。秒传通过文件哈希判断服务器是否已有相同文件跳过上传。但这也意味着服务器可以推断用户上传了哪些文件。对于敏感内容应提供禁用秒传选项强制重新上传。五、总结大文件上传的工程实践围绕分片策略、并发控制、断点续传和文件校验四个核心环节。分片将大文件切分为可管理的小块并发池控制同时上传的分片数断点续传通过查询已上传分片跳过已完成部分文件哈希确保上传完整性。关键权衡在于分片大小与内存占用、并发数与服务器压力、哈希计算的时间成本以及秒传的隐私风险。大文件上传的目标是在不可靠网络环境下提供可靠的上传体验。
网站建设 高端定制 企业官网