大文件上传最难的,不是切片,是认文件:秒传、续传和文件指纹怎么做

0 阅读18分钟

很多讲大文件上传的文章,到“前端切片 + 后端合并”就收了。

然后读者真回去做,第二轮问题立刻追上来:

  • 同一个文件第二次上传,能不能直接秒传
  • 上传到一半断网了,能不能只传剩下的
  • 浏览器一刷新,进度为什么全没了
  • 一个 20GB 的文件,到底拿什么做唯一标识

这时候你会发现,大文件上传真正难的,其实不是切片。

切片只是入口。

真正难的是两件事:

你怎么定义“这是同一个文件”,以及你怎么保存“这次上传已经进行到哪了”。

我现在越来越觉得,S3 之所以最后会变成很多团队的默认答案,不是因为它比你更会切片。

而是因为它把“上传状态”这件事做成了一套很稳的基础设施。

但这篇我不想一上来就讲 S3。

先自己做一遍。用 Node.js 把这套东西从 0 顺下来,很多问题反而会看得更清楚。

第一版先别想太多:就是切片、上传、合并

如果只是做一个最小可用版,思路其实不复杂:

  1. 前端把文件按固定大小切片
  2. 每一片单独上传到 Node 后端
  3. 后端把分片先落到临时目录
  4. 全部分片到齐后,再按顺序合并成最终文件

接口形状大概会长这样:

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;
};

你会发现,一旦这么拆,秒传的逻辑就顺了:

  1. 客户端先提交文件指纹
  2. 服务端查 FileObject
  3. 如果已经有完全相同的内容,就直接新建一条 UserFile 关联
  4. 整个上传流程可以直接返回完成

所以秒传这件事,真正依赖的不是“前端切片”。

它依赖的是你有没有把“文件内容”和“用户文件记录”拆开。

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 对象本身,而是这次上传的元数据:

  • uploadId
  • fileFingerprint
  • fileName
  • fileSize
  • chunkSize
  • uploadedChunks

这些东西可以放 IndexedDB,轻一点也可以先放 localStorage

但要注意,这里保存的是“上传会话”,不是“文件本体”。

也就是说,普通 Web 页面刷新以后,常见做法其实是:

  1. 本地恢复出上一次的 uploadId 和上传进度
  2. 用户重新选择同一个文件
  3. 前端重新计算或比对文件指纹
  4. 命中同一个会话后,继续补传缺失分片

这套流程已经能覆盖大多数业务了。

如果你想做得更丝滑,可以看 File System Access APIMDN 文档里提到,FileSystemFileHandle 可以让你在支持的浏览器里拿到文件句柄,再次取回文件;但同一份文档也提醒,文件访问权限在页面刷新后并不一定还能保留,如果这个 origin 没有别的标签页存活,权限状态可能就丢了。

所以这东西更像增强项,不是基础盘。

别把“刷新后自动继续上传”理解成“浏览器天然会替你记住文件”。

浏览器帮你记住的,更多是一个线索。

真正能不能续上,还是要靠服务端会话和你重新拿回那份文件。

4. 超大文件怎么“认文件”:别指望一个 ID 包打天下

这里才是最容易踩坑的地方。

你说“那就算一个 hash 不就行了”。

原则上没错。

问题是,谁算,怎么算,什么时候算。

对一个几百 MB 的文件,前端算完整 SHA-256 还勉强能聊。

但文件一旦上到几个 GB、几十 GB,你就会立刻撞上几个现实问题:

  • 计算 hash 本身就要读完整个文件一次
  • 如果放在主线程,页面会非常难受
  • 就算放进 Web Worker,时间成本也还是在
  • 用户可能会觉得“怎么上传还没开始,CPU 就先满了”

所以我更建议把“认文件”这件事拆开,不要一个 ID 打天下。

如果把它讲得再白一点,用户嘴里那个“文件唯一标识”,其实经常混着 4 个问题:

  1. 浏览器刷新以后,怎么找到上一次上传会话
  2. 第二次选中同一个文件,怎么知道这次也许可以续传
  3. 服务端怎么快速判断“这份内容大概率见过”
  4. 最终入库时,怎么确认“这就是那份内容”

这 4 个问题,最好拆成 4 层身份来看:

  • uploadId:一次上传尝试的过程 ID
  • resumeKey:浏览器本地恢复会话用的线索
  • fileFingerprint:上传前用于预检查的候选内容指纹
  • fullHash:服务端最终确认内容时用的权威哈希

先看最轻的一层。

resumeKey:给浏览器自己找回会话用

如果你的目标只是“用户刷新页面后,尽量把上一次上传接回来”,那浏览器本地其实可以先存一个很便宜的键:

function createResumeKey(file: File) {
  return `${file.name}:${file.size}:${file.lastModified}`;
}

这个 key 很便宜。

也很好用。

因为用户在同一台机器上,刷新页面后重新选中同一个文件时,name + size + lastModified 往往不会变。前端就能先用它去 IndexedDB 找到上一次保存的 uploadIdchunkSizeuploadedChunks

但这玩意儿只适合本地恢复。

别拿它做服务端内容去重。

原因很简单:

  • 改个文件名就变了
  • 拷贝一次文件,lastModified 也可能变
  • 两个不同文件也可能碰巧同名同大小

它是浏览器的线索,不是内容身份。

fileFingerprint:先用便宜办法判断“像不像同一个内容”

真正跟秒传、预检查更相关的,是 fileFingerprint

这里最常见的做法,就是抽样算指纹。

思路不复杂:

  1. 不读整个文件
  2. 只读首部、尾部和中间几段固定位置的数据
  3. 再把文件大小这类稳定元信息一起放进去
  4. 最后算一次 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');
}

这就比较像线上会用的版本了。

因为它把两件重活合成了一次磁盘遍历。

那第二次上传到底怎么秒传

把这几层身份拆开以后,秒传这件事就好理解了。

理想状态下,它会走这样一条链路:

  1. 浏览器先生成 resumeKey
  2. 如果本地有旧会话,先尝试恢复上传
  3. 同时浏览器生成 fileFingerprint
  4. 服务端用 fileFingerprint 查候选文件或候选会话
  5. 如果命中的是已经确认过 fullHash 的历史文件,就可以直接建立引用,返回秒传成功
  6. 如果只是命中候选但还不够确定,就走续传或正常上传,最后再用 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_sessions
  • uploaded_chunks
  • file_objects
  • user_files

这里面最重要的是 upload_sessionsuploaded_chunks

因为秒传、续传、刷新恢复,这三个能力其实都在吃这两张表。

第二步:准备接口只做三件事

prepare 接口最好只做三件事:

  1. 校验本次上传是否合法
  2. 判断是不是已经有完整文件,可以直接秒传
  3. 判断是不是已有未完成会话,可以直接恢复

这一步其实就是上一节那套“认文件”逻辑的真正落地版。

关键不在于参数有多少。

关键在于顺序别乱:

  • 先看是不是已有未完成会话,能不能恢复
  • 再看是不是已经命中已验证内容,可以直接秒传
  • 最后才新建上传会话

如果前端愿意提前算完整 fullHash,那当然可以把秒传判断做得更硬。

但超大文件场景里,更常见的现实是:前端只会先给你 resumeKeyfileFingerprint,真正的完整哈希要等服务端在合并阶段再算。

所以 prepare 接口最重要的职责,不是“一次把真相全算出来”,而是先把“恢复上传”“直接秒传”“新建会话”这三条路分开。

第三步:每个 chunk 落盘时就记录状态,不要等 complete 再回头猜

很多实现喜欢在 complete 阶段扫临时目录,看看一共有多少分片。

也不是不行。

但如果你真要做稳定恢复,我更建议每片上传成功时就记数据库:

type UploadedChunk = {
  uploadId: string;
  chunkIndex: number;
  size: number;
  checksum?: string;
  storedPath: string;
};

这样做的好处很现实:

  • 你不怕应用实例重启
  • 你不怕临时目录扫描变慢
  • 你可以很快返回 uploadedChunks
  • 你后面要迁移到对象存储,也不会重写整个状态层

第四步:complete 时做两件额外的事

很多教程到这里就结束了:合并,返回成功。

但线上最好再补两步:

  1. 合并时顺手计算完整 fullHash
  2. 合并完成后,再做一次去重判断

第二步很关键。

因为你上传前做的只是“候选秒传判断”,真正权威的去重机会,其实是在服务端拿到完整文件以后。

流程会更稳:

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 MiB5 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_objectsuser_files

断点续传,S3 帮你减轻了存储压力,但状态还是你要管

S3 的 ListParts 确实能帮你知道一个 uploadId 下面已经传了哪些 part。

AWS 文档也明确说了,part 可以独立上传、失败后单独重传,而且上传不会自己过期,除非你主动 completeabort

这已经比你自己扫临时目录轻松很多了。

但就算这样,业务后端通常还是会保留自己的上传会话表。

原因很简单:

  • 你得知道这次上传属于谁
  • 你得知道这个 uploadId 对应哪个业务对象
  • 你得知道这个会话是“上传中”还是“已经进入转码”

S3 负责 part。

你负责 session。

浏览器刷新恢复,S3 也替不了你

这一点最容易被忽视。

就算后端已经切到 S3 multipart upload,浏览器刷新以后要恢复,前端还是得先找回这些东西:

  • 本地保存的 uploadId
  • 当前文件对应的 fileFingerprint
  • partSize
  • 已完成的分片范围

然后再去服务端查会话,或者直接对 uploadIdListParts

也就是说,页面刷新恢复这件事,本质上还是“本地元数据 + 服务端会话”的配合。

它不是对象存储单方面能解决的。

uploadId 不是文件唯一标识

这个坑非常值得单独提一句。

不管是你自己做,还是上 S3,uploadId 都只代表“一次上传过程”。

它不是文件内容 ID。 也不是秒传 ID。 更不是你业务里的资源 ID。

所以别把这几个东西混在一起:

  • uploadId
  • fileFingerprint
  • fullHash
  • fileObjectId
  • userFileId

一旦混了,后面不是续传逻辑乱掉,就是秒传逻辑串掉。

最后

如果你只想问“大文件上传怎么做”,那最短答案当然可以是:

前端切片,后端收片,最后合并。

但那只是第一层。

真正有工程价值的部分,是后面这几件事:

  • 第二次上传怎么秒传
  • 断了以后怎么只补缺的 chunk
  • 页面刷新以后怎么恢复会话
  • 超大文件怎么定义一个既够快又够准的身份

你把这几件事自己在 Node.js 里认真做一遍,再回头看 Amazon S3 multipart upload,会很容易明白它为什么会变成事实上的行业标准。

它不是突然多了什么神奇能力。

它只是把那套最烦、最重、最容易出事故的基础设施,提前帮你做稳了。

但就算这样,秒传、断点恢复、文件身份、用户绑定这些业务层问题,最后还是你的系统自己要收。

参考资料