很多讲大文件上传的文章,到“前端切片 + 后端合并”就收了。
然后读者真回去做,第二轮问题立刻追上来:
- 同一个文件第二次上传,能不能直接秒传
- 上传到一半断网了,能不能只传剩下的
- 浏览器一刷新,进度为什么全没了
- 一个 20GB 的文件,到底拿什么做唯一标识
这时候你会发现,大文件上传真正难的,其实不是切片。
切片只是入口。
真正难的是两件事:
你怎么定义“这是同一个文件”,以及你怎么保存“这次上传已经进行到哪了”。
我现在越来越觉得,S3 之所以最后会变成很多团队的默认答案,不是因为它比你更会切片。
而是因为它把“上传状态”这件事做成了一套很稳的基础设施。
但这篇我不想一上来就讲 S3。
先自己做一遍。用 Node.js 把这套东西从 0 顺下来,很多问题反而会看得更清楚。
第一版先别想太多:就是切片、上传、合并
如果只是做一个最小可用版,思路其实不复杂:
- 前端把文件按固定大小切片
- 每一片单独上传到 Node 后端
- 后端把分片先落到临时目录
- 全部分片到齐后,再按顺序合并成最终文件
接口形状大概会长这样:
POST /api/uploads/prepare
PUT /api/uploads/:uploadId/chunks/:chunkIndex
POST /api/uploads/:uploadId/complete
GET /api/uploads/:uploadId/status
临时目录一般也差不多:
tmp/uploads/{uploadId}/{chunkIndex}.part
files/{fileId}
前端拿到 uploadId 以后,开始并发上传切片。
后端收到每一片,就往临时目录写:
type UploadChunkPayload = {
uploadId: string;
chunkIndex: number;
chunkSize: number;
totalChunks: number;
fileName: string;
fileSize: number;
};
等所有分片都传完,再调 complete,后端用 stream 方式把所有 .part 顺序拼起来。
async function mergeChunks(
uploadId: string,
totalChunks: number,
targetPath: string,
) {
const writable = createWriteStream(targetPath);
for (let i = 0; i < totalChunks; i += 1) {
const chunkPath = getChunkPath(uploadId, i);
await pipeFileIntoStream(chunkPath, writable, { end: false });
}
writable.end();
}
这一版能跑。
演示也够用了。
但它离“线上能扛事”还差得挺远。因为大文件上传真正绕不过去的问题,不在第一次上传,而在第二次。
真正会追上来的,是这 4 个问题
1. 所谓“秒传”,本质上不是提速,而是去重
很多人第一次听“秒传”,理解成“上传得更快”。
其实不是。
秒传真正的意思是:
服务端已经有这份内容了,所以这次根本不用再传。
这里有个很关键的边界。
如果你只是拿文件名判断,比如用户又传了一个 demo.zip,那不叫秒传,那叫误判。
如果你拿“文件名 + 文件大小”判断,误判概率会低一点,但本质上还是不靠谱。
真正能支撑秒传的,是“内容级标识”。
也就是说,你需要一张单独的文件对象表,别把“用户看到的文件”跟“底层真实内容”混成一张表。
我一般会拆成三层:
type UploadSession = {
uploadId: string;
userId: string;
fileFingerprint: string;
fileName: string;
fileSize: number;
chunkSize: number;
totalChunks: number;
status: 'initiated' | 'uploading' | 'merged' | 'completed' | 'failed';
};
type FileObject = {
fileObjectId: string;
fullHash: string;
fileSize: number;
storagePath: string;
status: 'available' | 'quarantined' | 'deleted';
};
type UserFile = {
userFileId: string;
userId: string;
fileObjectId: string;
originalFileName: string;
};
你会发现,一旦这么拆,秒传的逻辑就顺了:
- 客户端先提交文件指纹
- 服务端查
FileObject - 如果已经有完全相同的内容,就直接新建一条
UserFile关联 - 整个上传流程可以直接返回完成
所以秒传这件事,真正依赖的不是“前端切片”。
它依赖的是你有没有把“文件内容”和“用户文件记录”拆开。
2. 断点续传,本质上是服务端记住了哪些块已经成功
断点续传也很容易被说空。
很多实现只是本地记一个“上传到 67%”。
这不够。
因为真正能恢复上传的,不是一个百分比,而是一个明确的分片集合:
- 这次上传的
uploadId - 分片总数
totalChunks - 已上传成功的
chunkIndex[] - 每片的大小、校验值、落盘状态
也就是说,服务端至少得能回答这个问题:
这次上传里,哪些 chunk 我已经确认收到了。
所以 status 接口通常会返回这样的东西:
{
"uploadId": "upl_xxx",
"status": "uploading",
"uploadedChunks": [0, 1, 2, 5, 6, 8]
}
前端恢复时只补缺的:
const missingChunks = allChunks.filter(
(chunk) => !uploadedChunkSet.has(chunk.index),
);
这才叫断点续传。
如果你没有一张分片状态表,或者没有至少把成功的 chunk index 记到 Redis / 数据库 / 元数据文件里,那所谓的续传,最后大概率还是从头再来。
3. 浏览器刷新后保存进度,保存的不是文件,而是会话
这个点特别容易被讲错。
浏览器刷新后,你能稳定保存下来的,通常不是那个 File 对象本身,而是这次上传的元数据:
uploadIdfileFingerprintfileNamefileSizechunkSizeuploadedChunks
这些东西可以放 IndexedDB,轻一点也可以先放 localStorage。
但要注意,这里保存的是“上传会话”,不是“文件本体”。
也就是说,普通 Web 页面刷新以后,常见做法其实是:
- 本地恢复出上一次的
uploadId和上传进度 - 用户重新选择同一个文件
- 前端重新计算或比对文件指纹
- 命中同一个会话后,继续补传缺失分片
这套流程已经能覆盖大多数业务了。
如果你想做得更丝滑,可以看 File System Access API。MDN 文档里提到,FileSystemFileHandle 可以让你在支持的浏览器里拿到文件句柄,再次取回文件;但同一份文档也提醒,文件访问权限在页面刷新后并不一定还能保留,如果这个 origin 没有别的标签页存活,权限状态可能就丢了。
所以这东西更像增强项,不是基础盘。
别把“刷新后自动继续上传”理解成“浏览器天然会替你记住文件”。
浏览器帮你记住的,更多是一个线索。
真正能不能续上,还是要靠服务端会话和你重新拿回那份文件。
4. 超大文件怎么“认文件”:别指望一个 ID 包打天下
这里才是最容易踩坑的地方。
你说“那就算一个 hash 不就行了”。
原则上没错。
问题是,谁算,怎么算,什么时候算。
对一个几百 MB 的文件,前端算完整 SHA-256 还勉强能聊。
但文件一旦上到几个 GB、几十 GB,你就会立刻撞上几个现实问题:
- 计算 hash 本身就要读完整个文件一次
- 如果放在主线程,页面会非常难受
- 就算放进
Web Worker,时间成本也还是在 - 用户可能会觉得“怎么上传还没开始,CPU 就先满了”
所以我更建议把“认文件”这件事拆开,不要一个 ID 打天下。
如果把它讲得再白一点,用户嘴里那个“文件唯一标识”,其实经常混着 4 个问题:
- 浏览器刷新以后,怎么找到上一次上传会话
- 第二次选中同一个文件,怎么知道这次也许可以续传
- 服务端怎么快速判断“这份内容大概率见过”
- 最终入库时,怎么确认“这就是那份内容”
这 4 个问题,最好拆成 4 层身份来看:
uploadId:一次上传尝试的过程 IDresumeKey:浏览器本地恢复会话用的线索fileFingerprint:上传前用于预检查的候选内容指纹fullHash:服务端最终确认内容时用的权威哈希
先看最轻的一层。
resumeKey:给浏览器自己找回会话用
如果你的目标只是“用户刷新页面后,尽量把上一次上传接回来”,那浏览器本地其实可以先存一个很便宜的键:
function createResumeKey(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`;
}
这个 key 很便宜。
也很好用。
因为用户在同一台机器上,刷新页面后重新选中同一个文件时,name + size + lastModified 往往不会变。前端就能先用它去 IndexedDB 找到上一次保存的 uploadId、chunkSize 和 uploadedChunks。
但这玩意儿只适合本地恢复。
别拿它做服务端内容去重。
原因很简单:
- 改个文件名就变了
- 拷贝一次文件,
lastModified也可能变 - 两个不同文件也可能碰巧同名同大小
它是浏览器的线索,不是内容身份。
fileFingerprint:先用便宜办法判断“像不像同一个内容”
真正跟秒传、预检查更相关的,是 fileFingerprint。
这里最常见的做法,就是抽样算指纹。
思路不复杂:
- 不读整个文件
- 只读首部、尾部和中间几段固定位置的数据
- 再把文件大小这类稳定元信息一起放进去
- 最后算一次
SHA-256
这样做的好处是,你不用在前端把整个 20GB 文件先扫一遍,上传前也能很快拿到一个候选指纹。
浏览器端代码大概可以写成这样:
const SAMPLE_SIZE = 2 * 1024 * 1024;
function pickSampleRanges(fileSize: number) {
if (fileSize <= SAMPLE_SIZE * 3) {
return [{ start: 0, end: fileSize }];
}
const middleStart = Math.max(0, Math.floor(fileSize / 2 - SAMPLE_SIZE / 2));
return [
{ start: 0, end: SAMPLE_SIZE },
{ start: middleStart, end: middleStart + SAMPLE_SIZE },
{ start: fileSize - SAMPLE_SIZE, end: fileSize },
];
}
function concatUint8Arrays(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
function toHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export async function buildFileFingerprint(file: File) {
const ranges = pickSampleRanges(file.size);
const sampledChunks = await Promise.all(
ranges.map(async ({ start, end }) => {
const chunk = file.slice(start, end);
return new Uint8Array(await chunk.arrayBuffer());
}),
);
// 这里故意只放“内容稳定相关”的信息。
// 文件名和 lastModified 更适合做本地 resumeKey,
// 不适合直接参与服务端内容去重。
const meta = new TextEncoder().encode(
JSON.stringify({
size: file.size,
ranges,
}),
);
const payload = concatUint8Arrays([meta, ...sampledChunks]);
const digest = await crypto.subtle.digest('SHA-256', payload);
return toHex(digest);
}
这个 fileFingerprint 很适合做两件事:
prepare阶段快速找已有未完成会话- 在服务端做“候选去重”,缩小可能命中的文件集合
但注意,它依然只是候选。
因为抽样指纹再强,也不等于完整内容哈希。
fullHash:最后真正拍板的,还是完整内容哈希
一旦文件真的合并完成,服务端就该算一次完整 fullHash 了。
这一步才是内容身份真正落锤的时候。
如果后端是 Node.js,最直白的写法就是流式读取文件做 SHA-256:
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
export async function computeFullHash(filePath: string) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk);
}
return hash.digest('hex');
}
这个写法的好处是,不会把整个大文件一次性读进内存。
更进一步一点,你甚至可以把“合并分片”和“计算完整哈希”放在同一趟里做,避免文件合并完之后又重新读一遍:
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { createReadStream, createWriteStream } from 'node:fs';
export async function mergeChunksAndHash(
uploadId: string,
totalChunks: number,
targetPath: string,
) {
const hash = createHash('sha256');
const writable = createWriteStream(targetPath);
for (let i = 0; i < totalChunks; i += 1) {
const chunkPath = getChunkPath(uploadId, i);
for await (const chunk of createReadStream(chunkPath)) {
hash.update(chunk);
if (!writable.write(chunk)) {
await once(writable, 'drain');
}
}
}
writable.end();
await once(writable, 'close');
return hash.digest('hex');
}
这就比较像线上会用的版本了。
因为它把两件重活合成了一次磁盘遍历。
那第二次上传到底怎么秒传
把这几层身份拆开以后,秒传这件事就好理解了。
理想状态下,它会走这样一条链路:
- 浏览器先生成
resumeKey - 如果本地有旧会话,先尝试恢复上传
- 同时浏览器生成
fileFingerprint - 服务端用
fileFingerprint查候选文件或候选会话 - 如果命中的是已经确认过
fullHash的历史文件,就可以直接建立引用,返回秒传成功 - 如果只是命中候选但还不够确定,就走续传或正常上传,最后再用
fullHash收口
伪代码大概像这样:
async function prepareUpload(input: PrepareInput) {
const resumable = await uploadSessions.findActiveByFingerprint(
input.userId,
input.fileFingerprint,
);
if (resumable) {
return {
mode: 'resume',
uploadId: resumable.uploadId,
uploadedChunks: await uploadedChunksRepo.list(resumable.uploadId),
};
}
const matchedObject = await fileObjects.findByFingerprint(
input.fileFingerprint,
);
if (matchedObject?.fullHashVerified) {
return {
mode: 'instant',
fileObjectId: matchedObject.fileObjectId,
};
}
const session = await uploadSessions.create(input);
return { mode: 'new', uploadId: session.uploadId, uploadedChunks: [] };
}
这里最值钱的不是代码。
而是这个判断顺序:
- 先找恢复会话
- 再找已验证内容
- 最后才新建上传
这样浏览器刷新、上传中断和第二次上传,才不会互相打架。
最后再把边界讲死一点
如果你的业务对误判非常敏感,比如法律文档、医疗影像、财务归档、源代码包,那就别只靠抽样 fileFingerprint 直接做跨用户秒传。
因为这本质上是在拿一点点碰撞风险换体验。
更稳的做法通常是:
- 抽样指纹只负责候选命中和恢复会话
- 真正的跨用户去重,还是落到服务端确认过的
fullHash - 如果业务真的要求零误判,那就接受更贵一点的完整哈希计算成本
反过来说,如果这份文件你以前已经完整上传过一次,服务端其实已经有了它的权威 fullHash。这时候第二次上传能不能秒传,靠的往往不是“这次再把完整 hash 算一遍”,而是“这次给出的候选指纹,能不能稳定命中服务端历史上已经确认过的那份内容”。
这事说白了就是:
超大文件的身份,不是一个哈希值就能包办的。浏览器要有浏览器的线索,服务端要有服务端的权威。先用便宜标识把流程跑起来,再用完整哈希把结果收住,这才是更像工程实现的写法。
如果后端是 Node.js,我会怎么一步步落
如果让我自己从 0 做,我大概会按这个顺序来。
第一步:先把上传会话和文件对象建出来
别急着写合并脚本。
先把数据模型想清楚:
upload_sessionsuploaded_chunksfile_objectsuser_files
这里面最重要的是 upload_sessions 和 uploaded_chunks。
因为秒传、续传、刷新恢复,这三个能力其实都在吃这两张表。
第二步:准备接口只做三件事
prepare 接口最好只做三件事:
- 校验本次上传是否合法
- 判断是不是已经有完整文件,可以直接秒传
- 判断是不是已有未完成会话,可以直接恢复
这一步其实就是上一节那套“认文件”逻辑的真正落地版。
关键不在于参数有多少。
关键在于顺序别乱:
- 先看是不是已有未完成会话,能不能恢复
- 再看是不是已经命中已验证内容,可以直接秒传
- 最后才新建上传会话
如果前端愿意提前算完整 fullHash,那当然可以把秒传判断做得更硬。
但超大文件场景里,更常见的现实是:前端只会先给你 resumeKey 和 fileFingerprint,真正的完整哈希要等服务端在合并阶段再算。
所以 prepare 接口最重要的职责,不是“一次把真相全算出来”,而是先把“恢复上传”“直接秒传”“新建会话”这三条路分开。
第三步:每个 chunk 落盘时就记录状态,不要等 complete 再回头猜
很多实现喜欢在 complete 阶段扫临时目录,看看一共有多少分片。
也不是不行。
但如果你真要做稳定恢复,我更建议每片上传成功时就记数据库:
type UploadedChunk = {
uploadId: string;
chunkIndex: number;
size: number;
checksum?: string;
storedPath: string;
};
这样做的好处很现实:
- 你不怕应用实例重启
- 你不怕临时目录扫描变慢
- 你可以很快返回
uploadedChunks - 你后面要迁移到对象存储,也不会重写整个状态层
第四步:complete 时做两件额外的事
很多教程到这里就结束了:合并,返回成功。
但线上最好再补两步:
- 合并时顺手计算完整
fullHash - 合并完成后,再做一次去重判断
第二步很关键。
因为你上传前做的只是“候选秒传判断”,真正权威的去重机会,其实是在服务端拿到完整文件以后。
流程会更稳:
merge chunks
-> compute fullHash
-> check file_objects by fullHash
-> if exists: delete merged temp file, create reference only
-> else: move merged file to final storage, create file_object
这样即使前面的抽样指纹不够准,最后也能靠完整 fullHash 把账收回来。
你自己实现到这一步,就会明白 S3 为什么会变成默认答案
写到这里你会发现,自己做大文件上传其实不是做不了。
Node 也完全能做。
但你很快就会开始嫌这些事烦:
- 临时文件目录越来越大
- 合并过程吃磁盘 IO
- 应用层既要管上传,又要管存储
- 多实例部署时,临时文件和状态一致性都麻烦
- 还没算上清理策略、权限控制和跨机房带宽
也就是这时候,很多团队会自然转向对象存储。
以 Amazon S3 为例,它那套 multipart upload 其实就是把你上面自己写过的一部分,做成了托管版标准流程。
截至 2026-04-18,AWS 官方文档仍然建议对象达到 100 MB 左右时就考虑 multipart upload;并给出了 10,000 个 part、单 part 5 MiB 到 5 GiB、对象最大 48.8 TiB 这些边界。
更关键的是流程形状,几乎一眼就能对上:
你的 prepare/session -> S3 CreateMultipartUpload
你的 chunkIndex -> S3 partNumber
你的 uploaded_chunks -> S3 ListParts + 你自己的状态表
你的 merge -> S3 CompleteMultipartUpload
你的 abort cleanup -> S3 AbortMultipartUpload
这也是为什么我会说,S3 不是“另一个方案”。
它更像是你自己实现那套方案的工业化版本。
但切到 S3 以后,这 4 个问题并不会自动消失
这个也很重要。
很多人以为上了 S3,秒传、续传、刷新恢复这些问题就一起解决了。
没有。
S3 解决的是“分片上传和对象拼装”这层基础设施,不是你的全部业务语义。
秒传,还是你自己负责
S3 会给你 uploadId,会帮你收 part。
但它不会替你判断:
- 这个用户和上次那个用户是不是可以共用同一个对象
- 这个文件是不是已经在你的业务库里存在
- 应不应该新建一条用户文件记录
所以秒传这件事,上了 S3 以后本质没变。
你还是得有自己的 file_objects 和 user_files。
断点续传,S3 帮你减轻了存储压力,但状态还是你要管
S3 的 ListParts 确实能帮你知道一个 uploadId 下面已经传了哪些 part。
AWS 文档也明确说了,part 可以独立上传、失败后单独重传,而且上传不会自己过期,除非你主动 complete 或 abort。
这已经比你自己扫临时目录轻松很多了。
但就算这样,业务后端通常还是会保留自己的上传会话表。
原因很简单:
- 你得知道这次上传属于谁
- 你得知道这个
uploadId对应哪个业务对象 - 你得知道这个会话是“上传中”还是“已经进入转码”
S3 负责 part。
你负责 session。
浏览器刷新恢复,S3 也替不了你
这一点最容易被忽视。
就算后端已经切到 S3 multipart upload,浏览器刷新以后要恢复,前端还是得先找回这些东西:
- 本地保存的
uploadId - 当前文件对应的
fileFingerprint - partSize
- 已完成的分片范围
然后再去服务端查会话,或者直接对 uploadId 做 ListParts。
也就是说,页面刷新恢复这件事,本质上还是“本地元数据 + 服务端会话”的配合。
它不是对象存储单方面能解决的。
uploadId 不是文件唯一标识
这个坑非常值得单独提一句。
不管是你自己做,还是上 S3,uploadId 都只代表“一次上传过程”。
它不是文件内容 ID。 也不是秒传 ID。 更不是你业务里的资源 ID。
所以别把这几个东西混在一起:
uploadIdfileFingerprintfullHashfileObjectIduserFileId
一旦混了,后面不是续传逻辑乱掉,就是秒传逻辑串掉。
最后
如果你只想问“大文件上传怎么做”,那最短答案当然可以是:
前端切片,后端收片,最后合并。
但那只是第一层。
真正有工程价值的部分,是后面这几件事:
- 第二次上传怎么秒传
- 断了以后怎么只补缺的 chunk
- 页面刷新以后怎么恢复会话
- 超大文件怎么定义一个既够快又够准的身份
你把这几件事自己在 Node.js 里认真做一遍,再回头看 Amazon S3 multipart upload,会很容易明白它为什么会变成事实上的行业标准。
它不是突然多了什么神奇能力。
它只是把那套最烦、最重、最容易出事故的基础设施,提前帮你做稳了。
但就算这样,秒传、断点恢复、文件身份、用户绑定这些业务层问题,最后还是你的系统自己要收。