Servlet这个关键词,在Java Web圈子里几乎是每个人最开始接触的东西,但也是很多人工作三五年后突然被问住的东西。最近在处理一个线上问题时,看到一条非常典型的报错,feignclient failed to parse multipart servlet request; nested exception is ...,顺着这条日志排查下去,发现很多开发对Servlet的理解停留在"会用@WebServlet注解"或者"Spring MVC里配个DispatcherServlet"的层面,一旦遇到和底层容器、请求解析相关的异常,就完全找不到方向。
这篇文章我不打算从Servlet规范第一章开始念,而是围绕这条实际报错,把Servlet的工作原理、multipart请求的解析链路、FeignClient调用文件上传接口时容易踩的坑,以及最终怎么修、怎么避免,一次性讲透。适合正在做Spring Cloud微服务、经常用Feign调用文件上传接口的Java开发,也适合写后端接口时被multipart请求折磨过、想搞清楚底层机制的人。
1. Servlet到底是什么:从一次HTTP请求的生命周期说起
1.1 容器、Servlet和请求的三角关系
很多人学Servlet的时候被"Servlet容器"和"Servlet"这两个概念绕晕。我换个说法:Servlet容器是运行环境,它负责监听端口、接收HTTP请求、解析请求数据,然后找到对应的Servlet去处理;Servlet是业务处理逻辑的载体,它不关心网络通信,只关心"请求来了之后我该干什么"。
一次HTTP请求进来,经历的链路大概是这样的:
- Tomcat、Jetty或Undertow这类容器在启动时,会扫描所有的Servlet,注册到内部的映射表里。
- 容器监听指定端口,收到一个HTTP请求后,先解析请求行、请求头、请求体。
- 根据请求的URL路径、Servlet的映射规则(比如
/api/*),找到对应的Servlet实例。 - 容器构造
HttpServletRequest和HttpServletResponse对象,调用Servlet的service()方法。 - Servlet根据请求方法分发到
doGet()、doPost()等具体方法。 - 业务代码处理完后,把结果写回
HttpServletResponse,容器负责把响应发送给客户端。
这中间最关键的是第2步和第4步。第2步决定了一个请求能不能被正确解析,第4步决定了你是否能拿到想要的请求参数。一旦这两步中间任何一个环节出问题,就会看到各种奇怪的异常,我们后面要讲的failed to parse multipart servlet request就是典型的请求解析阶段出问题。
1.2 三个最容易被忽视的Servlet细节
Servlet是单实例多线程的。容器启动时每个Servlet默认只创建一个实例,所有的请求共享这个实例。这也是为什么在Servlet里写实例变量需要特别小心,并发环境下的线程安全问题几乎都出在这里。但很多人只记住了"别写实例变量",没想过为什么容器要这么设计——省内存、避免反复创建对象的开销,代价就是你必须保证Servlet是线程安全的。
请求对象是有生命周期的。HttpServletRequest和HttpServletResponse只在service()方法执行期间有效,方法返回后容器会把它们销毁或回收。如果你在异步处理中保存了请求对象,或者在其他线程里尝试读写请求的InputStream,大概率会拿到一堆奇怪的异常。
getParameter和getInputStream不能混用。一个请求体的内容只能被解析一次。如果你先调用了request.getParameter("name"),容器可能已经读取了请求体来解析表单参数,这时候再去拿request.getInputStream()读文件内容,会发现流已经到末尾了。这个限制在multipart请求里尤其致命,后面讲报错的时候会再提到。
理解这三个细节,再去看FeignClient的multipart报错,思路会清晰很多。
2. 当FeignClient遇上文件上传:报错场景还原
2.1 报错日志的第一层线索
先复现一下现场。微服务A需要调用微服务B的文件上传接口,代码大概长这样:
@FeignClient(name = "file-service", configuration = FileUploadFeignConfig.class) public interface FileUploadClient { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) R<String> upload(@RequestPart("file") MultipartFile file); }调用的时候,抛出了这么一条异常:
feign.FeignException: [413 Request Entity Too Large] during [POST] to [...] feign.FeignException$PayloadTooLarge: [413] during [POST] to [http://file-service/upload] at feign.FeignException.clientErrorStatus(FeignException.java:192) ...或者更直接的一条:
feign.feign.FeignException$BadRequest: [400] during [POST] to [...] [Failed to parse multipart servlet request; nested exception is java.lang.IllegalStateException: Unable to process parts as no multi-part configuration has been provided]还有一种是Spring Cloud OpenFeign里常见的包装方式:
feign.FeignException$BadRequest: [400] ... body: {"timestamp":"...","status":400,"error":"Bad Request","path":"/upload","message":"Failed to parse multipart servlet request; nested exception is java.io.IOException: The temporary upload location [/tmp/tomcat.xxx.work] is not valid"}看到failed to parse multipart servlet request这种关键字,第一反应不能是"去网上搜一下这段英文什么意思",而是要先搞清楚:这个异常是在哪一层被抛出来的?是调用方FeignClient的问题,还是服务提供方Tomcat的问题,还是服务提供方接口代码的问题?层级定位错了,后面的排查全部白费。
2.2 为什么"multipart servlet request"会解析失败
MultipartServlet这个类名其实有点误导,它实际上是Spring Framework里的一个内部工具类,完整的名字是org.springframework.web.multipart.support.StandardMultipartHttpServletRequest。当请求的Content-Type是multipart/form-data时,Spring MVC会调用StandardServletMultipartResolver来解析请求体,把二进制内容拆分成一个个part,每个part可能是文件,也可能是普通表单字段。
回到那条异常消息本身。Unable to process parts as no multi-part configuration has been provided这句话翻译过来是:请求体是multipart类型,但是Servlet容器里没有为这个应用配置multipart解析器。
很多人看到这里会疑惑:Spring Boot不是自动配置了multipart吗?spring.servlet.multipart.enabled=true不是默认就开启的吗?为什么还会报这个错?
问题出在自动配置的边界上。Spring Boot确实默认开启了multipart支持,但它的实现是基于StandardServletMultipartResolver的,这个解析器需要依赖Servlet容器提供的javax.servlet.http.Part解析能力。如果你的接口走的是Spring MVC的@RequestParam("file") MultipartFile file这种写法,Spring的DispatcherServlet会主动调用multipart resolver去解析请求体,一切正常。
但是,当你的服务收到一个请求,请求的Content-Type确实是multipart/form-data,可Spring MVC框架层面压根没有走DispatcherServlet的multipart解析流程时,容器自身不会自动去拆解请求体,就会报这个Unable to process parts。
触发这种情况的场景有很多种,最常见的就是:服务提供方把文件上传接口写成了HttpServletRequest直接接收,或者Filter/Interceptor提前读取了请求流,再或者就是FeignClient调用时请求头没正确设置,服务端接收到的Content-Type不对,导致Spring的解析器没有触发后面的逻辑。
再来看The temporary upload location ... is not valid这种情况。Tomcat处理multipart请求时,会把超过阈值的文件先写到临时目录,默认是${java.io.tmpdir}下的一个随机目录,比如/tmp/tomcat.xxx.work。如果这个目录被系统清理掉了(Linux的/tmp清理策略非常常见),Tomcat再想往里面写临时文件,就会报目录不可用。
这两种根因,一个是配置缺失,一个是环境清理,但异常信息都指向同一个表面现象——failed to parse multipart servlet request。如果不分青红皂白直接改代码,很容易修错方向。
3. 完整的排查链路:从异常堆栈到根因锁定
3.1 第一步:确认异常发生的边界
拿到堆栈信息后,第一件事不是读异常消息,而是把堆栈完整拉出来,重点看它是在哪一行代码开始变的。
如果堆栈是你服务里的FileUploadClient.upload()这里抛出的,说明异常是在HTTP响应层出现的,也就是说服务提供方返回了一个4xx/5xx状态码,FeignClient把响应体包装成了FeignException。这种情况下,服务提供方的日志才是关键。
如果堆栈是在Tomcat的ApplicationFilterChain、StandardHostValve这些容器类里抛出的,说明请求还没进入业务代码,在容器解析请求体的时候就挂了。这种问题通常和服务提供方自己的接口代码无关,而是和请求本身、容器配置有关。
区分这两者的意义在于:很多人在服务A收到FeignException后,反复检查FeignClient的配置,改了一晚上注解,最后发现问题出在服务B的Tomcat上——目录不存在了、临时目录权限不对、甚至服务B根本没有配置multipart。方向错了,花多少时间都是白费。
3.2 第二步:复现与隔离变量
既然异常信息明确提到了multipart servlet request,我建议用最原始的方式做一次复现测试:直接用curl命令手动构造一个multipart请求,打向服务提供方的接口。
curl -v -X POST http://file-service/upload \ -H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW" \ -F "file=@/path/to/test.pdf"如果curl手动请求能正常上传,说明服务提供方这边的接口和容器配置没问题,问题大概率出在FeignClient的编码器上,也就是请求构造这一侧。如果手动请求也报同样的异常,那问题就在服务提供方,可以直接聚焦到Tomcat和Spring MVC的multipart配置上。
这个隔离步骤几乎可以少走一半弯路。我之前遇到过很多案例,开发觉得FeignClient是"主要嫌疑人",于是在调用方改配置、加依赖、调参数,折腾了好几天。实际上手动请求一发就原形毕露,服务端Tomcat临时目录被Linux的systemd-tmpfiles清掉了,改Feign完全是无用功。
3.3 第三步:定位根因
通过隔离测试确认问题归属后,就需要具体看根因了。根据异常信息的不同,大概可以分成下面几类:
| 异常信息关键字 | 根因方向 | 排查重点 |
|---|---|---|
Unable to process parts as no multi-part configuration has been provided | 容器/Spring MVC的multipart配置缺失 | 查看服务是否禁用了multipart,spring.servlet.multipart.enabled是否为false;DispatcherServlet是否配置了MultipartResolver |
The temporary upload location ... is not valid | Tomcat临时目录失效 | 检查${java.io.tmpdir}指向的目录是否存在,权限是否正确,是否被系统定期清理 |
the request was rejected because its size exceeds the configured maximum | 文件大小超过限制 | 查看spring.servlet.multipart.max-file-size和max-request-size配置 |
Current request is not a multipart request | 请求的Content-Type不是multipart/form-data | 检查FeignClient调用时的Content-Type头是否正确设置;是否有拦截器篡改了请求头 |
有意思的是,最后一种情况在FeignClient里特别常见。如果你在@FeignClient接口上写了consumes = MediaType.APPLICATION_JSON_VALUE,或者全局配置里给所有Feign请求统一加了JSON的Content-Type,那么请求体虽然内容是multipart格式,但Content-Type头不是multipart/form-data。服务端一看:这不是multipart请求,那就不走multipart解析逻辑,直接按普通POST处理,然后你会在controller里发现MultipartFile参数为null,或者直接报Current request is not a multipart request。
4. 修复方案的对比与选择
4.1 方案一:正确配置Feign的encoder
FeignClient要发送multipart请求,核心在于请求编码器。Spring Cloud OpenFeign对multipart的支持需要feign-form这个库。如果你的服务里没引入它,或者引入了但没正确配置SpringFormEncoder,Feign会把MultipartFile对象用默认的编码器序列化,结果就是服务端收到的请求体完全不是multipart格式。
最简单、最省事的配置方式是加依赖,然后让Spring Boot自动装配。
<dependency> <groupId>io.github.openfeign.form</groupId> <artifactId>feign-form</artifactId> <version>3.8.0</version> </dependency> <dependency> <groupId>io.github.openfeign.form</groupId> <artifactId>feign-form-spring</artifactId> <version>3.8.0</version> </dependency>然后在FeignClient的配置类里,把Encoder替换成SpringFormEncoder:
@Configuration public class FileUploadFeignConfig { @Bean public Encoder feignFormEncoder() { return new SpringFormEncoder(); } }这套配置本身就解决了一大批"Feign传文件失败"的问题。很多时候改完这个,之前相关的报错就都消失了,因为Feign现在能正确地把MultipartFile编码成真正的multipart/form-data请求体。
4.2 方案二:调整Servlet容器的multipart配置
刚才说过,Spring Boot默认启用了multipart解析,但参数默认值很小,只有1MB。生产环境传PDF、传图片,动辄几十MB,一超过限制就会报错。
在application.yml里调大这些参数:
spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB location: /data/tmp这里有两个点我要单独拎出来说。
max-file-size和max-request-size的区别。max-file-size是单个文件的大小上限,max-request-size是整个请求体的大小上限。如果一个请求同时上传5个文件,每个文件20MB,那每个都没超过max-file-size,但加起来超过max-request-size,请求一样会被拒绝。
location参数最好主动指定。默认情况下Tomcat会把临时文件写到操作系统临时目录,Linux下就是/tmp。但很多服务器环境的/tmp会被定时清理,导致应用运行几天后突然出现The temporary upload location ... is not valid。我的习惯是显式指定一个独立目录,比如/data/tmp,并在部署脚本里保证这个目录存在且可写。这样既规避了系统清理的风险,也方便排查磁盘占用问题。
如果你用的不是Spring Boot,而是在传统Tomcat下部署war包,那就在web.xml里给Servlet配置multipart参数:
<servlet> <servlet-name>uploadServlet</servlet-name> <servlet-class>com.example.UploadServlet</servlet-class> <multipart-config> <max-file-size>52428800</max-file-size> <max-request-size>52428800</max-request-size> <file-size-threshold>1048576</file-size-threshold> </multipart-config> </servlet>file-size-threshold表示文件大小超过这个值时才写到磁盘临时文件,否则直接放内存。设成1MB是个比较折中的值,太大会占用更多内存,太小会增加磁盘IO。
4.3 方案三:服务提供方的接口设计调整
这个方案不是首选,但确实是很多团队的实际情况。有些老系统里的上传接口不是标准的Spring MVC写法:
@PostMapping("/upload") public R<String> upload(HttpServletRequest request) { List<Part> parts = request.getParts(); // 手动处理parts }这种写法有一个前提:request.getParts()能拿到东西,是因为容器已经完成了解析。但如果你的项目里Filter层提前读取了request的InputStreream,或者请求被转发过一次,parts可能就没了。
更稳的做法是改用Spring MVC标准的多文件接收方式:
@PostMapping("/upload") public R<String> upload(@RequestParam("file") MultipartFile file) { // 直接处理file }或者接收多个文件:
@PostMapping("/upload") public R<String> upload(@RequestParam("files") MultipartFile[] files) { ... }用Spring MVC的MultipartFile替代裸的HttpServletRequest + Part,等于把multipart解析交给了Spring框架,这件事比你自己手动从容器里取要可靠得多。特别是你的服务里还有各种Filter、Interceptor的时候,Spring的解析器会缓存解析结果,后面再获取MultipartFile就走缓存,不会重复读取请求流。
如果实在无法改接口签名,必须用HttpServletRequest,那就要确保获取parts之前没有任何地方消费了请求体,同时在web.xml或配置类里显式声明multipart配置。
5. 让Servlet的multipart配置更健壮:参数调优与常见坑
5.1 关键参数与它们的作用
把multipart相关的配置用一张表理清楚,以后排查问题直接对着表查:
| 参数 | 默认值 | 作用 | 典型错误设置 |
|---|---|---|---|
spring.servlet.multipart.enabled | true | 是否启用multipart解析 | 为了"加速"被改成false,然后文件上传全挂 |
spring.servlet.multipart.max-file-size | 1MB | 单个文件大小上限 | 设置了但没同时调大max-request-size |
spring.servlet.multipart.max-request-size | 10MB | 整个请求体大小上限 | 单文件调大了,多文件请求还是被拒 |
spring.servlet.multipart.file-size-threshold | 0B | 超过该大小写入磁盘临时文件 | 设成0,小文件也写磁盘,IO开销大 |
spring.servlet.multipart.location | 系统临时目录 | 磁盘临时文件目录 | 不设置,/tmp被清理后异常 |
这里我想再强调一遍,max-request-size是个非常容易被忽略的坑。我见过不少人把max-file-size调到了100MB,结果上传一个大文件时还是报错,日志显示的是请求被拒绝。一看max-request-size还停留在默认的10MB,因为他们只记住了"max-file-size是文件大小限制",不知道整个请求体的限制是另一个参数在管。
5.2 容易踩的坑
坑一:Filter里读了请求流,multipart就废了。如果你在Filter里做日志记录或者参数篡改,直接调用request.getInputStream()或request.getReader(),那请求体就被消费掉了,后面Spring MVC的multipart解析器拿不到数据,MultipartFile参数直接为空,或者报Failed to parse multipart servlet request。
正确的做法是用ContentCachingRequestWrapper包装请求,通过它的getContentAsByteArray()拿请求体,而不是读取原始的InputStream。注意ContentCachingRequestWrapper本身也有坑——如果你在Filter里调用了getInputStream(),再去调用getContentAsByteArray(),可能拿到的还是不完整的数据。安全起见,最好是先让请求完整走完,在finally块里拿缓存数据。
坑二:Nginx层把请求体转发丢了。微服务架构里,很多服务前面会挂Nginx做反向代理。Nginx默认client_max_body_size是1MB,如果你的文件超过这个大小,Nginx直接返回413,请求根本到不了Tomcat。这时候你在Spring层调什么参数都没用。
排查方法很简单:手动curl请求服务端口,看是否正常;再curl请求Nginx端口,看是否被413。如果Nginx挡了,在Nginx配置里加:
client_max_body_size 50m;这个配置可以在http、server或location块里设置,具体看你的代理范围。
坑三:FeignClient的请求头被全局配置污染。如果你的项目里有全局的Feign请求拦截器(RequestInterceptor),给所有请求加了Content-Type: application/json,那么文件上传请求也会被影响。Feign调用方正确配置了encoder,但仍然失败,原因就在这里。
解决方式是在@FeignClient注解里单独指定configuration,给文件上传客户端用独立的配置,不要在全局拦截器里统一设置Content-Type。
坑四:临时目录的磁盘空间不足。大文件上传时,Tomcat先把文件写入临时目录,处理完再删除。但如果并发上传量大,临时目录磁盘被占满,就会出现No space left on device之类的异常。监控临时目录的磁盘使用率,比你等到用户报障再排查要靠谱得多。
6. 事后复盘:一切异常都该回到Servlet原理去思考
把这次FeignClient multipart报错的排查链路完整梳理一遍,你会发现所有的问题本质上都和Servlet的工作原理有关:请求的生命周期、请求体的单次可读性、容器的multipart解析机制、临时目录的管理策略。
这也是我一直建议团队里后端开发认真学习Servlet规范的原因。Servlet规范是Java Web的基础,Spring MVC只是在这个基础上做了一层封装。封装把很多细节藏起来了,但不代表这些细节不存在。当你调@RequestParam("file") MultipartFile file的时候,背后的Spring代码正在处理:检查请求的Content-Type,构造StandardMultipartHttpServletRequest,调用容器的getParts()方法,处理解析过程中的IO异常。这一整套流程中任何一个环节失败,抛出来的异常就到了你眼前。
碰到这类问题,别急着搜异常消息、复制粘贴解决方案,先问自己三个问题:异常是在哪一层抛出来的?请求体是从哪个环节开始丢失或被改变的?我当前的配置参数有没有覆盖住生产环境的真实请求体大小?
如果你能熟练回答这三个问题,大部分multipart相关的问题都能在十分钟内定位到根因。至于FeignClient的多文件上传、多part包含普通字段和文件这种复杂请求,处理方法其实和单文件一样,只是编码器配置更加规范。日常开发中我还会额外建议:文件上传接口的参数校验一定要放在服务端做,不要依赖调用方的自觉;大文件上传考虑分片和断点续传,这是在Servlet这层配置之外的事情,但不做迟早要踩更大的坑。
我把这些踩过的坑、排查过的路径都写在这里了,希望你在下次遇到failed to parse multipart servlet request时,可以少走一些弯路。