☰
大文件分片上传与秒传续传:从原理到前后端实战
2026/10/14 2:33:29 网站建设 项目流程

网页大文件分片上传一直是前后端协作里最容易翻车的一环。我刚接手某内容平台的上传模块时,用户上传1GB视频素材,常规的 multipart 表单请求几乎必失败——要么网关层直接把大请求拦下来,要么服务端处理到一半超时断掉,要么机器内存被打满直接 OOM。后来我把分片上传的完整链路走了一遍,又按“文件指纹 + 分片索引 + 服务端分片状态”这套思路把秒传和续传落到代码里,问题才算真正解决。

这篇文章会把原理和源码拆开讲。不管你是偏前端还是偏后端,都可以跟着把整套方案搭起来:前端负责切片、算指纹、并发控制、跳过已传分片;后端负责分片接收、状态查询、按序合并。读完你可以直接抄走一套能跑的浏览器端 + Node.js 实现,也能避开我踩过的那些隐性坑。

1. 大文件上传卡顿的根子:传统上传的瓶颈与分片思路

1.1 一个100MB文件在传统上传里会遭遇什么

先复盘一下,为什么大文件走传统表单上传会这么痛苦。

第一个卡点是前置网关和 Web 容器。很多对公网的请求都会经过一层反向代理,这些组件对请求体大小有默认限制,几百MB的请求经常直接返回 413。当然你可以调大这个阈值,但调大只是把门槛往后推,不是根治。

第二个卡点是服务端内存。多数服务端框架在处理 multipart 请求时,会先把文件缓冲到内存或临时目录。如果每个请求进来都带着几百MB的数据,一个中等并发就能把进程内存吃掉大半,尤其是跑在容器里的小规格实例,OOM 来得比想象中快得多。

第三个卡点是网络的不确定性。文件越大,传输时间越长,出错概率线性上升。TCP 断开、浏览器超时、用户中途切网,哪一个环节断了,整段数据全部作废,用户只能从头再来。文件越大,这种“赌一次成功”的传输方式越不划算。

分片上传的思路很简单:把一个大文件切成若干个独立的小块,每一块都通过单独的 HTTP 请求上传,全部上传完成后再在服务端按顺序拼回原文件。这样一来,单个请求体的大小可控,任一分片失败可以单独重传,服务端每次只处理一个小分片,内存压力小,用户体验也从“全有或全无”变成了渐进式推进。

1.2 分片之后才冒出来的几个问题

分片不是“拿个切片函数把文件剁了”就完事,它会把原来的一个大问题拆成好几个小问题,而且每个都绕不开。

第一个是分片顺序问题。HTTP 请求本来就是异步的,多个分片并发发出后,到达服务端的顺序完全不可控。服务端不能假设先到的是前面的块,所以每个分片必须带上一个 index,合并时严格按 index 排序。

第二个是重复上传问题。用户网络抖动导致重试,或者前端某个请求超时后重发,同一个分片可能被提交多次。服务端接收分片的接口必须幂等,同一个 index 重复写不能产生脏数据。

第三个是分片归属问题。不同用户可能上传内容完全相同的文件,也可能上传大小相同但内容不同的文件。如果只按“文件名 + index”管理分片,很容易互相污染。每个分片必须挂在一个全局唯一的文件 Hash 之下,才能保证归属清晰。

第四个是合并完整性。分片各自上传成功,不代表合并结果就是原始文件。缺一个分片、多一个分片、某个分片内容损坏,都会导致最终文件打不开。所以服务端要做分片缺席检查,合并后最好做一次完整文件的 Hash 校验。

这四个问题的答案,指向同一个设计:用 fileHash 标识文件的唯一身份,用 index 标识分片的顺序位置。秒传和续传的整套体系,就是围绕这两个值构建的。

2. 秒传背后的“查重”逻辑:从指纹到索引

2.1 文件指纹怎么算:Hash选型与分块读取

秒传的本质是“服务端已经存过同一个文件,这次根本不用传”。关键在怎么判断“同一个文件”。最直接的想法是按文件名判断,但文件名根本不可靠:两个用户可能把不同内容命名为同名文件,也可能把同一份文件改名上传。唯一可靠的依据是文件内容的 Hash,它相当于文件的身份证号。

常见的浏览器端 Hash 选型有下面几种,我整理成表格方便对比:

算法浏览器端增量计算性能碰撞风险适用说明
MD5支持(spark-md5)快理论上存在碰撞上传去重场景够用,工程上大量使用
SHA-1支持中等理论上已降级新项目不建议当首选
SHA-256Web Crypto 可算中等偏慢低安全性要求高时用,但增量计算不便

我自己的项目用 spark-md5 算 MD5,理由很实际:上传去重不是对抗性安全场景,碰撞概率可以接受,而且 spark-md5 支持对 ArrayBuffer 增量追加,内存占用可控、计算速度快。如果你的安全要求更高,想用 SHA-256,那我提醒一句:Web Crypto 的crypto.subtle.digest需要一次性传入完整 ArrayBuffer,大文件直接会把内存打爆,要用它做增量 Hash 需要额外的分块改造,成本不低。

不管用哪种算法,Hash 计算过程都不能一次性把整个文件读进内存。内部必须分块读取,每读一小块就追加到 Hash 引擎里,直到文件读完。这既是控制内存,也是控制响应时间。计算大文件 Hash 的耗时从几百毫秒到数秒不等,手机端尤其明显,所以后面我会专门讲怎么放到 Worker 里跑。

2.2 秒传接口的返回分支与幂等设计

前端拿到 fileHash 之后,要先请求一个 check 接口。这个接口干两件事:先查“完整文件是否已经存在”,如果存在直接返回秒传成功;如果不存在,再查“这个 hash 下面已经传了哪些分片”,为的是给断点续传提供清单。

后端查重,正式项目通常是查数据库:文件表里有没有 hash 匹配且状态为 completed 的记录。简化演示可以直接查文件系统:上传目录里是否已经存在用{fileHash}_{fileName}命名的完整文件。返回结果我会设计成这样:

{ "exist": false, "uploadedChunks": [0, 1, 2, 5, 6] }

如果exist为 true,前端直接结束,把进度跳到 100%,这就是用户感知到的“秒传”。很多用户第一次看到刚选择完文件就显示上传完成,会以为是网络快,其实服务端根本没收到文件数据。

这里必须澄清一个常见误区:秒传不是把文件快速传上去,而是“发现根本不用传”。它前置依赖的是 fileHash 查重逻辑,查重本身有计算成本,但相比传完整文件,这点成本完全可以忽略。

check 接口也要保持幂等。用户重复点击、重复发起检查,不会产生任何副作用,它只是查一遍状态。这样前端在刷新、重试、重新选择文件时都可以反复调用它。

3. 续传的数据基础:分片状态清单与断点记录

3.1 服务端怎么记录“已经有哪些分片”

要支持续传,服务端必须能回答一个问题:这个文件已经收到了哪些分片?我目前用的方案是文件系统+目录结构:

upload_tmp/ {fileHash}/ chunk_0 chunk_1 chunk_2

每个 fileHash 一个目录,目录下每个分片一个文件,文件名直接用 index。要查已上传分片,列目录就够了。

这个方案优点很多:不依赖数据库就能跑,适合小型项目做原型;分片写入天然幂等,同样 index 的文件重复覆盖不影响结果;合并时按 index 读文件名排序非常自然。扫描函数写出来也简单:

function scanChunks(chunkDir) { return fs.readdirSync(chunkDir) .filter(f => f.startsWith('chunk_')) .map(f => parseInt(f.replace('chunk_', ''), 10)); }

但在正式环境里,我建议还是引入数据库。文件系统扫描在分片数量多、文件规模大的时候会变慢,也不方便存元数据。数据库里至少要有两张表:upload_task负责记录 fileHash 与文件元信息、状态;upload_chunk负责记录每个分片的归属和上传状态,以 fileHash + chunkIndex 作为联合主键。

写入分片状态时用“冲突则覆盖/忽略”的方式做幂等。数据库的好处是既能支撑秒传的快速查重,也能支撑分片清单的精确查询,还能做统计和清理任务。

3.2 check接口是续传的总开关

续传的理解,有一个特别关键的转变:前端的“断点”其实不重要,重要的是服务端已经存了多少分片。

用户刷新页面、关闭浏览器、断网重启,只要服务端的分片数据还在,那么下次拿起同一个文件——注意 hash 相同——check 接口就能返回已经上传过的分片列表。前端拿到这个清单,把本地切片过滤一遍,只传缺失部分就行了。

你会发现,续传根本不需要在前端本地持久化“上次传到百分之多少”这个状态。因为服务端的分片存在性就是断点本身。这样带来的好处是,你在手机浏览器传了三分之一,换到电脑浏览器再传同一个文件,只要 hash 一致、服务端数据没清理,照样能接上。

前端保证续传的关键动作,其实是下面这条链路:

  1. 选择文件。
  2. 分片并计算 fileHash。用户中途改过文件内容,hash 就会变,那就变成一个全新的任务,续不上是正常的。
  3. 调用 check 接口。
  4. 用返回的 uploadedChunks 过滤本地分片列表。
  5. 并发上传剩余分片。
  6. 全部成功后调用 merge 接口。

这里有个容易绕远路的理解:很多人以为续传要靠前端记录“上传到第几个分片了”,其实不需要,后端的分片存在性就是唯一的真源。把模型想清楚,代码自然就简洁。

4. 前端源码拆解:切片、指纹计算与并发上传控制

4.1 从File.slice到Chunk对象:切片与Index设计

先看切片这段。浏览器里对文件做切片用的是File.prototype.slice,它会返回原始文件某个字节区间的 Blob 子对象,不是复制数据,所以切片本身的内存成本很低。

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB function splitFile(file) { const count = Math.ceil(file.size / CHUNK_SIZE); const chunks = []; for (let i = 0; i < count; i++) { const start = i * CHUNK_SIZE; const end = Math.min(file.size, start + CHUNK_SIZE); chunks.push({ index: i, start, end, size: end - start, blob: file.slice(start, end) }); } return chunks; }

index 是从 0 开始的整数,这是整个系统的排序锚点。服务端合并时绝对要按 index 排,不要拿 start 或者 end 当标识,因为数值长度不一致、排序特别容易乱。

分片大小为什么选 2MB?这是一个工程上的平衡。如果分片太小,一个几百MB的文件会切成上千个 HTTP 请求,请求数和握手开销压不住;如果分片太大,比如 50MB,单分片传输时间重新变长,断点粒度又变粗了。实际项目里 2MB 到 10MB 是常见区间,我习惯先按 2MB 起步,再根据用户的带宽情况做动态调整。

4.2 SparkMD5分块读取与Web Worker防卡顿

接下来是 hash 计算。我用 spark-md5 配合 FileReader 分块读取:

function calcFileHash(file, chunkSize = CHUNK_SIZE) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; function readNext() { const slice = file.slice(offset, offset + chunkSize); reader.readAsArrayBuffer(slice); } reader.onload = (e) => { spark.append(e.target.result); offset += chunkSize; if (offset < file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror = (e) => reject(e); readNext(); }); }

核心逻辑是每次只读一个分片的 ArrayBuffer,追加到 spark 实例里,然后继续读下一段,全程不积攒完整文件内容,内存占用有上界。这个分块大小可以和上传切片大小保持一致,代码逻辑可以复用。

但这里有个大坑:如果文件超过几百MB,或者用户设备性能一般,hash 计算过程可能要好几秒。直接在浏览器主线程跑,页面会卡死,用户会以为浏览器崩溃了。正确做法是丢到 Web Worker 里算:

// hash.worker.js import SparkMD5 from 'spark-md5'; self.onmessage = (e) => { const file = e.data.file; const chunkSize = e.data.chunkSize || 2 * 1024 * 1024; // 在 worker 里做同样的分块读取与 spark 追加 self.postMessage(hashResult); };

主线程这边只需要new Worker('hash.worker.js'),然后把 File 对象通过postMessage传过去。File/Blob 支持结构化克隆,可以跨线程传输。这样计算期间界面依然流畅,用户体验完全不一样。这个细节属于典型的“不上线不知道,一上线被用户骂”的问题。

4.3 并发池实现:限制同时上传数量并自动跳过已有分片

分片上传本身很简单,构造 FormData 发 fetch 就行:

async function uploadChunk(fileHash, chunk) { const formData = new FormData(); formData.append('fileHash', fileHash); formData.append('index', chunk.index); formData.append('chunk', chunk.blob); const res = await fetch('/api/upload/chunk', { method: 'POST', body: formData, }); if (!res.ok) throw new Error(`upload chunk ${chunk.index} failed`); return res.json(); }

但我再三提醒自己,千万不能用Promise.all把所有分片一次性发出去。几十个分片同时请求,会把服务端连接打满,也会触发浏览器的并发限制。所以必须做一个并发池,最多同时跑 N 个请求:

async function concurrentRun(tasks, limit) { const results = []; const queue = [...tasks]; const workers = []; for (let i = 0; i < limit && queue.length; i++) { workers.push(consume()); } async function consume() { while (queue.length) { const task = queue.shift(); results.push(await task()); } } await Promise.all(workers); return results; }

再加上重试逻辑,上传接口一旦失败就按指数退避重试,避免瞬时网络抖动拖垮整批任务:

async function uploadChunkWithRetry(fileHash, chunk, retries = 2) { for (let attempt = 0; ; attempt++) { try { return await uploadChunk(fileHash, chunk); } catch (err) { if (attempt >= retries) throw err; await sleep(1000 * Math.pow(2, attempt)); } } }

整体调度里,先过滤掉已经存在的分片,再把过滤后的任务丢进并发池:

const uploadedSet = new Set(checkResult.uploadedChunks); const tasks = chunks .filter(c => !uploadedSet.has(c.index)) .map(c => () => uploadChunkWithRetry(fileHash, c)); await concurrentRun(tasks, 3);

并发数为什么取 3 而不是 10?因为浏览器到同一域名的连接资源有限,而且服务端是 IO 密集处理,并发 2 到 4 通常最稳。并发开太高,请求倒是发出去了,服务端连接、磁盘 IO、内存全部排队,整体吞吐反而下降,后面我会专门讲到这个坑。

5. 后端源码拆解:分片落地、状态查询与流式合并

5.1 分片存储的目录设计与命名规范

后端我用 Node.js + Express + multer 来演示。先初始化必要的目录和依赖:

const express = require('express'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const { pipeline } = require('stream/promises'); const app = express(); const UPLOAD_DIR = path.resolve(__dirname, 'uploads'); const TEMP_DIR = path.resolve(__dirname, 'upload_tmp'); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR); if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR); app.use(express.json()); const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, TEMP_DIR), filename: (req, file, cb) => cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}`), }), }).single('chunk');

为什么 multer 落盘时用随机临时名字,而不是直接用chunk_{index}?因为到达这个回调的时候,服务端还没开始解析业务字段,并不知道目标分片目录是否存在。稳妥的做法是先把文件落到临时位置,随后在业务处理阶段创建目录并移动到最终位置。

接收分片并落地的接口:

app.post('/api/upload/chunk', upload, (req, res) => { const { fileHash, index } = req.body; if (!req.file || fileHash === undefined || index === undefined) { return res.status(400).json({ code: 1, message: 'missing params' }); } const chunkDir = path.join(TEMP_DIR, fileHash); fs.mkdirSync(chunkDir, { recursive: true }); const target = path.join(chunkDir, `chunk_${index}`); fs.renameSync(req.file.path, target); res.json({ code: 0, message: 'ok' }); });

这里有几个细节值得说。第一,最终文件名必须用chunk_${index},这样合并时按 index 读即可。第二,重复写同一个 index 直接覆盖,天然幂等,网络重试不会产生脏数据。第三,用 fileHash 做目录名,从根上隔离了不同文件的分片,同名分片索引在不同 hash 目录下井水不犯河水。

5.2 check接口:已有文件查秒传、分片清单查续传

check 接口是秒传和续传共用的大门。先查完整文件是否已经存在,再查分片清单。

app.post('/api/upload/check', (req, res) => { const { fileHash } = req.body; if (!fileHash) return res.status(400).json({ code: 1, message: 'missing fileHash' }); const existedFile = fs.readdirSync(UPLOAD_DIR).find(name => name.startsWith(fileHash + '_')); if (existedFile) { return res.json({ exist: true, uploadedChunks: [] }); } const chunkDir = path.join(TEMP_DIR, fileHash); const uploadedChunks = fs.existsSync(chunkDir) ? scanChunks(chunkDir) : []; res.json({ exist: false, uploadedChunks }); });

我用startsWith(fileHash + '_')来寻找已完成的文件,要求合并时把最终文件命名为${fileHash}_${fileName}。这样做的好处是,既保留了原始文件名,也保留了 hash 索引。如果只存 hash 不存名字,同名不同内容的覆盖问题会立刻出现。

正式项目里,这个接口应该换成数据库查询:upload_task表按 fileHash 查状态,upload_chunk表按 fileHash 查已上传 index。文件系统方案的逻辑能跑通,但性能和并发能力都有限,适合做 Demo 和中小流量场景。

5.3 合并流程:按序用流拼接,避免内存爆炸

merge 接口是整条链路的最后一棒。它做三件事:校验分片齐全、按顺序拼接文件、清理临时目录。

app.post('/api/upload/merge', (req, res) => { const { fileHash, fileName, totalChunks } = req.body; const chunkDir = path.join(TEMP_DIR, fileHash); const uploaded = scanChunks(chunkDir); for (let i = 0; i < totalChunks; i++) { if (!uploaded.includes(i)) { return res.status(400).json({ code: 1, message: `chunk ${i} missing`, missingIndex: i }); } } const targetPath = path.join(UPLOAD_DIR, `${fileHash}_${fileName}`); const writeStream = fs.createWriteStream(targetPath); (async () => { try { for (let i = 0; i < totalChunks; i++) { const src = path.join(chunkDir, `chunk_${i}`); await pipeline(fs.createReadStream(src), writeStream, { end: false }); } writeStream.end(); fs.rmSync(chunkDir, { recursive: true, force: true }); res.json({ code: 0, message: 'merge ok', url: `/download/${fileHash}` }); } catch (err) { writeStream.destroy(); res.status(500).json({ code: 1, message: err.message }); } })(); });

讲三个关键点。

第一,pipeline(fs.createReadStream(src), writeStream, { end: false })里的end: false很重要。它表示每个分片管道结束后,不要关闭目标写入流,这样多个分片才能依次写进同一个最终文件。最后手动调用writeStream.end()收尾。

第二,合并前必须做分片缺席检查。如果某个分片缺失,直接返回missingIndex,前端拿到这个值之后可以精准补传那几个分片,而不是重新传整个文件。这个设计在前端网络抖动时特别救命。

第三,合并完成后清理临时目录,否则磁盘空间会被半途而废的任务慢慢塞满。正式环境还应该定时扫描临时目录,清理长时间未完成的老任务。

如果还想更稳妥,合并完成后应该再算一次最终文件的 Hash,和 fileHash 比对。如果对不上,说明分片数据有损坏,应该删除重传。这是花钱买不到的质量保障。

6. 实战避坑:来自真实上线后的五个问题

6.1 并发数开太高,连接和磁盘先被拖垮

我第一次实现时,天真地把并发开到了 10,想着一口气把所有分片怼上去。结果小带宽服务器瞬间被打满,客户端倒是并发发请求,服务端连接排队、磁盘 IO 排队,部分分片直接超时失败。后来把并发收敛到 3,配合失败重试,整体吞吐反而更稳了。

经验是并发数不是一个越大越好的参数。如果服务端走的是 CDN 或云存储,可以适当高一些;如果是单机部署,2 到 4 通常是最优区间。上线后观察带宽、内存、连接数,再慢慢调。

6.2 大文件hash计算把页面主线程卡死

这个坑前面说过一次,但值得再强调一遍。第一次没上 Worker,一个 1GB 文件算 hash,页面几乎处于假死状态,用户疯狂刷新。改成 Worker 之后,计算期间页面依然流畅,体验天差地别。

另外 Worker 里也必须有分块读取逻辑,不能图省事一次性把整个文件读成 ArrayBuffer 再喂给 spark。那样 chrome 标签页的内存占用会直接起飞,文件越大越明显。分块大小和上传分片保持一致即可。

6.3 合并时缺一个分片,生成的文件打不开

上线后有一段时间,用户偶尔反馈文件上传显示 100%,下载下来却打不开。排查到最后发现是 merge 接口没有校验分片齐全,某个分片因为网络超时没有上传成功,但前端当时的并发循环没有正确捕获 fetch 异常,导致“看似全部完成”。

后来改成 merge 前全量检查分片存在性,缺哪个返回哪个,前端拿到missingIndex后做补偿式补传。从那以后,再也没有出现过这种静默失败的问题。任何时候都不要相信“前端说有就是有”,服务端必须自己确认状态。

6.4 秒传判断只查文件名,同名不同内容的文件被吞

最早做秒传时图省事,觉得上传目录里存在同名完整文件就直接返回秒传。结果有人上传了一个同名但内容完全不同的新版文件,直接秒传成功,下载下来却是旧内容,数据等于被吞了。

后来改成以 fileHash 为准,最终文件命名改为{fileHash}_{fileName},彻底规避同名歧义。如果你做秒传,无论用数据库还是文件系统,判断依据必须是内容指纹,不能是文件名。

6.5 进度条回跳与百分比失真

进度条看似是小事,实际上很影响用户信任。最初我用“已成功上传的分片数 / 总分片数”算进度,结果分片上传顺序不稳定,加上失败重试,进度经常回跳。 后来改成按字节数统计:累计已成功上传分片的字节数之和,除以文件总字节数。并且把 hash 计算阶段和上传阶段分开显示,否则用户会看到进度卡在“计算校验”那一步,误以为程序坏了。分片成功时累加字节数时,要注意用 Set 去重,避免重试成功同一个分片导致重复累计。

这套骨架我在项目里跑了很长时间,后续加了取消上传、暂停上传、任务列表这些周边功能,但核心始终没有动:fileHash 管归属和查重,index 管顺序和续传断点,服务端分片存在性就是所有人能依赖的断点状态。如果你现在正被大文件上传折磨,建议先把这套秒传 + 续传链路跑通,再回头折腾进度、暂停、重试这些锦上添花的东西,会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询