前端大文件上传与断点续传:从分片策略到并发控制的工程实践
一、大文件上传的"黑洞":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: Set<number>; 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: Map<string, UploadProgress> = new Map(); private abortController: AbortController | null = null; constructor(config: Partial<UploadConfig> = {}) { 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): Promise<string> { /** * 使用 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) => Promise<boolean> ): Promise<Map<number, boolean>> { /** * 并发上传池:控制最大并发数 * 使用信号量模式限制同时上传的分片数 */ const results = new Map<number, 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 ): Promise<boolean> { /** * 上传单个分片 * 包含重试和超时机制 */ 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): Promise<number[]> { /** * 查询已上传的分片列表 * 用于断点续传:跳过已上传的分片 */ 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): Promise<boolean> { 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 ): Promise<boolean> { 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 在后台线程计算,避免阻塞主线程。
秒传的隐私风险。秒传通过文件哈希判断服务器是否已有相同文件,跳过上传。但这也意味着服务器可以推断用户上传了哪些文件。对于敏感内容,应提供"禁用秒传"选项,强制重新上传。
五、总结
大文件上传的工程实践围绕分片策略、并发控制、断点续传和文件校验四个核心环节。分片将大文件切分为可管理的小块,并发池控制同时上传的分片数,断点续传通过查询已上传分片跳过已完成部分,文件哈希确保上传完整性。关键权衡在于分片大小与内存占用、并发数与服务器压力、哈希计算的时间成本,以及秒传的隐私风险。大文件上传的目标是在不可靠网络环境下提供可靠的上传体验。