SpringBoot分片上传实战:从单体上传到大文件并发与断点续传

发布时间:2026/10/18 19:28:00
SpringBoot分片上传实战:从单体上传到大文件并发与断点续传
1. 上传慢的真凶单体上传的三大瓶颈与分片思路做后端开发的朋友应该都有过这种经历用户反馈说视频文件传不上去一看日志连接断了或者一个接近200MB的文件传了十分钟卡在最后90%的位置一动不动。排查半天发现不是带宽问题而是整个上传方案从设计上就扛不住大文件。项目标题里的“上传文件慢”往往不是网络慢而是单体上传这个方案本身存在硬伤。单文件整体上传最大的问题有三个。第一个是连接超时风险一个文件从客户端到服务端要持续占用一个HTTP连接文件越大耗时越长中间路由器、代理、浏览器或服务端任意一方超时断连整个文件就得重新传。第二个是带宽利用率低绝大多数HTTP上传是串行传输单个TCP连接的窗口在弱网环境下很难跑满带宽尤其跨地域传输时实际吞吐量可能只有带宽上限的三成。第三个是失败成本极高没有断点续传机制一旦中断就从头再来用户体验和服务器带宽都是双重浪费。分片上传的思路其实很朴素把一个大文件在客户端切成多个几MB的小块分别独立上传服务端全部接收后再按顺序合并还原。这样做的好处是即时有某个分片失败只需要重传出错的那一片而不是整个文件同时还可以多分片并发上传把带宽跑满。这个思想在早期的断点续传软件里就有雏形放在SpringBoot服务里实现起来也并不复杂。不过在动手写代码之前得先明确一个边界分片上传不是银弹。如果只是几十MB的小文件用分片反而是过度设计多出来的合并逻辑、分片管理都会变成额外的复杂度。一般建议文件超过100MB或者网络环境明显不稳定时才考虑分片方案。下面我按一个完整的实战项目来拆解整个实现过程从后端接口设计到前端切片配合再到生产环境里容易踩的坑一次性讲清楚。2. 后端核心实现分片接口设计与合并流程2.1 接口规划三个接口分别承担什么职责分片上传的后端至少要拆成三个接口来设计把“初始化、上传分片、合并文件”三个动作分开。有人图省事只写一个上传接口让前端把所有分片直接POST上来这种设计在中小文件上勉强能用但一旦分片多了缺少初始化环节就没办法做秒传和断点续传后面想优化体验就得重构。第一个接口是文件初始化接口入参包含文件名、总大小、分片大小、总分片数。这个接口的作用是告诉服务端“我要开始传一个文件了”服务端据此生成一个唯一的文件标识一般用UUID预留临时目录。第二个接口是分片上传接口入参包含文件标识、分片序号、分片文件数据服务端把每个分片落到临时目录里。第三个接口是合并接口入参包含文件标识和原始文件名服务端把所有分片按序号合并成完整文件并清理临时分片。上传期间的临时目录结构建议按文件标识隔离不要所有分片平铺在一个目录里。我见过不少项目把所有文件的上百分片全丢进同一个文件夹结果清理和排查问题都特别痛苦。按文件标识建子目录后续做分片不存在检查、垃圾清理都会方便很多。分片上传还有几个核心参数需要前后端约定好这里列一个我项目里用的典型配置参数推荐值说明chunkSize2MB ~ 10MB分片太小会导致请求数过多太大则失去分片意义concurrency3 ~ 5并发上传的分片数太多会触发浏览器连接限制identifierUUID或文件MD5用MD5可实现秒传用UUID则存储简单totalChunks由文件大小和分片大小计算前端计算好传给后端后端做完整性校验2.2 分片上传与合并的SpringBoot实现先看分片上传的主接口代码。这里我用的是一个相对标准的写法包含分片文件存档、分片数量校验、断点续传查询三个能力RestController RequestMapping(/api/file) public class FileUploadController { // 分片上传的根路径实际部署时建议配置到独立磁盘或分布式存储 private static final String UPLOAD_DIR /data/uploads; // 服务端保存分片的临时目录按文件标识隔离 PostMapping(/chunk) public R uploadChunk( RequestParam(file) MultipartFile file, RequestParam(identifier) String identifier, RequestParam(chunkNumber) Integer chunkNumber, RequestParam(chunkSize) Long chunkSize, RequestParam(totalChunks) Integer totalChunks) { if (file.isEmpty()) { return R.fail(分片文件为空); } try { // 按文件标识创建子目录防止分片混淆 Path chunkDir Paths.get(UPLOAD_DIR, identifier); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } // 分片文件的命名规则直接用数字序号合并时排序更方便 Path chunkPath chunkDir.resolve(chunkNumber .tmp); file.transferTo(chunkPath.toFile()); return R.success(分片上传成功); } catch (IOException e) { // 这里要打日志生产环境排查上传问题全靠它 return R.fail(分片服务端存储失败); } } // 查询已上传的分片列表用于断点续传 GetMapping(/chunk/list) public R chunkList(RequestParam(identifier) String identifier) { SetInteger uploaded new HashSet(); Path chunkDir Paths.get(UPLOAD_DIR, identifier); if (!Files.exists(chunkDir)) { return R.success(Collections.emptyList()); } try (StreamPath stream Files.list(chunkDir)) { stream.forEach(path - { String fileName path.getFileName().toString(); uploaded.add(Integer.parseInt(fileName.replace(.tmp, ))); }); } catch (IOException e) { return R.fail(分片列表查询失败); } return R.success(uploaded); } }合并接口的逻辑相对多一些核心操作是读所有分片、按序号排序、按顺序写入目标文件然后清理临时分片。这里特别要注意分片排序的问题。如果分片命名用的是String类型默认按字典序排“10.tmp”会排在“2.tmp”前面合并出来的文件必然损坏。所以排序时一定要转成int比较或者命名时用“00001.tmp”这种补零格式。// 合并分片为完整文件 PostMapping(/merge) public R mergeChunks(RequestParam(identifier) String identifier, RequestParam(fileName) String fileName) { Path chunkDir Paths.get(UPLOAD_DIR, identifier); Path targetFile Paths.get(UPLOAD_DIR, System.currentTimeMillis() _ fileName); try { ListPath chunkPaths new ArrayList(); try (StreamPath stream Files.list(chunkDir)) { chunkPaths.addAll(stream.collect(Collectors.toList())); } if (chunkPaths.isEmpty()) { return R.fail(分片不存在); } // 按分片序号排序int比较是关键 chunkPaths.sort(Comparator.comparingInt(p - { String name p.getFileName().toString(); return Integer.parseInt(name.replace(.tmp, )); })); // 用BufferedOutputStream顺序写入避免多次随机IO try (BufferedOutputStream outputStream new BufferedOutputStream( Files.newOutputStream(targetFile))) { byte[] buffer new byte[8 * 1024]; for (Path chunkPath : chunkPaths) { try (InputStream inputStream Files.newInputStream(chunkPath)) { int len; while ((len inputStream.read(buffer)) ! -1) { outputStream.write(buffer, 0, len); } } } } // 清理分片临时目录否则磁盘会被垃圾分片占满 deleteChunkDir(chunkDir); return R.success(targetFile.toString()); } catch (IOException e) { return R.fail(合并失败); } }2.3 秒传与断点续传记忆Identifier背后的细节做完上面的基础功能后会发现一个体验问题如果文件传到一半断了用户重新打开页面怎么知道哪些分片已经传过了断点续传的实现关键就在这里——前端启动上传前先调用分片列表查询接口后端把已上传的分片序号返回前端只需要跳过这些序号继续传剩下的部分。更进一步的体验是秒传。如果文件之前已经上传过接口直接返回“合并成功”而不需要真正传输。实现方式是把文件标识改成文件的MD5值上传前先请求一个“检测接口”后端去已存储文件的索引表里查MD5是否存在。如果存在直接告诉前端“这个文件已经传过了不用再传”。这里说的索引表通常是一张数据库表记录文件MD5、文件路径、文件大小这几个字段。把identifier从UUID改成MD5对断点续传也天然友好同一个文件在不同浏览器或不同时间传入时identifier是完全一致的分片列表查询接口能直接找到之前未完成的临时分片断点续传就成立了。不过MD5计算在高并发场景下要注意一点大文件的MD5计算耗时较长前端可以放在Web Worker中异步计算否则主线程会卡顿。我在实际项目里通常的做法是前端先用文件大小和文件名生成一个临时标识上传成功后服务端再计算一次完整文件的MD5存入索引表——这样初始化上传的时候不需要等待MD5计算完成体验更流畅。3. 前端切片与并发控制后端设计得再好前端不会配合也白搭3.1 File.slice切割与分片元数据组装后端接口就绪之后前端是分片上传真正执行的地方。前端的核心操作是调用File.slice()方法把文件内存映射成多个Blob片段。这里有个很多人一开始会犯的错误以为slice()会把文件真实切分成多个磁盘文件实际上浏览器底层只是通过文件字节偏移量生成了多个Blob引用并没有复制文件内容所以切片这个过程本身几乎是零成本、瞬时完成的。// 前端切片核心逻辑 const CHUNK_SIZE 5 * 1024 * 1024; // 5MB每片 function splitFile(file) { const chunks []; const totalChunks Math.ceil(file.size / CHUNK_SIZE); for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); // slice()不会真实复制文件数据只是创建了偏移量引用 chunks.push({ chunk: file.slice(start, end), chunkNumber: i 1 }); } return { chunks, totalChunks, chunkSize: CHUNK_SIZE }; }分片时有两个细节值得注意。第一个是分片大小不能一概而论需要根据网络环境和文件大小动态调整。局域网内1GB文件可以切成10MB分片并发上传但公网弱网环境下10MB分片很容易超时2MB到5MB更稳妥。第二个是分片序号从1开始还是从0开始要前后端统一我见过前后端一个从0开始一个从1开始导致合并永远失败的案例这类低级错误往往最耗时间排查。切片完成后每个分片要带上元数据一起提交。这里额外注意await并发上传时不要用同一个FormData实例否则浏览器会报“FormData已经被使用”之类的错误每个分片都应该是独立的FormData对象。// 单个分片上传 async function uploadChunk(chunkInfo, identifier, totalChunks) { const formData new FormData(); // 注意每个分片都用新的FormData实例 formData.append(file, chunkInfo.chunk); formData.append(identifier, identifier); formData.append(chunkNumber, chunkInfo.chunkNumber); formData.append(chunkSize, CHUNK_SIZE); formData.append(totalChunks, totalChunks); const response await fetch(/api/file/chunk, { method: POST, body: formData }); if (!response.ok) { throw new Error(分片 ${chunkInfo.chunkNumber} 上传失败); } return response.json(); }3.2 并发控制、失败重试与进度计算并发上传并不是“把所有分片一次性全发出去”那么简单。浏览器对同一个域名的并发连接数是有限制的HTTP/1.1下Chrome约6个、Firefox约6个如果一次性提交10个分片多出来的请求会排队反而造成拥塞和超时。所以必须自己实现一个并发池控制同时上传的分片数。我用一个简单的思路维护一个分片队列和一个‘空闲容器’数组循环取未上传的分片启动上传任务每完成一个就从空闲容器中补一个分片进来。同时给单个分片加失败重试机制连续失败三次才放弃避免因为一次瞬时网络波动导致整个任务失败。// 带并发限制的分片上传 async function uploadWithConcurrency(chunks, identifier, totalChunks, concurrency 4) { const results new Array(chunks.length).fill(false); let successCount 0; const pool async () { while (queueCursor chunks.length) { const currentIndex queueCursor; const info chunks[currentIndex]; for (let retry 0; retry 3; retry) { try { await uploadChunk(info, identifier, totalChunks); results[currentIndex] true; successCount; updateProgress(successCount / totalChunks * 100); break; } catch (e) { // 重试前延迟200ms避免连发重试请求 await new Promise(resolve setTimeout(resolve, 200 * (retry 1))); } } } }; // 创建并发池任务 const workers []; for (let i 0; i concurrency; i) { workers.push(pool()); } await Promise.all(workers); return { successCount, results }; }进度的计算逻辑要按“已上传成功分片数 / 总分片数”而不是“已发送字节数 / 总字节数”。因为分片大小是均匀的两者数值上差别不大但用分片数统计能直接配合断点续传逻辑展示更稳定的进度条。真实的文件上传项目里用户对进度条的心理预期很高如果进度条一直在90%左右徘徊很大程度上是合并接口耗时而导致前端已经传完分片却仍在等待。这种场景下要把“上传中”和“合并中”两个阶段分开展示避免用户误以为卡住了。4. 实测效果与对比从10分钟到几十秒的真实变化4.1 单体上传与分片上传的耗时对比我拿一个真实项目里的场景做测试对比。文件为一个约520MB的视频网络环境为中等偏上的公网链路测试服务器带宽约100Mbps。同一台服务器、同一个SpringBoot应用分别用单体上传和分片上传跑了一轮。上传方式配置总耗时中断重传成本单体上传单连接约8分钟中断后全量重传分片上传4并发5MB分片约1分20秒只需重传失败分片分片上传3并发2MB分片约2分05秒只需重传失败分片这个对比能看出两个结论。第一分片并发上传在耗时上确实有明显优势因为多个TCP连接同时传输吞吐量不再是单连接的瓶颈。第二分片大小不是越大越好也不是越小越好5MB在这个网络条件下是更合理的折中点2MB分片因为请求数多了近两倍额外的网络往返开销反而拖慢了整体速度。另外一个隐藏收益是稳定性。单体上传时我故意在传输到一半时断开网络重连结果全程重传。分片上传则只需重传当时正在传、未完成的那两个分片其他已经上传成功的分片全部保留。这个差异在生产环境里对用户体验的提升非常明显用户不会因为一次弱网波动就彻底放弃上传。4.2 合并耗时的优化空间分片上传整体跑完后合并环节同样有讲究。520MB文件、100多个5MB分片用普通的Files.copy逐文件拼接耗时大概在8到15秒之间。这个时间对于用户来说是可以接受的但如果文件到了几个GB级别合并耗时可能超过一分钟。我尝试过几种合并优化方案。第一种是用FileChannel.transferTo做零拷贝传输合并速度比BufferedOutputStream逐字节拷贝明显更快在机械硬盘上差异尤其明显。第二种是分片合并和文件写入采用大块缓冲比如把示例里的8KB缓冲换成1MB减少系统调用次数。第三种是干脆不在应用服务器上合并而是直接把分片上传到对象存储服务利用云服务端的合并能力这是后面进阶部分要展开的内容。从实践经验看普通服务器应用内部用FileChannel或者大缓冲的BufferedOutputStream已经足够没必要为了合并优化引入太复杂的架构。合并耗时只要控制在总上传时长的五分之一以内用户感知就不会太明显。5. 生产环境实战我在分片上传项目里踩过的坑5.1 分片序号排序的隐藏炸弹上面合并代码里提到过排序问题这里想展开专门说一下因为它确实藏得太深了。正常情况下分片文件命名用数字序号加.tmp后缀比如1.tmp、2.tmp、10.tmp如果按String类型排序结果会变成1.tmp、10.tmp、2.tmp。合并时文件顺序错乱最终视频播放到某个位置就会花屏或者卡住而这个位置不固定排查起来极其困难。我遇到过一个案例某开发者的分片上传方案在文件小于20MB时合并总是成功但一旦超过20MB合并出来的文件就是坏的。原因是小于20MB时分片数量少、恰好字典序和数字序一致超过20MB之后出现了10.tmp排到2.tmp前面的情况才暴露。排查时需要非常仔细地看排序逻辑否则很容易忽略。解决方案就是代码里加个Comparator.comparingInt或者命名时统一补零为固定长度的字符串。5.2 临时分片清理与磁盘占满问题分片上传项目上线后要面对的一个实际问题就是磁盘占用。用户上传了一半就关闭页面是常态临时分片文件会滞留在服务器上。如果并发用户一多几天不清理磁盘就满了服务直接崩溃。我通常用三招组合处理。第一招是在合并成功后立即删除分片临时目录第二招是写一个定时任务扫描上传临时目录清理超过24小时未被合并的分片文件因为正常分片上传任务不会持续超过24小时第三招是给分片上传接口做并发限流同一IP或者同一identifier的并发请求做限制防止接口被恶意利用刷爆磁盘。这第三点特别重要。分片上传接口如果对外暴露且没有限制攻击者可以伪造大量identifier上传垃圾分片。一个230MB的文件如果不做限制按5MB分片切就有46个分片请求每个请求都会在磁盘上留下文件最终把存储空间耗尽。生产环境一定要在网关或应用层对分片上传做频率限制和总大小限制。5.3 文件名乱码与路径穿越问题文件名的处理也是一个容易出问题的点。前端传过来的原始文件名可能包含了中文字符、空格甚至特殊符号如果不做处理在合并时直接拼接到文件路径里轻则文件名乱码重则出现路径穿越漏洞——比如文件名是../../etc/passwd拼接到目标路径后可能写到了系统目录。我的处理方式是对原始文件名先用URLDecoder解码并做Base64或UUID重新命名存储数据库表单独存原始文件名供下载时使用。这样服务端磁盘上保存的文件名和用户看到的文件名完全隔离既避免乱码又消除路径穿越风险。另外还要注意跨域配置里Access-Control-Expose-Headers要包含自定义的响应头否则前端获取不到后端返回的上传结果。6. 进阶优化方向从“能用”到“好用”的几步走6.1 大文件合并的三种策略选型分片上传解决了传输粒度和失败重试的问题但合并这个动作在超大文件场景下始终是个瓶颈。三种常见的合并策略各有侧重实际选型取决于对性能和一致性的要求。第一种是应用内相互合并同步返回适合单体应用、文件大小在几个GB以内的场景实现最简单。第二种是分片上传完成后触发异步合并任务前端轮询或通过回调拿到完成状态适合超大文件且用户可以接受稍等片刻的场景。第三种是把分片上传到对象存储服务然后利用对象存储自带的合并功能——只需上传分片时指定相同的对象路径调用一次合并接口即可服务端不参与文件数据流转性能和可靠性都更高。这里要说明的是在本地SpringBoot应用里模拟对象存储是完全可行的开源方案里有MinIO这类兼容S3协议的解决方案支持分片上传的完整生命周期。如果项目已经有对象存储依赖直接把分片落盘逻辑替换为调用存储SDK的上传接口代码改动量反而不大。6.2 上传进度、并发参数与网络环境的自适配进阶到“好用”这个层级就不能把所有用户的并发参数都写死。不同网络环境下最优参数差异很大移动4G网络和千兆局域网的最优并发数是完全不同的。我在项目里采取的做法是提供一个测速接口前端在正式上传前先请求后端很小的探测文件测量当前网络延迟和带宽动态决定分片大小和并发数。逻辑不复杂网络延迟高就减小分片大小并降低并发防止超时带宽高就增大分片大小并提高并发充分利用吞吐量。进度的展示也要做两阶段分片上传阶段按“已完成分片数 / 总分片数”显示进度合并阶段单独显示“文件合并中”。如果不做区分用户看到进度条停在95%会非常焦虑。还有一个非常实用的小细节是给分片上传接口设置合理的超时时间。SpringBoot默认的Tomcat连接超时可能只有几秒到几十秒2MB分片在弱网下可能传输超过这个时间。如果超时设置不当分片上传会反复出现超时失败。生产环境一般建议把分片上传接口的读取超时设置到60秒以上同时结合前端重试机制基本能覆盖绝大多数弱网场景。分片上传整体做下来技术本身并不复杂真正的复杂度都在各种边界情况和体验细节里。从单体上传到分片上传最大的变化是把“一刀切”的思维切换成了“拆细、并发、可恢复”的思路这个思路同样适用于其他大文件传输场景。我自己的体会是做完这一套以后再遇到用户反馈上传慢、上传失败的问题心里就有底了排查方向也已经非常明确不会再像之前一样只能让用户换个网络重试。