SpringBoot大文件分片上传实战:从协议设计到并发优化

发布时间:2026/10/17 2:36:17
SpringBoot大文件分片上传实战:从协议设计到并发优化 1. 上传慢的瓶颈为什么整文件直传撑不住接到“上传文件慢”的反馈后我没有急着改代码而是先把整条链路看了一遍。浏览器把一个大文件直传到 SpringBoot 服务看起来只是“选文件、点上传”两步实际要过五关客户端网络、反向代理、应用容器、中间件、磁盘。任何一环成为瓶颈体现到用户那边都是同一个结果——慢或者干脆失败。1.1 单请求直传的四个致命约束第一个约束是请求体大小和超时时间。很多反向代理和应用容器默认只允许 1MB 到 10MB 的请求体超时时间默认 60 秒。100MB 文件传一半被断掉往往不是用户网络差而是代理层直接把连接掐了。我之前实际排查过一个内网项目带宽跑满能到 100MB/s但上传 200MB 文件总是报 413查到最后就是代理默认配置没调。第二个约束是内存。SpringBoot 接收MultipartFile时如果代码直接调file.getBytes()或把整个文件读进byte[]200MB 的文件会让堆内存出现一个 200MB 的峰值。并发上来以后GC 压力放大整个服务响应变慢这就是一条典型的连锁反应链路。实际生产中因为上传大文件把服务搞到频繁 Full GC 的例子不少见。第三个约束是断网重试成本。弱网环境下一旦连接断开整个文件必须重新传。用户的心理预期是“我已经传了 80%应该从 80% 接着传”但整包上传做不到。就算只在最后 1% 断了也得重头再来这个体验非常糟糕。第四个约束是没有精确进度和可恢复性。单文件直传时进度条基本是假的最多显示“传输中”无法精确到百分比。用户长时间看不到反馈第一反应是刷新页面或再点一次提交这反而造成重复上传请求进一步拖慢系统。1.2 哪些业务场景必须换成分片上传我自己的判断标准很简单文件超过 100MB或者使用场景明确包含弱网、移动网络、跨地域传输再或者上传中断的代价高到难以接受就值得上分片。反过来小于 10MB 的文件完全没必要分片分片本身会增加请求数量和服务端 IO反而变慢。分片上传的本质是“拆整为零、并行传输、最后拼接”。可以类比寄快递一整块又大又重的货物快递公司不会硬塞进一辆车而是拆成几箱分别运输到了目的地再组装。任何一个箱子丢了只需要补寄这一箱而不是把全部货物重新寄一遍。分片上传就是这个思路的数字化版本。SpringBoot 做分片上传核心是把“一个完整的 HTTP 请求承载一个文件”变成“多个 HTTP 请求各自承载文件的一小块”服务端接收后按顺序拼回去。这样一来一个分片失败只需要重传那一片带宽利用率也能通过并发提升整体的上传慢问题才算真正得到解决。2. 分片上传的核心设计与协议约定分片上传不是简单地“切几刀发几个请求”就能跑前后端需要约定一套协议。协议设计得不好后面合并、断点续传、校验都会很痛苦。我建议把上传流程拆成四个阶段初始化、分片上传、状态查询、合并完成每一个阶段对应一个接口。2.1 分片上传到底解决哪些核心痛点分片上传解决的问题有三个一是突破单请求大小限制不再依赖调整全局配置去容忍大文件二是把传输的“粒度”变小失败后重试的代价从分钟级变成秒级三是给进度计算和断点续传提供了数据基础前端能精确知道哪些分片已经上传成功。但分片上传也引入了新的复杂度。第一是分片文件怎么命名、怎么存、怎么合并第二是并发上传时服务端和前端都要控制节奏避免把磁盘 IO 打满第三是分片上传过程中可能出现重复、乱序、缺片需要一个校验机制兜底。这些复杂度都是可控的关键在协议设计时提前想清楚。2.2 接口协议初始化、上传、合并、状态查询我常用的接口协议是四段式。第一段是初始化接口前端先带上文件名、文件大小、分片大小等信息请求服务端。服务端生成一个全局唯一的uploadId创建好对应的临时目录并把该任务已存在的分片编号返回给前端。第二段是分片上传接口前端把某个分片通过multipart/form-data提交上来带上uploadId、分片索引、总分片数等信息。服务端只做一件事把这个分片写入uploadId对应的目录文件名就是分片索引。第三段是状态查询接口前端通过uploadId查询已上传分片列表用于断点续传时跳过已上传的切片。第四段是合并接口当前端确认所有分片都传完后调用合并接口。服务端把所有分片按索引顺序拼接成完整文件然后做大小和完整性校验最后清理临时目录。前后端只需要围绕uploadId和分片索引这两个核心标识来通信。uploadId标识“这一次上传任务”分片索引标识“这个任务里的第几块”。2.3 分片大小和并发数怎么定分片大小没有一个绝对标准需要看文件大小和网络情况。我常用的经验值是 5MB 到 10MB。100MB 文件切 5MB 一片一共 20 片请求数可控弱网环境下5MB 的重传成本也比 50MB 低得多。分片太小比如 512KB会让请求数暴增100MB 文件要发 200 个请求服务端每个请求都有 IO 开销和响应延迟整体反而慢。并发数方面浏览器对同一域名的并发连接数有限制HTTP/1.1 下通常会限制在 6 个左右。所以前端并发数设在 3 到 5 比较合理。并发太高服务端磁盘随机写增多上传速度不会线性提升反而可能把服务拖垮并发太低带宽利用不起来在大文件场景下偏慢。从计算角度举个例子一个 200MB 文件分片大小 5MB共 40 片。并发 4 的时候最理想情况只要 10 个“轮次”就能传完每轮传输量 20MB。相比一次性传 200MB单次请求的数据量降低了 40 倍单请求超时风险显著下降。3. SpringBoot 后端实现从接口到文件落盘后端部分我用最常规的 SpringBoot 工程来演示不引入额外中间件。存储先用本地磁盘目录因为分片上传的核心逻辑和存储介质无关把本地目录换成云存储的 SDK 代码思路是一样的。3.1 工程初始化与存储目录规划创建一个标准 SpringBoot 项目引入spring-boot-starter-web就行。文件上传不需要额外依赖文件复制用 JDK 自带的java.nio通道完成。存储目录我建议不要放在src/main/resources或者 classpath 下面而是放到应用启动时配置的独立目录比如/data/upload。目录结构按uploadId分文件夹每个分片命名为0.part、1.part这样的索引名。spring: servlet: multipart: max-file-size: 10MB max-request-size: 100MB这里有一个容易踩的坑max-file-size是单个文件的大小限制但分片上传每个请求里的文件只是一个分片所以这个值要设置成“允许单个分片的最大大小”而不是整个文件的大小。比如分片是 5MB设置 10MB 就够留点余量就行。3.2 初始化接口创建上传任务初始化接口接收文件名、文件大小、分片大小返回uploadId和已上传分片编号列表。已上传分片列表来自扫描临时目录下的.part文件这样即使前端刷新页面也可以恢复进度。RestController RequestMapping(/upload) public class UploadController { private final UploadService uploadService; public UploadController(UploadService uploadService) { this.uploadService uploadService; } PostMapping(/init) public ResultUploadInitVO init(RequestBody UploadInitDTO dto) { return Result.ok(uploadService.init(dto)); } }Service public class UploadService { private final Path storageRoot Path.of(/data/upload); public UploadInitVO init(UploadInitDTO dto) { String uploadId UUID.randomUUID().toString().replace(-, ); Path dir storageRoot.resolve(uploadId); try { Files.createDirectories(dir); } catch (IOException e) { throw new UploadException(创建上传目录失败); } UploadInitVO vo new UploadInitVO(); vo.setUploadId(uploadId); vo.setChunkSize(dto.getChunkSize()); vo.setUploadedChunks(listUploadedChunks(dir)); return vo; } private ListInteger listUploadedChunks(Path dir) { try (StreamPath stream Files.list(dir)) { return stream .map(p - p.getFileName().toString()) .filter(name - name.endsWith(.part)) .map(name - Integer.parseInt(name.substring(0, name.lastIndexOf(.)))) .sorted() .collect(Collectors.toList()); } catch (IOException e) { return List.of(); } } }初始化这一步看起来简单其实是整套流程的地基。uploadId必须全局唯一否则两个用户传同一个文件名会互相覆盖。临时目录创建后后面所有分片都往这个目录里写合并完成后再整体删掉。3.3 分片上传与断点续传接口分片上传接口是调用最频繁的接口性能直接决定上传速度。每个分片落盘时MultipartFile.transferTo是最省事的方法底层会帮你处理流复制。文件命名直接使用分片索引比如3.part同一个索引重复上传时直接覆盖天然具备幂等性。PostMapping(/chunk) public ResultVoid uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(index) int index, RequestParam(fileName) String fileName, RequestPart(file) MultipartFile file) { uploadService.saveChunk(uploadId, index, file); return Result.ok(); }public void saveChunk(String uploadId, int index, MultipartFile file) { Path dir storageRoot.resolve(uploadId); // 防御防止路径穿越确保目录在允许的根目录下 if (!dir.toAbsolutePath().normalize().startsWith(storageRoot.toAbsolutePath().normalize())) { throw new UploadException(非法上传路径); } try { file.transferTo(dir.resolve(index .part).toFile()); } catch (IOException e) { throw new UploadException(分片写入失败); } }断点续传的关键就是前端拿到init接口返回的uploadedChunks列表后把已存在的分片跳过。如果某个分片在传输中丢失前端只需要重新上传那一个索引即可。服务端无状态不需要维护额外会话。注意transferTo的行为在有些环境下如果目标文件已存在会直接覆盖如果并发写同一个索引会出现覆盖竞争。前端控制好同一分片只发一次后端不需要加锁因为分片文件都是独立文件。3.4 合并分片顺序拼装与校验合并接口是分片上传的最后一步也是出错率最高的一步。我的实现思路是先检查分片数量和大小再按索引顺序把每个.part文件追加写入目标文件最后删除临时目录。PostMapping(/merge) public ResultMergeVO merge(RequestBody MergeDTO dto) { return Result.ok(uploadService.merge(dto)); }public MergeVO merge(MergeDTO dto) { Path dir storageRoot.resolve(dto.getUploadId()); if (!Files.isDirectory(dir)) { throw new UploadException(上传任务不存在); } long partCount 0; try (StreamPath stream Files.list(dir)) { partCount stream.filter(p - p.getFileName().toString().endsWith(.part)).count(); } catch (IOException e) { throw new UploadException(读取分片目录失败); } if (partCount ! dto.getTotalChunks()) { throw new UploadException(分片不完整期望 dto.getTotalChunks() 实际 partCount); } String safeFileName generateSafeFileName(dto.getFileName()); Path target storageRoot.resolve(dto.getUploadId() - safeFileName); try (FileChannel out FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i dto.getTotalChunks(); i) { Path part dir.resolve(i .part); try (FileChannel in FileChannel.open(part, StandardOpenOption.READ)) { long position 0; while (position in.size()) { position in.transferTo(position, in.size() - position, out); } } } } catch (IOException e) { throw new UploadException(合并文件失败); } deleteDirectory(dir); return new MergeVO(target.toString()); }这里有一个非常关键的细节FileChannel.transferTo在某些平台和某些文件系统下单次复制的数据量可能有限制不能保证一次调用就把所有字节复制完。所以要写一个循环不断把当前复制位置往前推进直到整个分片都写完。这个坑不熟的人很容易踩结果就是合并出来的文件大小不对。另外建议在合并前把每个分片的大小记录下来比如前端上传分片时带上每个分片的实际字节数服务端校验一下对发现问题文件很有帮助。4. 前端分片实现原生代码搞定切片与并发后端接口就绪后前端要做的就是切片、并发、重试。为了不依赖特定框架我用原生 JavaScript 配合浏览器FileAPI 实现。这套逻辑可以方便地移植到 Vue 或 React 项目中。4.1 用 File.slice 划分切片浏览器拿到用户选择的File对象后可以用File.prototype.slice方法按字节切块不需要读取完整文件内容不占内存。切出来的每个Blob就是分片直接放进FormData就可以上传。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function createChunks(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push(file.slice(start, end)); start end; } return chunks; }切完之后chunks.length就是总分片数每个分片在数组里的下标就是分片索引。上传前先调用初始化接口拿到uploadId然后把每个分片和uploadId、索引、总分片数一起POST到后端。4.2 并发上传与进度统计并发控制在 JavaScript 里用固定数量 worker 的模式很清晰。创建几个异步任务每个任务不断从分片队列里取下一个分片上传直到队列清空。这样做可以精确控制同时进行的请求数避免把浏览器和服务端同时压垮。const CONCURRENCY 4; let finished 0; async function worker(uploadId, chunks, uploadedSet) { while (index chunks.length) { const i index; if (uploadedSet.has(i)) { finished; continue; } await uploadChunk(uploadId, i, chunks[i]); finished; onProgress(finished, chunks.length); } } function startUpload(file, uploadId, uploadedSet) { const chunks createChunks(file); index 0; finished 0; const workers Array.from({ length: CONCURRENCY }, () worker(uploadId, chunks, uploadedSet) ); return Promise.all(workers); } function uploadChunk(uploadId, index, chunk) { const form new FormData(); form.append(uploadId, uploadId); form.append(index, index); form.append(file, chunk); return fetch(/upload/chunk, { method: POST, body: form, }).then((res) { if (!res.ok) throw new Error(分片上传失败); }); }整体进度可以直接用“已成功分片数 / 总分片数”计算不需要关注每个分片内部传输了多少字节。上传大文件时这个百分比比传统的“已传字节数”可靠得多因为单个分片本身很小分片级别的完成度对用户来说更直观。4.3 失败重试与断点续传的交互细节真实环境里分片请求一定会失败。超时、网络闪断、服务端偶发错误都可能发生。我的建议是每个分片失败后先不退出而是记录失败索引最多重试 3 次如果重试后仍然失败再提示用户手动重试。断点续传的实现依赖两个信息uploadId和uploadedChunks。用户刷新页面或重新打开页面时先把uploadId存到localStorage用文件名和文件大小作为 key。下次选择同一个文件时检查本地记录调用初始化接口获取已上传列表就能跳过已完成的切片只传缺的那几片。这里要注意一个体验细节合并接口不能由每个分片独立触发必须等所有分片都成功后统一调用。我在前端用一个Promise.all等待并发任务全部完成再拉取最新的已上传列表做一次校验最后调合并接口。如果合并失败前端要保留分片数据允许用户在修复问题后再次合并而不是重新传整个文件。5. 实战中的坑与排查实录分片上传的原理不复杂但一进到生产环境各种边缘问题就冒出来了。这一节我把自己和身边同事踩过的坑整理成清单方便你对照排查。5.1 分片顺序错乱或重复上传分片是并发上传的到达服务端的顺序本身是乱的。后端的合并逻辑必须依赖“分片索引 排序”而不是依赖“先到先得”。我的做法是文件名直接用索引合并时按索引从 0 到 N 顺序读取不受上传到达顺序影响。重复上传通常来自前端重试。同一个分片重新提交时服务端直接覆盖原来的.part文件理论上没问题。但要注意文件写入的原子性如果transferTo还没写完另一个请求又覆盖同一个文件可能出现文件内容交叉。最简单的规避方法是前端保证同一个分片同时只允许一个上传请求不并行重试后端可以在接收分片时先写一个临时随机文件名写成功后再改名成index.part。5.2 合并出来的文件打不开或 MD5 对不上这是反馈最密集的问题。合并后的文件打不开常见原因有三个。第一个是漏片。前端以为传完了实际有分片因网络问题失败后没重试成功但合并接口刚好在总数对得上的情况下执行了。我的解决办法是后端合并前不仅校验分片数量还校验每个分片是否存在且大于 0 字节。第二个是顺序错误。如果合并时没有按索引排序而是按文件系统返回的目录顺序拼接结果必然是乱序。MD5 校验可以快速发现这个问题。第三个是文件内容被截断。transferTo如果不做循环复制单次调用可能只复制部分数据合并出的文件刚好少了尾部若干字节。这个问题隐蔽性强表面看能打开文件但校验值和原始文件不一致。排查这些问题的通用手段是上传完成后让服务端计算合并文件的 MD5前端计算原始文件的 MD5两者一致才返回成功。不一致时记录日志并自动清理重传避免脏文件流到下游。5.3 上传依然很慢先查这五个地方分片上传都做了还是很慢就需要按顺序排查基础设施和配置了。第一看反向代理和应用容器对请求体大小和超时的限制。client_max_body_size、连接超时、读取超时是否都调大尤其是分片虽然小了但合并接口可能会长时间运行。第二看带宽利用率。用浏览器开发者工具观察同时活跃的请求数确认是否真的在并发上传还是串行执行。HTTP/1.1 下同域名并发连接数有限如果上传请求走了同一个域名且并发只有 1速度上不去很正常。第三看服务端磁盘写性能。机械磁盘在随机写入场景下性能很差分片并发写会放大这个问题。最快验证方法把并发数从 4 调到 1如果速度反而更稳定基本就是磁盘并发写瓶颈。第四看是否有额外的数据校验开销。比如服务端每收到一个分片就计算一次 MD5CPU 和磁盘读开销都会增加在分片数很大的时候影响明显。第五看前端是否等到每个分片完成后才发下一个。有些实现用了await串行上传进度虽然能显示但实际是串行的达不到加速目的。并发 worker 的数量要从代码层面确认。5.4 磁盘占用与临时文件清理分片上传过程中服务端磁盘占用会突然增加。文件越大分片目录越多磁盘占用越多。用户上传到一半关闭页面分片目录就一直滞留时间久了可能吃掉大量磁盘空间。我的建议是加一个定时清理任务定期扫描上传目录把最后修改时间超过 24 小时的临时目录删除。同时在上传初始化时记录任务创建时间在查询状态时如果发现任务已过期直接标记为失效。另外合并接口执行成功后要主动删除分片目录不能等清理任务来兜底。删除要用递归方式因为目录里还有大量.part文件直接删目录会失败。6. 比分片更关键的几个工程化建议分片上传跑通以后我强烈建议再做几件事否则这个功能在上线后容易变成新的故障点。6.1 文件安全别让上传口变成后门文件上传接口如果只做了分片逻辑、没做安全校验领导迟早找你喝茶。重点要控制四件事。一是文件类型。不能只看文件名后缀要用服务端解析的真实文件类型判断比如读文件头几个字节判断是否为图片或文档。PPT、exe、压缩包里的恶意文件这类场景要单独设计白名单。二是文件名。前端传入的fileName必须经过处理重命名为随机字符串或uploadId 时间戳不能带着原始文件名直接落盘。否则用户传一个../../etc/passwd之类的名字配合路径拼接就可能造成目录穿越。三是大小限制。分片上传容易绕过“单文件大小限制”因为每个分片都很小。必须在初始化接口就校验整个文件的总大小超过阈值直接拒绝创建任务。四是权限。上传接口要接上现有登录鉴权体系不能匿名可传。尤其在生产环境匿名上传等于给公网开放了一个免费的垃圾文件存储入口。6.2 合并性能与磁盘 IO 调优合并大文件时最怕的是把文件全部读进内存再写出来。200MB 文件可能还好2GB 文件就可能触发内存问题。用FileChannel按分片流式复制是最稳妥的方案内存占用基本恒定为几十 KB。如果并发上传的分片数很多可以给合并过程加一个轻量限流比如同一时间只允许一个合并任务在跑。做法很简单定义一个ReentrantLock或用Semaphore(1)包住合并方法。虽然牺牲了一点并发度但能防止多个大文件同时合并时把磁盘 IO 打满影响其他业务写入。再往深一点说还可以把分片文件的写入路径从普通目录换到内存盘或者性能更好的磁盘卷上传速度会有明显提升。但这种方案要结合服务器硬件条件不要为了追求极致性能盲目上先做好监控再说。6.3 向云存储和异步合并演进本地磁盘分片上传是一个很好的起点但业务量上来以后我更建议把分片直接转存到云存储服务让云存储自己去处理分片并发和最终合并。这种方案下SpringBoot 只负责生成上传凭证和回调校验大流量不经过应用服务器性能瓶颈就转移到了云存储侧。另一个演进方向是异步合并。分片全部上传完成后把合并请求放进消息队列由后台任务慢慢合并。用户界面先提示“上传完成文件处理中”合并完成后通过回调或轮询把最终结果返回。这样可以把计算峰值削掉服务器不会被瞬时合并压力卡住。异步合并要注意重复回调的问题。消息队列消费端要做幂等相同uploadId的合并任务只能执行一次否则可能出现同一文件被合并两遍、报文件已存在的错误。分片上传这套东西做完我最大的体会是不要一上来就追求最复杂的架构。先想清楚业务到底需要什么100MB 以下的文件把代理超时调整好、单请求配置调好可能根本不需要分片真要处理几百 MB 甚至几个 GB 的文件再把分片拆出来做细。比起各种花哨特性把分片校验、临时目录清理、文件安全这三件事做扎实这个功能才能真正扛得住线上流量。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询