☰
Spring Cloud大文件分片上传:从普通上传到断点续传的完整实践
2026/10/11 13:24:53 网站建设 项目流程

Spring Cloud 项目里做网页端上传,我的观点一直很明确:别等线上事故来教育,才去改分片上传。早几年我刚接手微服务平台时,负责传一个几十MB的附件,前端等超时、后端内存报警、Nginx还顺手扔回一个413,排查了大半天才发现限制不止一层。后来我把整条链路彻底改成“前端切片 + 服务端合片 + 存储层接管”的形态,几百MB到几个G的附件都能稳定传完。这篇就把完整思路、接口设计、服务端实现、前端细节和踩坑记录一次性写清楚。

1. 网页端上传大文件,问题到底出在哪

1.1 一条上传请求要穿过多少关卡

很多团队把“上传”想得太简单:浏览器选个文件,请求发出去,后端一收就完事。真实链路是浏览器到Nginx,Nginx转发到Spring Cloud Gateway,网关再路由到文件服务,文件服务解析Multipart请求,最后写磁盘或对象存储。这一路上每一关都有各自的限制。

Nginx默认的client_max_body_size是1MB,稍微大点的文件直接413。Spring Boot默认的spring.servlet.multipart.max-file-size也是1MB,超了会抛MaxUploadSizeExceededException。Spring Cloud Gateway内部使用Netty转发,虽然没有一个统一的“body大小上限”,但它转发时会缓冲请求体,大文件上来之后会把内存占满,GC都来不及回收。更隐蔽的是各种默认超时,比如网关转发、Tomcat连接超时、Read/Write Timeout,任何一个先到,上传就断。

还有一类问题容易被忽略:同步长连接占用。文件一次传几分钟,后端线程始终被这个请求占着。一旦有几个用户同时传大文件,线程池和连接池很容易被打满,正常小请求也跟着遭殃。所以“文件传不上去”从来不是某一层的问题,而是整条链路一次性被压垮。

1.2 直接调大限制不是长久之计

我遇到过的最偷懒的办法,是所有人把Nginx、Spring、Gateway的限制全部改成无上限,然后祈祷服务器别挂。小团队内部用用还行,只要用户量上来,内存、带宽、线程、超时这些问题会同时爆发。更难受的是没有进度条,传了一半断掉就得从头再来,用户心态直接崩。

所以单次上传大文件这件事,本质上不是“让请求体变大一点”,而是“把一个巨大请求拆成一堆小请求”。这样才能主动控制单请求大小、单线程占用时长、失败重试范围,也才能做出进度和续传功能。下面这套方案就是围绕这个核心思路展开的。

2. 核心方案:前端分片、服务端合片、存储接管

2.1 三句话讲清楚整体设计

第一句话:前端把文件按固定大小切成多个分片,每个分片是一个独立上传请求,上传完成后再通知后端合并成原文件。第二句话:如果后端用的是对象存储,分片甚至可以不经过业务服务器,由前端直接上传到对象存储,后端只负责管理状态和触发合并。第三句话:每个分片都带文件唯一标识和分片序号,后端记录“已经收到哪些分片”,这样断点续传和秒传就都顺理成章了。

这套架构隔离了“大文件”和“微服务请求”之间的矛盾。业务网关不再需要背负一个几百MB的请求体,单个分片只有几MB到十几MB,Nginx、Spring的限制也基本不用动。

2.2 分片大小怎么定

分片大小没有标准答案,但一般建议在5MB到20MB之间。分片太小,比如1MB,一个5GB文件要拆5000片,HTTP请求本身的开销会非常明显,服务端接收和记录状态的次数也成倍增加。分片太大,比如100MB,又回到了接近单次上传的老路,一旦网络抖动,重试时间还是很久。

具体取值要看场景。移动端、弱网环境建议2MB到5MB;内网或者带宽充足的Web端,10MB到20MB体验更好。我做内部管理系统时常用10MB,分片数等于文件大小除以10MB向上取整,最后一片可能不足10MB,这是正常的。总之,定一个“单分片在正常情况下能在几十秒内传完”的大小,体验会比较稳。

2.3 标准接口怎么设计

上传服务不管内部实现多复杂,外部暴露的接口最好固定成几个:

  • 初始化上传:POST /upload/init,传入文件名、总大小、分片大小、文件业务指纹,返回uploadId。
  • 上传分片:POST /upload/part,传uploadId、chunkIndex和分片二进制数据,返回该分片是否成功。
  • 查询进度:GET /upload/status/{uploadId},返回已上传的分片序号列表,前端据此续传或计算进度。
  • 合并文件:POST /upload/merge,传入uploadId、总大小、总片数,服务端核对分片完整性后执行合并。

uploadId是一次上传会话的标识,所有分片都挂在它下面。服务端可以用Redis保存这个会话的元数据和已上传分片集合。每次前端重新打开页面,如果同一个文件的指纹没变,就可以通过查uploadId或查文件指纹状态,把所有已经传过的分片跳过去,这就是断点续传的基础。

3. 服务端实现:Spring Cloud里怎么落地

3.1 存储层怎么选

我把存储方案分成三类,大家可以根据自己团队资源来选。

第一类是本地磁盘。优点是部署简单,直接写服务器目录,适合内部工具和小并发系统。缺点是扩展性差,多实例部署时分片可能分散在不同机器上,合并变得麻烦,而且磁盘损坏数据就没了。

第二类是分布式文件系统或NAS。适合有运维团队、需要多实例共享存储的场景。但合并大文件时的性能和存储成本要提前评估。

第三类是对象存储,尤其是兼容S3协议的系统,比如MinIO或云供应商的对象存储。这是我最推荐的方案。对象存储原生支持Multipart Upload,前端或后端先创建Multipart Upload,然后逐片上传,最后调用CompleteMultipartUpload合并,整个过程不需要业务服务器处理文件内容。

维度本地磁盘传统NAS兼容S3的对象存储
部署成本低中中高(可自建MinIO)
多实例共享困难支持支持
分片合并能力需要自己写需要自己写原生API
数据可靠性低中高
后续扩展弱中强(CDN、生命周期)

如果项目自建,我更倾向直接上MinIO,它能提供一个和S3高度兼容的接口,以后迁移云对象存储或者换成其他实现,代码改动都很小。

3.2 用自己的Spring Boot服务也能做分片合并

很多团队在开始阶段不一定有条件引入对象存储,那就在Spring Boot里自己实现。实现思路很直接:每个分片先单独落盘,合并时再按序号顺序写进最终文件。这里有个关键经验:千万不要用“每次上传分片时直接往目标文件追加”的方式。前端并发上传时,到达顺序是乱的,你根本不知道chunk_3是不是比chunk_1先到,一次追加写错,整个文件就废了。

更稳的做法是每个分片独立保存。比如上传目录下按uploadId建目录,分片文件命名为chunk_0、chunk_1、chunk_2。合并时再按序号顺序读取并写入最终文件,顺序完全可控。核心代码大概长这样:

@PostMapping("/upload/part") public Result uploadPart(@RequestParam String uploadId, @RequestParam Integer chunkIndex, @RequestParam MultipartFile file) { File dir = new File(storageDir, uploadId); if (!dir.exists()) { dir.mkdirs(); } File chunkFile = new File(dir, "chunk_" + chunkIndex); try (InputStream in = file.getInputStream(); FileOutputStream out = new FileOutputStream(chunkFile)) { in.transferTo(out); } stringRedisTemplate.opsForSet().add(partKey(uploadId), chunkIndex.toString()); return Result.ok(); }

合并接口稍微复杂一点。合并前先校验Redis里的分片数量是否等于totalChunk,不等于直接抛出“分片不完整”。合并时用普通文件流按顺序读写,避免一次性读入内存:

@PostMapping("/upload/merge") public Result merge(@RequestParam String uploadId, @RequestParam String fileName, @RequestParam Integer totalChunk) { File dir = new File(storageDir, uploadId); if (!dir.exists()) { throw new BadRequestException("uploadId不存在"); } Set<String> uploaded = stringRedisTemplate.opsForSet().members(partKey(uploadId)); if (uploaded == null || uploaded.size() != totalChunk) { throw new BadRequestException("分片不完整"); } File target = new File(finalStorageDir, fileName); try (FileOutputStream targetOut = new FileOutputStream(target)) { for (int i = 0; i < totalChunk; i++) { File chunkFile = new File(dir, "chunk_" + i); if (!chunkFile.exists()) { throw new BadRequestException("缺少分片: " + i); } try (FileInputStream in = new FileInputStream(chunkFile)) { in.transferTo(targetOut); } } } // 这里可以做最终MD5校验,然后异步清理临时目录和Redis key deleteQuietly(dir); stringRedisTemplate.delete(partKey(uploadId)); return Result.ok(); }

注意合并时文件名要防穿越,不能直接把用户传入的fileName拼到路径里,最好用UUID或业务ID重新命名,把用户原始文件名单独存到数据库。

3.3 分片元数据和状态怎么管理

分片状态我习惯用Redis来存。一个uploadId对应一个Set集合,集合里存已上传的分片序号。这样查询进度只需要求差集,前端把状态拿回去之后,直接从第一个缺失分片继续上传,逻辑非常清爽。

这中间要考虑几个问题。首先是过期时间。临时分片不能一直留着,Redis里的keys要设置TTL,我一般给两天,同时磁盘上对应目录也保留两天,由定时任务扫描清理。其次是幂等性。同一分片可能被前端重试上传多次,后端应该先判断这个分片是否已存在,存在就直接覆盖或跳过,不能报错。最后是并发合并保护。两个请求同时触发merge,可能会重复合并造成数据错乱,建议用Redis的分布式锁或数据库状态字段控制,同一个uploadId只允许一个merge任务执行。

用本地磁盘方案时,最终文件校验也值得做。分片全部传完后,可以按顺序流式计算整个文件的MD5,和前端初始化的文件指纹比对。不过几GB的文件全量计算MD5比较慢,稳妥做法是前端在init接口提交一个“总文件大小+首尾分片指纹+抽样片段指纹”组成的业务指纹,后端合并后只做抽查,减少性能压力。

3.4 网关这一层的实际处理

Spring Cloud Gateway在微服务架构里负责路由和鉴权,原则上传大文件尽量别走网关。最合理的方式是给文件服务单独开一个域名或路径,从Nginx直接转发到文件服务实例,不经过Gateway。如果公司安全规范要求必须经过网关,那就单独给文件服务建一个网关路由,并对该路由做特殊处理:关闭针对body的全局过滤器、调大转发超时、设置适当的Netty内存限制。

这里有个很容易踩的坑:Gateway默认会对请求做一些缓存和Rewrite处理,遇到超大请求体容易内存爆炸。不要指望靠一个“全局大文件上传过滤器”解决所有问题,把流量绕开才是正道。我自己被线上事故教育过之后,已经养成了习惯:凡是超过50MB的上传,一律走直连路径或对象存储预签名URL,业务网关只管普通API。

4. 前端实现与细节

4.1 上传流程串起来

前端流程可以拆成六步:

  1. 用户选择文件后,异步计算文件业务指纹。
  2. 调用/upload/status或直接调用/upload/init,后端返回是否需要续传。
  3. 如果文件指纹已存在且完整,直接秒传,返回文件URL。
  4. 否则按固定大小对文件切片。
  5. 使用并发控制上传缺失分片,并实时统计整体进度。
  6. 所有分片完成后再调merge接口,拿到最终结果。

断点续传的关键在于第2步。前端重新打开页面后,同样计算文件指纹,然后向后端查状态。只要指纹一致,后端就能返回现有的uploadId和上传进度,前端只需要把缺失的分片重新传一遍。

4.2 前端分片核心伪代码

原生JavaScript就可以实现分片。File对象继承自Blob,通过slice(start, end)能直接切出子文件,这比用canvas之类的方式简单得多。

const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB const file = selectedFile; const totalChunk = Math.ceil(file.size / CHUNK_SIZE); const uploadId = await initUpload({ fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE, fingerprint: fingerprint }); const uploadedSet = new Set(statusResult.uploadedChunks || []); const maxConcurrent = 3; let cursor = 0; async function worker() { while (cursor < totalChunk) { const current = cursor++; if (uploadedSet.has(String(current))) continue; const blob = file.slice(current * CHUNK_SIZE, (current + 1) * CHUNK_SIZE); const formData = new FormData(); formData.append('chunk', blob, 'blob'); formData.append('uploadId', uploadId); formData.append('chunkIndex', current); try { await axios.post('/upload/part', formData, { timeout: 5 * 60 * 1000, onUploadProgress: (e) => updatePartProgress(current, e) }); uploadedSet.add(String(current)); } catch (err) { // 记录失败分片,稍后统一重试 } } } await Promise.all(Array.from({ length: maxConcurrent }, worker)); await axios.post('/upload/merge', { uploadId, fileName: file.name, totalChunk });

这里要注意并发数。前端并发不要一下拉满几十个,因为浏览器和服务端连接数都是有限的,大量并发反而会导致网络拥塞和分片乱序。3到6个并发是比较合适的灰度值。

4.3 文件指纹和进度条

文件指纹用来实现秒传和断点续传。完全用MD5计算一个5GB文件,可能要几十秒甚至更久。为了不卡住页面,可以用Web Worker在后台线程计算。如果时间还是太长,可以退一步:抽取文件头部、尾部、中间几个固定位置的块,组合成“业务指纹”。这样速度很快,虽然理论上存在碰撞风险,但对绝大多数业务系统来说完全够用。后端合并完成后,仍然可以生成正式MD5存库,用于后续完整性校验。

进度条的计算也要小心。如果单纯用“已上传分片数/总分片数”会显得一顿一顿,尤其是每个分片正在上传时几乎看不到变化。更平滑的做法是给每个分片计算权重,当前正在上传的分片再叠加该分片内部的onUploadProgress百分比,最后除以总片数。这样进度条才会持续往前走,用户感知上更像一个成熟产品。

5. 常见问题与排查技巧实录

5.1 上传一直413怎么办

413最常见的两个点:Nginx和Spring Multipart。排查时先看Nginx access log里的响应码,如果返回413,基本就是client_max_body_size太小。别忘了修改后执行nginx -t和reload。如果用分片上传,Nginx限制只需要大于单个分片大小,比如分片10MB,Nginx可以设20MB或更大。

Spring层面如果报MaxUploadSizeExceededException,检查spring.servlet.multipart.max-file-size和max-request-size。因为分片请求的body基本等于分片大小+表单字段,所以给两者都设置为“单分片大小+几MB余量”最合理。不要把值设成无上限,留一个合理的上限能防止异常请求打垮服务。

5.2 合并后文件损坏

分片上传成功但合并后文件打不开,大概率是分片写入顺序或合并逻辑有问题。我踩过最典型的坑是先到先写:前端并发发出分片请求,后端用追加写模式往同一个文件里写,chunk_2可能比chunk_1早到,写出来文件顺序全乱。改成“每片独立文件 + 合并时按索引顺序读”后,这个问题再也没出现过。

另一个坑是前端在切最后一个分片时,没有正确计算结束位置。slice的结束位置不能等于文件末尾再加1,超出范围会导致最后一个分片大小异常,合并时多出垃圾字节。建议在debug环境下打印每个分片的blob.size,和后端实际接收大小对比一下。

5.3 断点续传怎么失效了

断点续传失效,一般不是后端没存状态,而是前端拿不到期望的上传会话。比如用户第一次上传后,隔了一天回来,Redis里的状态已经过期了;或者前端重新计算指纹时,因为算法不一致,生成的结果和第一次不一样,后端就当成新文件处理。

我的习惯是:前端把计算出来的指纹存在本地IndexedDB或LocalStorage里,再次选择同一个文件时,先用文件名和大小匹配本地记录,再向后端查询。如果后端返回状态过期,再重新初始化上传,并提示用户“部分分片可能已过期,将从断点后续传”。同时后端清理临时分片的TTL不要设太短,至少保留24到48小时。

5.4 合并几百MB时后端CPU和内存飙升

用自建服务合并大文件时,如果读取方式是byte[] allBytes = new byte[fileSize],内存必炸。合并的正确姿势是流式复制,也就是前面代码里的FileInputStream.transferTo(FileOutputStream)。JDK 9以上都可以用,底层会针对文件到文件的拷贝做优化,几GB文件合并时也不会有明显内存占用。

还有一个优化是异步合并。前端传完所有分片后,merge接口先把它当任务入库或入队,返回merging状态,后端后台线程慢慢合并。前端每隔几秒轮询一次状态,合并完成后通知业务方。这样即使合并耗时一分钟,用户请求也不会一直挂着,不至于触发网关超时。

6. 一个更省心的后续扩展

如果你的Spring Cloud项目已经跑了一段时间,我建议把上传能力再往“直传对象存储”方向迁移。核心变化是前端通过后端接口获取一个临时上传凭证,然后分片直接传到对象存储,后端只记录每个分片的完成状态,最终合并也由对象存储的API完成。这样最大的收益是,文件数据流完全不再经过应用服务器,带宽、内存、磁盘压力全部转移到存储层。

具体做法也不复杂:后端用MinIO SDK或S3 SDK创建MultipartUpload,然后把每个分片的预签名URL返回给前端,前端发PUT请求直传某一片。每传完一片,后端记录UploadPart返回的ETag;全部完成后,后端组装Part信息,调用CompleteMultipartUpload。这套改造对前端的影响很小,只需要把上一节的分片请求地址从后端接口换成预签名URL。

配合这个方案,还可以在初始化接口里统一做权限和配额检查:用户是否有上传权限、附件总大小是否超限、文件类型是否被允许。大文件传了一半才被拒绝是最劝退的体验,把校验前置到init阶段,既省流量又省存储。

最后再提一个被很多人忽略的点:分片上传不是银弹。如果文件只有5MB、10MB,直接在普通上传接口里把文件大小限制放宽就够了,没必要非得切片。只有当文件大到“单次请求会压垮某层中间件”或“用户无法忍受重传成本”时,才值得动用这套完整方案。看文件大小、看用户网络、看网关压力,再决定要不要上,别为了技术而技术。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询