刚把一个Spring Boot项目里的内嵌Tomcat从9.0.83升到9.0.112,本以为是例行公事——9.0.83已经是两年前的版本了,中间累积了一堆安全更新,升级属于补作业。结果升级完第二天,业务方就找过来说批量上传附件时,一个原本稳跑的接口开始偶发报500。打开日志一看,异常来自org.apache.tomcat.util.http.fileupload.impl.FileCountLimitException。
这条异常在普通Spring Boot开发节奏里几乎见不到,但只要你的系统里有"一次请求提交大量文件"的场景,迟早会撞上它。这篇文章把我这次排查的完整过程、Tomcat multipart解析的前因后果、以及最终采用的修复方案都记录下来,给同样被这次升级坑到的同学一个可以直接参考复现的路径。
1. 一次例行的安全升级,怎么就报错了
1.1 升级的背景与版本选择
项目的Spring Boot版本是2.7.x,BOM里默认托管的Tomcat是9.0.x系列。9.0.83大概是2023年底发布的版本,到9.0.112已经是9.0.x这条线非常靠后的维护版本了,中间横跨了二十多个小版本。这期间Tomcat团队修复了大量安全问题,覆盖HTTP请求走私、multipart解析拒绝服务、WebSocket相关漏洞等。对于任何要接外部请求、跟其他系统做数据交换的服务来说,往后的安全版本靠拢都是必须的动作。
这里有一个很多团队容易忽略的点:升级内嵌Tomcat其实完全可以和Spring Boot版本升级解耦。Spring Boot的依赖管理BOM把Tomcat版本集中定义在tomcat.version属性上,你只需要覆盖这个属性,就能让项目里所有tomcat-embed-*组件整体切换版本。不需要改Spring Boot版本,也不动业务代码,风险面小得多。
1.2 Spring Boot里覆盖Tomcat版本的正确写法
在Maven工程里,直接在pom.xml的外层properties中覆盖即可:
<properties> <java.version>1.8</java.version> <tomcat.version>9.0.112</tomcat.version> </properties>前提是项目继承了spring-boot-starter-parent,或者显式引入了spring-boot-dependencies的BOM。这样spring-boot-dependencies里的依赖管理会把tomcat-embed-core、tomcat-embed-el、tomcat-embed-websocket这些组件统一定位到9.0.112。
Gradle工程则是通过Spring Boot的依赖管理插件属性来覆盖:
ext['tomcat.version'] = '9.0.112'这里有个容易踩的小坑:很多新手会写成tomcat.version = '9.0.112',这只是一个普通变量,Spring Boot的依赖管理完全不会读它。必须通过ext['tomcat.version']这种属性槽方式去覆盖。升级完第一步一定要验证版本真正生效:
mvn dependency:tree -Dincludes=org.apache.tomcat.embed输出里看到tomcat-embed-core:9.0.112才算覆盖成功。我见过不止一个人改了pom.xml但没继承parent、也没引入BOM,导致版本属性根本没被消费,升了个寂寞。
1.3 升级后最先炸出来的是上传接口
版本切换本身很顺利,应用启动正常,普通接口访问也没问题。真正的故障出现在第二天——业务方反馈,一个用于批量提交附件的接口开始报500。
这个接口的业务逻辑并不复杂:客户端在一次multipart/form-data请求里携带几十到几百个小文件,服务端通过Spring MVC的MultipartFile数组接收后循环处理。在9.0.83上一直稳定,升级后才开始偶发失败。注意"偶发"这个特征:文件数量少的时候正常,文件一多就必现,这是典型的请求解析层问题,而不是业务代码的逻辑毛病。
日志中的关键堆栈是这样一段:
org.apache.tomcat.util.http.fileupload.impl.FileCountLimitException: The request exceeds the maximum number of files allowed. at org.apache.tomcat.util.http.fileupload.impl.FileItemIteratorImpl.throwFileCountLimitException(FileItemIteratorImpl.java:154) at org.apache.tomcat.util.http.fileupload.impl.FileItemIteratorImpl.initFormData(FileItemIteratorImpl.java:134) at org.apache.catalina.connector.Request.parseParts(Request.java:1866)异常类名冷门,包名org.apache.tomcat.util.http.fileupload属于Tomcat自己维护的multipart解析组件,不是Spring MVC层的东西。这个位置说明问题出在Tomcat把请求体解析成文件列表的阶段,Spring MVC的Controller方法根本还没进。
这次经历的一个直观感受是:容器版本升级,表面上只是依赖数字变了,底层解析行为可能已经天翻地覆。9.0.83到9.0.112之间夹杂了大量针对上传解析的安全加固,行为变化完全不意外。
2. FileCountLimitException到底在说什么
2.1 异常家族的来历
FileCountLimitException在Tomcat中对应的是一整套multipart解析限额体系。Tomcat早年把Apache Commons FileUpload的代码fork进来,自己维护了一套解析实现,类名和Commons里的几乎一一对应。所以排查思路可以直接沿用Commons FileUpload的老经验:解析器在遍历请求体时,一旦文件数量或大小超过预设阈值,就会中断解析并抛出对应异常。
和FileCountLimitException并列的还有FileSizeLimitException(单个文件大小超限)和SizeLimitExceededException(请求总大小超限)。这三条异常分别对映Tomcat Context配置中的三个参数:maxFileCount、单文件大小上限、maxRequestSize。
2.2 multipart请求从进入Tomcat到Spring MVC的完整链路
一个上传请求到达Tomcat后,要经过好几层才会进入你的Controller。
首先,Tomcat的Request组件根据请求头里的Content-Type: multipart/form-data; boundary=...把原始请求体按照boundary拆分成一个一个的Part。每个Part可能是普通表单字段,也可能是一个文件。这个拆分动作由Tomcat的Request.parseParts()完成,它内部会使用org.apache.tomcat.util.http.fileupload那套解析器。
接着,Spring MVC的DispatcherServlet在预处理的HandlerMapping阶段检查Content-Type,如果是multipart,就交给StandardServletMultipartResolver。这个解析器核心操作就是调用request.getParts()拿到所有Part,然后封装成Spring的MultipartFile列表。
Tomcat的限额校验发生在getParts()内部,也就是上面链路的第二层。解析器逐段读取请求体、识别每个Part的Header、判断是文件还是字段,每识别出一个文件就计数一次,当计数超过maxFileCount时直接抛异常终结解析。整个请求就以500返回,业务代码一行都执行不到。
2.3 maxFileCount参数与9.0.83到9.0.112的行为差异
maxFileCount是Tomcat Context层面的配置项,语义很直白:单个multipart请求中允许出现的最大文件数量。9.0.112版本中,Tomcat官方配置文档给出的默认值是10000。
比较早期的9.0.x版本对这个参数的态度更宽松,很多部署场景甚至不会真正触发到数量上限。升级之后,新版本为了防范恶意构造的超大数量multipart请求拖垮解析进程,对数量校验更严格了,于是原本一次提交几千个文件的存量业务就正面撞上了这个限制。
关于具体是哪个小版本改的默认值,说实话我没有精力逐行去翻Git提交记录,而且不同发行版、不同部署方式、甚至不同Spring Boot版本封装出来的Context配置都可能不一样。但这不影响解决问题,你只需要知道一个核心事实:Tomcat从不允许一个multipart请求里出现无限多的文件,maxFileCount就是那道闸门。闸门的默认值在哪个版本收紧、你的部署环境实际生效值是多少,用最小复现一测就出来,方法后面会讲。
2.4 为什么Spring Boot的spring.servlet.multipart.*管不到这个限制
大多数人的第一反应是去改Spring Boot的上传配置,比如:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=100MB改完就会发现一点用都没有,该报还是报。原因在于,Spring Boot暴露给开发者的multipart配置只覆盖了Servlet标准MultipartConfigElement里的三个维度:单个文件大小max-file-size、请求总大小max-request-size、文件写入磁盘的阈值file-size-threshold。这三个参数在Tomcat解析时确实会被读取,但"文件数量上限"不在其中。
MultipartConfigElement从Java Servlet规范层面就没有"最大文件数量"这个字段,Spring Boot的MultipartProperties自然也没有对应属性项。数量限制完全是Tomcat自己的Context参数,Spring Boot根本不把它暴露到application.properties里。你翻遍整个配置文件也找不到spring.servlet.multipart.max-file-count这样的键。
搞清楚这一点,问题的边界就清晰了:要调数量限制,只能在Tomcat容器层面下手,Spring Boot的业务配置管不到。
3. 排查实录:从500到根因的完整链路
3.1 第一步:区分异常发生在哪个环节
排查任何上传接口的500,第一件事都是看异常到底从哪一层抛出来。如果异常出现在Controller或Service内部,堆栈里大概率带着你业务类名;如果问题出在请求解析阶段,堆栈顶部就是Tomcat和Spring MVC的filter链。
我们日志里最早的抛出点是FileCountLimitException,紧接着是org.apache.catalina.connector.Request.parseParts和StandardMultipartHttpServletRequest的那一串调用。这个位置明确说明异常发生在DispatcherServlet真正调用Controller方法之前,是Tomcat解析阶段直接抛出的。
这一步很关键。之前见过不少团队把"偶发上传失败"当成业务问题去查数据库、查文件服务器,最后绕了一大圈才发现是容器解析层的问题。解析阶段的异常通常和请求体积、文件数量、请求格式强相关,跟业务代码没关系,方向不能跑偏。
3.2 第二步:用最小复现确定触发阈值
异常信息本身已经提示得很明白——超出允许的最大文件数量。下一步要确认的就是"允许的到底是多少"。
我直接写了临时接口,不落库、不写文件,只接收MultipartFile[]并打印数组长度,方便做触发阈值测试。接口大概是这个形态:
@PostMapping("/debug/upload") @ResponseBody public String debugUpload(@RequestPart("files") MultipartFile[] files) { return "received files: " + files.length; }然后用脚本分批构造请求,按500、1000、2000、5000、10000、10001个文件的梯度往上加。实测下来,文件数在10000以内全部通过,超过10000就稳定抛FileCountLimitException。基本确认当前环境生效的maxFileCount就是10000。
之前9.0.83为什么没报?我们在升级前的环境上跑了同样的测试,10000个文件同样是能过的,但更多文件数量时也只是遇到请求大小限制,没有数量限制的报错。对比下来可以确认:9.0.112对这个限制的收紧是真实的,历史存量请求在旧版上碰不到这道闸门,新版本则把闸门真正关上了。
3.3 第三步:排除干扰项,锁定根因
排查过程中我同步验证过几个容易混淆的猜测:
一是Nginx层是否限了请求头大小。我们的上传走的是Nginx代理,但检查后发现Nginx的client_max_body_size配置足够大,并且直接绕过Nginx访问服务端口同样复现问题,排除。
二是Spring MVC层是否额外限制了文件数量。查看了StandardServletMultipartResolver的默认行为以及全局异常处理,没有发现任何以"文件数量"为条件的拦截逻辑,排除。
三是是否和文件大小相关。测试时故意使用0字节空文件,依然触发异常,说明触发条件是"文件个数"而不是"文件体积",进一步指向maxFileCount。
到这里根因已经没悬念:升级到9.0.112后,Tomcat的multipart解析对文件数量施加上限,批量上传请求一旦超过10000个文件就被强制中断。
3.4 第四步:查证之后,方案被逼到了唯一路径
我最希望的结果是配置里加一行直接解决,但查遍Spring Boot 2.7.x的MultipartProperties源码和官方文档,确认它确实只有max-file-size、max-request-size、file-size-threshold、location这几个配置项,没有任何文件数量相关的键。接着查了server.tomcat.*,也没有暴露maxFileCount。
于是唯一的可控路径就是自定义TomcatServletWebServerFactory,在Tomcat Context创建时手工写入maxFileCount。这也是社区在Tomcat 9 + Spring Boot组合下处理这类问题的标准做法。
4. 真解与替解:调maxFileCount的落地方式
4.1 解法一:通过TomcatServletWebServerFactory调整maxFileCount
这是最直接、改动也最少的方式。核心原理是:Spring Boot启动内嵌Tomcat时,会通过TomcatServletWebServerFactory创建StandardContext,我们可以注册ContextCustomizer来干预这个创建过程,在Context生效前把maxFileCount写进去。
import org.apache.catalina.Context; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) public class TomcatMultipartCustomizer { @Bean public WebServerFactoryCustomizer<TomcatServletWebServerFactory> fileCountCustomizer() { return factory -> factory.addContextCustomizers((Context context) -> { // 单个multipart请求允许的最大文件数量,按业务峰值翻倍预留 context.setMaxFileCount(20000); }); } }代码里的factory.addContextCustomizers会接收一个ContextCustomizer函数式接口,在Tomcat创建Context时执行。context.setMaxFileCount()是Tomcat 9中Context接口的标准方法,传入目标数值即可覆盖默认限制。
我把它做成了独立的配置类,不挂在已有的WebMvc配置里,职责单一,后续调参只改这一处,也不用担心影响其他和容器相关的配置。实际验证:改成20000后,最小复现里10001个文件的请求稳定通过,接口恢复正常。
这里还要提醒一点:如果你用application.yml,想把这个值外部化,可以在自定义类里用@Value读取配置项。但这个配置项名字要自己定义,不要试图映射到spring.servlet.multipart.*底下,框架不会认。
4.2 解法二:在独立部署模式下如何用context.xml配置
如果你的项目不是Spring Boot内嵌容器,而是把WAR包丢到独立Tomcat里跑,那解决办法反而更传统——在META-INF/context.xml里直接写:
<Context maxFileCount="20000"> ... </Context>或者修改Tomcat安装目录下conf/context.xml的默认配置。独立部署模式下Tomcat会用这个文件来初始化Context,maxFileCount直接生效。
但Spring Boot内嵌容器不读conf/context.xml,它完全由TomcatServletWebServerFactory编程式构建Context。虽然有办法在Spring Boot里硬塞META-INF/context.xml让内嵌Tomcat加载,但处理起来非常绕,而且Spring Boot对Context默认参数的赋值可能覆盖文件里写的值,搞半天不如方案一干净。
结论:内嵌容器场景直接放弃context.xml路线,用编程定制方式最可控。
4.3 解法三:业务层分批上传,这个才是治本
把限制调大能解决眼前问题,但我不建议把这当成唯一手段。一次请求携带上万个文件,解析时内存里要同时维护所有Part的临时信息,无论Tomcat允不允许,资源消耗都很可观。恶意攻击者也能构造大量几乎为空的Part,绕过单文件大小限制去打解析器的CPU和内存。
更稳妥的做法是在业务层主动约束请求形态。例如:客户端把一次大规模提交拆成多个批次,每批控制在几百个文件以内;或者要求上游先打成zip压缩包上传,服务端解压后逐条处理。这样maxFileCount维持默认值甚至更小都足够,安全边界也清晰得多。
我们最终采用的是组合方案:先把maxFileCount调到20000,兼容存量调用方;同时和业务方约定新接入的批量场景一律走压缩包方式,逐步淘汰"一次传几千个散文件"的旧用法。既止住了线上故障,又留了长期收敛的方向。
4.4 三条方案的对比
| 方案 | 生效位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 调maxFileCount | Tomcat Context | 改动小、生效快 | 治标不治本,调太大有风险 | 兼容存量多文件请求 |
| context.xml配置 | 独立Tomcat配置文件 | 传统、直观 | 内嵌Tomcat下不适用 | WAR包独立部署 |
| 业务分批/压缩包 | 客户端与服务端 | 根治、降低资源风险 | 需要端上配合、改造周期长 | 新接入方、长期治理 |
三个方案不是互斥的。本次故障先用方案一止血,长期用方案三收口,这也是我建议其他团队复用的思路。
5. 改完还要注意的周边参数与风险边界
5.1 maxFileCount和maxRequestSize、maxSwallowSize的联动关系
maxFileCount不是Tomcat上传限额里唯一的项,它和另外两个参数会互相牵制。
maxRequestSize限制整个multipart请求体的总大小,单位是字节。假设你把maxFileCount调到20000,每个文件限制1MB,但maxRequestSize仍然只放开到10MB,那请求解析照样会在总大小上失败。二者是叠加限制,不是替代关系。Spring Boot里它对应spring.servlet.multipart.max-request-size。
maxSwallowSize则更隐蔽。它限制的是Tomcat在解析失败后、为了复用连接而继续消费剩余请求体的数据量上限。如果请求体过大导致解析中断,Tomcat会清空余下数据再复用连接,maxSwallowSize兜底的就是这部分。设置不当可能出现"解析已经失败、连接却长时间不释放"的现象,表现为后续请求的响应被拖慢。
调maxFileCount的时候,我建议把三个参数摆在一起评估。一组合法的配置应该是:单文件大小限制、请求总大小限制、文件数量限制、异常后吞入量限制,四项配合成一道完整防线。缺失任何一环,都可能被恶意请求钻空子。
5.2 maxFileCount设成-1到底行不行
Tomcat的maxFileCount支持负数,设成-1表示不对文件数量做限制。文档层面合法,但我的建议是生产环境不要这么干。
原因前面已经提到:这个限制本质上是防DoS的一道闸门。攻击者可以构造一个multipart请求,里面塞上百万个几乎为空的Part,让解析器为每个Part创建对象、维护状态、逐段读取,CPU和内存很快被打满。单文件大小限制对此完全无效,因为每个Part可以只有几个字节。maxFileCount一旦放开,这道防线就废了。
如果你的接口只接受受信调用方,且单次文件数量本身有业务约束,那默认值甚至更小的值都够用。真要调,取业务峰值的1.5到2倍就足够,别图省事直接开无限。
这次故障里有个细节很值得记录:我们最开始临时设过-1来止血,结果当晚就有压测脚本用几万个空文件把上传接口的CPU打到了接近满负荷。虽然没造成严重事故,但也说明防护上限一旦放开,出问题的概率就上来了。
5.3 修改后的回归测试与长期监控
配置改完,不能只测一次能上传就收工。我建议至少覆盖三种场景:正常数量上传(几百个文件)、接近阈值的上传(比如阈值是20000,就测19000左右)、超过阈值的上传(20001个)。第三种场景应该返回可控的业务错误,而不是把FileCountLimitException的堆栈直接甩给客户端。
在全局异常处理中补一层是必要的:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MultipartException.class) public ResponseEntity<String> handleMultipartException(MultipartException e) { if (e.getCause() instanceof org.apache.tomcat.util.http.fileupload.impl.FileCountLimitException) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body("上传文件数量超出限制,请分批提交"); } return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body("请求体解析失败"); } }另外,Tomcat跨这么大的版本升级,安全加固带来的行为变化不可能只有文件数量这一处。升级到9.0.112后,和请求解析相关的接口都应该重点回归一遍,尤其是涉及大multipart、文件名特殊字符、非标准boundary这三类请求特征的场景。这次升级过程中我们还额外发现过,特殊字符文件名在9.0.112下的解析结果和老版本不一致,虽没直接导致故障,但同样是版本行为差异的一部分。
监控方面,建议给上传接口加上"单次请求Part数量"的可观测指标。我们后来在filter里统计了每个multipart请求解析出的Part数量,并和maxFileCount做比值告警,超过80%就提醒。这样以后再出现请求数量逼近阈值时,能在人工试探和攻击真正造成影响前发现苗头。
这次排查最让我意外的不是异常本身,而是"升级一个小版本号,居然把一个跑了两年的上传接口打挂"。回过头想,9.0.83到9.0.112隔了近两年的Tomcat维护期,中间对multipart解析的反DoS加固、边界行为收敛几乎是必然的。以后再升这类底层组件,我会先查发布说明,逐条核对和请求解析相关的变更;升级后专门安排上传下载接口的回归,重点覆盖文件数量多、单文件大、文件名特殊这三类请求;同时把异常包装成业务可读的错误返回,避免客户端直接面对FileCountLimitException这种冷冰冰的类名。
如果你也走在9.0.83往9.0.112升级的路上,建议先看两眼自己的上传接口有没有"一次提交大量文件"的场景,提前把maxFileCount的预期值定好,别等线上报错再排查。