Spring 文件上传全链路深度解析(二):限额、故障与不落盘直传
浏览器控制台一行 net::ERR_CONNECTION_RESET,Network
面板里那条 POST /api/upload
停在待响应状态,状态码是空的。翻后端日志——INFO 翻到
DEBUG,一行异常都没有。把同一个文件切成 5 MB
分片再传,一切正常。
- 一个上传请求,从 Nginx 到对象存储,中间有几道限额?分别由谁拦下?
- 为什么被拒绝之后,客户端看到的是”连接被重置”而不是一个 413?
- Tomcat 有个
maxSwallowSize(“最多替客户端吞掉多少请求体”)——它为什么能让应用连一句日志都留不下?- 为什么写在 Controller 里的异常处理方法,明明写了却没被调用?
- 上传一定要先落到本地临时文件吗?能不能边收边写进对象存储?
- 预签名 URL 直传和”应用流式转发”差在哪里,代价是什么?
MultipartFile.getOriginalFilename()到底能不能信?
这篇接着 (一):从报文到临时文件
往下走。第一篇讲报文怎么被解析、MultipartFile
从哪里来;这一篇只关心一件事:这套机制在真实系统里以什么方式失败,以及怎么绕开它。
口径是 Spring Boot 3.2+ / Spring Framework 6.1 / Tomcat 10.1,2.x 与
3.0/3.1 的差异单独标注。数据路径的架构选择可以和姊妹篇 Spring
文件下载全方案深度解析
的「五、架构层」对着看:下载是把数据踢出应用进程,上传是把数据挡在应用进程之外,方向相反,取舍同源。
六、四道限额,四种失败表现
6.1 全链路图
先建立全局地图,后面每节只拆一层。
1 | 浏览器 |
最反直觉的一点:越靠外的限制,越容易让客户端拿不到一个像样的响应。
6.2 第一道:Nginx 的
client_max_body_size
四道限额里最外面的一道,由 Nginx 自己说话。
1 | location /api/upload { |
默认 1m 来自 Nginx
官方文档。它可写在 http / server /
location
三层,但只在请求真正经过的那一层生效——放在
http 里被内层 location
覆盖是很常见的翻车点。
超限时 Nginx 自己返回
413 Request Entity Too Large,请求根本不会到后端。文档还提醒:浏览器没法正确显示这个错误——这正是后面要把它改写成
JSON 的原因。
6.3 第二道:Tomcat Connector 的几个数
| 属性 | 默认值 | 限制的是什么 |
|---|---|---|
maxPostSize |
2097152(2 MiB) |
会被转换成请求参数的请求体字节数 |
maxParameterCount |
10000 |
参数 + 上传文件的总个数,超出部分被忽略 |
maxPartCount |
50 |
multipart/form-data 的 part
个数上限 |
maxPartHeaderSize |
512 |
每个 part 的头部字节上限,超出直接拒绝 |
maxSwallowSize |
2097152(2 MiB) |
连接层”吞掉”未读请求体的上限 |
maxParameterCount
有个必须交代的来源:Connector 源码的字段注释写着
“10000 by default. The default Tomcat server.xml configures a
lower default of 1000.” Tomcat 自带的 server.xml
会把它调成 1000,而 Spring Boot 内嵌容器不读
server.xml,所以内嵌场景用 10000,外置 Tomcat
场景(沿用官方 server.xml)是
1000。照着外置经验对日志会对不上,原因就在这里。
maxPostSize 是最大的坑:Tomcat
文档明确说它不是请求体总大小的通用上限,只在 Tomcat
为解析参数而处理请求体时生效。落到 multipart/form-data
上,它只约束”用来生成 getParameter()
那批数据”,也就是普通表单字段:
1 | // org.apache.catalina.connector.Request#parseParts(Tomcat 10.1.x) |
文件部分不算进 maxPostSize 的账。 把
server.tomcat.max-http-form-post-size 从 2 MB 调到 50
MB,对”用户传了一个 30 MB 的文件”毫无帮助。
Spring Boot 只暴露了其中两个(默认值来自 Boot 3.2 的
ServerProperties,映射在
TomcatWebServerFactoryCustomizer):
1 | server: |
maxParameterCount 和 maxPartCount
没有对应的 Boot 属性,要自己加
TomcatConnectorCustomizer:
1 | factory.addConnectorCustomizers(connector -> { |
调大前先算账。Tomcat 文档的内存估算公式是
maxPartHeaderSize × maxPartCount × maxConnections × 2——按默认值:512 × 50 × 8192 × 2,约
400 MB。这是文档明确提示的 DoS 面。
6.4 第三道:Spring 的 multipart 配置
真正会送进 Tomcat 解析器的就是这几项:
1 | spring: |
默认值来自
MultipartProperties(maxFileSize = DataSize.ofMegabytes(1)、maxRequestSize = DataSize.ofMegabytes(10)、fileSizeThreshold = DataSize.ofBytes(0)、resolveLazily = false)。它们被写进
MultipartConfigElement,再交给 Tomcat 内嵌的
fileupload:
1 | // Request#parseParts |
StandardServletMultipartResolver
的类注释里有一句关键的话:这些设置在 Servlet
容器层面被应用,容器不允许从 MultipartResolver
层面去改。 所以别指望用 MultipartResolver bean
设上限——Boot 已经把这些值落到 servlet 注册的
MultipartConfigElement 上了。
6.5
第四道:maxSwallowSize——没有状态码的那一道
前三道至少发生在 HTTP 语义之内。第四道不是。
HTTP/1.1 的连接可以复用。Tomcat 处理完一个请求,若
keep-alive 还开着,就必须保证 socket
里下一个字节是下一个请求的起始行。如果当前请求体还没读完(比如应用已经拒绝并返回
413),这些字节会污染下一个请求——所以 Tomcat
必须把它们读掉丢弃,这个动作叫
swallow(吞掉)。maxSwallowSize
就是”最多吞多少”,默认 2 MiB,源码里是写死的常量:
1 | // org.apache.coyote.http11.AbstractHttp11Protocol |
超了会怎样?准备响应头时先预判一次:
1 | // org.apache.coyote.http11.Http11Processor#checkMaxSwallowSize |
还有一环:413 本身就会关掉 keep-alive。
1 | // Http11Processor#statusDropsConnection |
真正下手的是输入侧的 filter:
1 | // org.apache.coyote.http11.filters.IdentityInputFilter#end |
那句注释后面挂着 Apache 的一篇文档,讲的正是”服务端先关连接、客户端还没读完响应”这个经典问题。这就是”连接被重置”的来源。
这个 IOException
的归宿,解释了为什么日志里什么都没有:
1 | // Http11Processor#endRequest |
它只把错误状态改成
CLOSE_CONNECTION_NOW(isError = true、isIoAllowed = false),一声不吭——只有非
IOException 的 Throwable 才会走到下面那个
ERROR 分支,所以”这条路径永不打印”只对 IOException
成立。要退回 socket 层才有 catch:
1 | // org.apache.coyote.AbstractProtocol.ConnectionHandler#process |
DEBUG 级别才可见。 默认 INFO 的生产日志里,一次连接被吞爆就是这么消失的。
串起来,引子里的故障有了完整解释:
- 客户端发起 100 MB
上传,
Content-Length: 104857600; checkMultipart触发getParts(),Tomcat 发现Content-Length > max-request-size,抛出SizeLimitExceededException(还没开始读请求体);- Spring 把它翻译成
MaxUploadSizeExceededException,写出 413; - 请求体还剩约 100 MB,超过
maxSwallowSize:checkMaxSwallowSize()关掉 keep-alive; IdentityInputFilter.end()吞到第 2 MiB 时抛IOException;endRequest()就地捕获,状态变CLOSE_CONNECTION_NOW,service()返回SocketState.CLOSED——Tomcat 直接关连接,客户端还在写,于是收到 RST;- 已经写进 socket 缓冲的那份 413,随着连接一起没了。浏览器报
ERR_CONNECTION_RESET。
还有一个容易误读的细节:isError 变 true
之后,service() 里会执行
response.setStatus(500)。但响应早就 commit
了,改不动报文,只能让访问日志和统计里出现一个
500。于是三方各说各话:前端说连接重置,访问日志说
500,应用日志什么都没有。
6.6 四种失败表现对照
| 拦在哪一层 | 触发条件 | 状态码 | 客户端看到什么 | 后端日志 |
|---|---|---|---|---|
| Nginx / 网关 | 请求体 >
client_max_body_size(默认 1m) |
413 |
Nginx 错误页;浏览器可能显示不了 | 无,请求没到应用 |
Tomcat maxPostSize |
表单字段累计超 2 MiB(文件不计) | 无固定值 | 取决于应用 | 默认无 |
Tomcat maxPartCount /
maxParameterCount |
part 或参数个数超限 | 无固定值(Tomcat 的
FailedRequestFilter → 400) |
取决于应用 | 默认无 |
Spring max-file-size /
max-request-size |
单文件或请求体超限 | Boot 3.2+ 是 413;Boot
3.0/3.1 与 2.x 是 500 |
可能拿到 413,也可能被重置 | 默认几乎无 |
Tomcat maxSwallowSize |
拒绝之后客户端还在发,剩余 > 2 MiB | 无 | ERR_CONNECTION_RESET |
默认无(DEBUG 才有) |
一句话总结:只要失败发生在”应用已经拒绝,但请求体还在路上”这个时间窗里,你能拿到的诊断信息几乎为零。
七、为什么
@ExceptionHandler 接不住
@ExceptionHandler 是 Spring MVC 里”在一个 handler
类(Controller 或全局
advice)上声明「我来处理这个异常」“的那套机制。先给结论:它大多数时候能接住——接不住的是另外几种情况。要分清,得先知道异常在哪里、以什么类型、在哪个时机抛出来。
7.1 原始形态:不是 Spring 的异常
解析发生在 Tomcat 内嵌的 fileupload 里,size 超限至少有这么几处抛点:
1 | // org.apache.tomcat.util.http.fileupload.impl.FileItemIteratorImpl#init |
注意第一个抛点:它拿 Content-Length 先判,body
一个字节都没读。 这是 Tomcat
自带的”快速拒绝”,不需要你写。此外还有两处兜底:Content-Length
缺席(chunked)时,FileItemStreamImpl$LimitedInputStream#raiseError(流式读取时的兜底校验)会在读到超限时抛出;part
头部超 maxPartHeaderSize 则由
MultipartStream#readHeaders(解析 part
头部的那一段)抛出。异常层级是:
1 | FileUploadException |
FileCountLimitExceededException(part 个数超限)直接继承
FileUploadException,不在 SizeException
之下——这个差别后面会用上。
7.2
一路被包成 IllegalStateException,再被 Spring”猜”出来
7.1 里那层 FileUploadIOException 会在这里先被拆掉:
1 | // org.apache.tomcat.util.http.fileupload.FileUploadBase#getItemIterator |
所以落到 parseParts 手上时,它已经是干净的
SizeException。parseParts
再把它们统一收口:
1 | } catch (SizeException | FileCountLimitExceededException e) { |
Spring 侧接住它的是:
1 | // org.springframework.web.multipart.support.StandardMultipartHttpServletRequest |
这是本篇最值得盯住的代码。Spring 认不出异常类型,它是在遍历 cause 链、匹配异常消息文本。 两个后果:
- 命中条件是”消息里同时有 exceed 和 size/length”(或 request +
big/large)。Tomcat
的英文消息恰好满足;换个容器、换个语言环境、或换一个自定义包装异常,这条匹配就可能失手,退化成
MultipartException。 - 即便命中,上限也是
-1。
getMaxUploadSize()返回 -1 只表示”上限未知”——真实的max-file-size/max-request-size已经在这一路包装里丢掉了,不要拿它拼错误文案。
所以 advice 至少要留两条:一条
MaxUploadSizeExceededException,一条兜底的
MultipartException。
7.3
时机:checkMultipart 跑在 getHandler 之前
先看这段顺序——后面所有”接不住”都由它决定。
1 | // DispatcherServlet#doDispatch |
抛异常时 mappedHandler 还是 null,这个
null 一路传到异常解析器:
1 | // ExceptionHandlerExceptionResolver#getExceptionHandlerMethod |
结论很硬:写在 Controller 里的
@ExceptionHandler(MaxUploadSizeExceededException.class)
不会被执行,只有全局 @ControllerAdvice 的会。
因为异常在”知道该由哪个 Controller 处理”之前就抛出来了。
还有一个开关能解释这种”行为不一致”:把 resolve-lazily
设成 true,解析推迟到参数解析阶段,那时
handlerMethod 已经有值,Controller
里的局部处理器就能生效了。同一个异常,换个配置换条路径。
7.4 没人接也不会是 500(Spring 6.1 / Boot 3.2 起)
MaxUploadSizeExceededException 从 Spring 6.1
起实现了 ErrorResponse(6.0.x 及更早没有):
1 | public class MaxUploadSizeExceededException extends MultipartException implements ErrorResponse { |
所以即便你什么都不写,优先级最低的
DefaultHandlerExceptionResolver 也会兜底:
1 | if (ex instanceof ErrorResponse errorResponse) { |
客户端拿到的 413 很可能不是你写的,是它送的。
这处分界容易被画错:分界点是 Spring 6.1(Boot 3.2),不是
6.0。
逐版本核对下来,v5.3.39、v6.0.0、v6.0.14
的 MaxUploadSizeExceededException 都还是
extends MultipartException,没有
ErrorResponse;直到 v6.1.0 才变成
implements ErrorResponse(引入提交
3b80f2c4,提交信息自述把状态码从 500 改成
413)。机制上的分工也要分清:DefaultHandlerExceptionResolver
的 ErrorResponse 分支 6.0.0 就已存在,只是
6.0 的这个异常没实现 ErrorResponse,所以 6.0 依然走
500。
所以正确读法是:Boot 2.x(5.3.x)和 Boot 3.0 / 3.1(6.0.x)→ 500;Boot 3.2+(6.1+)→ 413。 跨版本对比错误响应时先确认这一点。
补一个保守限定:上面说的是默认配置。如果显式打开了
spring.mvc.problemdetails.enabled(Boot 里该属性默认
false),兜底者会变成
ProblemDetailsExceptionHandler,响应体形态不同——但状态码仍是
413。
7.5 真正接不住的四类
| 情况 | 为什么接不住 |
|---|---|
| 请求被 Nginx / 网关拦下 | 应用根本没被调用,DispatcherServlet
没参与 |
请求在 DispatcherServlet
之前被拒 |
MultipartFilter、Servlet
Filter、Tomcat 参数解析都在 handler 链之外 |
| 响应已经 commit | handleErrorResponse
只在响应未提交时改状态码,已提交就只打一条 warn |
| 连接已经被关 | 处理器可能跑了,但写出去的字节没人读——6.5 那条路 |
第一类里有个容易踩的组合:MultipartFilter 在
DispatcherServlet 之前解析 multipart,异常发生在 filter
里,@ControllerAdvice 完全看不到。
八、把「失败」变成可诊断的响应
8.1 全局 advice:让每次拒绝都留痕
这份处理器只有三个方法,却正好补上 7.2 留下的两个缺口。
1 |
|
三个要点都是前面推理的直接后果:必须放全局(7.3);必须留
MultipartException 兜底(7.2
的消息匹配不可靠);不要把 -1
当前上限(7.2)。
8.2 解析之前先看一眼
Content-Length
Tomcat 自己已有 Content-Length
预检(7.1),所以这一层的价值不在”更快拒绝”,而在两点:网关没拦住时给出一致的
JSON,以及完全绕开 multipart 解析器的初始化与 part
缓冲。
1 |
|
边界必须说清:Content-Length
是声明值,可以伪造,也可以缺席——chunked
编码的请求就没有它,getContentLengthLong() 返回 -1,这个
guard
直接放行。所以它是”快速失败”的优化,不是限额;真正的限额仍是
6.5 那几道。
8.3 网关层也要给 JSON
Nginx 产生的 413 和后端产生的 413 形态不一致,前端就得写两套解析。统一它:
1 | error_page 413 = @too_large; |
一条容易被忽略的一致性约束:网关的上限要大于后端的上限。 反过来的话,后端还没机会回 413,Nginx 已经把连接掐了——也就是引子里那种现象。
8.4 让 Tomcat 的静默
IOException 说话
把 org.apache.coyote 的日志级别临时开到 DEBUG,能看到
abstractConnectionHandler.ioexception.debug
那条消息。四种策略的代价:
| 做法 | 怎么配 | 代价 |
|---|---|---|
| 默认 | max-swallow-size 2MB |
超限就断连;日志默认静默 |
| 放大或关闭上限 | 调大
server.tomcat.max-swallow-size |
处理线程会被”吞请求体”占住(Tomcat 侧小于 0 表示不限制) |
| 干脆不吞 | Context 属性
swallowAbortedUploads = false(StandardContext
默认 true) |
连接立即关闭,响应可能到不了客户端;线程立刻释放 |
| 网关先拦 | client_max_body_size |
上限要和后端对齐 |
第三行值得展开一句,因为它走的是另一条代码路径:Tomcat
Request 上的 checkSwallowInput() 只在
swallowAbortedUploads 为 false 时才真的下发
DISABLE_SWALLOW_INPUT;Tomcat
AbstractProcessor 收到后调它自己的
disableSwallowRequest() 并把状态置为
CLOSE_CLEAN(可写完响应、但连接必须关)。这个开关默认是
true,所以默认路径不在这里——默认走的是 6.5
那条”吞到上限再抛 IOException“的路。
生产上更稳的组合是网关先拦 + 后端保留默认
maxSwallowSize +
拒绝路径写完整日志:把”连接被重置”变成”客户端收到了 413
JSON”。
九、不落盘:流式直传对象存储
9.1 “先落盘再上传”浪费在哪
file-size-threshold 默认 0B,意味着 Tomcat
几乎会把每个 part 都写进磁盘——写到哪由
MultipartConfigElement 的 location
决定,没配就是容器临时目录。于是最常见的写法是这样:
1 | Path tmp = Paths.get("/data/upload-tmp", UUID.randomUUID().toString()); |
一次上传,数据落两遍盘、读两遍。这段代码里唯一必要的动作是第 ② 步。
9.2 边收边写:把 part 的流直接交给 SDK
把 9.1 里唯一必要的那一步单独写出来,就是一个完整实现:
1 |
|
RequestBody.fromInputStream(InputStream, long)
的第二个参数是必需的,它和 contentLength(...)
声明的是同一个长度,两处必须一致。写错的后果不是”被拒”,而是静默丢数据:SDK
的 javadoc
写明,声明长度小于实际内容时,客户端会把流截断到声明的字节数、只发这么多,且不报错;反过来声明长度大于实际内容时,服务端等不到足够的字节,请求才会失败。前者最危险——业务侧会以为文件传完了。S3Client.putObject(PutObjectRequest, RequestBody)
是 SDK v2 的标准签名。
9.3 流式直传的四条边界
- 一次性的流不可重放,所以不可重试。
InputStream只能读一遍,SDK 的重试在流式 body 上无法重新读取。要重试就得放在可重放的载体上(本地文件、字节数组),或者接受”失败即让客户端重传”——后者正是分片上传要解决的问题。 - 超时要覆盖两端。 一次请求里串了”客户端传到应用”和”应用传到对象存储”两段,按两段之和估。
- “不落盘”不等于零内存。 容器读 socket 有自己的缓冲,SDK 写 HTTP 也有缓冲。省掉的是应用自己那一次额外的落盘与读取。
- 失败语义变粗。 对象存储写失败时请求体可能已被消费一部分,没法回滚,只能让客户端整份重来。
9.4 transferTo 的四个坑
它的 javadoc 写得很直白:目标已存在会先被删除;如果文件在文件系统里是被”移动”过去的,这个操作之后就不能再调用了,所以只能调一次。
坑一:相对路径的基准目录不是你的当前目录。 javadoc 里有一句”相对目标路径的基准目录由容器决定”。落到 Tomcat 上:
1 | // org.apache.catalina.core.ApplicationPart#write |
file.transferTo(new File("uploads/a.png"))
会写进临时目录下的
uploads/a.png,然后被容器清理掉。传绝对路径。
坑二:只能调一次。 落盘的 part 走的是
renameTo(失败才退化成复制),源临时文件随之消失:
1 | // DiskFileItem#write |
DiskFileItem.write 的 javadoc
同样写明”只保证第一次调用有效”。所以 transferTo 之后再
getInputStream(),读到的不是原来的内容——顺序错了就是静默的空文件。
坑三:目标文件已存在会被先删掉。 就是上面那行
if (file.exists() && !file.delete())。transferTo(new File(dir + file.getOriginalFilename()))
等于让客户端决定覆盖谁。
坑四:绝对路径是 Spring 帮你兜的底。 Servlet
规范没保证 Part.write 支持绝对路径:
1 | // StandardMultipartFile#transferTo(File) |
这段兜底是尽力而为:只在”目标不存在”时才触发。
9.5 分片上传、断点续传、秒传的骨架
大文件不要走单请求。最小骨架三步:
1 | // 1) 初始化,拿到 uploadId |
AWS 文档给的硬数字要先拿来框住方案:单次 PUT 最大 5 GB;分片上传最多 10000 片、片号 1~10000;每片 5 MiB ~ 5 GiB,最后一片没有最小限制;对象最大 48.8 TiB。
几个设计点:
- 分片大小取多少。 5 MiB 是下限不是建议值。片太小 → 请求数与元数据暴涨、合并变慢;片太大 → 单次失败的重传代价高。按”目标大小 ÷ 1000 片以内”反推,常见落在 8~64 MiB。
- 并发与顺序。
片之间独立,可以并发,但要限并发数,否则一条大上传能把出口带宽吃满。合并按
partNumber排序,所以完成顺序不影响结果——这正是分片能并发的根本原因。 - 断点续传。
要持久化的只有两样:
uploadId和”哪些片已成功”。续传前用 S3 的ListParts问一次服务端,比本地记录更可靠。 - 秒传。 上传前先算整文件摘要,命中”摘要 → 对象”映射就直接建引用。四条取舍:客户端要能算摘要;摘要必须能流式算;服务端要防止”拿别人的摘要引用别人的文件”(至少校验对象大小);分片上传时整文件摘要客户端不方便算,常见替代是用各片 ETag 拼出对象 ETag 对账。
- 别忘回收。 没有 complete 的 multipart upload 会一直占着存储和计费,要配生命周期规则定时中止。
十、绕过应用进程:预签名 URL 直传
10.1 三种数据路径
下载篇用「五、架构层」把传输从应用进程里剥离出去,上传这边是同一个问题的镜像:
| 经应用转发(传统) | 应用流式转发 | 预签名直传 | |
|---|---|---|---|
| 数据路径 | 客户端 → 应用 → 存储 | 客户端 → 应用 → 存储 | 客户端 → 存储 |
| 应用带宽 | 双向(进 + 出) | 双向 | 0 |
| 应用内存 | 取决于缓冲策略 | 常数级 | 0 |
| 占用线程 | 整个上传期间 | 整个上传期间 | 只有签名那一瞬间 |
| 鉴权时机 | 上传前 | 上传前 | 签名时;之后由签名有效期约束 |
| 写入前能否看到内容 | 能 | 能 | 不能 |
| 进度回调 | 应用能拿到字节数 | 应用能拿到字节数 | 只有客户端知道 |
| 失败重试 | 应用内部可重试 | 可重试(body 必须可重放) | 客户端重试同一 URL(有效期内) |
| 本地落盘 | 至少一次 | 无 | 无 |
| 适用 | 小文件、需要落地前强校验 | 需要边收边转(加密 / 转码 / 查毒) | 大文件、高并发、内容无关的落地 |
最关键的一行是”写入前能否看到内容”:预签名直传把数据面交出去的同时,也交出了”在写入之前检查内容”的能力。 这是它唯一且无法绕过的代价。
10.2 服务端:只签一个 PUT
服务端只做一件事——生成一个带签名的 PUT 地址:
1 |
|
用到的都是 SDK v2
里手写的部分,可以放心查证:S3Presigner#presignPutObject(PutObjectPresignRequest)、PutObjectPresignRequest.Builder#signatureDuration(Duration)
与
#putObjectRequest(...)、PresignedRequest#url()
/ #expiration()。
把 contentType 和 contentLength
一起签进去是有意为之:它们进入签名,客户端就改不动了。
不签的话,一个”只允许传 2 MB 图片”的 URL 可以被拿去传别的东西。
10.3 客户端:浏览器直接 PUT
客户端拿到的就是一个能直接 PUT 的 URL,全程不经过后端:
1 | const meta = await fetch('/api/uploads/presign', { |
10.4 直传的边界条件
- 单次 PUT 有大小上限。 S3 单次 PUT 最大 5
GB,超过必须走分片;而分片直传要逐片签名——
uploadId和partNumber都要进入签名,不能一个签名复用所有片。 - 签名只管”能不能写”,不管”写了什么”。 所以 key 必须由服务端决定,前缀按租户隔离。
- 进度回调得自己接。 应用看不到字节流,想知道进度只能让客户端上报;也无法在传输中途取消,只能依赖签名过期或后置校验。
- 完成后必须校验一次。 前端说”传完了”不算数——后端要用
headObject确认对象确实存在、大小符合预期,再写业务记录。 - 有效期是安全边界,而且有硬上限。 有效期内同一 URL
可反复 PUT(覆盖语义),TTL
越长泄漏后可利用时间越久。
S3Presigner的类注释写明预签名有效期不能超过 7 天(生成时就报错),所以 15 分钟这类短 TTL 是常规起点而不是硬性规定。 - 内容校验只能后置。 需要查毒 / 转码时,唯一可行的做法是”上传完成后触发异步任务,不通过就删除对象”,并把状态告诉业务方。这一步漏掉,直传就是一个任意文件落地入口。
十一、安全与工程清单
11.1
getOriginalFilename() 不可信
这是 Spring 自己在 javadoc 里写明的:文件名可能包含目录部分,可能包含
..
这类恶意字符,建议不要直接使用,而是自己生成唯一文件名。而它的实现就是把
Content-Disposition 里解析出的字符串原样返回:
1 | // StandardMultipartHttpServletRequest.StandardMultipartFile |
于是下面这行是典型的目录穿越:
1 | // ❌ 客户端可以传 ../../../../etc/cron.d/x,也可以传 /etc/passwd |
正确姿势分两种。默认方案:key 由服务端生成(UUID / 雪花 ID),原始文件名只作为元数据入库、展示时转义。必须用原名时,至少归一化再校验前缀:
1 | Path root = Paths.get("/data/uploads").toAbsolutePath().normalize(); |
11.2 空字节:Tomcat 会拦,但拦的方式影响错误处理
文件名里带 \u0000
是经典的”骗过后缀校验、截断真实路径”手法。Tomcat 内嵌的 fileupload
直接拒绝:
1 | // org.apache.tomcat.util.http.fileupload.util.Streams#checkFileName |
它在 parseParts 里走的是另一个分支:
1 | } catch (InvalidContentTypeException | InvalidFileNameException e) { |
两个后果:不会被包成
MaxUploadSizeExceededException,所以”文件过大”的
advice 接不到它;消息里也没有 exceed/size 关键字,Spring 只会抛通用的
MultipartException。兜底处理器必须存在,否则客户端等到的是一个
500。
11.3
数量与头部:maxPartCount /
maxPartHeaderSize
- 单个 part 的头部默认上限 512 字节,超了直接拒绝——这是防止超长
Content-Disposition撑爆内存的第一道。 - part 个数默认上限 50,参数总数默认上限 10000(外置 Tomcat 见 6.3 的
server.xml说明)。这两个 Boot 没有属性。 - 调大之前复习那条公式:
maxPartHeaderSize × maxPartCount × maxConnections × 2,默认约 400 MB。
11.4 扩展名 vs 魔数
扩展名和 Content-Type
都是客户端给的,Content-Disposition 里的
filename
也是。只按它们做白名单,等于让上传者自己声明”我是安全的”。真正的校验要读文件头:
1 | // 魔数校验必须发生在写入最终存储之前 |
这条约束和预签名直传是冲突的——直传时应用在写入前读不到字节。所以“是否需要写入前强校验”本身就是选择架构的决定性条件,而不只是安全清单里的一项。
11.5 存储位置、临时目录、并发、审计
- 绝不落在 webroot。 上传目录不能位于 Tomcat 的
webapps/、src/main/resources/static或任何被静态资源处理器覆盖的路径下。放进去等于给上传者开了一个可访问、可执行的入口。 - 临时目录会先被打满。
file-size-threshold默认0B,part 直接落盘,并发上传大文件时先耗尽的往往是磁盘而不是内存。要么用spring.servlet.multipart.location指到独立分区并配配额与监控,要么走预签名直传,本地根本不落。 - 并发与配额。 上传是少数能同时把带宽、磁盘、线程吃满的接口。按用户/租户限流、限制单请求体积、限制同一账号并行的分片上传数。
- 日志与审计。
记全”谁、何时、传了什么、多大、结果如何”:对象
key、size、contentType、摘要、IP、租户。记录前先过滤控制字符(原始文件名可能带
\u0000)。最后一条:大文件上传失败时前后端各留一份记录——后端记拒绝原因,前端记ERR_CONNECTION_RESET,否则就是引子里那种”三方各说各话”。
结语:四道限额,三条数据路径
开头那个故障不是 bug,是四个独立机制叠在一起的结果:Tomcat 发现超限就拒绝(6.4)、拒绝后要吞掉剩余体(6.5)、吞不下的部分直接断连(6.5)、断连的异常默认不记日志(6.5)。缺任何一环,这个现象都不会这么”干净”。
压成一套可执行的决策顺序:
- 先把四道限额对齐。
从外到内逐层收窄:
client_max_body_size≥ 网关上限 ≥max-request-size≥max-file-size。并且记住maxPostSize只算表单字段,别拿它当文件上限。 - 再把拒绝路径做成可读的响应。 全局
@ControllerAdvice+ 网关 413 JSON + 留意maxSwallowSize那条无状态码的路径。这一步投入产出比最高——它把”连接被重置”变成”客户端收到 413 JSON 且后端有日志”。 - 然后决定数据路径。 小文件、需要写入前强校验 → 应用内处理;需要边收边转(加密 / 转码 / 查毒)→ 应用流式转发;大文件、高并发、内容无关 → 预签名直传;超大文件 → 无论走哪条路都要分片 + 断点续传。
- 最后才谈优化。 流式直传省掉的是应用侧那一次额外的落盘与读取;分片带来并发能力,也带来 ETag 记账、未完成分片回收这些新的运维面。
三个最值得记住的结论:
maxSwallowSize是”故障看起来像网络问题、日志里却什么都没有”的根因,它的异常默认只在 DEBUG 级别可见。@ExceptionHandler失效多半不是写错了,而是异常抛出得太早(checkMultipart早于getHandler)、太晚(响应已 commit、连接已关),或者干脆不经过DispatcherServlet。- 预签名直传最大的代价不是复杂度,是你放弃了”写入前看到内容”的能力——需要强内容校验的场景不要用它。
延伸阅读
- (一):从报文到临时文件——multipart
报文格式、
parseParts内部实现、MultipartFile的内存/磁盘阈值原理,以及 2.x→3.x 的解析器选型与配置前缀变化,本篇只做引用。 - Spring 文件下载全方案深度解析——「五、架构层」的三种模式与本篇「十、绕过应用进程」互为镜像。
- 一次下载失败排查:被 Filter 吞掉的异步响应流——同样是”响应写了但客户端拿不到”,那边的元凶是缓存包装器,这边是连接被关闭。
- 深入 WebFlux 与 Spring Cloud Gateway——如果网关是 Spring Cloud Gateway,上传体在 Reactor Netty 上的处理又是另一套机制。