注册
web

当上传不再只是 /upload,我们是怎么设计大文件上传的

业务背景

在正式讲之前,先看一个我们做的大文件上传demo。
下面这个视频演示的是上传一个 1GB 的压缩包,整个过程支持分片上传、断点续传、暂停和恢复。

可以看到速度不是特别快,这个是我故意没去优化的。
前端那边计算文件 MD5、以及最后合并文件的时间我都保留了,
主要是想让大家能看到整个流程是怎么跑通的。

output1111.gif

平时我们在做一些 SaaS 系统的时候,文件上传这块其实基本上都设计的挺简单的。
前端做个分片上传,后端把分片合并起来,最后存 OSS 或者服务器某个路径上,再返回一个 URL 就完事了。
大多数情况下,这样的方案也确实够用。

但是最近我在做一个私有化项目,场景完全不一样。
项目是给政企客户部署的内部系统,里面有 AI 大模型客服问答的功能。
客户需要把他们内部的文档、手册、规范、图纸、流程等资料打包上传到服务器,用来做后续的向量化、知识检索或者模型训练。

这类场景如果还沿用之前 SaaS 系统那种上传方式,往往就不太适用了。
因为这些文件往往有几个共同点:

  1. 文件数量多,动辄几百上千份(Word、PDF、PPT、Markdown 都有);
  2. 文件体积大,打成 zip 动不动就是几个 G,甚至十几二十个 G;
  3. 上传环境复杂,客户一般在内网或局域网,有的甚至完全断网;
  4. 有安全要求,文件不能经过云端 OSS,里面可能有保密资料;
  5. 需要审计,要能知道是谁上传的、什么时候传的、文件现在存哪;
  6. 上传完之后还要进一步处理,比如自动解压、解析文本、拆页、向量化,然后再存入 Milvus 或 pgvector。

所以这种情况还用 SaaS 系统那种“简单上传+云存储”方案的话,那可能问题就一堆:

  • 上传中断后用户一刷新浏览器就得重传整个包;
  • 集群部署时分片打到不同机器上根本无法合并;
  • 多人同时上传可能会发生文件覆盖或路径冲突;
  • 没有任何上传记录,也追踪不到是谁传的;
  • 对政企来说,审计、合规、保密全都不达标。

所以,我们需要重新设计文件上传的功能逻辑。
目的是让它不仅能支持大文件、断点续传、集群部署,还能同时适配内网环境、权限管控,以及后续的 AI 文档解析和知识向量化等处理流程。


为什么很多项目只需要一个 upload 接口

如果我们回头看一下自己平常做过的一些常规 Web 项目,尤其是各种 SaaS 系统或者后台管理系统,
其实大多数时候后端只会提供一个 /upload 接口, 前端拿到文件后直接调用这个接口,后端保存文件再返回一个 URL 就结束了。

甚至我们在很多项目里,前端都不会把文件传给业务服务,
而是直接通过前端 SDK(比如阿里云 OSS、腾讯云 COS、七牛云等)上传到云存储,
上传完后拿到文件地址,再把这个地址回传给后端保存。

这种方式在 SaaS 系统或者轻量级的业务里非常普遍,也非常高效。 主要原因有几个:

  1. 文件都比较小,大多数就是几 MB 的图片、PDF 或 Excel;
  2. 云存储足够稳定,上传、下载、访问都有完整的 SDK 支撑;
  3. 系统是公网部署,不需要考虑局域网、内网断网这些问题;
  4. 对安全和审计的要求不高,文件内容也不是涉密数据;
  5. 用户体验优先,所以直接把文件上传到云端是最省事的方案。

换句话说,这种“一个 upload 接口”或“前端直传 OSS”模式,其实是面向通用型 SaaS 场景的。
对于绝大多数互联网业务来说,它既够快又够省心。

但一旦项目换成政企、私有化部署或者 AI 训练平台这种环境,
就完全不是一个量级的问题了。
这里的关键不在“能不能上传”,
而在于文件上传之后的可控性、可追溯性和安全性


前端常见的大文件上传方式

在重新设计后端接口之前,我们先来看看现在前端常见的大文件上传思路。
其实近几年前端这块已经比较成熟了,主流方案大体都是围绕几个核心点展开的:
秒传检测、分片上传、断点续传、并发控制、进度展示。

一般来说,前端拿到文件后,会先计算一个文件哈希值,比如用 MD5。
这样做的目的是为了做秒传检测
如果服务器上已经存在这个文件,就可以直接跳过上传,节省时间和带宽。

接下来是分片上传
文件太大时,前端会把文件拆成多个固定大小的小块(比如每块 5MB 或 10MB),
然后一片一片地上传。这样做可以避免一次性传输大文件导致浏览器卡顿或网络中断。

然后就是断点续传
前端会记录哪些分片已经上传成功,如果上传过程中网络中断或浏览器刷新,
下次只需要从未完成的分片继续上传,不用重新传整包文件。

在性能方面,前端还会做并发控制
比如同时上传三到五个分片,上传完一个就立刻补下一个,
这样整体速度比单线程串行上传要快很多。

最后是进度展示
通过监听每个分片的上传状态,前端可以计算整体进度,
给用户展示一个实时的上传百分比或进度条,让体验更可控。

可以看到,前端的大文件上传方案已经形成了一套相对标准的模式。
所以这次我在重新设计后端的时候,就打算基于这种前端逻辑,
去构建一套更贴合企业私有化环境的上传接口控制体系。
目标是让前后端的职责划分更清晰:
前端负责切片、控制与恢复;后端负责存储、校验与合并。


后端接口设计思路

前端的大文件上传流程其实已经相对固定了,我们只要让后端的接口和它配合得上,就能把整个上传链路打通。
所以我这次重新设计时,把上传接口拆成了几个比较独立的阶段:
秒传检查、初始化任务、上传分片、合并文件、暂停任务、取消任务、任务列表。
每个接口都只负责一件事,这样接口的职责会更清晰,也方便后期扩展。

一、/upload/check —— 秒传检查

这个接口是整个流程的第一步,用来判断文件是否已经上传过。
前端在计算完文件的全局 MD5(或其他 hash)后,会先调这个接口。
如果后端发现数据库里已经有相同 hash 的文件,就直接返回“已存在”,前端就不用再上传了。

请求示例:

POST /api/upload/check
{
"fileHash": "md5_abc123def456",
"fileName": "training-docs.zip",
"fileSize": 5342245120
}

返回示例:

{
"success": true,
"data": {
"exists": false
}
}

如果 exists = true,说明服务端已经有这个文件,可以直接走“秒传成功”的逻辑。

伪代码示例:

@PostMapping("/check")
public Result checkFile(@RequestBody Map body) {
// 1. 校验 fileHash 参数是否为空
// 2. 查询 file_info 表是否已有该文件
// 3. 如果文件已存在,直接返回秒传成功(exists = true)
// 4. 如果文件不存在,查询 upload_task 表中是否有未完成任务(支持断点续传)
}
,>

二、/upload/init —— 初始化上传任务

如果文件不存在,就要先初始化一个新的上传任务。
这个接口的作用是创建一条 upload_task 记录,同时返回一个唯一的 uploadId
前端会用这个 uploadId 来标识整个上传过程。

请求示例:

POST /api/upload/init
{
"fileHash": "md5_abc123def456",
"fileName": "training-docs.zip",
"totalChunks": 320,
"chunkSize": 5242880
}

返回示例:

{
"success": true,
"data": {
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"uploadedChunks": []
}
}

uploadedChunks 用来支持断点续传,如果之前有部分分片上传过,就会在这里返回索引数组。

伪代码示例:

@PostMapping("/init")
public ResultinitUpload(@RequestBody UploadInitRequest request) {
// 1. 检查是否已有同 fileHash 的任务,若有则返回旧任务信息(支持断点续传)
// 2. 否则创建新的 upload_task 记录,生成 uploadId
// 3. 初始化分片数量、大小、状态等信息
// 4. 返回 uploadId 与已上传分片索引列表
}

三、/upload/chunk —— 上传单个分片

这是整个上传过程里调用次数最多的接口。
每个分片都会单独上传一次,并在服务端保存为临时文件,同时写入 upload_chunk 表。
上传成功后,后端会更新 upload_task 的进度信息。

请求示例(表单上传):

POST /api/upload/chunk
Content-Type: multipart/form-data

formData:
uploadId: b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b
chunkIndex: 0
chunkSize: 5242880
chunkHash: md5_001
file: (二进制分片数据)

返回示例:

{
"success": true,
"data": {
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"chunkIndex": 0,
"chunkSize": 5242880
}
}

伪代码示例:

@PostMapping(value = "/chunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result uploadChunk(@ModelAttribute UploadChunkRequest req) {
// 1. 校验任务状态,禁止上传已取消或已完成的任务
// 2. 检查本地目录(或云端存储桶)是否存在,不存在则创建
// 3. 接收当前分片文件并写入临时路径
// 4. 写入 upload_chunk 表,标记状态为 “已上传”
// 5. 更新 upload_task 的 uploaded_chunks 数量
}

四、/upload/merge —— 合并分片

当前端确认所有分片都上传完后,会调用 /upload/merge
后端收到这个请求后,去检查所有分片是否完整,然后按照索引顺序依次合并。
合并成功后,会删除临时分片文件,并更新 upload_task 状态为“完成”。
如果启用了云存储,这一步也可以直接把合并后的文件上传到 OSS。

请求示例:

POST /api/upload/merge
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"fileHash": "md5_abc123def456"
}

返回示例:

{
"success": true,
"message": "文件合并成功",
"data": {
"storagePath": "/data/uploads/training-docs.zip"
}
}

伪代码示例:

@PostMapping("/merge")
public Result mergeFile(@RequestBody UploadMergeRequest req) {
// 1. 检查 upload_task 状态是否允许合并
// 2. 校验所有分片是否都上传完成
// 3. 如果是本地存储:按 chunk_index 顺序流式合并文件
// 4. 如果是云存储:调用云端分片合并 API(如 OSS、COS)
// 5. 校验文件 hash 完整性,更新任务状态为 COMPLETED
// 6. 将最终文件信息写入 file_info 表
}

五、/upload/pause —— 暂停任务

这个接口用于在上传过程中手动暂停任务。
前端可能会在网络波动或用户主动点击暂停时调用。
后端会更新任务状态为“已暂停”,并记录当前已上传的分片数。

请求示例:

POST /api/upload/pause
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b"
}

返回示例:

{
"success": true,
"message": "任务已暂停"
}

伪代码示例:

@PostMapping("/pause")
public ResultpauseUpload(@RequestBody UploadPauseRequest req) {
// 1. 查找对应的 upload_task
// 2. 更新任务状态为 “已暂停”
// 3. 返回任务状态确认信息
}

六、/upload/cancel —— 取消任务

如果用户想放弃本次上传,可以调用 /cancel
后端会把任务状态标记为“已取消”,并清理对应的临时分片文件。
这样能避免磁盘上堆积无用数据。

请求示例:

POST /api/upload/cancel
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b"
}

返回示例:

{
"success": true,
"message": "任务已取消"
}

伪代码示例:

@PostMapping("/cancel")
public Result cancelUpload(@RequestBody UploadCancelRequest req) {
// 1. 查找对应的 upload_task
// 2. 更新任务状态为 “已取消”
// 3. 删除或标记已上传的分片文件为待清理
// 4. 返回操作结果
}

七、/upload/list —— 查询任务列表

这个接口我们用于管理后台查看当前上传任务的整体情况。
可以展示每个任务的文件名、大小、进度、状态、上传人等信息,方便追踪和审计。

请求示例:

GET /api/upload/list

返回示例:

{
"success": true,
"data": [
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"fileName": "training-docs.zip",
"status": "COMPLETED",
"uploadedChunks": 320,
"totalChunks": 320,
"uploader": "admin",
"createdAt": "2025-10-20 14:30:12"
}
]
}

伪代码示例:

@GetMapping("/list")
public Result> listUploadTasks() {
// 1. 查询所有上传任务
// 2. 按创建时间或状态排序
// 3. 返回任务摘要信息(任务名、状态、进度、上传人等)
}

接口调用顺序小结

那我们这整个上传过程的调用顺序就是:

1. /upload/check     → 秒传检测
2. /upload/init → 初始化上传任务
3. /upload/chunk → 循环上传所有分片
4. /upload/merge → 所有分片完成后合并
(可选)/upload/pause、/upload/cancel 用于控制任务
(可选)/upload/list 用于任务追踪与审计

接口调用顺序示意图

下面这张时序图展示了前端、后端、数据库在整个上传过程中的交互关系。

Untitled diagram-2025-11-10-031845.png

这样安排有几个好处:

  1. 逻辑衔接顺:上面刚讲完每个接口的职责,下面立刻用图总结;
  2. 视觉节奏平衡:读者读到这里已经看了不少文字,用图能缓解阅读疲劳;
  3. 承上启下:这张图既总结接口流程,又能自然引出下一节“数据库表设计”。

这套接口设计基本能覆盖大文件上传在企业项目中的常见需求。
接下来,我们再来看看支撑这套接口背后的数据库表设计。
数据库的作用是让上传任务的状态可追踪、可恢复,也能在集群部署时保持一致性。


数据库表设计思路

前面说的那一套接口,要真正稳定地跑起来,
后端必须有一套能记录任务状态、分片信息、文件存储路径的数据库结构。
因为上传这种场景不是“一次请求就结束”的操作,它往往会持续几分钟甚至几个小时,
所以我们需要让任务状态可以追踪、可以恢复,还要能支撑集群部署。

我这次主要设计了三张核心表:
upload_task(上传任务表)、upload_chunk(分片表)、file_info(文件信息表)。
它们分别负责记录任务、分片和最终文件三层的数据关系。

一、upload_task —— 上传任务表

这张表是整个上传过程的“总账”,
每一个文件上传任务,不管分成多少片,都会在这里生成一条记录。
它主要用来保存任务的全局信息,比如文件名、大小、上传进度、状态、存储方式等。

CREATE TABLE `upload_task` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`upload_id` varchar(64) NOT NULL COMMENT '任务唯一ID(UUID)',
`file_hash` varchar(64) NOT NULL COMMENT '文件哈希(用于秒传与断点续传)',
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
`file_size` bigint(20) NOT NULL COMMENT '文件总大小(字节)',
`chunk_size` bigint(20) NOT NULL COMMENT '每个分片大小(字节)',
`total_chunks` int(11) NOT NULL COMMENT '分片总数',
`uploaded_chunks` int(11) DEFAULT '0' COMMENT '已上传分片数量',
`status` tinyint(4) DEFAULT '0' COMMENT '任务状态:0-待上传 1-上传中 2-合并中 3-完成 4-取消 5-失败 6-已合并 7-已暂停',
`storage_type` varchar(32) DEFAULT 'local' COMMENT '存储类型:local/oss/cos/minio/s3等',
`storage_url` varchar(512) DEFAULT NULL COMMENT '文件最终存储地址(云端或本地路径)',
`local_path` varchar(512) DEFAULT NULL COMMENT '本地临时文件或合并文件路径',
`remark` varchar(255) DEFAULT NULL COMMENT '备注信息',
`uploader` varchar(64) DEFAULT NULL COMMENT '上传人',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `upload_id` (`upload_id`),
KEY `idx_hash` (`file_hash`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传任务表(支持多种云存储)';

设计要点:

  • upload_id 是前端初始化任务后由后端生成的唯一标识;
  • file_hash 用来支持秒传逻辑;
  • status 控制任务生命周期(等待、上传中、合并中、完成等);
  • storage_typestorage_url 可以兼容多种存储方案(本地、OSS、COS、MinIO);
  • uploaded_chunks 字段让任务能随时恢复,适配断点续传。

二、upload_chunk —— 分片表

这张表对应每个上传任务下的所有分片。
每一个分片都会单独在这里占一条记录,用来追踪它的上传状态。
这张表的存在让我们能做断点续传、进度统计、以及合并前的完整性检查。

CREATE TABLE `upload_chunk` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`upload_id` varchar(64) NOT NULL COMMENT '所属上传任务ID',
`chunk_index` int(11) NOT NULL COMMENT '分片索引(从0开始)',
`chunk_size` bigint(20) NOT NULL COMMENT '实际分片大小(字节)',
`chunk_hash` varchar(64) DEFAULT NULL COMMENT '可选:分片hash(用于高级去重)',
`status` tinyint(4) DEFAULT '0' COMMENT '状态:0-待上传 1-已上传 2-已合并',
`local_path` varchar(512) DEFAULT NULL COMMENT '分片本地路径',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_task_chunk` (`upload_id`,`chunk_index`),
KEY `idx_upload_id` (`upload_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传分片表';

设计要点:

  • upload_id 是任务外键,和 upload_task 一一对应;
  • chunk_index 代表分片顺序,合并文件时会按这个排序;
  • chunk_hash 可选字段,用来在上传前后做完整性校验;
  • status 字段控制上传进度(待上传、已上传、已合并);
  • 唯一索引 (upload_idchunk_index) 避免重复插入分片。

通过这张表,我们可以轻松实现断点续传:
当用户重新开始上传时,后端只返回未完成的分片索引,前端跳过已上传的部分。

三、file_info —— 文件信息表

这张表记录的是上传完成后的“最终文件信息”,
相当于系统的文件索引表。只要文件合并成功并通过校验,
后端就会往这里写入一条记录。

这张表支撑秒传功能,也能被后续的文档解析或向量化任务使用。

CREATE TABLE `file_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`file_hash` varchar(64) NOT NULL COMMENT '文件hash,用于秒传',
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
`file_size` bigint(20) NOT NULL COMMENT '文件大小',
`storage_type` varchar(32) DEFAULT 'local' COMMENT '存储类型:local/oss/cos/minio/s3等',
`storage_url` varchar(512) DEFAULT NULL COMMENT '文件最终存储地址(云端或本地路径)',
`uploader` varchar(64) DEFAULT NULL COMMENT '上传人',
`status` tinyint(4) DEFAULT '1' COMMENT '状态:1-正常,2-删除中,3-已归档',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `file_hash` (`file_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已上传文件信息表(支持多云存储)';

设计要点:

  • file_hash 是全局唯一标识,用于秒传和查重;
  • storage_url 记录最终可访问路径;
  • status 可扩展为删除、归档等后续操作;
  • 这张表和业务系统中的“文档解析”、“知识库构建”可以直接关联。

四、三张表之间的关系

这三张表之间的关系我们可以简单理解为:

upload_task  (上传任务)
├── upload_chunk (分片详情)
└── file_info (最终文件)
  • upload_task 管理任务生命周期;
  • upload_chunk 跟踪每个分片的上传进度;
  • file_info 保存最终文件索引,用于秒传与后续 AI 处理。

这样设计的好处是:

  • 上传状态可追踪;
  • 上传任务可恢复;
  • 文件信息可统一管理;
  • 多节点部署也能保证一致性。

上传状态流转与任务恢复机制

有了前面的三张核心表,整个上传的过程就能被“状态机化”管理。
简单来说,我们希望每一个上传任务从创建、上传、合并到完成,都能有一个明确的状态,
系统也能在任意阶段中断后恢复,不需要用户重新来一遍。

我们把整个上传任务的生命周期划分成几个关键状态:

WAITING(0, "待上传"),
UPLOADING(1, "上传中"),
MERGING(2, "合并中"),
COMPLETED(3, "已完成"),
CANCELED(4, "已取消"),
FAILED(5, "上传失败"),
CHUNK_MERGED(6, "已合并"),
PAUSED(7, "已暂停");

一、WAITING(待上传)

当用户在前端发起上传、文件切片还没真正传上来之前,
系统会先生成一个上传任务记录(也就是 /upload/init 接口那一步)。
这个时候任务只是“登记”在数据库里,还没开始传数据。

我们可以理解为:

任务刚创建,还没开始跑。

此时前端拿到 uploadId,就可以开始逐片上传了。

在数据库层面,upload_task.status = 0,所有的分片表里还没有数据。

二、UPLOADING(上传中)

当第一个分片开始上传时,系统会把任务状态更新为 上传中
这时候每上传一块分片,都会往 upload_chunk 表里写入一条记录,
并且更新任务的 uploaded_chunks 字段。

我们会周期性地根据分片上传数量去更新进度条,
比如已上传 35 / 100 块,系统就知道这部分可以恢复。

这个阶段是任务生命周期里最活跃的一段:
用户可能暂停、断网、刷新页面、甚至浏览器崩溃。
但是没关系,因为分片信息都落地到数据库了,
我们能随时通过 upload_chunk 的状态重新恢复上传。

三、PAUSED(已暂停)

如果用户主动点击“暂停上传”,
系统就会把任务状态标记为 PAUSED

暂停并不会删除分片,只是告诉系统“不要再继续发请求”。
这样当用户重新点击“继续上传”时,
前端只需从后端拿到“哪些分片还没上传”,就能断点续传。

这个状态一般只在用户控制的情况下出现,
比如网络不好、或者中途切换网络时暂停。

四、CANCELED(已取消)

取消和暂停不同,取消意味着用户彻底放弃了这个上传任务。
任务会被标记为 CANCELED,同时系统可以选择:

  • 删除已经上传的临时分片文件;
  • 或者保留一段时间等待清理任务。

在后台日志中,这个状态主要用于审计:
记录谁取消了任务、在什么时间、上传了多少进度。

五、MERGING(合并中)

当所有分片都上传完成后,
后端会自动或手动触发文件合并逻辑(调用 /upload/merge)。
此时任务状态会切换为 MERGING,表示系统正在进行最后一步。

在这一步里:

  • 如果是本地存储,会逐个读取分片文件并拼接为完整文件;
  • 如果是云存储(比如 OSS、MinIO),则会触发服务端的分片合并 API。

合并过程通常比较耗时,尤其是几 GB 的文件,
所以单独拿出来作为一个明确状态是必要的。

六、CHUNK_MERGED(已合并)

有些系统会把合并成功但未做后续处理的状态单独标出来,
比如文件已经合并,但还没入库、还没解析。
这个状态可以让我们在合并之后还有机会做文件校验或后处理。

不过在实际项目里,也可以直接跳过这一步,
合并完后立刻进入下一状态——COMPLETED

七、COMPLETED(已完成)

文件合并完成、验证通过、存储路径落地、写入 file_info 表,
这时候任务就算彻底完成了。

在这个状态下:

  • 用户可以正常访问文件;
  • 系统可以执行后续的解析任务(比如文档拆页、向量化等);
  • 文件具备秒传条件,下次再上传同样的文件会直接跳过。

COMPLETED 是整个生命周期的终点状态。
在数据库中,任务记录会更新最终路径、存储类型、完成时间等字段。

八、FAILED(上传失败)

上传过程中如果出现异常,比如网络中断、磁盘写入异常、OSS 上传失败等,
系统会标记任务为 FAILED
这一状态不会自动清理,
方便管理员事后追踪错误原因或人工恢复。

失败任务在设计上一般允许“重新启动”,
也就是通过任务 ID 重新触发上传,从未完成的分片继续。

我们可以通过下面这张图可以更直观地看到整个上传任务的生命周期:

Untitled diagram-2025-11-10-033341.png

九、任务恢复机制

在这套机制下,任务恢复就变得非常自然。
前端每次进入上传页面时,只要传入文件的 hash,
后端就能通过 upload_task 和 upload_chunk 判断:

  1. 这个文件有没有上传任务;
  2. 如果有,哪些分片已经上传;
  3. 任务当前状态是什么(暂停、失败还是上传中)。

然后前端只需补传那些未上传的分片即可。
这就是我们常说的 断点续传(Resumable Upload) 。

在集群环境中,这套逻辑同样成立,
因为任务与分片状态都落在数据库,不依赖单台服务器。
无论请求打到哪一台机器,上传进度都是统一可见的。

十、中断后如何续传

在实际使用中,用户上传中断是很常见的。
比如文件太大上传到一半,浏览器突然关了;
或者公司网络断了,机器重启了;
甚至有人直接换了电脑继续操作。

如果系统没有任务恢复机制,那用户每次都得重新传一遍,
尤其是那种几个 G 的文件,不但浪费时间,还容易出错。
所以我们在设计这套上传中心时,
一开始就考虑了“断点续传”和“任务恢复”的问题。

1. 恢复上传靠的其实是数据库里的状态

断点续传的核心逻辑,其实很简单:
我们让任务和分片的状态都写进数据库。

每当用户重新进入上传页面、选中同一个文件时,
前端会先计算出文件的 hash,然后调用 /upload/check 接口。
后端收到 hash 后,会依次去查三张表:

  1. 先查 file_info
    如果能查到,说明文件之前已经上传并合并成功,
    这时候直接返回“文件已存在”,前端就能实现“秒传”,不需要重新上传。
  2. 查不到 file_info,就去查 upload_task
    如果找到了对应任务,就说明这个文件上传到一半被中断了。
    这时我们会返回这个任务的 uploadId。
  3. 再查 `upload_chunk``
    系统会统计出哪些分片已经上传成功,哪些还没传。
    然后返回一个“未完成的分片索引列表”给前端。

前端拿到这些信息后,就能从中断的地方继续往下传,
不用再重复上传已经完成的部分。

2. 前端续传时的流程

前端拿到旧的 uploadId 和未完成分片列表后,
只需要跳过那些已经上传成功的分片,
然后照常调用 /upload/chunk 去上传剩下的部分。

上传过程中,每个分片的状态都会被实时更新到 upload_chunk 表中,
upload_task 表的 uploaded_chunks 也会跟着同步增加。
当所有分片都上传完后,任务状态自动进入 MERGING(合并中)阶段。

所以整个续传过程,其实就是**“基于数据库状态的增量上传”**。
用户不需要额外操作,系统自己就能恢复上次的进度。

3. 任务状态和恢复判断

任务是否允许恢复,系统会根据 upload_task.status 来判断。
大致逻辑是这样的:

状态是否可恢复说明
WAITING可以任务刚创建,还没开始传
UPLOADING可以正在上传中,可以继续
PAUSED可以用户主动暂停,可以恢复
FAILED可以上传失败,可以重新尝试
CANCELED不可以用户主动取消,不再恢复
COMPLETED不需要已经完成,直接秒传
MERGING等待中系统正在合并,前端等待即可

这套判断逻辑让任务的行为更清晰。
比如用户暂停上传再回来时,可以直接恢复;
如果任务已经取消,那就算用户重启也不会再自动续传。

4. 多机器部署下的恢复问题

有些人会担心:如果我们的系统是集群部署的,
上传时中断后再续传,万一请求打到另一台机器上,
还能恢复吗?

其实没问题。
因为我们所有任务和分片的状态都是写进数据库的,
不依赖内存或本地文件。

也就是说,哪怕用户上次上传在 A 机器,这次续传到了 B 机器,
系统仍然能根据数据库的记录知道:
这个 uploadId 下的哪些分片已经上传完,哪些还没传。

所以集群部署下也能无缝续传,不会出现“不同机器不认任务”的情况。

5. 小结

整个任务恢复机制靠的就是两张表:upload_task 和 upload_chunk
upload_task 负责记录任务总体进度,
upload_chunk 负责记录每个分片的上传状态。

当用户重新上传时,我们查表判断进度,
前端从未完成的地方继续传,就能实现真正意义上的“断点续传”。

这套机制有几个显著的好处:

  • 上传进度可追踪;
  • 中断后可恢复;
  • 支持集群部署;
  • 不依赖浏览器缓存或 Session。

所以,只要数据库没丢,任务记录还在,
上传进度就能恢复,哪怕换机器、重启系统都没问题。


文件合并与完整性校验

前面的所有步骤,其实都是在为这一刻做准备。
当用户的所有分片都上传完成后,接下来最重要的工作就是:
把这些分片拼成一个完整的文件,并且确保文件内容没有出错。

这一步看似简单,但其实是整个大文件上传流程里最容易出问题的地方。
尤其在集群部署下,如果不同分片分布在不同机器上,
那合并逻辑就不能只靠本地文件路径去拼接,否则根本找不到所有分片。

所以我们先来理一理整个思路。

一、合并的触发时机

前端在检测到所有分片都上传完成后,会调用 /upload/merge 接口。
这个接口的作用就是通知后端:
“这个任务的所有分片都传完了,现在可以开始合并了。”

后端接收到请求后,会先去查数据库确认几个关键信息:

  1. 这个任务对应的 uploadId 是否存在;
  2. upload_chunk 表里所有分片是否都处于 “已上传” 状态;
  3. 当前任务状态是否允许合并(例如不是暂停、取消或失败)。

确认无误后,任务状态会从 UPLOADING 变成 MERGING
正式进入文件合并阶段。

二、本地合并逻辑

如果系统配置的是本地存储(也就是 cloud.enable = false),
那所有分片文件都保存在服务器的临时目录中。

合并逻辑大致是这样的:

  1. 后端按分片的 chunk_index 顺序,依次读取每个分片文件。
  2. 逐个写入到一个新的目标文件中,比如 merge.zip
  3. 每合并一个分片,就更新数据库中的状态。
  4. 合并完成后,把任务状态更新为 COMPLETED,并写入最终路径。

整个过程看起来很直观,
但这里有两个要点需要特别注意:

  • 写入顺序要严格按照分片索引,否则文件内容会错乱;
  • 文件 IO 要用流式写入(Stream) ,避免内存一次性读取所有分片导致溢出。

合并完成后,我们会计算整个文件的 MD5,与原始 fileHash 对比,
如果不一致,就说明合并过程中数据丢失或出错。
这种情况任务会被标记为 FAILED,并在日志中留下异常记录。

三、云端合并逻辑

如果我们配置了云存储(比如 OSS、COS、MinIO 等),
那分片文件就不是存在本地磁盘,而是上传到云端的对象存储桶里。

在这种情况下,合并逻辑就不需要我们自己拼文件了,
因为大部分云存储服务都提供了“分片合并”的 API。

比如以 OSS 为例,上传时我们调用的是 uploadPart 接口,
合并时只需要调用 completeMultipartUpload
它会根据上传时的分片顺序自动合并为一个完整对象。

整个过程的优点是:

  • 不占用本地磁盘;
  • 不受单机 IO 限制;
  • 云端自动校验每个分片的完整性。

所以在云存储场景下,我们只需要做两件事:

  1. 通知云服务去执行合并;
  2. 成功后记录最终的文件地址(storage_url)到数据库。

这样整个流程就闭环了。

四、集群部署下的合并问题

单机情况下,合并很简单,因为所有分片都在本地。
但如果系统是集群部署的,分片请求可能打到了不同机器,
这时候分片文件就会分散在多个节点上。

我们在设计时考虑了三种解决方案:

方案 1:共享存储(私有化部署下比较推荐)

最常见的做法是把所有机器的上传目录指向同一个共享路径,
比如通过 NFS、NAS、或对象存储挂载到 /data/uploads
这样无论用户上传的分片打到哪台机器,
最终都会写入同一个物理目录。

当合并请求发起时,任意一台机器都能访问到完整的分片文件。
这是目前在企业部署中最稳定、最通用的方案。

方案 2:云存储中转

如果机器之间没有共享目录,那我们可以让每个分片先上传到云端,
合并时再调用云服务的 API 进行分片合并。
这种方式适合公网可访问的 SaaS 环境。
但对于政企内网部署,就不一定行得通。

方案 3:统一调度节点

还有一种是我们自己维护一个“合并调度节点”,
所有分片上传完后,系统会把合并任务分配到一个指定节点执行,
这个节点会从其他机器拉取分片(比如通过 HTTP 内部传输或 RPC)。
这种方式更复杂,适合大规模分布式存储场景。

在私有化项目中,我们一般采用第一种方式——共享目录 + 本地合并
既能保证性能,也能兼顾安全性。

五、完整性校验

文件合并完成后,最后一步是完整性校验。
我们会重新计算合并后文件的 MD5,与前端最初上传的 fileHash 对比。

如果一致,就说明文件合并成功,内容没有丢失;
如果不一致,就说明某个分片损坏或顺序错误,
任务会被标记为 FAILED,并自动记录错误日志。

这样可以确保文件数据的安全性,
避免在后续 AI 解析或向量化阶段出现内容异常。

六、异步处理与性能优化

开头的视频里我们也看到了,整个上传和合并过程我们是同步执行的。
从前端开始上传分片,到最后文件合并完成,都在等待同一个流程走完。
这种方式在演示时很直观,但在真实项目中其实问题不少。

最明显的一个问题就是——时间太长。
像我们刚才那个 1GB 的文件,即使网络稳定、服务器性能还可以,
整个流程也要几分钟甚至更久。
如果我们让前端一直等待响应,接口超时、连接断开、前端刷新这些问题就都会冒出来。

所以,在真正的业务系统里,我们一般会把合并、校验、迁移 OSS 或解析入库这些操作改成异步任务来做。
接口只负责接收分片、登记状态,然后立刻返回“任务已创建”或“上传完成,正在处理中”的提示。
后续的合并、校验、清理临时文件这些工作交给后台的异步线程、任务队列或者调度器去跑。

这样做的好处有几个:

  • 前端体验更流畅,不用卡在“等待合并”阶段;
  • 后端可以批量处理任务,减少高峰期的 IO 压力;
  • 如果任务失败或中断,也能通过任务表重试或补偿;
  • 对接外部存储或 AI 解析流程时,也能自然衔接后续任务链。

简单来说,上传只是第一步,
而合并、校验、转存这些操作本质上更像是后台任务。
我们在系统设计时只要把这些环节分开,让接口尽量“轻”,
这套上传系统就能在面对更大文件、更复杂场景时依然稳定可靠。

七、小结

整个合并与校验阶段,是把前面所有分片上传工作“收尾”的过程。
我们通过以下机制保证了稳定性:

  • 本地存储场景下:顺序读取 + 流式写入 + hash 校验;
  • 云存储场景下:依赖云端分片合并 API;
  • 集群环境下:通过共享存储或统一调度节点解决文件分散问题;
  • 数据库层面:实时记录状态,便于追踪和审计。

最终,当文件合并成功、校验通过后,
系统会将结果写入 file_info 表,
整条上传链路就算是完整闭环。


最后

我们平常做的项目,大多数时候文件上传都挺简单的。
前端传到 OSS,后端接个地址存起来就行。
但等真正做私有化项目的时候,也就会发现很多地方都不一样了。
要求更多,考虑的细节也多得多。

像这次做的大文件上传就是个很典型的例子。
以前那种简单方案,放在这种环境下就完全不够用了。
得考虑断点续传、任务恢复、集群部署、权限、审计这些东西,
一步没想好,后面全是坑。

我们现在这套设计,其实就是在解决这些“现实问题”。
接口虽然多一点,但每个职责都很清晰,
任务状态能追踪,上传中断能恢复,
甚至以后如果我们想单独抽出来做一个文件系统模块也完全没问题。
不管是拿来给知识库用,还是 AI 向量化、文档解析,这套逻辑都能复用。

其实很多以前觉得“简单”的功能,
一旦遇到复杂场景,其实都得重新想。
但好处是,一旦做通了,这套东西就能稳定用很久。

到这里,大文件上传这块我们算是完整走了一遍。
以后再遇到类似需求,我们就有经验了,
不用再从头掉坑里爬出来一次哈。


作者:洛卡卡了
来源:juejin.cn/post/7571355989133099023

0 个评论

要回复文章请先登录注册