☰
SpringMVC大文件上传下载流式处理与内存优化实战
2026/10/19 16:40:59 网站建设 项目流程

1. 为什么百M大文件一到SpringMVC手里就"死"给你看

先说一个我经常在项目里见到的场景:某系统上线半年都好好的,突然某天用户上传一个120MB的设计稿压缩包,前端转了半天圈,后端直接报OutOfMemoryError,Tomcat挂掉,全员遭殃。排查日志时代码写得很干净——@RequestParam("file") MultipartFile file,然后file.getBytes()交给后续处理。问题恰恰出在这条"人畜无害"的代码链上。

SpringMVC本身只是门面,底层走的还是Servlet规范。默认情况下,一个MultipartFile对象会先把整个请求体吃进内存,内存里放不下再往临时目录刷。具体表现由两个参数决定:file-size-threshold是内存阈值,默认0,意味着只要超过0字节就直接落盘;max-file-size限制单文件大小,Spring Boot默认1MB,Spring MVC纯XML配置则取决于Servlet容器。很多项目中这两个参数被调大之后(比如max-file-size设成500MB),看似能传了,但是getBytes()这个方法会强行把整个文件再拉回内存。500MB打进JVM堆里,GC直接进入"生无可恋"模式,吞吐量掉到冰点,极端情况下就是OOM。

下载方向也没好到哪去。很多人习惯写ResponseEntity<byte[]>,内部再调FileUtils.readFileToByteArray或者Files.readAllBytes,逻辑上爽快,可文件一到百M级别,这一行代码就是一颗炸弹:它要先创建一个和文件等大的byte数组,把整个文件读进堆内存,再交给Spring框架输出。100MB的文件至少吃掉200MB以上的内存(byte数组本身+拷贝过程中的临时对象),QPS稍微一上来,堆直接爆炸。

还有一类问题是隐形的——超时。客户端上传100MB文件走普通宽带,快的时候几秒钟,慢的时候(移动网络、跨地域传输)轻松超过60秒。Tomcat的connectionTimeout默认20秒,Nginx的client_body_timeout默认60秒,任何一个超时都会让连接被中间层切断。前端看是"上传失败请重试",后端日志什么都没打,因为请求根本没完全进来。这是大附件场景里最容易被忽视的"三不管"地带:不怪SpringMVC,不怪代码,怪你没有看清整个链路里每一层的超时和大小限制。

所以大附件处理的本质,不是"让SpringMVC能接收大文件",而是"绕开SpringMVC默认的舒适区,用Servlet规范提供的流式能力去手动接管数据通道"。理解这一点,后面所有的改造就顺理成章了。

2. 上传改造:从"饭来张口"到"边接边存"

2.1 核心思路:把MultipartFile当接口看,别当保险箱

MultipartFile提供了三个常用获取数据的方式:getBytes()、getInputStream()、transferTo(File dest)。绝大多数人只认识getBytes(),然后踩坑;transferTo()其实已经是框架级优化,底层会把临时文件直接rename或者用流复制,比getBytes()内存友好很多,但它在某些容器和相对路径组合下有个著名坑:transferTo的javadoc写的是"把接收到的文件转移到目标文件",但如果你传的是相对路径,它会相对于临时目录去解析,而不是当前工作目录。也就是说,你代码里写file.transferTo(new File("upload/abc.zip")),实际文件可能被挪到/tmp/upload/abc.zip,因为Tomcat的临时目录是/tmp。这个坑我在某跨平台系统项目里踩过一次,排查了半天,最后发现文件都在/tmp下躺着。

最稳妥的方案:不要依赖MultipartFile的便捷方法,直接从HttpServletRequest拿Part,再从Part拿InputStream,自己用缓冲区手动写文件。这样内存占用由你控制,文件路径也完全由你说了算,还顺手解决了transferTo相对路径的坑。

2.2 配置层:该放开的放开,该守住的守住

无论你用Spring Boot的application.yml还是传统web.xml,都要做两层配置。

Spring Boot场景,application.yml里加上:

spring: servlet: multipart: enabled: true max-file-size: 1024MB max-request-size: 1024MB file-size-threshold: 2MB

纯XML场景,在web.xml的servlet配置里加:

<multipart-config> <max-file-size>1073741824</max-file-size> <max-request-size>1073741824</max-request-size> <file-size-threshold>2097152</file-size-threshold> </multipart-config>

注意一个容易被忽略的逻辑:max-file-size是单个文件上限,max-request-size是整个请求体的上限。如果你的接口允许一次传多个文件,max-request-size必须比max-file-size大,否则传两个60MB文件的时候请求会被拦截,但日志里你只能看到令人绝望的"the request was rejected because its size exceeds the configured maximum"。

file-size-threshold我习惯设成2MB或4MB。这个值决定文件在内存里累积到多少之后再落盘临时文件。设大了一点(比如32MB)在文件都不大的场景能提升性能,但百M场景千万别设太大,不然并发一上来,每个上传请求都先在堆里攒32MB,GC先受不了。

2.3 代码层:手动接管InputStream,落盘可控可查

配置完成后,Controller可以写成这样:

@PostMapping("/upload") public String upload(HttpServletRequest request) throws Exception { String fileName = null; // 注意:如果表单里mixed了多个文件字段,需要遍历request.getParts() for (Part part : request.getParts()) { String submittedFileName = part.getSubmittedFileName(); if (submittedFileName == null || submittedFileName.isEmpty()) { // 非文件字段(普通表单字段) continue; } // 处理文件名,防路径穿越:只保留文件名部分 String safeName = new File(submittedFileName).getName(); fileName = UUID.randomUUID().toString() + "_" + safeName; File dir = new File("/data/upload"); if (!dir.exists()) { dir.mkdirs(); } File target = new File(dir, fileName); try (InputStream in = part.getInputStream(); FileOutputStream out = new FileOutputStream(target)) { byte[] buffer = new byte[64 * 1024]; int len; long total = 0; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); total += len; } // 如果后续需要记录大小,total就是文件的真实字节数 } } return fileName; }

这里有两个值得说道的点。

第一,为什么缓冲区选64KB而不是8KB?8KB是很多教程里默认的StreamingOutput缓冲区,但实测下来,在百M文件场景中64KB到128KB的缓冲区能明显减少read和write系统调用的次数,磁盘顺序写的吞吐量会更高。太大也没必要,1MB的缓冲区在这种场景里收益微乎其微,反而白白占用内存。

第二,part.getSubmittedFileName()是Servlet 3.1才有的方法,它能直接从Part上拿到客户端上传的原始文件名。有些老项目用的Servlet 3.0,就只能手动解析part.getHeader("content-disposition")里的filename字段,那个解析代码写起来很烦,要注意引号和编码。

2.4 为什么这种写法更抗造:内存账算给你看

我拿一个100MB文件为例,对比两种写法的内存账。

传统写法:MultipartFile.getBytes()→ 第一步,容器把整个请求体接进来,超过file-size-threshold的部分已经落到临时文件;第二步,getBytes()把磁盘上的临时文件整个读入堆内存,创建一个100MB的byte数组;第三步,后续处理(比如计算MD5)再创建一份等大数组。高峰期3个并发上传,光是上传逻辑就吃掉600MB堆。

流式写法:不管请求体多大,内存里始终只有64KB缓冲区加少量元数据。磁盘上临时文件在写入过程中确实会占100MB空间,但那不占JVM堆。3个并发上传,堆内存占用几乎可以忽略,吃的是磁盘I/O。

这就是"边接边存"和"先收完再处理"的本质区别。数据流到了就写盘,写完就释放,内存永远有边界。

3. 下载改造:别把整个文件扛进堆里再发出去

3.1 两种流式输出的正确姿势

下载比上传简单一些,但踩坑的人同样不少。最典型的错误是用ResponseEntity<byte[]>返回文件内容,这个前面分析过内存账,就不再重复了。正确的思路是:让SpringMVC只负责把OutputStream交给Servlet容器,数据由你自己往流里灌,灌的过程中内存占用始终可控。

第一种姿势:直接用HttpServletResponse.getOutputStream()写。

@GetMapping("/download/{fileId}") public void download(@PathVariable String fileId, HttpServletResponse response) throws IOException { // 模拟:根据fileId找到磁盘上的文件 File file = findFileById(fileId); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + URLEncoder.encode(file.getName(), "UTF-8")); response.setContentLengthLong(file.length()); try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[64 * 1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); } }

注意几个细节:

  • setContentLengthLong不能用setContentLength,那个方法接收的是int,100MB以上的文件int装不下,会变成负数导致浏览器行为异常。
  • filename*=UTF-8''是RFC 5987的标准写法,用来处理中文文件名。URLEncoder.encode之后把空格变成+,这在Content-Disposition里其实不合规,更严谨的做法是手动把+替换成%20。实测主流浏览器都能正确处理,但如果你在旧版IE上测试,还是老老实实用filename加filename*双写兜底。

第二种姿势:用ResponseEntity<FileSystemResource>,让Spring帮你处理流。

@GetMapping("/download/{fileId}") public ResponseEntity<FileSystemResource> download(@PathVariable String fileId) { File file = findFileById(fileId); FileSystemResource resource = new FileSystemResource(file); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename*=UTF-8''" + URLEncoder.encode(file.getName(), "UTF-8")) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(file.length()) .body(resource); }

这种方式代码更简洁,FileSystemResource底层走的是InputStreamResource的流式逻辑,不会把文件装进内存,但前提是不要用byte[]或ByteArrayResource包装。很多人倒在这一步:觉得Resource就安全了,结果用ByteArrayResource包了一下,白瞎了流式设计。

3.2 零拷贝与sendfile:Tomcat帮你省的最后一道力

如果文件在本地磁盘且Tomcat配置得当,流式输出的最终形态是零拷贝。Servlet容器在输出响应时,如果识别到你的OutputStream是原生的ServletOutputStream且未被包装,底层能触发sendfile系统调用,让数据直接从磁盘文件发送到网卡,完全绕过JVM堆和用户态缓冲区。这意味着:下载100MB文件时,JVM堆内存几乎零消耗,连那64KB缓冲区都快省了。

触发sendfile的前提条件比较苛刻:响应内容不能经过压缩过滤器的处理。一旦你配置了CompressionFilter或者在代码里直接对OutputStream做了包装,sendfile就会失效,退回普通拷贝。所以压测下载接口时,要特别留意是不是有全局压缩过滤器在暗地里"帮你"把性能拉低。如果你用的是Nginx做前置代理,Nginx的sendfile on也要开着,否则到Nginx这一层又会复制一遍。

3.3 大文件下载的Range支持:该不该做

我的建议:如果有断点续传或分片下载需求,Range支持值得做;如果只是给浏览器一个完整的"另存为",可以先不做,但要预留好。Range请求的本质是客户端带着Range: bytes=0-1048575来访问,服务端返回206 Partial Content和指定区间的字节。实现它不需要引入新的依赖,解析请求头然后调整InputStream的起点即可。

一个基础版本的做法是:

String rangeHeader = request.getHeader("Range"); if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { long start = Long.parseLong(rangeHeader.substring(6).split("-")[0]); long end = Math.min(start + 1024 * 1024 - 1, file.length() - 1); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + file.length()); // 设置Accept-Ranges: bytes,并跳过inputStream的前start个字节 }

这里有个小坑:skip方法在FileInputStream上返回的是实际跳过的字节数,有些实现(比如BufferedInputStream)在skip超过一定值时可能不准,稳妥做法是循环调用skip直到累计跳过指定长度。另外,Range解析要容错,遇到bytes=abc这种情况直接返回416 Range Not Satisfiable,否则客户端会莫名卡死。

4. 链路排障:Nginx、Tomcat、临时目录、网络超时一处都不能漏

4.1 前置Nginx的两行配置定生死

很多系统上传下载走的是浏览器 → Nginx → Tomcat 的链路。Nginx默认client_max_body_size只有1MB,你没改的话,前端传一个2MB的文件就会收到413 Request Entity Too Large,而且Tomcat日志里干干净净,因为请求根本就没到后端。

需要调整的配置集中在http块或server块:

client_max_body_size 1024m; client_body_buffer_size 128k; client_body_temp_path /tmp/nginx_body_temp; client_body_timeout 120s;

client_body_buffer_size是Nginx接收请求体时在内存里的缓冲区,超过就落盘到client_body_temp_path指定的临时目录。这个值不用设太大,128k就够,落盘也不慢。真正要留意的是临时目录的剩余磁盘空间:如果系统盘只有8G,用户连续传几个大文件,Nginx会先把请求体在临时目录写好,再转发给后端,磁盘一旦写满,报错会非常诡异——有时是500 Interal Server Error,有时是连接中断。client_body_timeout默认60秒,如果用户上传很慢(比如2G文件传到一半卡住),达到这个值Nginx会断开连接。

4.2 Tomcat侧配置与maxSwallowSize这个隐藏刺客

Tomcat侧需要开的配置主要有两个。第一是连接器本身的参数,在server.xml的<Connector>里:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="120000" maxSwallowSize="2097152" maxPostSize="10485760"/>

connectionTimeout从默认的20秒调到120秒,给大文件上传留出余量。maxSwallowSize这个参数非常隐蔽,它的作用是:当客户端上传请求已被处理完(比如代码提前抛异常返回了),Tomcat会尝试把剩余的数据"吞掉"以复用连接。默认值2MB,意味着如果请求体还剩超过2MB没读完,Tomcat会直接抛异常——你会在日志里看到类似More than the maximum number of request body bytes have been read的报错。解决方法是把这个值调大,比如-1表示不限制,或者直接设成和max-file-size一致。很多团队排查线上环境"上传大文件必现500,传小文件没问题",根因往往就是这个参数。

第二是Spring层的multipart配置,前面已经写过:max-file-size和max-request-size设成一致且足够大,file-size-threshold设成合理值。这里补充一个判断标准:max-request-size必须覆盖你的真实业务场景。假设接口最多同时接收5个200MB的文件,那max-request-size至少是1024MB。这个参数如果设小了,连到中间层Nginx都救不了你。

4.3 服务端临时目录:权限、空间、清理一个都不能少

上传过程中Servlet容器会把超过file-size-threshold的文件写入javax.servlet.context.tempdir指定的目录,通常是Tomcat的/temp或系统的/tmp。几个常见的生产事故根源:

  • /tmp目录权限不对,容器进程写不进去,上传直接报IOException: No space left on device——但这句话有时候是误导,实际可能是权限拒绝。
  • /tmp所在分区被其他应用写满,容器在写临时文件时IO异常。
  • 临时文件未及清理,长时间跑下来堆积了几十GB垃圾文件。

我的建议:如果你手动接管了InputStream,文件已经写到了自己的/data/upload目录,那javax.servlet.context.tempdir就只在"容器尚未交给你InputStream"之前短暂使用,量级很小,基本不用管。但如果你依赖了MultipartFile.transferTo(),这个临时目录的清理就很重要,因为transferTo在Tomcat上其实是"移动临时文件",移动失败时会回退到复制再删除,删除失败就会留下孤儿临时文件。系统卷空间告急时要留意这个目录。

4.4 并发上传时的Tunning:别让线程池成了瓶颈

耗尽内存的问题解决了,下一个瓶颈往往是Tomcat的线程池。默认配置下Tomcat最大线程数maxThreads是200,maxConnections稍大一些。大文件上传的特点是:单个请求占用时间长(几十秒甚至几分钟),一个线程在处理某个大文件时,这个线程无法服务其他请求。当同一个时刻有20个用户上传100MB文件,即使你的机器性能很好,Tomcat线程池也可能被打满,其他所有接口(包括查询列表这种轻接口)全部超时。

对应措施有两个方向:

一是把大文件上传接口和其他业务接口的线程池隔离。这种方法需要自定义ThreadPoolExecutor,并让Spring MVC用CompletableFuture或者TaskExecutor去异步处理文件落盘逻辑。Controller接收完请求后马上返回"文件上传中"状态,后端线程池慢慢处理。

二是把上传请求从业务Tomcat中剥离,直接由独立的文件服务处理。这不是SpringMVC范畴了,但很多成熟项目确实是这样做的:Nginx直接代理文件上传请求到专门的upload服务,业务API只负责生成凭证和查询状态。这个架构切分会带来明显收益,但初期改造量不小,不建议在文章里展开太多,知道这个方向即可。

5. 从百M到上G:分片、断点续传与秒传的落地思路

5.1 为什么百M靠流式,上了G就得换玩法

一百MB级别的文件,上面这些方案足够了:流式上传、流式下载、超时调优,性能瓶颈基本可控。但如果文件涨到1GB、2GB甚至更大,你马上会碰到新的三堵墙:

  • 网络中断:一个1GB文件传到800MB,Wi-Fi闪断,整个上传作废,用户心态直接爆炸。这个场景里"能传"已经不稀罕,"能续传"才是刚需。
  • 体验问题:单次大文件上传没有进度条概念没法看,体验很差,用户根本不知道传到哪了。
  • 服务端压力:所有大文件同时涌入,临时空间瞬间吃紧,合并操作对磁盘I/O的冲击非常大。

这个阶段的核心思路是分片上传:前端把大文件按固定大小切成若干片,一片一片独立上传,每片上传成功后后端记录状态;全部传完后,后端按顺序合并。分片理论上有两重收益:一是单片失败只需重传这一片而不是整个文件,天然抗网络抖动;二是单片大小可控(比如5MB),服务端每次只需要处理5MB的请求,内存压力和超时风险大大降低。

5.2 分片上传的后端设计:从搭积木到拼积木

后端分片接口的设计模式并不复杂,核心是三个接口:

// 1. 初始化上传会话:前端拿到uploadId POST /upload/init // 返回: { uploadId: "uuid", chunkSize: 5242880, totalChunks: 200 } // 2. 上传单片数据 POST /upload/chunk // 参数: uploadId, index, totalChunks, data // 后端把data写入 ${uploadId}/${index}.part // 3. 合并分片 POST /upload/merge // 参数: uploadId, fileName // 后端把 ${uploadId} 目录下所有part按index排序合并成最终文件

合并分片的代码可以参考这样写,利用FileChannel.transferTo做零拷贝合并:

File dir = new File(uploadDir, uploadId); File[] parts = dir.listFiles((d, name) -> name.endsWith(".part")); Arrays.sort(parts, Comparator.comparingInt(f -> Integer.parseInt(f.getName().replace(".part", "")))); File merged = new File(uploadDir, finalFileName); try (FileChannel out = new FileChannel.open(merged.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (File part : parts) { try (FileChannel in = FileChannel.open(part.toPath(), StandardOpenOption.READ)) { in.transferTo(0, in.size(), out); } } } // 合并完成后删除分片目录 FileUtils.deleteDirectory(dir);

有个细节要注意:FileChannel.transferTo在合并大文件时确实高效,但它是站在"操作系统帮你搬运"的视角,如果跨文件系统(比如NFS挂载)可能会静默失败返回部分长度,稳妥做法是写个循环判断transferred < remaining。

分片大小怎么定?如果内网部署,50MB一片没有问题;如果是公网环境弱网用户,5MB左右比较稳妥。前端用File.slice(start, end)切出来的分片天然带起始偏移,后端按index存就行,不需要重复校验文件内容偏移——index已经够了。

5.3 秒传与断点续传:能用MD5解决的不必上重型方案

"秒传"听起来玄乎,原理很简单:文件上传前,前端先把文件做一个MD5摘要,后端拿到这个摘要去文件存储表里查,如果发现相同摘要且大小一致的文件,直接返回"上传成功"并关联到已有文件记录,不需要真的传输。

一个基础做法是:

@PostMapping("/upload/check") public UploadCheckResult check(@RequestBody CheckRequest req) { File existing = findByMd5AndSize(req.md5, req.size); if (existing != null) { // 秒传:直接把用户文件记录关联到existing return UploadCheckResult.existed(existing.id); } return UploadCheckResult.notExisted(); }

注意MD5对大文件来说计算耗时不小,前端在弱设备上算一个1GB文件的MD5可能要几十秒。替代方案是先按文件大小和文件头/尾的片断做初筛,命中后再算全量MD5。

断点续传在分片体系里天然存在:每片上传成功后,前端记录已传分片索引,重连后只需重新上传未成功的片。服务端也可以提供一个"查询已上传分片"的接口,让前端在断网恢复后主动同步状态,避免盲目重传。

5.4 何时该上对象存储,而不是继续堆SpringMVC代码

聊到这里,需要泼一盆冷水:如果项目的大文件场景持续扩大(比如亿级用户平台),继续在SpringMVC里堆代码不是不可以,但会越来越累。Nginx上传临时目录的磁盘规划、多节点部署时文件服务器的一致性、合并操作对CPU的冲击、存储备份机制——这些都是绕不开的运维负担。成熟的方案是使用对象存储服务,把大文件直接传给对象存储的临时凭证接口,业务服务只做元数据管理。这个切换不需要推翻SpringMVC的上传架构,而是将"接收文件并落盘"这一层从业务服务里剥离出去。

但要说明的是,如果只是企业内部系统、文件量有限、并发不高,在自己服务器上用Nginx加独立磁盘做文件目录,配合SpringMVC的流式接口,完全够用且成本更低。技术选型永远看场景,不追潮流。

6. 我在实战中反复用到的几条原则

文章写到这里,主体方案都讲完了。最后沉淀几条我自己的实操原则,不一定是教科书标准,但都是从线上故障里活下来的经验。

第一,永远不要在代码里让一个文件占满你的JVM堆。无论是上传的getBytes还是下载的byte[],这两路由"大文件"触发的内存峰值,是整个业务系统里最危险的不确定因素。宁可多写几行流式代码,也别贪图几行代码的爽快。

第二,调参前先看完整链路。别一上来就改Spring的multipart配置。先理清楚:Nginx的client_max_body_size是多少?Tomcat的maxSwallowSize是多少?中间还有没有别的代理层?一次上传请求走完整个链路,每一层都有自己的限制,任何一个环节没放开,都会得到"失败"的结果,且日志不一定能帮你定位到那一层。

第三,临时目录要有专人看管。无论是Nginx的client_body_temp_path、Tomcat的临时目录,还是你自己的上传目录,都要设置配额监控,要定期清理。大文件系统的"磁盘满"事故,几乎从来不是一夜之间出现的,而是三周前的某个配置失误慢慢积累出来的。

第四,分片接口必须做幂等。前端网络抖动导致重复上传同一分片时,后端不能报错也不能产生重复文件。设计上最好让每个分片的写入是"覆盖式"的,同一个index的分片只保留一份。合并时发现缺片,返回明确的错误码和缺失索引列表,这样前端前端可以实现精准补传。

大附件处理没有银弹。百M级别靠流式方案已经能解决得比较舒服,上G级别就要考虑分片、断点续传、对象存储的组合方案。但无论方案怎么演进,"数据流有界,内存不失控"这八个字,始终是设计时最该守住的底线。

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

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

立即咨询