简介:这是一份面向Spring Boot开发者的文件上传进阶资料,聚焦断点续传与分片上传两种大文件传输方案。资源结合MultipartFile、分片合并、状态管理等核心知识点,并附带前后端配套实现,可帮助有一定Java基础的学习者理解上传机制,并直接参考代码改造到实际项目中。资源包共114个文件,压缩包仅112KB,其中主要包含51个Java源码、45个编译后class文件、11个XML配置、2个SQL脚本、2个YAML配置以及JS、FTL模板等辅助资源,能清晰对应后端逻辑、接口配置、数据库表与页面交互。已有1717人学习下载,属于轻量而完整的小型开源参考实现。通过该资源,读者可以掌握断点续传的偏移量处理思路、分片上传的分片接收与合并流程,同时获得上传状态管理与异常恢复的代码参考,适合用于毕设或企业项目中大文件上传模块的快速落地。
1. 为什么你的大文件上传总在最后一秒翻车:Spring Boot 断点续传与分片上传
项目里总有那么一个需求:上传 2GB 的安装包、视频素材、离线文档。前端 file 选择框一点,进度条走到 98%,Wi-Fi 抖了一下,请求失败,只能从头再来。这不是网络不好,而是你把整个文件放进了一个 HTTP 请求里。springboot断点续传或分片上传,就是把这个大请求拆成若干个小请求,每传成功一片就记录一片,失败后下次只补传没完成的片。这套方案在后端主要落在 Spring Boot 里,前端可以用 vue-simple-uploader,也可以自己用 File.slice 拼一个;做法不复杂,但参数、状态记录和并发处理的坑不少。适合正在做文件上传功能、或者被 springboot 面试题里这道题卡住的人。下面按后面能直接落地的顺序讲。
2. 分片上传与断点续传的原理和选型:动手前先定这 4 件事
2.1 分片上传不是把文件切小那么简单:从「一次大请求」到「可重试小请求」
先说清分片上传的优势在哪。常规上传是把整个文件负载放到请求体里,对服务器、网关和浏览器都有压力;Nginx、Tomcat 或者云负载均衡器通常会设置请求体上限和超时时间,文件一大,任何一个中间节点断连,整个请求就失败。把文件切成若干分片后,每个分片是独立的小请求,几百 KB 到几 MB 的请求很快就能完成,单个请求失败只需要重传那一片。断点续传是建立在分片上的「记忆」:后端记录每个 fileId 下哪些分片已经写成功,前端再次上传时先问一遍后端,跳过已传的。
有一个容易混淆的点:浏览器下载文件有 Range 头,可以实现下载续传;上传时却很少有客户端和服务器原生支持。所以文件上传的断点续传必须依赖业务层的状态记录,而不是 HTTP 协议本身。这也是面试官喜欢追问「为什么分片上传要后端配合」的原因——因为后端不记录状态,前端切多少次片都没有续传能力。
这里要约定几个核心参数,前端和后端必须一致:
| 参数 | 含义 | 推荐取值 |
|---|---|---|
| fileId | 一次上传任务的唯一标识,一般由前端生成 | uuid 或 file.md5 + 时间戳 |
| chunkSize | 每个分片的大小(字节) | 2MB~10MB,弱网场景可调小 |
| index | 分片序号,从 0 开始 | 0 到 totalChunks-1 |
| totalChunks | 总分片数 | Math.ceil(file.size / chunkSize) |
fileId 是整个断点续传的钥匙。后端用它创建临时目录,前端用它查询已上传的分片。如果前端每次进入页面都重新生成 fileId,断点续传就永远续不上。总分片数也不能太夸张,一个 5GB 文件如果切 1MB 片,会有 5120 个请求,虽然可行,但合并时排序和校验的负担变重。常见选择是 2MB~5MB,兼顾重传代价和请求数量。
2.2 后端接口拆几块:三个接口的职责边界
后端最少需要三个接口:
- 分片上传接口:接收单个分片,按 fileId 和 index 写入临时存储。
- 校验分片接口:根据 fileId 返回已上传分片列表,让前端跳过已传分片。
- 合并接口:当所有分片都上传后,把临时分片合并成完整文件,并清理临时数据。
校验接口是断点续传的关键。vue-simple-uploader 这类组件默认会先发探测请求,如果后端没有实现校验接口,前端就会认为所有分片都没传过,每次都从零开始上传。这也就是为什么网上不少项目号称做了断点续传,实际上只是做了分片上传。另有一个可选接口是秒传,前端先把文件的 MD5 发给后端,后端查库发现相同文件已经存在,就直接返回成功,跳过整个上传过程;这个功能适合重复上传多的场景,但它不能替代分片上传本身。
三个接口里,分片上传接口对时序要求最高。前端很可能并发发多个分片请求,后端如果对同一个 fileId 的写入不加控制,分片数据可能互相覆盖,合并时才发现文件不对。
2.3 上传组件选型:手写 axios 队列,还是 vue-simple-uploader
前端常用两条路。
一是手写。用 File 对象的 slice 方法切分文件,把每个分片组成 FormData,用 axios 发请求。好处是协议自己定,和任何后端都能对接,调试时能看到每个请求的状态码;坏处是要自己实现进度汇总、失败重试和并发控制,代码量不小。
二是用 vue-simple-uploader。它在浏览器端封装了分片、并发、重试、进度和校验逻辑,开箱即用;但它的默认请求方式和校验约定是固定的,后端必须按它的约定返回探测结果。如果项目本身就是 Vue,并且不想重复造轮子,组件方案更省事;如果上传功能只是某个后台页面的一小块,或者后端接口还有自己的加密、签名逻辑,手写反而更好控制。
组件方案还有一个隐性成本:默认的并发数、重试策略和校验策略不一定符合你的约束。有些组件要求后端返回特定状态码,前后端要一起调。手写方案虽然代码多,但每个请求的 url、header、body 都是自己控制的,后续接权限、签名、限流都好处理。我一般会建议:临时内部工具用 vue-simple-uploader 快速出效果;对外提供的文件服务或长期维护的产品功能,手写分片上传更稳妥,因为出了问题你能看到每一个请求。
2.4 分片状态记录在哪儿:临时目录、Redis 还是数据库
分片状态记录有三种典型位置。
临时目录扫描是最朴素的做法:后端用 fileId 建目录,每收到一个分片就写一个状态文件,校验接口直接读目录。这个方案不依赖额外组件,缺点是在分布式部署时,多个实例各自有本地磁盘,分片状态不共享,需要做会话保持或者共享存储。
Redis 适合做高并发下的状态缓存,用 hash 结构记录某个 fileId 已收到的分片索引,读写都很快。用 Redis 时建议把 fileId 作为 key,用 set 记录分片索引,例如 SADD upload:{fileId} 0 1 2,校验接口直接返回 SMEMBERS 结果。缺点是 Redis 有清理策略,任务中断几小时,记录可能过期,所以更适合给数据库做缓冲,或者用于合并时的锁。Redis 的持久化配置也要注意,appendonly 关闭时重启会丢数据,断点续传状态也就丢了。
MySQL 是最终要落地的记录:文件表记录 fileId、文件名、大小、MD5、状态;分片表记录每个分片是否上传完成。校验接口查分片表,合并时更新文件表状态。这个方案在分布式环境下最可靠,代价是每次上传分片都要写一次数据库。对绝大多数单体 Spring Boot 项目,建议用「临时目录 + 一个状态文件」起步,等真的需要多实例横向扩展了,再把状态迁移到 Redis 或数据库。第 3 章的代码就是按这个思路写的。
3. 用 Spring Boot 写断点续传后端:分片接收、校验与合并的完整代码
3.1 定义目录规范和存储方案
后端先把目录约定好。常见做法是在应用目录下建一个 upload_tmp,按 fileId 再建子目录:
upload_tmp/ <fileId>/ <fileId>.part # 所有分片按偏移量写入的同一个文件 <fileId>.done # 记录已收到的分片索引 merged/ <原始文件名>为什么不把每个分片单独存成一个文件?因为分片一多,文件数量就非常大,合并前还要做一次「按 index 排序」的 IO 操作;把所有分片按偏移量写进同一个 .part,合并时直接把文件改名或用流复制到最终位置就行。这个方案的代价是并发写同一个文件时必须加锁,不能两个线程同时 seek 和 write。
3.2 分片上传接口:MultipartFile 接收后按偏移量写入
先给出分片上传接口的完整代码:
@RestController @RequestMapping("/upload") public class ChunkUploadController { private static final int CHUNK_SIZE = 2 * 1024 * 1024; // 与前端约定一致 private final String UPLOAD_ROOT = System.getProperty("user.dir") + "/upload_tmp/"; @PostMapping("/chunk") public ResponseEntity<Map<String, Object>> uploadChunk( @RequestParam("file") MultipartFile file, @RequestParam("fileId") String fileId, @RequestParam("index") int index, @RequestParam("totalChunks") int totalChunks) throws IOException { File dir = new File(UPLOAD_ROOT + fileId); if (!dir.exists()) { dir.mkdirs(); } File partFile = new File(dir, fileId + ".part"); synchronized (fileId.intern()) { try (RandomAccessFile raf = new RandomAccessFile(partFile, "rw")) { raf.seek((long) index * CHUNK_SIZE); raf.write(file.getBytes()); } } // 追加记录这个分片已经收到 File doneFile = new File(dir, fileId + ".done"); Files.write(doneFile.toPath(), (index + ",").getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); return ResponseEntity.ok(Map.of("code", 0, "msg", "ok")); } }代码逻辑并不复杂:每次上传,把文件指针 seek 到 index * CHUNK_SIZE 的位置,再写入本次的分片字节。这样即使分片乱序到达,最后 .part 文件里每个分片也都在正确的位置。synchronized (fileId.intern()) 保证同一个 fileId 的写入是串行的,避免两个并发请求同时写导致错位。
有几个参数值得注意。CHUNK_SIZE 必须和前端约定的分片大小完全一致,否则 seek 位置会算错;这里用固定 2MB,实际项目建议做成配置项。MultipartFile 的 getBytes() 会把整个分片读进内存,分片超过 10MB 时要注意内存占用,更稳的写法是先转成 InputStream 再写入。totalChunks 在这个接口里没有用到,但建议保留,合并前可以校验实际收到的分片数与它是否一致。另外 Map.of 要求 Java 9+,如果你的 Spring Boot 2.x 项目还在 Java 8,换成 HashMap 即可。
3.3 校验接口:返回已上传分片列表
分片上传完成后,前端需要知道哪些分片已经存在。对应接口如下:
@GetMapping("/check") public ResponseEntity<Map<String, Object>> checkChunks( @RequestParam("fileId") String fileId) throws IOException { File dir = new File(UPLOAD_ROOT + fileId); List<Integer> uploaded = new ArrayList<>(); File doneFile = new File(dir, fileId + ".done"); if (doneFile.exists()) { String content = new String(Files.readAllBytes(doneFile.toPath()), StandardCharsets.UTF_8); for (String part : content.split(",")) { if (!part.isEmpty()) { uploaded.add(Integer.parseInt(part)); } } } return ResponseEntity.ok(Map.of("code", 0, "uploaded", uploaded)); }这个接口要返回完整的分片索引列表,而不是只返回「是否全部完成」。前端拿到列表后,会跳过其中已存在的 index。如果后端只给一个布尔值,前端必须自己根据列表长度判断哪些片缺失,逻辑就容易出错。实际项目中,我会在这个接口里顺手做两件事:一是把已上传分片数与文件总大小一起返回,前端可以对比;二是如果校验发现 .part 文件已经存在且长度等于文件总大小,返回一个 completed 标志,前端可以直接调用合并接口,省去最后一轮重复上传。
3.4 合并接口:校验大小、落盘并清理临时文件
当所有分片都完成后,前端调用合并接口:
@PostMapping("/merge") public ResponseEntity<Map<String, Object>> merge( @RequestParam("fileId") String fileId, @RequestParam("fileName") String fileName, @RequestParam("totalSize") long totalSize) throws IOException { File dir = new File(UPLOAD_ROOT + fileId); File partFile = new File(dir, fileId + ".part"); if (!partFile.exists()) { return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", "分片文件不存在")); } if (partFile.length() != totalSize) { return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", "已上传大小不一致: " + partFile.length() + " vs " + totalSize)); } File mergedDir = new File(UPLOAD_ROOT + "merged"); if (!mergedDir.exists()) { mergedDir.mkdirs(); } File dest = new File(mergedDir, fileName); // .part 文件本身已经是按偏移量排好的完整文件,直接搬过来 Files.move(partFile.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); deleteDir(dir); return ResponseEntity.ok(Map.of("code", 0, "path", dest.getAbsolutePath())); }合并接口最大的价值不是拼接,而是校验。partFile.length() 与前端传的 totalSize 不一致,说明有分片漏传或者多传,直接返回错误,不要往下走。移动文件比边读边写更省 IO,这里用 Files.move 把 .part 直接改成最终文件名。deleteDir 就是递归删除这个 fileId 的临时目录,避免磁盘堆积。要注意的是,确保临时目录和合并目录在同一个磁盘分区,否则 Files.move 会退化成复制,大文件会非常慢。如果用了数据库或 Redis,合并接口还需要把文件状态改成 already_merged,并把真实路径存下来,后续下载接口根据这个状态返回文件。
3.5 秒传接口:MD5 校验什么时候值得做
秒传是加分项,不是必选项。前端在开始上传前算好整个文件的 MD5,传给后端:
@PostMapping("/exists") public ResponseEntity<Map<String, Object>> exists( @RequestParam("md5") String md5, @RequestParam("size") long size) { boolean exists = fileMetaService.existsByMd5AndSize(md5, size); return ResponseEntity.ok(Map.of("code", 0, "exists", exists)); }逻辑很简单:查文件表,相同 MD5 和大小已经存在,前端就跳过整个上传流程直接显示成功。要注意两个坑:一是大文件在前端计算 MD5 很慢,2GB 文件可能要几十秒,用户会以为页面卡了,通常要做进度提示或者放到 Web Worker 里算;二是 MD5 会碰撞,所以生产中最好把大小作为条件,再加一层文件名或目录归属校验,不完全依赖哈希。
4. 前端与后端打通断点续传:手写 File.slice 与 vue-simple-uploader 两条路
4.1 手写前端:切分、探测、并发上传和失败重试
后端三个接口就位后,前端核心逻辑可以写得很薄。用原生 File API 切分,用 axios 上传分片:
async function uploadWholeFile(file, fileId, chunkSize = 2 * 1024 * 1024) { const totalChunks = Math.ceil(file.size / chunkSize); // 1. 先查询已上传分片 const uploaded = await fetch(`/upload/check?fileId=${fileId}`) .then(r => r.json()) .then(d => d.uploaded || []); // 2. 并发上传缺失分片,并限制并发数 const queue = []; for (let index = 0; index < totalChunks; index++) { if (uploaded.includes(index)) continue; const blob = file.slice(index * chunkSize, Math.min(file.size, (index + 1) * chunkSize)); const form = new FormData(); form.append('file', blob); form.append('fileId', fileId); form.append('index', index); form.append('totalChunks', totalChunks); const task = axios.post('/upload/chunk', form) .catch(() => retryLater(form)); // 失败重试 queue.push(task); if (queue.length >= 3) { await Promise.all(queue); queue.length = 0; } } await Promise.all(queue); // 3. 全部完成后通知后端合并 await axios.post('/upload/merge', null, { params: { fileId, fileName: file.name, totalSize: file.size } }); }这个函数完成了三件事:先探测已传分片,再把缺失的分片并发传上去,最后触发合并。文件切片通过 file.slice 完成,注意最后一个分片的结束位置是文件总大小,不能用 (index + 1) * chunkSize 直接算。这里的并发控制是每攒满 3 个请求就等待一次,避免一下发出几千个请求把浏览器和 Tomcat 打爆。
uploaded.includes(index) 这一步就是断点续传的落点:页面刷新后,只要 fileId 不变,后端能查到之前已经写好的分片索引,前端就会跳过。这也是 fileId 需要按固定规则生成的原因——用文件 MD5 加时间戳生成,同一个文件再次上传时可以复用之前的记录。断点续传能不能生效,很多时候不是后端代码的问题,而是前端把 fileId 随手 new 了一个。
4.2 并发数和分片大小怎么定:两个影响体验的参数
明白代码之后,实际影响体验的是 chunkSize 和并发数两个量。
分片大小影响「失败重传的粒度」和「请求数量」之间的平衡。分片太小,比如 256KB,重传粒度小了,但总请求数多,合并时文件句柄和状态记录的开销大;分片太大,比如 50MB,又退化成接近整文件上传,断了以后重传代价大。常见选择:
| 网络环境 | 分片大小 | 并发数 |
|---|---|---|
| 内网/带宽充足 | 5MB~10MB | 3~5 |
| 公网普通宽带 | 2MB~4MB | 2~3 |
| 弱网/移动网络 | 1MB~2MB | 1~2 |
并发数不是越大越好。浏览器对同一域名的连接数有限制,TCP 连接过多会相互争抢带宽;后端每接收一个分片就要解析一次 multipart 请求,并发太高时线程池和内存都吃紧。一般 3 个并发足够。
提示:chunkSize 前端和后端必须一致。上线后如果调整,要把旧任务先处理完,否则历史临时文件的偏移量会对不上。
4.3 用 vue-simple-uploader 对接 Spring Boot:校验与响应格式
如果选用 vue-simple-uploader,核心配置里和断点续传相关的部分如下:
new Uploader({ target: '/upload/chunk', chunkSize: 2 * 1024 * 1024, testChunks: true, successStatuses: [200], checkChunkUploadedByResponse: (chunk, message) => { // Spring Boot 校验接口返回的 JSON,含 uploaded 列表 const data = JSON.parse(message); // 组件序号一般从 1 开始,转成后端从 0 开始的 index const index = chunk.chunkNumber - 1; return data.uploaded && data.uploaded.includes(index); } })vue-simple-uploader 开启 testChunks 后,每个分片在真正上传前会先发探测请求,探测结果由 checkChunkUploadedByResponse 回调决定。其中的 chunk 对象在不同封装版本里字段略有差异,但通常能拿到 chunkNumber 或起始字节偏移,换算成 index 再拿去和后端返回的 uploaded 列表比对。关键是要保证后端探测接口返回的 JSON 能被这个函数解析,两边字段对不上就会变成「永远在重新上传」。
我一般会在后端校验接口把 uploaded 列表做成数组返回,前端回调里做 includes 判断,这是最容易理解和排错的方式。这里有个常见误解:有人以为 testChunks 只是把探测请求发出去,后端返回 200 就算已存在,这是不对的。后端必须返回能区分具体分片的响应体,前端才能精确跳过。
4.4 合并的时机:最后一个分片回调再触发
不论手写还是组件,合并接口都必须在最后一个分片完成之后调用,不能在「所有分片发出」之后调用。上面手写版是在 Promise.all 之后调用 merge,组件版要放在 fileSuccess 或所有 chunk 的回调里。
一个很容易犯的错:前端在进度显示 100% 时立刻跳转页面或关闭窗口,而合并请求还没执行完。合并是大文件上传中最后一步 IO 操作,可能比单个分片上传慢得多,要在合并请求的 Promise resolve 之后再做后续跳转。否则前端显示传完了,后端临时目录里只有一堆分片,文件永远合不出来。
组件方案里还有一个选择:让组件自己合并,还是通知后端合并。我建议通知后端合并,理由很简单,后端才知道所有分片是否真正落盘。组件并发上传完成后,后端再做一次文件长度校验和完整性校验,把重量操作留在服务端,前端只需要等待结果。把「合并」放在前端做,等于把权限和风险都交出去了,生产环境不推荐。
5. 避坑清单:断点续传最容易翻车的 5 个场景
5.1 分片全部传完但合并乱序:后到的分片被追加到文件尾
现象:两个分片请求几乎同时到达,后端都打开同一个 .part 文件写入;合并后文件段序颠倒,视频花屏或压缩包损坏。
原因:如果后端用 append 模式打开文件,比如 FileWriter 或 new FileOutputStream(file, true),后到的分片会被追加到文件末尾,而不是它应该所在的偏移量。并发越高,段序越乱。还有些实现把每个分片存成独立文件,但合并时直接按文件名排序,导致 2、10、20 这样的序号排序错误,10 排到了 2 前面。
解决:写入时显式 seek 到 index * chunkSize,用 RandomAccessFile 而不是 append;分片独立存放时,合并前按 index 数值排序。多实例并发写同一个文件时,用 fileId 粒度的锁把写入串行化。
5.2 Spring Boot multipart 默认 1MB 限制:分片一传就 413
现象:前端明明切了 2MB 的分片,请求发出去就报 MaxUploadSizeExceededException,或者返回 413。
原因:Spring Boot 的 multipart 默认单文件大小上限是 1MB,总请求大小是 10MB,分片超过限制直接被拒。这个默认值太保守,经常被忽视。
解决:在 application.yml 里调大:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB如果是 Spring Boot 1.x,配置前缀是 spring.http.multipart,新版换成了 spring.servlet.multipart;网上老博客里的配置直接复制到新版本不会生效,这也是「springboot 版本太高」最常见的一个坑。假如前面还有 Nginx,Nginx 的 client_max_body_size 也要同步调大,两个限制同时存在时以小的为准。本地调试时在 IDEA 的 Run Configuration 里可以设置程序启动参数覆盖端口或配置文件,但 multipart 上限这种配置还是写进 yml 更稳妥。
5.3 校验接口说「分片已存在」,前端却从头再传
现象:断点续传完全没生效,页面刷新后进度从 0% 开始,后端日志里还能看到重复上传同一个分片。
原因:常见有两类。一是前端每次进页面重新生成 fileId,fileId 不同,后端自然查不到上次的目录;二是校验接口的响应格式和前端组件的解析逻辑不一致,组件认为该分片不存在。
解决:fileId 要按固定规则生成,同一个文件的 fileId 在有效期内保持不变;校验接口返回的 uploaded 数组要和前端约定的字段名完全一致。调试时可以打开组件的 testChunks 开关,观察每个分片先发出的探测请求,看返回 JSON 是否符合前端解析逻辑。这是断点续传里最典型的黑匣子问题,把探测请求和响应打印出来,原因会立刻清楚。
5.4 合并后文件大小对但内容损坏:视频打开到一半花屏
现象:合并后的视频或压缩包能打开,但播放到某段画质异常,或者解压时报 CRC 错误;文件的大小和原始文件一模一样。
原因:分片写入位置算错。最常见的是 seek 用了 index * chunkSize 却把 index 从 1 开始,或者没有考虑最后一个分片不足 chunkSize 的情况;还有一个原因是前端和后端约定的 chunkSize 不一致,比如前端按 2MB 切,后端按 1MB 的偏移量写,数据全部错位。
解决:后端 seek 写法固定为 (long) index * chunkSize,index 从 0 开始;合并前校验 part 文件总长度等于 totalSize。如果还出错,把 .part 文件保留下来,用二进制比较工具对比原始文件对应 offset 的内容,能快速定位哪一段错位。合并成功后建议对最终文件再算一次 MD5,和前端上传前算的 MD5 比对,这比人工抽查可靠得多。
5.5 临时目录只写不收,磁盘被打满
现象:系统监控里磁盘占用持续上涨,上传目录里有大量 .part 和 .done 文件,再过一阵所有分片上传都报「磁盘空间不足」。
原因:合并接口只在成功时清理临时目录;上传到一半放弃、浏览器直接关闭、或者上传失败没调合并接口的应用,临时目录里残留着文件。日积月累,数量非常可观。
解决:合并成功后递归删除 fileId 目录;另外写一个 Spring @Scheduled 定时任务,扫描 upload_tmp 下修改时间超过 24 小时的目录,直接删除。删除前判断目录里是否还有活跃上传,可以用状态文件里的最近修改时间做依据。这个回收任务不能省,生产环境里很多「上传功能用一段时间后突然失灵」的问题,都是磁盘被临时文件悄悄占满了。
6. 进阶验证与生产化:把断点续传从「能跑」变成「能扛」
6.1 用 curl 模拟断点续传自测
后端接口写完,先用脚本自测一遍,不要急着接前端。curl 可以模拟「传一半断掉,再传另一半」的过程:
# 先只传 0 号分片 curl -F "file=@part_0" -F "fileId=test-001" -F "index=0" -F "totalChunks=10" \ http://localhost:8080/upload/chunk # 查询校验接口,看是否只返回了 0 curl "http://localhost:8080/upload/check?fileId=test-001" # 再传剩下的分片,最后合并 curl -X POST "http://localhost:8080/upload/merge?fileId=test-001&fileName=test.bin&totalSize=20000000"这个流程验证的是后端状态记录是否可靠:中途没有传其他分片,check 接口应该能正确返回已传列表,merge 接口也应该能拒绝不完整的文件。如果 check 接口返回空,说明状态写入或目录结构有问题,先修后端再折腾前端。
6.2 并发合并与幂等:用 Redis 锁保证只有一个合并请求成功
多个客户端同时点击合并,或者后端重试机制重复触发合并,会导致重复改名、状态错乱。生产中对合并动作加一把锁更稳:
Boolean locked = redisTemplate.opsForValue() .setIfAbsent("upload:merge:" + fileId, "1", Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(locked)) { return ResponseEntity.ok(Map.of("code", 409, "msg", "合并已在处理中")); }锁住后,后到的合并请求直接返回「处理中」,前端可以轮询稍后再问。注意锁的过期时间要大于合并操作的真实耗时,否则大文件合并超过 5 分钟,锁自动过期,第二个请求又进来了。Redis 锁不是唯一方案,单机部署用 synchronized 也够,但这个 lock key 的设计思路能直接迁移到多实例。
6.3 我最后会做的三件事
上传功能上线前,我会把分片大小、并发数、临时目录回收时间三个参数全部提成配置项,不写死在代码里;会在校验接口的响应里同时返回 uploaded 列表和 completed 标志,给前端省一次多余的合并请求;会保留每次上传任务的完整日志,包括 fileId、分片总数、实际写入总数和耗时。断点续传最怕的就是出了问题时只能看到「上传失败」四个字,连哪个分片出了问题都不知道。
这也是我做这个功能最大的教训:一开始图省事只写了分片接口和合并接口,没写校验接口,后来前端每次刷新都从头传,才补上 check 接口。断点续传不是一个文件接口,而是一整套「状态记录 + 查询 + 合并」的闭环,少一环都不叫续传。希望这个完整流程和避坑清单能帮到你。
本文还有配套的精品资源,点击获取