上个月和第三方系统联调文件上传接口对方很笃定地发来一段请求日志说接口返回了 415。我看了一眼后台日志一行异常就那么躺在那里Content type multipart/form-data; boundary----WebKitFormBoundary7MA4YWxkTrZu0gW; charsetUTF-8 not supported。这个报错名字很长看着很唬人尤其是刚接触文件上传的同事第一反应基本是“文件太大boundary 丢了Spring Boot 版本有问题”其实真相比这简单得多而且只要理解了 Spring MVC 挑选消息转换器的逻辑排查起来十分钟都用不到。这篇文章不打算绕弯子直接从这次真实排障经历出发把这个报错从现象到原理、从复现到修复完整讲一遍。如果你后端用的是 Spring Boot前端用的是浏览器 FormData、axios、curl 或者某些接口调试工具遇到带charsetUTF-8的 multipart 请求头这篇文章基本能帮你一步到位。1. 报错现场与问题定位415 是怎么被抛出来的1.1 这个异常长什么样先看一个典型的异常栈org.springframework.web.HttpMediaTypeNotSupportedException: Content type multipart/form-data;boundary----WebKitFormBoundary7MA4YWxkTrZu0gW;charsetUTF-8 not supported at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:161) at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:108) ...对应的 HTTP 响应状态码是 415 Unsupported Media Type也就是“不支持的媒体类型”。这个状态码本身已经把问题说得很明白了服务端不认这个 Content-Type。但奇怪的是multipart/form-data明明是文件上传最标准的格式为什么服务端不认关键就在异常信息里的那一串参数boundary----WebKitFormBoundary...; charsetUTF-8。multipart/form-data是主媒体类型boundary是必需参数问题基本出在最后的charsetUTF-8上。1.2 Content-Type 是怎么影响参数绑定的我们可以把 Content-Type 理解成快递单上的“物品类型”。快递员看到这个类型才知道把包裹交给哪个仓库处理。Spring MVC 也类似收到 HTTP 请求后要根据 Content-Type 找到合适的HttpMessageConverter由这个转换器去解析请求体再把解析结果绑定到 Controller 方法的参数上。如果请求的 Content-Type 恰好没有一个转换器能处理Spring 就会抛出HttpMediaTypeNotSupportedException。文件上传场景中负责处理multipart/form-data的通常是FormHttpMessageConverter。它默认支持的媒体类型列表里写的是multipart/form-data不带任何额外参数。于是问题来了当客户端送来的 Content-Type 是multipart/form-data; boundary...; charsetUTF-8时Spring 需要判断“我支持的multipart/form-data”能不能包含“客户端发来的这个带了一堆参数的媒体类型”。不同版本的 Spring 对这件事的容忍度不一样有些版本直接忽略多余参数有些版本则严格比较一旦发现charsetUTF-8不在自己认可的范围内就判定不匹配于是 415。1.3 charsetUTF-8 为什么是一个多余参数这里要先厘清一个容易混淆的概念multipart/form-data本身在规范里并没有charset参数。每个 part 内部可以有自己的字符集比如某个文本文件可以声明Content-Type: text/plain; charsetUTF-8但整个 multipart 请求的 Content-Type 只需要multipart/form-data加一个boundary就够了。很多 HTTP 客户端库和接口调试工具有个坏毛病喜欢在请求头里“好心”补全字符集。它们看到是表单提交就自动追加charsetUTF-8结果拼出来一个非标准的 Content-Type。浏览器自己发 FormData 的时候一般不会这么干但一些第三方工具、服务端之间互相调用时这个问题就特别常见。我之前还遇到过更离谱的同一个接口浏览器上传正常某个内部服务用 HTTP 客户端调用就 415。最后抓包发现该客户端底层拦截器在请求头里强制拼了charsetUTF-8。所以看到这个报错第一优先级不是改后端而是先确认请求头到底长什么样。2. Spring 内部的 Content-Type 解析链路从 DispatcherServlet 到 canRead2.1 一个请求进入 Spring Boot 后发生了什么要彻底搞懂这个报错得先知道一个 multipart 请求从进来到绑定参数中间经过了几道关卡。Spring MVC 处理一个请求的大致路径是DispatcherServlet接收到请求。HandlerMapping根据 URL 找到对应的 Controller 方法。HandlerMethodArgumentResolver负责解析方法参数。解析参数时如果需要读取请求体会调用HttpMessageConverter系列组件。HttpMessageConverter.canRead()判断当前转换器能不能处理请求的 Content-Type。如果所有转换器都返回 false抛出HttpMediaTypeNotSupportedException。关键在于第 4 步到第 6 步。不同的参数绑定方式走的解析器不一样对 Content-Type 的敏感度也不一样。2.2 HttpMessageConverter 的 canRead 到底在比什么Spring Boot 启动时会自动装配一批HttpMessageConverter。常见的包括转换器默认支持的媒体类型典型用途ByteArrayHttpMessageConverterapplication/octet-stream、*/*byte[] 参数StringHttpMessageConvertertext/plain、*/*String 参数FormHttpMessageConverterapplication/x-www-form-urlencoded、multipart/form-data表单和 multipartMappingJackson2HttpMessageConverterapplication/json、application/*jsonJSON 对象当请求带charsetUTF-8时StringHttpMessageConverter看到的媒体类型是multipart/form-data主类型不是text/plain不匹配MappingJackson2HttpMessageConverter看到的也不是 JSON不匹配FormHttpMessageConverter按理说可以匹配因为它支持multipart/form-data但匹配时还要比较参数部分。Spring 的MediaType.includes()和isCompatibleWith()在比较媒体类型时会同时检查参数。FormHttpMessageConverter注册的媒体类型是裸的multipart/form-data没有charsetUTF-8这个参数于是某些版本下就会判为不兼容。这也是同一个项目升级 Spring Boot 版本后突然出现 415 的原因旧版本对参数比较宽松新版本更严格。如果你在项目里还加了自定义消息转换器或者通过extendMessageConverters调整了转换器顺序情况会更复杂。转换器顺序变了原本能被FormHttpMessageConverter接住的请求可能先被前面的某个转换器“看一眼”然后被跳过最后落到一个完全不支持的转换器上。这也是为什么网上有人加一个自定义转换器就解决了有人加了反而报错。2.3 RequestPart 与 RequestParam 的差别很大我排查这个问题时发现很多人把RequestPart和RequestParam混着用但对 Content-Type 的容忍度完全不同。RequestParam(file) MultipartFile file直接从MultipartResolver解析出来的 multipart 参数表里取文件不经过HttpMessageConverter。只要 Content-Type 是以multipart/开头它就不太关心后面的参数细节。所以这种写法对charsetUTF-8几乎是免疫的。RequestPart(file) MultipartFile file更强调“part”的概念会把请求体拆成不同的 part然后按 part 的 Content-Type 去匹配转换器对媒体类型的参数更敏感。一旦 multipart 请求头的格式不够标准就容易触发 415。这也是为什么很多实际项目里把RequestPart改成RequestParam后报错立刻消失。但要注意如果接口需要同时接收文件和 JSON 元数据比如PostMapping(/upload) public Result upload(RequestPart(file) MultipartFile file, RequestPart(meta) MetaDTO meta) { // ... }这种场景不能无脑改成RequestParam因为RequestParam没法把一个 part 的内容转换成一个复杂 DTO。这个时候还是要回到 Content-Type 本身去解决。3. 从客户端到服务端的四种修复方案3.1 根治客户端不要手动拼 Content-Type绝大多数情况下这个报错的病根都在客户端最干净的修复就是让框架自己生成 Content-Type。前端如果用浏览器 FormData 上传最标准的方式是const formData new FormData(); formData.append(file, file); fetch(/upload, { method: POST, body: formData });这里注意千万不要在 fetch 的 headers 里手动写Content-Type: multipart/form-data。一旦你手动设置了浏览器就不会自动补上boundary参数后端反而会报 “Missing boundary” 之类的错误。让浏览器自动生成它才知道把 boundary 和 body 拼成一套完整的东西。curl 也是一样的套路。很多人习惯这样加请求头curl -X POST http://localhost:8080/upload \ -H Content-Type: multipart/form-data \ -F filetest.txt这样写就有风险。正确做法是干脆不写-H直接用-F让 curl 自己处理curl -v -F file/tmp/test.txt http://localhost:8080/upload如果确实需要用接口调试工具并且工具允许自定义请求头那就要保证 Content-Type 只包含boundary不要追加charsetContent-Type: multipart/form-data; boundary----CustomBoundary123而且要保证请求体里的 boundary 和请求头里写的一致。这种手动拼接的方式不推荐容易顾此失彼。3.2 后端兜底放宽 FormHttpMessageConverter 的匹配范围如果客户端是第三方系统对方不愿意改代码那后端可以做一些兼容处理。一个思路是自定义一个继承FormHttpMessageConverter的转换器放宽canRead的匹配逻辑import org.springframework.http.MediaType; import org.springframework.http.converter.FormHttpMessageConverter; public class LenientFormHttpMessageConverter extends FormHttpMessageConverter { Override public boolean canRead(Class? clazz, MediaType mediaType) { if (mediaType ! null mediaType.getType().equalsIgnoreCase(multipart)) { return true; } return super.canRead(clazz, mediaType); } }然后在配置里把它加到转换器列表最前面import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; Configuration public class MultipartConverterConfig implements WebMvcConfigurer { Override public void extendMessageConverters(ListHttpMessageConverter? converters) { converters.add(0, new LenientFormHttpMessageConverter()); } }注意这个方案不是万能的。它主要影响走HttpMessageConverter的链路间接能缓解一部分RequestBody或 part 级转换问题。对于已经很标准的RequestParam MultipartFile其实不需要这个配置。加了之后要回归测试其他上传接口确保没有把本该失败的非 multipart 请求也被误放行。3.3 接口调整能把 RequestPart 换成 RequestParam 就换从我的实际经验看最省事且改动面最小的方案是把接口参数从RequestPart改成RequestParam。PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); // 业务处理 return Result.ok(originalFilename); }这个改动对调用方完全透明接口路径、请求参数名、返回结构都不用动。RequestParam方式直接从 multipart 解析结果里找file不再对 Content-Type 做严格的媒体类型匹配所以客户端带不带charsetUTF-8都无所谓。但要注意前面提到的限制如果 Controller 方法里还有RequestPart(meta) MetaDTO meta这种复杂对象就不要强行改成RequestParam。这时候可以拆接口比如一个接口只传文件另一个接口传元数据代价是调用方多一次请求。3.4 跨服务调用Feign 等 HTTP 客户端场景要检查拦截器还有一种很隐蔽的场景服务 A 通过内部 HTTP 客户端调用服务 B 的上传接口服务 B 返回 415。这种时候问题不在浏览器而在服务 A 的请求构造链路。我遇到过的情况是服务 A 的某个公共拦截器统一给所有请求头追加了charsetUTF-8。它本意是照顾纯文本请求结果把 multipart 请求也连累了。排查方法很简单在服务 B 的入口把请求头完整打出来看 Content-Type 是不是真的带了charsetUTF-8。如果是 Feign检查有没有自定义RequestInterceptor修改了 headersimport feign.RequestInterceptor; import feign.RequestTemplate; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class FeignInterceptorConfig { Bean public RequestInterceptor multipartCharsetFixInterceptor() { return new RequestInterceptor() { Override public void apply(RequestTemplate template) { template.removeHeader(Content-Type); } }; } }这种拦截器只建议加在上传专用的 Feign Client 上不要全局使用否则会破坏其他接口的 Content-Type。关键是定位是哪个环节拼出来的 charset而不是盲目删头。4. 一次真实复现模拟项目里的完整排查链路4.1 复现环境准备我这里用一个模拟项目来说明整个排查过程。模拟项目基于 Spring Boot 2.7.18JDK 8接口定义如下import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; RestController public class UploadController { PostMapping(value /upload, consumes MediaType.MULTIPART_FORM_DATA_VALUE) public Result upload(RequestPart(file) MultipartFile file) { return Result.ok(file.getOriginalFilename()); } }这个接口看起来没有任何问题。用浏览器 FormData 上传正常返回文件名。接下来我写了一个模拟“不规范客户端”的 Python 脚本手动构造带charsetUTF-8的 multipart 请求import requests boundary ----CustomBoundary123 body ( f--{boundary}\r\n Content-Disposition: form-data; namefile; filenametest.txt\r\n Content-Type: application/octet-stream\r\n \r\n hello world\r\n f--{boundary}--\r\n ).encode() headers { Content-Type: fmultipart/form-data; boundary{boundary}; charsetUTF-8 } resp requests.post(http://localhost:8080/upload, databody, headersheaders) print(resp.status_code) print(resp.text)运行之后服务端立刻抛出文章开头那个异常HTTP 状态码 415。这一步成功复现。4.2 逐步排除找到真正原因第一步先排除文件大小问题。如果请求体超过配置上限Spring 会抛MaxUploadSizeExceededException和HttpMediaTypeNotSupportedException不是同一个异常。所以异常类型已经排除了大小限制。第二步检查请求头。抓包确认脚本发出的 Content-Type 就是multipart/form-data; boundary----CustomBoundary123; charsetUTF-8。把charsetUTF-8去掉后再执行同样的请求接口恢复正常。到这里基本可以确定多出来的 charset 参数是元凶。第三步验证参数绑定方式的影响。把接口改成PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { return Result.ok(file.getOriginalFilename()); }再次用带charsetUTF-8的请求调用发现可以正常返回不再报 415。这说明RequestParam的解析链路确实比RequestPart宽容得多。第四步查看当前项目里有没有额外注册的HttpMessageConverter。如果项目里引入了某些增强 JSON 处理的库它可能改变转换器顺序让原本能匹配的FormHttpMessageConverter没机会上场。我这里干净项目里没有所以直接确认是 Spring 内置匹配逻辑导致的。4.3 这次排查的最终结论最终修复方案很简单让客户端去掉手动拼接的charsetUTF-8同时我把接口从RequestPart改成了RequestParam做了双保险。这里有一个很重要的经验排查这类报错不要一上来就搜“Spring Boot 版本问题”。先看请求头再试参数注解最后才是改全局配置。顺序搞反了很容易把项目改出新的兼容性坑。5. 把这次经验变成日常防御上传接口的排查与配置建议5.1 在日志里完整打印 Content-Type这次排障让我养成了一个习惯上传接口的入口处强制打印请求头中的 Content-Type。import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class MultipartLogInterceptor implements HandlerInterceptor { private static final org.slf4j.Logger log org.slf4j.LoggerFactory.getLogger(MultipartLogInterceptor.class); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String contentType request.getContentType(); if (contentType ! null contentType.toLowerCase().contains(multipart/form-data)) { log.info(multipart request, uri{}, content-type{}, request.getRequestURI(), contentType); } return true; } }这个日志的威力在于第三方说“我这边没问题”的时候你可以直接把日志截图甩过去。谁拼错了 Content-Type一目了然。无论客户端是浏览器、curl、内部服务还是什么接口调试工具只要进入后端真实的请求头都会露出来。5.2 顺带检查一遍 multipart 配置虽然这个报错和文件大小限制无关但排查上传问题时还是要顺手确认一下application.yml里的 multipart 配置配置项作用建议spring.servlet.multipart.max-file-size单个文件大小上限按业务最大文件再留一点余量spring.servlet.multipart.max-request-size整个请求体大小上限必须大于单个文件上限spring.servlet.multipart.file-size-threshold超过阈值后写入临时目录默认 0 表示直接写临时文件内存富余可调大spring.servlet.multipart.location临时文件目录保证磁盘空间足够比如单个文件最大 10MB那max-file-size可以设 12MBmax-request-size设 15MB。如果文件不大但一直上传失败先看这两个值别一上来就怀疑代码。5.3 联调前的约定multipart 请求头只留 boundary最后分享一个非常实用的团队约定所有涉及 multipart/form-data 的接口统一规定 Content-Type 只能写成这样multipart/form-data; boundary-------------------任意字符串禁止任何人手动附加charsetUTF-8或其他自定义参数。调用方只需要设置请求体格式Content-Type 交给 HTTP 库生成。这样可以避免很多没意义的兼容代码。我之前也做过一段时间的“后端兼容多参数 multipart”后来发现每兼容一个“非标准客户端”就多一个没人敢动的历史配置。与其这样不如拿规范说事。大部分对方只是不知道这个细节你把报错日志和标准文档发过去改动也就是删一个无效参数的事。这个报错看着复杂内核其实很朴实Content-Type 上多了一个不被接受的参数Spring 找不到能处理它的消息转换器所以 415。排查时从请求头入手先确认是谁把 charset 拼上去的再决定是改客户端、改接口注解还是加兼容配置。按这个思路走基本不会跑偏。