资讯详情

Spring Cloud大文件分片上传:从普通上传到断点续传的完整实践

📅 2026/10/11 13:24:54 | 华诺云谱 👁 阅读
Spring Cloud大文件分片上传:从普通上传到断点续传的完整实践
Spring Cloud 项目里做网页端上传我的观点一直很明确别等线上事故来教育才去改分片上传。早几年我刚接手微服务平台时负责传一个几十MB的附件前端等超时、后端内存报警、Nginx还顺手扔回一个413排查了大半天才发现限制不止一层。后来我把整条链路彻底改成“前端切片 服务端合片 存储层接管”的形态几百MB到几个G的附件都能稳定传完。这篇就把完整思路、接口设计、服务端实现、前端细节和踩坑记录一次性写清楚。1. 网页端上传大文件问题到底出在哪1.1 一条上传请求要穿过多少关卡很多团队把“上传”想得太简单浏览器选个文件请求发出去后端一收就完事。真实链路是浏览器到NginxNginx转发到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到十几MBNginx、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不存在); } SetString 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 上传流程串起来前端流程可以拆成六步用户选择文件后异步计算文件业务指纹。调用/upload/status或直接调用/upload/init后端返回是否需要续传。如果文件指纹已存在且完整直接秒传返回文件URL。否则按固定大小对文件切片。使用并发控制上传缺失分片并实时统计整体进度。所有分片完成后再调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限制只需要大于单个分片大小比如分片10MBNginx可以设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直接在普通上传接口里把文件大小限制放宽就够了没必要非得切片。只有当文件大到“单次请求会压垮某层中间件”或“用户无法忍受重传成本”时才值得动用这套完整方案。看文件大小、看用户网络、看网关压力再决定要不要上别为了技术而技术。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑