当前位置: 首页 > news >正文

大文件分片上传:从原理到实战,解决Web开发中的传输难题

1. 项目概述:为什么大文件上传必须分片?

如果你做过Web开发,尤其是涉及文件上传功能的后端,大概率遇到过用户反馈:“为什么我上传一个2G的视频总是失败?” 或者,在服务器日志里看到过令人头疼的“413 Request Entity Too Large”错误。这背后,就是传统单次上传(Whole File Upload)在面对大文件时的天然缺陷。

简单来说,Web页面支持大附件分片上传,就是将一个大文件在客户端(浏览器)切割成多个小块(即“分片”),然后逐个或并发地上传到服务器,服务器接收完所有分片后,再将它们按顺序合并还原成原始文件。这听起来简单,但要做好却涉及前后端协同、网络容错、用户体验等一系列复杂问题。它不仅是提升上传成功率的技术手段,更是现代Web应用处理富媒体内容(如视频、设计稿、数据集)的基石能力。

从最近的热搜词也能看出端倪:“大文件分片上传”、“minio文件分片上传加密”、“Web前端开发”等词频繁出现,说明这不仅是老生常谈,更是当前开发中的实际痛点。无论是做网盘、在线视频编辑、数据备份系统,还是企业级文档管理,分片上传都是绕不开的核心功能。接下来,我将结合多年踩坑经验,为你拆解其核心原理、主流实现方案以及那些文档里不会写的实操细节。

2. 核心需求与方案选型背后的逻辑

为什么不用简单的一次性POST?原因可以归结为以下四点,这也是我们设计分片上传方案时必须解决的四大核心需求:

2.1 突破网络与服务器的限制

这是最直接的原因。无论是Nginx、Apache等Web服务器,还是后端框架(如Spring Boot、Express),通常都有默认的请求体大小限制(例如Nginx的client_max_body_size默认仅为1M)。虽然可以调整,但无限制放大既不安全也不现实。分片上传将大请求拆分为多个符合限制的小请求,从根本上规避了这个问题。

2.2 实现断点续传,提升用户体验

网络不稳定是常态。一个5G的文件上传到99%时网络断开,如果从头再来,用户心态可能会崩溃。分片上传天然支持断点续传。因为每个分片都是独立的HTTP请求,我们只需要记录哪些分片已成功上传。当上传中断后重新发起时,可以先向服务器查询已上传的分片列表,然后只上传剩余的部分。这对移动端或网络环境差的用户至关重要。

3.3 充分利用带宽,提升上传速度

现代浏览器支持对同一域名的多个并发请求。我们可以利用这一点,同时上传多个文件分片,从而更充分地利用用户的上行带宽,理论上可以成倍缩短总上传时间。当然,并发数需要合理控制,避免对服务器造成过大压力或触发浏览器限制。

3.4 便于实现上传进度监控

对于单次上传,浏览器只能提供整个请求的发送进度,粒度很粗。而分片上传后,我们可以精确计算:已上传大小 = 已成功分片数 * 分片大小 + 当前正上传分片已发送大小。这样就能向用户展示一个准确、平滑的进度条,极大提升操作的可预期性。

基于这些需求,目前主流的方案有两种:

  1. 前端分片 + 后端合并:这是最经典和自主可控的方案。前端使用Blob.slice()方法切割文件,并逐个上传。后端负责接收、暂存分片,并在所有分片到达后合并。我们将重点讨论这种方案。
  2. 利用云存储服务商SDK:如阿里云OSS、腾讯云COS、AWS S3或MinIO,它们都提供了支持分片上传的客户端SDK。这种方式将分片逻辑、暂存和合并的复杂性转移给了云服务,后端只需生成上传凭证(预签名URL)并处理最终回调,开发量小,适合快速集成。从热词“minio文件分片上传加密”可以看出,基于MinIO等私有化部署方案也在被深入使用。

注意:方案选择没有绝对好坏。对于追求快速上线、运维能力有限的团队,云服务SDK是优选。对于需要深度定制(如自定义分片策略、加密算法、存储逻辑)或成本敏感的场景,自研前端分片+后端合并方案更合适。本文将以自研方案为主线进行深度剖析。

4. 前端核心实现:从文件切割到并发控制

前端是实现分片上传的“发动机”,其稳定性和效率直接决定用户体验。我们一步步来看关键实现。

4.1 文件分片策略:如何确定分片大小?

分片大小不是随便定的,它需要在效率、可靠性和服务器压力之间取得平衡。

  • 过小(如100KB):会导致分片数量极多,创建大量HTTP请求,增加前端管理和后端处理的开销,合并文件时磁盘IO压力大。
  • 过大(如100MB):失去了分片的意义,单个请求失败的成本高,断点续传的粒度粗,并发上传的优势也不明显。

一个经过实践检验的经验值是5MB ~ 10MB。这个范围有几点考虑:

  1. 能有效绕过大多数服务器的默认限制。
  2. 分片数量在可管理范围内。一个1G的文件,按5MB分片约为200个,按10MB分片约为100个。
  3. 与TCP传输特性匹配,能较好地利用带宽。
  4. 对于云服务(如S3),其分片上传API也常推荐5MB作为最小单元。

当然,这可以做成可配置的,甚至可以根据用户的网络速度动态调整。核心代码示例如下:

// 计算分片数量和创建分片数组 function createFileChunks(file, chunkSize = 5 * 1024 * 1024) { // 默认5MB const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); // 关键API:Blob.slice chunks.push({ chunk, // 分片Blob对象 index, // 分片序号,用于后端排序 start, // 在原始文件中的起始位置(可选,用于校验) end, // 在原始文件中的结束位置 hash: `${file.name}_${index}`, // 简易标识,生产环境建议用文件内容hash size: chunk.size, percentage: 0 // 上传进度 }); start = end; index++; } return chunks; }

4.2 生成文件唯一标识:实现秒传与校验

在分片上传前,为整个文件生成一个唯一标识(如MD5、SHA-256)至关重要。这个标识有两个核心作用:

  1. 秒传(Instant Upload):上传前,先将文件hash发送给服务器。服务器检查是否已有相同hash的文件存在。如果存在,则直接返回已上传文件的地址,无需再传任何分片。这对于网盘类应用节省存储空间和带宽极其有效。
  2. 分片校验:可以用这个文件hash,结合分片索引,生成每个分片的唯一标识(如${fileHash}_${chunkIndex}),用于后端去重和校验,防止重复上传错误的分片。

计算大文件hash是个CPU密集型任务,在主线程进行会导致页面卡顿。必须使用Web Worker在后台线程计算,或使用增量摘要算法。这里是一个使用spark-md5库在Worker中计算的简化示例:

// 在Web Worker中 (hash-worker.js) self.importScripts('spark-md5.min.js'); self.onmessage = function(e) { const { file, chunkSize } = e.data; const spark = new SparkMD5.ArrayBuffer(); const chunks = Math.ceil(file.size / chunkSize); let currentChunk = 0; const fileReader = new FileReader(); function loadNext() { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsArrayBuffer(file.slice(start, end)); } fileReader.onload = function(e) { spark.append(e.target.result); currentChunk++; if (currentChunk < chunks) { // 汇报进度 self.postMessage({ type: 'progress', percentage: (currentChunk / chunks) * 100 }); loadNext(); } else { // 计算完成,返回最终hash const hash = spark.end(); self.postMessage({ type: 'complete', hash }); self.close(); } }; loadNext(); };

4.3 并发上传与控制:避免浏览器请求池被占满

我们不能一次性发起所有分片的上传请求,那样会占满浏览器的HTTP请求池(通常对同一域名是6个),导致其他API请求被阻塞。需要一个队列机制来控制并发数。

class ConcurrentUploader { constructor(maxConcurrent = 3) { this.maxConcurrent = maxConcurrent; // 最大并发数,建议3-5 this.queue = []; // 任务队列 this.activeCount = 0; // 正在执行的任务数 } // 添加上传任务 addTask(taskFn) { return new Promise((resolve, reject) => { this.queue.push({ taskFn, resolve, reject }); this._run(); }); } // 执行队列 _run() { while (this.activeCount < this.maxConcurrent && this.queue.length) { this.activeCount++; const { taskFn, resolve, reject } = this.queue.shift(); taskFn() .then(resolve) .catch(reject) .finally(() => { this.activeCount--; this._run(); // 一个任务完成,尝试执行下一个 }); } } } // 使用示例 const uploader = new ConcurrentUploader(3); for (let chunk of chunks) { uploader.addTask(() => uploadChunk(chunk)).then(() => { console.log(`分片 ${chunk.index} 上传成功`); }); }

4.4 进度计算与展示:给用户明确的反馈

进度计算需要综合考量:总文件大小、已上传成功分片的总大小、以及当前正在上传的分片的实时进度。

// 假设有一个全局状态 const state = { file: fileObject, chunks: chunkArray, hash: fileHash, totalSize: fileObject.size, uploadedSize: 0 // 已上传字节数 }; // 在每个分片上传的进度事件中更新 function onChunkProgress(chunkIndex, event) { if (event.lengthComputable) { const chunk = state.chunks[chunkIndex]; // 计算这个分片本次上传的增量 const chunkLoaded = event.loaded; // 当前分片已上传字节 const chunkDelta = chunkLoaded - (chunk.loaded || 0); chunk.loaded = chunkLoaded; chunk.percentage = Math.round((chunkLoaded / chunk.size) * 100); // 更新总上传大小 state.uploadedSize += chunkDelta; // 计算总进度 const totalPercentage = Math.round((state.uploadedSize / state.totalSize) * 100); updateProgressBar(totalPercentage); // 更新UI } } // 分片上传成功时,将其大小计入 uploadedSize (避免进度回退) function onChunkSuccess(chunkIndex) { const chunk = state.chunks[chunkIndex]; if (!chunk.isSuccess) { state.uploadedSize += chunk.size; chunk.isSuccess = true; } }

实操心得:进度条偶尔会“回退”是常见问题。这是因为XMLHttpRequestFetch APIprogress事件在网络波动时,event.loaded值可能短暂减小。更稳健的做法是,以上传成功的分片总大小为主要进度依据,当前正在上传的分片进度作为辅助增量。只有当分片确认上传成功(收到HTTP 200响应)后,才将其完整大小累加到uploadedSize中。这样进度条只会前进或暂停,不会后退,用户体验更好。

5. 后端核心实现:接收、管理与合并

后端是分片上传的“大脑”和“仓库”,需要设计好API、分片存储和合并逻辑。

5.1 API设计:清晰的责任划分

通常需要设计三个核心接口:

  1. 初始化上传(/upload/init)

    • 请求:文件hash、文件名、文件总大小、分片大小。
    • 响应:返回uploadId(本次上传会话的唯一ID)和chunkList(服务器已存在的该文件分片索引列表,用于实现断点续传)。如果文件已存在,直接返回文件地址(秒传逻辑)。
  2. 上传分片(/upload/chunk)

    • 请求uploadId、分片索引chunkIndex、分片数据chunkmultipart/form-data)、分片hash(可选,用于校验)。
    • 响应:成功或失败。
  3. 合并文件(/upload/merge)

    • 请求uploadId、文件hash、文件名、总分片数。
    • 响应:合并成功后的文件访问URL。

5.2 分片存储策略:临时与永久

分片在合并前是临时数据,存储设计需考虑:

  • 存储位置:可以是服务器本地磁盘的临时目录,也可以是独立的对象存储(如MinIO)或Redis(如果分片很小)。使用对象存储时,每个分片作为一个独立对象上传,合并时可能需要触发服务端的compose操作。
  • 目录结构:良好的目录结构便于管理和清理。例如:temp_uploads/{fileHash}/{chunkIndex}.part。以fileHashuploadId为目录名,可以天然隔离不同文件的分片。
  • 清理机制:必须有一个后台任务,定期清理超过一定时间(如24小时)未合并的临时分片目录,防止磁盘被占满。

5.3 合并文件的正确姿势:效率与安全

合并操作是IO密集型操作,最直接的方式是顺序读取所有分片并写入新文件。但对于超大文件,这可能导致内存溢出或长时间阻塞请求线程。

高效安全的合并方法:

  1. 使用流(Stream)进行合并:这是Node.js、Java、Go等语言的高效做法。以Node.js为例:

    const fs = require('fs').promises; const path = require('path'); async function mergeChunks(fileHash, fileName, totalChunks, uploadDir, finalDir) { const chunkDir = path.join(uploadDir, fileHash); const finalPath = path.join(finalDir, `${fileHash}_${fileName}`); // 按分片索引排序 const chunkPaths = Array.from({length: totalChunks}, (_, i) => path.join(chunkDir, `${i}.part`) ); // 使用写流,顺序追加每个分片 const writeStream = fs.createWriteStream(finalPath); for (const chunkPath of chunkPaths) { const chunkBuffer = await fs.readFile(chunkPath); writeStream.write(chunkBuffer); } writeStream.end(); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); // 合并完成后,删除临时分片目录 await fs.rm(chunkDir, { recursive: true, force: true }); return finalPath; }
  2. 分片校验:在合并前或合并后,应计算最终文件的hash,与前端最初传来的文件hash进行比对,确保文件在传输和合并过程中未出错。

  3. 异步合并:对于非常大的文件,合并操作可能耗时数十秒。切勿在同步的HTTP请求中执行合并!正确的做法是:

    • 接收到合并请求后,立即返回“合并已开始”的响应。
    • 将合并任务推入消息队列(如Redis、RabbitMQ)或交给线程池/后台进程处理。
    • 通过WebSocket、Server-Sent Events (SSE) 或让客户端轮询另一个接口,来通知用户合并完成。

5.4 数据库设计:记录上传状态

需要一个简单的表来跟踪上传会话,这是实现断点续传和清理过期数据的基础。

CREATE TABLE `upload_sessions` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `upload_id` varchar(64) NOT NULL COMMENT '上传会话ID', `file_hash` varchar(128) NOT NULL COMMENT '文件唯一哈希', `file_name` varchar(255) NOT NULL, `file_size` bigint(20) NOT NULL, `chunk_size` int(11) NOT NULL, `total_chunks` int(11) NOT NULL, `uploaded_chunks` text COMMENT '已上传的分片索引列表,JSON格式,如[0,1,2]', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0:上传中, 1:已完成, 2:已过期', `storage_path` varchar(500) DEFAULT NULL COMMENT '最终存储路径', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_upload_id` (`upload_id`), KEY `idx_file_hash` (`file_hash`), KEY `idx_status_created` (`status`,`created_at`) -- 用于清理任务 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

当初始化上传时,插入一条记录。每成功上传一个分片,就更新uploaded_chunks字段。合并成功后,更新statusstorage_path。一个定时任务可以扫描status=0created_at超过阈值的记录,清理对应的临时文件并更新状态为“已过期”。

6. 高级优化与安全考量

实现基础功能只是第一步,要让分片上传健壮、高效、安全,还需要考虑更多。

6.1 秒传与文件去重

如前所述,利用文件hash可以实现秒传。后端在/upload/init接口中,先查询files表(存储最终文件信息的表)是否存在相同的file_hash。如果存在,直接返回该文件的访问地址,前端提示用户“秒传成功”。这不仅能提升用户体验,更能为公司节省大量存储和带宽成本。

6.2 分片上传的完整性校验

网络传输可能出错,必须校验。有两种级别:

  1. 分片级校验:前端在上传分片时,可以计算该分片的MD5,将其放在请求头(如X-Chunk-Hash)中。后端接收分片后,重新计算MD5进行比对,不一致则要求重传。
  2. 文件级校验:在所有分片合并完成后,后端计算整个文件的hash,与初始化时前端传来的file_hash比对。这是最终的质量关卡。

6.3 安全性加固

  1. 权限验证:每一个上传接口(init, chunk, merge)都必须携带用户身份令牌(如JWT),验证用户是否有权限上传。防止恶意用户消耗服务器资源。
  2. 文件类型与大小限制:在init接口就要校验文件后缀和MIME类型,防止上传可执行文件等危险类型。同时,虽然分片规避了单次请求大小限制,但仍需对file_size进行限制,防止磁盘被塞满。
  3. 病毒扫描:对于合并后的文件,尤其是用户上传的可执行文件、文档等,应通过异步任务进行病毒扫描,确认安全后再对外提供访问。
  4. 防止目录遍历攻击:处理文件名时,要过滤掉../等字符,防止恶意用户通过构造文件名将分片写入或合并到系统任意目录。

6.4 与云存储方案(如MinIO)的集成

如果你的后端使用MinIO,可以利用其原生的Multipart UploadAPI。这时后端的角色会发生变化:

  • 初始化:后端调用MinIO SDK的createMultipartUpload方法,获取一个uploadId,返回给前端。
  • 获取预签名URL:前端为每个分片向后端请求一个用于上传到MinIO的预签名URL(Presigned URL)。后端调用presignUrl生成,并将uploadIdpartNumber(即分片索引)信息包含在URL中。这样做的好处是,分片数据直接从前端传到MinIO,不流经你的应用服务器,极大减轻了服务器带宽和IO压力。
  • 上传分片:前端直接用预签名URL上传分片到MinIO。
  • 完成上传:所有分片上传完毕后,前端通知后端。后端调用completeMultipartUpload,告知MinIO所有分片已就绪,MinIO会自动完成合并。

这种“服务端签名,客户端直传”的模式是目前最主流的云上传架构,兼具安全性和高性能。

7. 实战中常见问题与排查技巧

即使设计得再完美,在实际开发和线上运维中还是会遇到各种问题。以下是我总结的一些典型“坑”和解决方法。

7.1 前端常见问题

问题一:iOS Safari上,大文件分片上传到一半失败或进度异常。

  • 排查:Safari对Blob.slice()方法的实现在某些版本上有兼容性问题,特别是当文件非常大时。此外,iOS应用进入后台后,网络请求可能被挂起。
  • 解决
    1. 使用File.prototype.slice的兼容性写法,或者引入blob-util库。
    2. 对于后台运行问题,可以考虑使用Service Worker(如果支持)来管理上传,或者提示用户保持应用在前台。
    3. 减小分片大小(例如降到2MB),增加重试机制。

问题二:并发上传时,浏览器控制台报错“net::ERR_INSUFFICIENT_RESOURCES”。

  • 排查:这是浏览器达到了并发请求或Socket数的资源上限。即使控制了并发数,如果分片数量极多,快速完成和发起新请求也可能导致此问题。
  • 解决:除了控制并发数,还需要加入延迟队列。例如,在一个分片上传完成后,不立即启动下一个,而是等待100-200毫秒。这给了浏览器网络栈喘息的时间。

问题三:进度条在接近100%时卡住很久,然后才显示完成。

  • 排查:这通常是后端合并文件耗时过长导致的。前端所有分片已上传完毕(进度99%),但等待后端合并的HTTP响应。
  • 解决:如前所述,必须将合并操作异步化。前端在调用/merge接口后,应轮询另一个接口(如/upload/status/{uploadId})来查询合并状态,而不是等待合并请求的响应。

7.2 后端常见问题

问题一:服务器磁盘空间被临时分片占满。

  • 排查:用户上传了超大文件但中途放弃,或者合并任务失败,导致临时分片未被清理。
  • 解决
    1. 实现强健的清理定时任务。每天扫描upload_sessions表中状态为“上传中”且创建时间超过24小时(可根据业务调整)的记录,删除其对应的临时目录。
    2. /upload/init接口中,检查服务器磁盘剩余空间,如果低于某个阈值(如10%),则拒绝新的上传请求。

问题二:合并超大文件时,服务器内存溢出(OOM)。

  • 排查:错误地使用fs.readFileSync或一次性读取所有分片到内存再写入。
  • 解决:务必使用流(Stream)来合并文件。如Node.js的createWriteStreamcreateReadStream,Java的Files.copy配合BufferedInputStream,Go的io.Copy等。流式处理只会占用很小的内存缓冲区。

问题三:高并发下,同一分片被重复上传,导致合并出错。

  • 排查:网络不稳定时,前端可能因未及时收到响应而重试上传;或者用户快速点击了两次上传按钮。
  • 解决:后端上传分片接口要实现幂等性。在存储分片前,先检查uploadIdchunkIndex对应的分片是否已存在且完整(可通过校验hash)。如果已存在,直接返回成功,避免重复写入。这需要将分片存储和其元信息(如hash)的检查作为一个原子操作。

7.3 网络与部署问题

问题:用户网络切换(如从WiFi切到4G)导致上传失败。

  • 解决:这是断点续传要解决的核心场景。关键在于,uploadId和文件分片信息需要在页面刷新或网络重连后依然存在。可以将这些信息持久化到localStorageIndexedDB中。当页面重新加载或检测到网络恢复时,先从本地存储恢复上传上下文,然后向服务器查询已上传分片列表,继续上传剩余部分。

问题:负载均衡环境下,用户的不同分片请求可能被分发到不同的后端服务器。

  • 解决:临时分片存储必须是共享的。不能存在服务器A的本地磁盘上。解决方案有:
    1. 使用共享文件系统(如NFS)。
    2. 使用对象存储(如MinIO、S3)作为临时存储。
    3. 使用分布式缓存(如Redis)存储小分片(不推荐用于大分片)。
    4. 通过“粘性会话”(Sticky Session)确保同一用户的上传请求都落到同一台服务器,但这降低了负载均衡的灵活性。

一个实用的排查清单可以总结如下:

问题现象可能原因排查方向与解决思路
上传进度卡在0%网络问题、CORS错误、初始化接口失败检查浏览器Network面板,查看首个init请求是否成功;检查后端CORS配置。
部分分片反复上传失败分片损坏、网络不稳定、服务器临时存储不可写1. 开启分片hash校验。2. 增加前端重试机制(如最多3次)。3. 检查服务器磁盘权限和空间。
合并接口返回超时文件太大,合并耗时过长将合并操作改为异步,接口立即返回“处理中”,通过其他接口查询结果。
秒传功能不生效文件hash计算不一致或后端查询逻辑有误对比前后端计算hash的算法和输入(确保计算的是文件内容,不包括文件名等元数据)。检查数据库files表的file_hash索引。
清理任务运行后,正在上传的文件出错清理时间阈值设置过短延长临时文件的保留时间(如从24小时改为48小时),或根据业务活跃时间调整。

8. 从零搭建一个简易分片上传Demo

理论说了这么多,我们用一个极简的Node.js(Express)+ 前端Vanilla JS的例子,把核心流程串起来。注意,此示例省略了错误处理、安全校验等生产级代码,仅用于演示核心链路。

8.1 后端服务 (server.js)

const express = require('express'); const multer = require('multer'); const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const app = express(); const PORT = 3000; // 临时存储分片 const TEMP_DIR = path.join(__dirname, 'temp'); const FINAL_DIR = path.join(__dirname, 'uploads'); // 确保目录存在 (async () => { await fs.mkdir(TEMP_DIR, { recursive: true }); await fs.mkdir(FINAL_DIR, { recursive: true }); })(); // 内存中存储上传会话(生产环境需用数据库) const uploadSessions = new Map(); // 1. 初始化上传 app.post('/api/upload/init', express.json(), (req, res) => { const { fileHash, fileName, fileSize, chunkSize } = req.body; const uploadId = crypto.randomUUID(); // 模拟秒传:检查最终文件是否已存在 const finalFilePath = path.join(FINAL_DIR, `${fileHash}_${fileName}`); if (fs.existsSync(finalFilePath)) { return res.json({ code: 0, message: '秒传成功', url: `/uploads/${fileHash}_${fileName}` }); } // 创建上传会话 uploadSessions.set(uploadId, { fileHash, fileName, fileSize, chunkSize, uploadedChunks: new Set() // 记录已上传分片索引 }); // 创建临时目录 const chunkDir = path.join(TEMP_DIR, fileHash); fs.mkdir(chunkDir, { recursive: true }); res.json({ code: 0, uploadId, uploadedChunks: [] }); }); // 配置multer处理文件分片 const storage = multer.diskStorage({ destination: function (req, file, cb) { const { uploadId, chunkIndex } = req.body; const session = uploadSessions.get(uploadId); if (!session) return cb(new Error('上传会话不存在')); const chunkDir = path.join(TEMP_DIR, session.fileHash); cb(null, chunkDir); }, filename: function (req, file, cb) { const { chunkIndex } = req.body; cb(null, `${chunkIndex}.part`); // 分片以索引命名 } }); const upload = multer({ storage }); // 2. 上传分片 app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => { const { uploadId, chunkIndex } = req.body; const session = uploadSessions.get(uploadId); if (!session) { return res.status(404).json({ code: 1, message: '上传会话不存在' }); } session.uploadedChunks.add(parseInt(chunkIndex)); res.json({ code: 0, message: '分片上传成功' }); }); // 3. 合并文件 app.post('/api/upload/merge', express.json(), async (req, res) => { const { uploadId, totalChunks } = req.body; const session = uploadSessions.get(uploadId); if (!session) { return res.status(404).json({ code: 1, message: '上传会话不存在' }); } const { fileHash, fileName } = session; const chunkDir = path.join(TEMP_DIR, fileHash); const finalFilePath = path.join(FINAL_DIR, `${fileHash}_${fileName}`); try { const writeStream = require('fs').createWriteStream(finalFilePath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `${i}.part`); const chunkBuffer = await fs.readFile(chunkPath); writeStream.write(chunkBuffer); } writeStream.end(); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); // 清理临时分片 await fs.rm(chunkDir, { recursive: true, force: true }); uploadSessions.delete(uploadId); res.json({ code: 0, message: '合并成功', url: `/uploads/${fileHash}_${fileName}` }); } catch (error) { res.status(500).json({ code: 1, message: '合并失败', error: error.message }); } }); // 静态文件服务,用于访问上传后的文件 app.use('/uploads', express.static(FINAL_DIR)); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); });

8.2 前端页面 (index.html)

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>分片上传演示</title> <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script> </head> <body> <input type="file" id="fileInput" /> <button onclick="uploadFile()">开始上传</button> <div>进度: <span id="progress">0</span>%</div> <div id="result"></div> <script> const CHUNK_SIZE = 1 * 1024 * 1024; // 演示用1MB async function uploadFile() { const file = document.getElementById('fileInput').files[0]; if (!file) return alert('请选择文件'); // 1. 计算文件hash (简化版,在主线程计算,大文件会卡顿) const spark = new SparkMD5.ArrayBuffer(); const arrayBuffer = await file.arrayBuffer(); spark.append(arrayBuffer); const fileHash = spark.end(); console.log('文件hash:', fileHash); // 2. 初始化上传 const initResp = await fetch('http://localhost:3000/api/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileHash, fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }) }).then(r => r.json()); console.log('初始化结果:', initResp); if (initResp.url) { // 秒传成功 document.getElementById('result').innerHTML = `秒传成功!文件地址: <a href="${initResp.url}" target="_blank">${initResp.url}</a>`; return; } const { uploadId, uploadedChunks = [] } = initResp; // 3. 创建分片 const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ chunk: file.slice(start, end), index: index, start, end }); start = end; index++; } // 4. 过滤掉已上传的分片 (断点续传) const chunksToUpload = chunks.filter(chunk => !uploadedChunks.includes(chunk.index)); const totalChunks = chunks.length; let uploadedCount = totalChunks - chunksToUpload.length; updateProgress(); // 5. 上传分片 (简易顺序上传,无并发控制) for (const chunkInfo of chunksToUpload) { const formData = new FormData(); formData.append('chunk', chunkInfo.chunk); formData.append('uploadId', uploadId); formData.append('chunkIndex', chunkInfo.index); try { await fetch('http://localhost:3000/api/upload/chunk', { method: 'POST', body: formData }); uploadedCount++; updateProgress(); } catch (error) { console.error(`分片 ${chunkInfo.index} 上传失败:`, error); alert('上传失败,请检查网络或控制台'); return; } } // 6. 所有分片上传完成,请求合并 const mergeResp = await fetch('http://localhost:3000/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, totalChunks }) }).then(r => r.json()); console.log('合并结果:', mergeResp); if (mergeResp.code === 0) { document.getElementById('result').innerHTML = `上传并合并成功!文件地址: <a href="${mergeResp.url}" target="_blank">${mergeResp.url}</a>`; } else { document.getElementById('result').innerHTML = `合并失败: ${mergeResp.message}`; } } function updateProgress() { const file = document.getElementById('fileInput').files[0]; const totalSize = file.size; // 简化进度计算:以上传完成的分片数估算 const uploadedSize = uploadedCount * CHUNK_SIZE; const percentage = Math.min(100, Math.round((uploadedSize / totalSize) * 100)); document.getElementById('progress').textContent = percentage; } </script> </body> </html>

这个Demo虽然简陋,但它清晰地展示了分片上传的完整闭环:计算hash、初始化、分片、上传、合并。你可以在此基础上,逐步添加并发控制、进度计算、错误重试、Web Worker计算hash等高级功能。

最后,我想分享一点个人体会:分片上传不是一个可以“一劳永逸”的功能,它需要根据你的具体业务场景(是用户偶尔上传,还是高频批量上传?)、基础设施(是否有对象存储?)、用户体验要求(是否需要极致的秒传和续传?)进行持续地调优和打磨。从确定分片大小、设计重试策略,到优化合并性能、完善监控报警,每一个环节都有细节可以深挖。最好的学习方式,就是动手实现一个基础版本,然后在真实的业务流量中去观察、去发现问题,再回头来迭代你的方案。

http://www.gsyq.cn/news/1604939.html

相关文章:

  • 《深入理解计算机系统》CSAPP八大实验通关指南与实战解析
  • 凑微分,幂等公式
  • GeoTools 多模块依赖最佳实践:一次 OrderedAxisAuthorityFactory 初始化失败的深度复盘
  • Nacos 注解全解析:7 个核心注解 + 5 个生产踩坑清单(2026 实测)
  • go: Deadline Pattern
  • 万字干货|2026 Go 后端通关学习路线,从底层原理到微服务面试全覆盖(附 Code Review 规范 + 线上故障排查方案)
  • 论文阅读笔记 | Thinking in Frames: How Visual Context and Test-Time Scaling Empower Video Reasoning
  • 泛微ECOLOGY9流程主明细行弹窗添加子明细的实现
  • 解除labelstdio数据标注一次上传图片数量限制的方法
  • 如何用N_m3u8DL-RE轻松下载加密流媒体视频:从新手到高手的完整指南
  • TAS3202 DAP架构解析:从定点运算到音频处理实战
  • 终极方案:用xmly-downloader-qt5实现喜马拉雅VIP音频永久保存的完整指南
  • Linux 用户态内存分配:glibc malloc
  • WinUtil:Windows系统优化终极工具 - 一键完成软件安装、系统调优与故障修复
  • 14-already flash encrypt or secure boot提示:ESP32S3误烧熔丝的补救方法
  • 猫抓浏览器扩展:全网视频音频资源一键抓取的终极指南
  • 高颜值出差住地铁口可猫咪的酒店步行 3 分钟到地铁
  • volatile有什么用
  • 告别繁琐操作:原神脚本让你的提瓦特冒险更智能高效
  • PCB 新手 18 类常见错误汇总
  • EtherCAT重学之二: EtherCAT 系统硬件架构
  • 大湾区EMBA特色测评:科学选型理性指南
  • 【LeetCode】第1题 两数之和
  • CBDC安全架构:密码学签名与硬件防护核心技术解析
  • 【单片机毕业设计】基于 STM32 的多模式智能路灯控制系统设计, 基于单片机的光照自适应路灯亮度调节系统设计(014001)
  • 为什么顶尖AI团队拒绝“通用提示词”?——稀缺首发:金融/医疗/法律三大垂直领域217条经审计Prompt资产包(限时开放下载)
  • Java 多线程:继承 Thread 与实现 Runnable 两种创建方式完整对比
  • 自动定期备份服务器数据
  • python下载M3U8视频脚本
  • AI截图工具免费下载,基于DeepSeek的OCR截图软件支持Mac和Win