大文件上传全解:分片上传、断点续传与秒传的工程实践

大文件上传全解:分片上传、断点续传与秒传的工程实践 之前在设计面试题时发现“大文件上传”是被问得最多、也最容易翻车的一道题目。很多人能写一个接收 MultipartFile 的接口但一旦被追问几个 GB 的文件直接上传会导致什么网络中断怎么恢复怎么实现秒传文件 Hash 怎么算才不卡页面往往就答不上来了。这篇文章围绕大文件上传与断点续传从原理到完整代码实现拆开讲一遍包含分片上传、断点续传、秒传的落地思路以及高频异常OOM、文件损坏、断点失效、IE 兼容等的排查与规避方案。内容既适合刚接触文件上传的新手也适合准备面试、想系统梳理这套方案的开发者。1. 为什么大文件上传容易翻车1.1 大文件上传的三个核心痛点大文件上传之所以不能和普通小文件一样直接提交是因为它天然面临三个问题。第一个是网络不稳定。一个 2 GB 的文件在公网环境上传时间可能长达十几分钟甚至更久。期间只要出现一次网络抖动、代理超时或者服务端重启整个上传就会失败。如果失败后只能从头再来用户心态和服务器带宽都会被拖垮。第二个是内存压力。传统上传方式要么把整个文件读进内存要么用 Base64 编码后塞进请求体。文件一上 GBJVM 或浏览器主进程很容易直接内存溢出也就是我们常说的 OOM。这不仅是后端问题前端把大文件读成 Data URL 或一次性 ArrayBuffer 同样会崩。第三个是体验问题。没有进度条、没有断点恢复、失败后无法续传用户面对一个 2 GB 文件只能干等。对中大型文件传输来说等待时间一旦超过几十秒体验就明显下降。1.2 分片上传、断点续传、秒传分别解决什么问题分片上传Chunked Upload是把一个大文件切成若干小片一片一片传给服务端全部传完后由服务端按顺序合并成完整文件。它解决了单次请求体过大、内存占用过高、失败重试成本高等问题。断点续传Resumable Upload是分片上传的进阶能力。文件传到一半被打断重新上传时先查询服务端已有的分片只传缺失的分片而不是整个文件重传。它的核心是“服务端分片持久化 客户端状态记录”。秒传Instant Upload / Quick Upload则是一种去重机制。文件本质上是一串二进制数据如果服务端已经存在内容完全相同的文件那么客户端根本不需要再传数据只要上传一个文件指纹Hash服务端比对命中后直接返回成功即可。这三者经常会被面试官混在一起问但实际上它们解决的是不同层面的问题分片解决“能不能传”断点续传解决“断了怎么办”秒传解决“重复文件要不要再传一遍”。1.3 是不是所有项目都需要用这套方案并不是。几十 MB 的小文件直接用传统 multipart 表单上传配合 Nginx 调大 client_max_body_size完全够用。只有当文件普遍达到数百 MB 甚至 GB 级别或者处于弱网环境时分片上传和断点续传才真正值得投入。另外需要注意大文件上传不只是前端把文件切碎这么简单。服务端要设计分片存储结构、合并逻辑、清理策略还要考虑用本地磁盘还是对象存储比如 MinIO、OSS、S3。这些决策都会影响后续的扩展性和维护成本。2. 环境准备与版本说明2.1 本次实战涉及的技术栈本文的完整示例包含后端接口、前端上传逻辑和 Python 客户端脚本是一个能直接跑通的最小闭环。具体环境如下模块技术选型说明后端Spring Boot 2.x JDK 8提供分片上传、合并、断点检测接口前端原生 JavaScript axios Web Worker负责分片、计算 Hash、并发上传哈希库spark-md5在 Web Worker 中增量计算文件指纹存储本地磁盘目录生产环境可替换为 MinIO / 阿里云 OSS测试脚本Python 3 requests验证脱离浏览器也能完成分片上传版本需要根据你的项目实际情况调整。本文示例以 Spring Boot 2.x 常见环境为准重点演示配置思路和代码结构Spring Boot 3.x 的核心逻辑完全相同只需要注意依赖坐标和 jakarta 命名空间的变化。2.2 项目目录结构建议先按下面的结构创建工程方便对照后文代码。upload-demo/ ├── backend/ │ ├── pom.xml │ └── src/main/java/com/example/upload/ │ ├── UploadApplication.java │ ├── config/UploadConfig.java │ ├── controller/FileUploadController.java │ ├── service/FileMergeService.java │ └── model/Result.java ├── frontend/ │ ├── index.html │ ├── upload.js │ ├── worker.js │ └── spark-md5.min.js └── python-client/ └── upload.py前端文件可以放到任意静态资源目录后端启动后直接浏览器打开 index.html 即可测试。3. 核心原理拆解3.1 分片上传原理分片上传的流程可以拆成三步。第一步客户端用 File.slice 方法把文件按固定大小切块拿到多个 Blob 对象。假设文件 10 MB每片 2 MB就会得到 5 个分片。第二步客户端把每个分片打包成 FormData连同文件唯一标识identifier、分片序号chunkNumber、总分片数totalChunks一起通过 POST 请求发送给服务端。服务端接到分片后不再做任何合并而是按标识和序号保存为独立的 .part 文件。第三步所有分片上传完成后客户端调用合并接口。服务端按 1 到 N 的顺序读取分片依次写入目标文件完成合并。这里有几个关键参数需要提前约定好identifier文件的唯一标识推荐使用整个文件的 MD5 或 SHA-1 值保证同一文件每次上传得到的标识一致。chunkNumber当前分片的序号从 1 开始。totalChunks总分片数服务端合并时用来判断分片是否齐全。3.2 断点续传原理断点续传的实现重点在“检测已有分片”这一步。客户端在上传前先调用一个检查接口把 identifier 和 totalChunks 传给服务端。服务端扫描分片目录返回已经存在的分片序号列表。客户端拿到这个列表后只需上传缺失的分片。例如一个文件被切成 100 个分片上次传了 30 个就断了。重新上传时检查接口返回 1 到 30客户端就从第 31 个分片继续传而不是重复传前 30 个。要让断点续传真正可靠服务端的分片必须持久化到磁盘或对象存储不能只存在内存里。否则服务端一重启客户端发现已有分片全没了又得从头传。3.3 秒传原理秒传依赖的是一条简单逻辑如果一个文件的内容已经被服务端保存过那就没必要再传内容了。具体做法是客户端先计算整个文件的 Hash通常用 MD5 或 SHA-1然后调用检查接口。服务端拿这个 Hash 和文件指纹表比对如果命中直接返回“文件已存在”前端提示秒传成功如果没有命中再走正常的分片上传流程。秒传需要注意两个问题。一是 Hash 计算成本。对几个 GB 的文件计算完整 MD5在普通 PC 上也需要好几秒所以不能放在浏览器主线程里计算否则页面会卡死这就是后文要讲的 Web Worker 方案。二是 Hash 碰撞概率。MD5 理论上存在碰撞可能但对绝大多数业务场景来说用“文件大小 文件内容 MD5”作为组合标识已经足够。对安全性要求极高的系统可以在秒传命中后再做一次抽样比对或全量比对。3.4 文件指纹计算与 Web Worker为什么专门说文件指纹计算因为这是很多人忽略的性能瓶颈。如果在前端主线程用 JS 循环读取大文件并计算 MD5浏览器 UI 会直接失去响应。Chrome 里甚至会弹出“页面无响应”的提示。正确做法是使用 Web Worker 在后台线程计算 Hash。下面是一个最小可用的 worker.js 示例// 文件路径frontend/worker.js importScripts(./spark-md5.min.js); self.onmessage async function (e) { var file e.data.file; var chunkSize e.data.chunkSize || 5 * 1024 * 1024; var spark new self.SparkMD5.ArrayBuffer(); var start 0; while (start file.size) { var chunk file.slice(start, Math.min(file.size, start chunkSize)); var buffer await chunk.arrayBuffer(); spark.append(buffer); start chunkSize; self.postMessage({ progress: Math.round((start / file.size) * 10000) / 10000 }); } self.postMessage({ hash: spark.end() }); };主线程创建 Worker把 File 对象传给 workerworker 分块读取文件并增量计算 Hash计算期间主线程依然可以操作页面。这也对应了热词里“前端使用 worker 上传大文件”的优化方向不仅 Hash 计算可以放 worker分片上传请求也可以放到 worker 里执行避免大量请求阻塞 UI 渲染。4. Spring Boot Vue 大文件上传完整实战这一节实现一个可运行的最小工程。后端提供「分片上传」「断点检测」「合并」三个接口前端负责分片、计算 Hash、并发上传、断点续传和秒传。4.1 后端依赖与基础配置后端只需要引入 spring-boot-starter-web以及配置文件需要使用的 Configuration Processor非必须!-- 文件路径backend/pom.xml -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependenciesSpring Boot 内置的 multipart 默认配置非常保守以 Spring Boot 2.x 为例默认 max-file-size 是 1MB、max-request-size 是 10MB远不够大文件场景。我们需要在 application.properties 里调大限制并配置分片目录# 文件路径backend/src/main/resources/application.properties server.port8080 file.upload.chunk-dir/data/uploads/chunks file.upload.merge-dir/data/uploads/files spring.servlet.multipart.max-file-size20MB spring.servlet.multipart.max-request-size200MB这里 max-file-size 指单次请求中单个文件的大小。分片上传时每个分片只有 5MB所以 20MB 足够。max-request-size 指整个请求的大小需要覆盖分片数据加表单字段的总和设置为 200MB 是为了留出余量。然后创建配置类// 文件路径backend/src/main/java/com/example/upload/config/UploadConfig.java Configuration ConfigurationProperties(prefix file.upload) public class UploadConfig { private String chunkDir; private String mergeDir; public String getChunkDir() { return chunkDir; } public void setChunkDir(String chunkDir) { this.chunkDir chunkDir; } public String getMergeDir() { return mergeDir; } public void setMergeDir(String mergeDir) { this.mergeDir mergeDir; } }4.2 后端分片上传接口创建一个通用返回类 Result方便前端统一处理// 文件路径backend/src/main/java/com/example/upload/model/Result.java public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } // getter/setter 省略 }控制器代码如下。uploadChunk 负责保存单个分片check 负责返回已上传分片列表并判断是否可以秒传merge 负责触发合并// 文件路径backend/src/main/java/com/example/upload/controller/FileUploadController.java RestController RequestMapping(/upload) public class FileUploadController { private final UploadConfig uploadConfig; private final FileMergeService mergeService; public FileUploadController(UploadConfig uploadConfig, FileMergeService mergeService) { this.uploadConfig uploadConfig; this.mergeService mergeService; } PostMapping(/chunk) public ResultVoid uploadChunk(RequestParam(file) MultipartFile file, RequestParam(