做过几年 SpringCloud 相关的服务端开发,文件上传这块算是踩坑最多的业务之一。尤其是分片上传,表面上看就是“文件切开、挨个传、后端拼起来”,可真放到微服务链路里跑,第一波线上问题往往是内存告警。我见过一个项目把 2GB 的文件切成 128 个 16MB 分片,前端一次性丢出 20 个并发,网关和服务节点的堆内存直接飘红,接口大面积超时。这还没算分片校验失败、断点续传补偿这些逻辑,光是内存这关就够喝一壶。
后来我花了不少时间专门调这块,把“内存占用”和“上传速度”这两个互相打架的指标一点点掰开来看,总结出一套在 SpringCloud 环境下相对稳的优化路径。这篇帖子不是教科书,是我自己实际调过的参数、压过的数据、以及踩过的几个典型坑。如果你正在做微服务文件上传、或者被分片上传的内存问题折磨过,可以参考一下我的思路。
1. 先算清楚账:一条上传链路里到底能吃掉多少内存
很多人一开始就把焦点放在“分片大小”上,觉得只要把分片调小,内存就下来了。但真实情况是,一个分片从客户端发出到最终落盘,会经过很多个“中转站”,每一站都可能产生一份完整拷贝,这些拷贝叠加在一起,才是真正的内存峰值。
1.1 微服务链路每一跳都在“复制”文件内容
我们先看一条常见链路:浏览器 / App 客户端 → Nginx → Spring Cloud Gateway → 业务服务 → 对象存储或磁盘。
每一跳的复制点大致如下:
- 客户端:请求体构造,完整的 Chunk 在内存里。
- Nginx:接收缓冲区,默认会缓冲一部分请求体。
- Spring Cloud Gateway:基于 Spring WebFlux 和 Netty,转发请求时如果做了 body 缓存,整个分片会落一次内存。
- 业务服务:Tomcat / Jetty 的 Servlet 容器在解析 multipart 时,如果没配阈值,分片内容直接进 JVM 堆。
- 业务代码:如果 Controller 里再调一次
file.getBytes()或者把MultipartFile转换成byte[]做校验,又是一份。
这里的关键是“多份拷贝”是叠加的,不是共享的。同一个 8MB 分片,在网关内存里有一份,在业务服务堆里有一份,在业务代码里再手动拷贝一份,那就是 24MB。看起来单次不多,但并发一上来,比如 10 个分片同时传,就是 240MB 额外开销,这个数字在 4G 堆的服务上已经非常可观了,何况线上还有别的业务流量。
1.2 用一个公式估算并发下的内存需求
我之前做容量评估的时候,会用一个很简单的估算公式:
链路峰值内存 ≈ 并发分片数 × 分片大小 × 链路拷贝系数
链路拷贝系数是个经验值:如果所有中间层都在合法且必要的情况下做最小缓存,这个系数可以压到 1.5 左右;如果什么都不管,默认行为往往是 2.5 到 3。
我们拿这个公式做一个对照表,假设一个服务节点堆内存 4G,Tomcat 默认最大线程数 200:
| 分片大小 | 并发数 | 链路拷贝系数 | 预估内存占用 | 4G 堆下的风险 |
|---|---|---|---|---|
| 2MB | 10 | 2 | 40MB | 低 |
| 5MB | 10 | 2 | 100MB | 中 |
| 8MB | 10 | 2 | 160MB | 中高 |
| 16MB | 10 | 2 | 320MB | 高 |
| 16MB | 20 | 2.5 | 800MB | 极高 |
这张表我压测过多次,虽然不同框架版本会有偏差,但量级基本准确。我要强调的不是“16MB 不能用”,而是“16MB 配上大并发、再撞上几处多余的 body 拷贝,很容易就出事”。
1.3 不要忽略 Netty 的堆外内存
在 SpringCloud 体系里特别容易踩的一个坑是:JVM 堆明明有空间,可服务还是崩了。原因往往是 Netty 的 direct buffer 也占用了大量堆外内存,超过-XX:MaxDirectMemorySize之后就抛 OOM。
网关和 WebFlux 服务尤其明显,因为 Netty 默认会把文件数据放到堆外。很多人在调整的时候只盯着-Xmx,完全忘了 direct memory 也是受限资源。后续我专门留一段讲这块的调参思路。
2. 分片大小不是拍脑袋定的,速度和内存在这里分道扬镳
分片大小是整个上传设计的核心参数。它在两个方向上拉扯:分片越小,内存压力越小,但网络请求越多,速度反而下降;分片越大,单请求吞吐看似高,但服务端内存和线程压力同步变大。找到平衡点,才算真正解决问题。
2.1 为什么分片不能无限小
我见过有人为了追求内存低,把分片切成 256KB。内存确实舒服了,但速度很难看。原因是每个分片都意味着一次独立的 HTTP 请求,而每次请求都有固定成本:
- TCP 连接建立时的握手和慢启动(如果连接复用没做好,成本更高)。
- HTTP 头部本身的开销,比如鉴权、签名、分片编号等,一个头下来几十上百 KB 不算夸张。
- 服务端每条分片记录都要写元数据表,256KB 一个片,1GB 文件要切 4096 片,数据库写入和查询压力会直线上升。
- 网络往返次数增多之后,任何一个分片抖动,都可能拖累整体完成时间。
从压测数据看,分片小于 1MB 时,网络往返和框架处理开销在整体耗时里的占比会明显上升;分片超过 16MB 后,内存和超时风险的增长速度又大于吞吐收益。2MB 到 8MB 算是一个比较务实的区间。
2.2 用带宽和目标并发反推片大小
我在定分片大小之前,会先做一个极简的测算:
假设用户平均上行带宽是 10Mbps,也就是 1.25MB/s。如果分片是 5MB,理论传一个分片需要 4 秒;前端并发开 4 个,理论上 4 秒能传 20MB。这个速度对于大多数办公网络和移动网络,已经足够体面。
再把服务端内存套进去:4 并发 × 5MB × 拷贝系数 2 = 40MB,对单个业务节点没什么压力。
反过来,如果强行用 16MB 分片,单分片要传 12.8 秒,为了不拖慢整体,前端必然想提高并发,并发一旦提到 8 个,内存就是 8 × 16MB × 2 = 256MB,后端压力立刻上一个台阶。这就是典型的“前端为了速度牺牲后端内存”。
所以我个人的经验是:先定并发,再定分片,最后用公式验证内存。而不是一开始就拍脑袋定一个 8MB 或者 16MB。
2.3 碎片化的分片导致合并阶段的额外内存
还有一个很容易被忽略的问题:分片合并时,服务端需要读取所有分片,按顺序拼接。如果分片太小、数量太多,合并阶段的文件句柄和 IO 线程压力会明显增加。而如果分片粒度均匀、数量控制在 100 到 1000 这个量级,合并时做增量写入就非常顺滑。
合并和校验建议用“边读边写边算”(流式读取分片、写入目标文件、同时用MessageDigest增量计算哈希),千万不要把一个分片全部readAllBytes()再处理。
3. 服务端接收分片的核心:别把文件“端”进内存
很多服务端优化方案都聚焦在 Nginx、网关的缓冲参数上,但我认为真正的第一步,是业务服务自己不要做“完整读入内存再处理”这件事。Spring Boot 默认的 multipart 处理策略,如果不调参,是会把你整个分片塞进内存的。
3.1 用 file-size-threshold 让分片自动落临时文件
Spring Boot 的 multipart 配置里有几个关键参数:
spring.servlet.multipart.max-file-size=20MB spring.servlet.multipart.max-request-size=80MB spring.servlet.multipart.file-size-threshold=2KBfile-size-threshold是指“超过多大就写到临时文件,而不是留在内存”。默认值是 0,意味着所有上传内容都会先进内存。把它配成 2KB 或者 1KB,基本上分片一进来就会被 Spill 到磁盘临时区,内存里只有很小的流式缓冲。
有人会担心“落到磁盘再读出来,IO 会不会拖慢速度?”实测下来,在 SSD 环境下,这种磁盘缓冲带来的性能损耗远小于内存翻倍的 GC 压力。分片上传本来就是高吞吐、低单片量的场景,磁盘顺序写完全扛得住。
3.2 用 Part 接口做真正的流式读取
即使配了file-size-threshold,还有一个常见陷阱:Controller 里一上来就写multipartFile.transferTo()还好说,但有些人会把MultipartFile转成byte[],或者取getBytes()自己再包一层。这一步会把已经写到临时文件的内容重新完整读进堆内存,等于前面的落盘策略白做了。
Spring 提供的Part接口可以从请求里直接拿InputStream边读边写:
@PostMapping("/upload/chunk") public ResponseEntity<?> uploadChunk(@RequestParam("file") Part file) throws IOException { String uploadId = request.getParameter("uploadId"); int chunkIndex = Integer.parseInt(request.getParameter("chunkIndex")); InputStream in = file.getInputStream(); // 边读边写,每次只缓冲一小块,比如 8KB try (OutputStream out = chunkStorage.openForWrite(uploadId, chunkIndex)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } return ResponseEntity.ok().build(); }这样 JVM 堆里任何时候最多只有一个 8KB 的 buffer,和分片大小彻底解耦。你没看错,不管分片是 5MB 还是 16MB,服务端的内存占用几乎是恒定的。这才是彻底解决内存问题的方向。
3.3 文件校验用增量散列,而不是整文件重算
分片合并完成后,通常要做 MD5 或 SHA-256 校验。暴力的写法是读取整个文件到内存,或者把整个文件流读一遍,这在大文件场景下很容易造成 IO 和内存的双重峰值。
正确做法是边写边累计散列值:
MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); digest.update(buffer, 0, len); } String checksum = new BigInteger(1, digest.digest()).toString(16);合并完成后同时拿到最终文件的 MD5,不用再读第二遍。这个细节在分片数量多的时候,能省下非常大的磁盘 IO 和内存开销。
4. Spring Cloud Gateway 层的缓冲和连接复用才是隐藏变量
前面讲的主要是业务服务,但实际生产环境里,网关往往是最先被分片上传打爆的那一层。因为网关负责接收前端请求、解析、鉴权、然后转发给下游,任何一个环节做了 body 缓存,内存开销就会成倍增加。
4.1 网关的默认行为会让每个分片多占一份内存
Spring Cloud Gateway 基于 Spring WebFlux,核心的 body 读取用的是DataBuffer。WebFlux 的 codec 默认对请求体的内存缓存大小限制是 256KB,超过之后会把数据写临时文件。所以如果前端直接上传一个 5MB 分片给网关,网关不会傻傻地把 5MB 全塞内存,它也会走类似本地文件的 swap 机制。
真正危险的是我们自己写的 GlobalFilter 或路由过滤器。常见的就是为了做日志、做签名校验、做鉴权,把请求体readBody()一遍,然后在转发的时候再读一遍,这就是两份完整拷贝。很多团队在排查内存暴涨的时候,往往忽略了这条。如果你确实需要校验请求体,请用增量校验的方式处理:
- 如果是验签,可以用数据流边读边计算 MAC 值,不要
collect成完整 byte[]。 - 如果是日志,建议只记录分片元数据(大小、序号、文件名),不要记录 body 内容。
4.2 连接复用对速度的影响比想象中更大
分片上传的“小请求多”特性,天然适合长连接。如果每个分片都重新建 TCP 连接,3 次握手 + 4 次挥手,再加上 TLS 握手,这些 RTT 成本累加起来,速度会被拖得很惨。
在 Spring Cloud Gateway 转发到下游服务时,需要注意底层 HTTP Client 是否开启了连接池和 keep-alive。默认情况下,如果每次请求都走new HttpClient(),或者没有复用连接池,网关与业务服务之间会产生大量短连接。对上传这种频繁交互的场景,影响尤其明显。
更优的做法是给网关配一个明确的连接池:
// WebClient 或 HttpClient 的连接池配置示例 HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) .responseTimeout(Duration.ofSeconds(30)) .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(30))) .wiretap(true); ConnectionProvider provider = ConnectionProvider.builder("upload-pool") .maxConnections(500) .pendingAcquireTimeout(Duration.ofSeconds(30)) .maxIdleTime(Duration.ofSeconds(60)) .build();maxConnections要和后端服务的线程池容量匹配,不要设置太大。连接空闲到了 60 秒会被回收,这样既保持了复用率,又不会浪费太多空闲连接。
4.3 HTTP/2 在这个场景下值得吗
如果你的网关和下游服务都支持 HTTP/2,那么可以考虑开启。HTTP/2 的多路复用可以在同一条 TCP 连接上并行传输多个分片,减少了 HTTP/1.1 的连接数限制。
但 HTTP/2 不是银弹,尤其是上传大分片时,TCP 的拥塞控制和流控会成为新瓶颈。我实测过的结果是:在局域网内,HTTP/2 比 HTTP/1.1 + keep-alive 的收益大约在 10% 左右;在跨公网的高延迟链路上,收益会大一些。如果你的系统如果用的是 Spring Cloud Gateway 2.x 及以上,开启起来成本不高,可以试试,但不要神话它。
5. 并发线程数与 JVM 参数的配合:两个最容易翻车的细节
分片上传优化的最后一步,是把线程模型、连接池参数和 JVM 内存参数对齐。这一节要讲的两个细节,我至少见过三四个团队在这里栽跟头。
5.1 不要盲目调大 Tomcat 的 max-threads
很多人的第一反应是“上传并发高,那就把 Tomcat 线程池调大”。但分片上传是 IO 密集型任务,线程一多,一来消耗系统内存(每个线程默认栈 1MB),二来会让 CPU 在上下文切换上浪费不少时间,三来会让内存峰值呈线性上升。
更适合的做法是给上传接口单独划分一个轻量线程池,控制并发上传的分片数。比如:
ThreadPoolTaskExecutor uploadExecutor = new ThreadPoolTaskExecutor(); uploadExecutor.setCorePoolSize(4); uploadExecutor.setMaxPoolSize(8); uploadExecutor.setQueueCapacity(200); uploadExecutor.setWaitForTasksToCompleteOnShutdown(true);这样即使前端一次性并发发出 20 个分片,真正进入业务处理的也只有 8 个,其余的在队列里排队,内存和磁盘 IO 就平稳了。前端不需要改逻辑,后端只需要在服务入口做流量整形,效果立竿见影。
5.2 JVM 堆和 Direct Memory 要一起规划
前面提到过 Netty 的堆外内存。在实际调优中,我一般会这样设置:
-Xms4g -Xmx4g -XX:MaxDirectMemorySize=1g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=100这里尤其注意MaxDirectMemorySize。如果你的服务既跑 Spring Cloud Gateway 又做业务,Netty、部分 HttpClient、以及某些异步日志框架都会吃堆外内存。设得太小,上传高峰时会直接 OOM;设得太大,会挤压操作系统留给文件缓存和临时缓冲区的空间。1G 是一个对大多数上传服务还算合理的起跳值,具体要结合分片大小和并发数做压测确认。
5.3 压测时看什么数据
优化做完了,别急着上线。我是这么压测的:
- 用 JMeter 模拟 10 个并发分片上传,跑 5 分钟。
- 观察 JVM heap 使用曲线,看有没有持续上升(上升=泄漏或没释放)。
- 观察 GC 日志,重点看 Full GC 的频率和耗时。
- 观察 Netty 的 direct memory 使用情况,多用 arthas 或 actuator 的 metrics 拿数据。
- 观察磁盘 IO 和临时目录清理情况,确认没有临时文件堆积。
如果压测期间 Full GC 频繁,说明对象分配过多,大概率是代码里有某个地方还在把分片完整读入内存。如果老是 Native memory OOM,那就是MaxDirectMemorySize太小,或者某个组件读取 body 后没有正确 release。
6. 排查内存飙升的实操链路:从一次失败案例说起
理论讲了这么多,我分享一个真实的排查过程,整个过程我走了不少弯路,最后才锁定根因。
6.1 表象:上传功能一上线,网关节点 CPU 和内存同步飙升
当时的架构是:前端把文件切成 8MB 分片,并发 8 路上传,经过 Nginx 进入 Spring Cloud Gateway,再转发到业务服务。上线后不久,网关节点的堆内存从正常 1G 直接涨到 3.5G,接近 OOM,接口响应普遍超过 3 秒。
我一开始的直觉是“分片太大”,于是把分片改成 4MB,把并发降到 4,结果改善非常有限。后来看 GC 日志,发现byte[]大对象分配比例很高,才意识到问题不在分片大小,而在于请求体被完整读了。
6.2 定位过程:从 GC 日志到代码审查
用jmap -histo:live <pid> | head -50查看对象直方图,byte[]总数排第一,且单个实例大小和分片大小高度吻合。再用 arthas 的trace命令跟踪上传接口,发现每次上传都会走进一个叫signatureFilter的全局过滤器,而这个过滤器为了验签,使用了DataBufferUtils.join()把整个 body 读成一个完整的DataBuffer。
更糟糕的是,验签完成之后,这个DataBuffer没有释放,又传给了下一个过滤器。网关在转发时,需要再次读取 body,于是同一份 8MB 分片在网关里同时存在两份完整拷贝,8 并发时就是 128MB 以上的额外内存。这还没算 Nginx 和 Tomcat 各自的一份,整体内存直接翻倍。
6.3 修复方式:流式验签 + 及时释放
修复分两步:
第一步,把验签逻辑改成边读边算签名,不再把整个 body 载入内存;如果框架限制必须拿到完整 body,那就显式在 filter 里释放掉:
// 不要这样做,除非明确知道内存代价 byte[] body = DataBufferUtils.join(exchange.getRequest().getBody()).block(); // 做了必要的校验之后,必须显式释放 // dataBuffer.release();第二步,在自定义 Filter 里对DataBuffer的生命周期进行严格管理,用完即 release,并避免 body 在多个过滤器之间反复缓存。
改造后的压测数据:8 并发 × 8MB 分片,网关堆内存峰值从 3.5G 降到 1.2G,P95 响应从 3 秒降到 800 毫秒左右。同样一套逻辑,之前再怎么调分片大小都没用,根因就是多了一次不必要的完整拷贝。
6.4 常见内存异常和应对速查表
| 现象 | 可能原因 | 排查方式 | 常用处理 |
|---|---|---|---|
| 堆内存持续上涨 | 分片细节被完整读入内存 | jmap 直方图、GC 日志 | 流式处理、file-size-threshold |
| 堆内存没涨但进程 OOM | Netty direct memory 超限 | 查看 max-direct-memory、arthas 查看内存 | 调整 MaxDirectMemorySize、流式转发 |
| 上传速度上不去 | TCP 连接频繁重建 | 抓包观察是否有大量握手 | 开启连接池和 keep-alive |
| 网关响应超时 | 过滤器多次缓存 body | trace 过滤器调用链路 | 用流式验签,减少 body 复制 |
| 临时文件堆积 | file-size-threshold 落盘后没有及时清理 | 查看临时目录 | 设置定时清理,或把临时文件放到 SSD 分区 |
7. 我的最终建议:先把“复制”减到最少,再谈参数调优
分片上传的速度和内存平衡,核心不是某个参数一调就灵,而是整条链路上“非必要复制”的数量。我相信很多人和我一样,一开始都盯着分片大小和堆内存使劲,但真正让系统稳定下来的,往往是把流式处理、连接复用、正确 release 这些看似基础的事情做到位。
我在实际项目里的最终配置大致是这样一套组合:
- 分片大小 5MB。
- 前端并发 4 到 6 路。
- 业务服务配置
file-size-threshold=1KB,强制最小化内存驻留。 - Controller 使用
Part流式读取。 - 网关自定义 Filter 只做元数据校验,不做 body 缓存。
- 网关和业务服务之间用连接池,开启 keep-alive。
- 网关节点
-Xms4g -Xmx4g -XX:MaxDirectMemorySize=1g。
用这套组合跑大半年,上传高峰期没有出现一次内存告警,速度也稳定在带宽瓶颈附近。调优不是越复杂越好,而是先把每一个“浪费”找出来干掉,剩下的参数才真正起作用。