本节目标:把借阅服务的「上传图书封面、下载电子附件」做成能上生产的上传下载接口——配好 multipart 与容器限制、看懂延迟解析、用流式读写扛住大文件、守住文件名安全,并定下下载响应的正确头信息。
适用版本:Spring Boot 4.1.x(Java 21)
14.1 上传下载与大文件
前面 13 章都在处理「结构化数据」:借阅记录、会员、图书元数据。这一章开始处理「非结构化数据」——图书封面图、电子书附件、导入导出的 Excel。这类请求的形态完全不同:请求体可能是几十上百 MB 的二进制流,响应也可能是一个文件下载。
本节先用最简单的一步:把文件落到应用本地磁盘,讲清上传下载本身的坑。落盘只是过渡,14.2 会把它换成对象存储,但本节讨论的配置、流式处理、文件名安全、响应头问题,换存储后一个都不会消失。
场景:POST /books/{id}/cover 上传封面,GET /books/{id}/attachments/{name} 下载附件。
14.1.1 MultipartFile 与 multipart 配置项
MultipartFile 是 Spring MVC 对「一个上传分段」的抽象,最常用的方法就四个:
| 方法 | 作用 | 风险 |
|---|---|---|
getOriginalFilename() | 客户端声明的原始文件名 | 不可信,可能含路径 |
getSize() | 字节数 | 可信度取决于是否解析完 |
getContentType() | 客户端声明的 MIME | 不可信,可伪造 |
getInputStream() | 流式读取 | 首选,内存友好 |
getBytes() | 一次性读进 byte[] | 大文件会把堆打满 |
真正决定「能传多大」的不是 MultipartFile,而是 spring.servlet.multipart.* 这组属性。Spring Boot 4.1.1 里它们由 org.springframework.boot.servlet.autoconfigure.MultipartProperties 承载(模块 spring-boot-servlet,4.x 的包名已从 3.x 的 ...autoconfigure.web.servlet 迁到这里),自动配置类是 MultipartAutoConfiguration:
# 单个文件上限,默认 1MB
spring.servlet.multipart.max-file-size=20MB
# 整个请求上限(多文件时要大于「文件数 × 单文件上限」),默认 10MB
spring.servlet.multipart.max-request-size=25MB
# 超过该阈值才写临时文件,否则驻留内存,默认 0(全部落盘到临时目录)
spring.servlet.multipart.file-size-threshold=512KB
# 临时文件目录,不配则用容器默认临时目录
spring.servlet.multipart.location=${java.io.tmpdir}/loan-upload
max-file-size 与 max-request-size 的关系最容易踩坑:如果允许一次传 5 个封面,每个 20MB,max-request-size 若仍是默认的 10MB,请求会在到达 Controller 之前就被拒。两者要一起调,max-request-size 应不小于「并发文件数 × 单文件上限」再加表单其余字段的余量。
超限时抛的是 MaxUploadSizeExceededException。它的处理位置见 14.1.2。
14.1.2 容器层还有一层限制
应用层的 max-file-size 只是第一道闸。Servlet 容器(Tomcat)在解析请求时还有自己的限制,且它们和 multipart 属性是两套东西。
这里要点名一个常被记错的地方:并不存在名为 maxSwaggerPostSize 的属性,网上以讹传讹的写法不要照抄。真正会拦住请求的是 Tomcat 连接器上的这几个属性:
| 属性 | 4.1 默认 | 作用范围 |
|---|---|---|
server.tomcat.max-http-form-post-size | 2MB | 只作用于 application/x-www-form-urlencoded 表单解析,不影响 multipart |
server.tomcat.max-part-count | 50(4.0 时为 10) | 单个请求允许的 multipart 分段数量 |
server.tomcat.max-part-header-size | 8KB | 每个分段头部的上限 |
server.tomcat.max-swallow-size | 2MB | 请求被拒后,容器愿意继续「吞掉」的剩余字节数 |
max-part-count 值得单独说:它在 Spring Boot 4.1.0-M3 从 10 提到 50。如果你的表单里有「封面 + 多个附件 + 若干文本字段」,分段数很容易超过 10,升级到 4.1 之前就需要显式调大,否则请求会被容器直接拒绝、连 Controller 都进不去:
# 一次提交里最多允许的分段数(文件 + 表单字段都算)
server.tomcat.max-part-count=100
max-swallow-size 的坑更隐蔽:请求超限被拒后,若剩余字节数超过 max-swallow-size,Tomcat 会直接断开连接而不是读完再返回 4xx。这会导致客户端收到的是「连接重置」而不是一个干净的 413/400。要返回可读的错误,就得把这个值调到能覆盖被拒请求的剩余体量,或者干脆让客户端在收到响应后主动放弃发送。
14.1.3 延迟解析与异常处理位置
默认情况下,只要请求的 Content-Type 是 multipart/form-data,Spring 会在进入 Controller 之前就把整个请求解析完——包括把所有分段落盘到临时目录。这意味着两件事:
- 超限异常发生在解析阶段,此时还没有 Controller 方法可返回,全局
@ExceptionHandler才是正确的兜底位置。 - 解析会消耗磁盘与时间,即使用户传错了参数,代价也已经付出。
spring.servlet.multipart.resolve-lazily=true 把解析推迟到「真正访问文件或参数时」。它的价值在于:可以在不解析请求体的前提下先做鉴权、限流、配额判断,不满足条件就早退,省掉一次昂贵的落盘。
@Configuration
public class UploadWebConfig {
// 懒解析下,需要在过滤器里主动调用 getParts() 才会触发解析
@Bean
FilterRegistrationBean<Filter> authBeforeParse(UploadGuard guard) {
FilterRegistrationBean<Filter> reg = new FilterRegistrationBean<>(new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (request.getContentType() != null
&& request.getContentType().startsWith("multipart/form-data")) {
guard.checkQuota(request); // 不读 body,只看身份与配额
}
chain.doFilter(request, response);
}
});
reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
return reg;
}
}
无论是否懒解析,MaxUploadSizeExceededException 都要有一个全局处理器,把容器抛出的异常翻译成业务语义的错误体:
@RestControllerAdvice
public class UploadExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
ResponseEntity<ApiError> tooLarge(MaxUploadSizeExceededException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(new ApiError("FILE_TOO_LARGE", "文件超过允许的大小上限"));
}
}
注意:如果超限发生在 resolve-lazily=true 且异常在 Controller 内部被触发,处理器同样能生效;但若请求还没进 DispatcherServlet 就被容器截断(如 14.1.2 的 max-swallow-size),任何 @ExceptionHandler 都无能为力——那时只能靠容器层的错误页或网关兜底。
14.1.4 流式读写:别用 getBytes()
最常见的性能事故是把 MultipartFile 当小文件处理:
// 反面教材:整个文件进堆,多个并发上传就能触发 Full GC 甚至 OOM
byte[] data = file.getBytes();
Files.write(target, data);
getBytes() 会把整个文件读进一个 byte[]。假设并发 20 个 20MB 的上传,峰值就是 400MB 的堆压力——还没算 getBytes() 内部可能产生的复制。正确做法是流式拷贝,内存占用只与缓冲区有关:
@Service
public class LocalFileStorage {
private final Path root;
public LocalFileStorage(@Value("${loan.storage.root}") Path root) {
this.root = root;
}
public Path store(String key, MultipartFile file) throws IOException {
Path target = root.resolve(key).normalize();
if (!target.startsWith(root)) { // 二次防御,见 14.1.5
throw new IllegalArgumentException("Illegal path");
}
Files.createDirectories(target.getParent());
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
}
}
file.transferTo(File) 是另一个可用选项,Servlet 容器实现下它可能直接做「临时文件改名」而零拷贝;但它的行为依赖容器,且对非 MultipartFile 的 File 语义有历史坑,跨存储后端时统一用 getInputStream() 更可预期。
读文件时同理:不要 Files.readAllBytes,用 InputStreamResource 或 StreamingResponseBody 把字节流直接交给响应:
@GetMapping("/books/{id}/attachments/{name}")
ResponseEntity<Resource> download(@PathVariable Long id, @PathVariable String name) throws IOException {
Path file = storage.load(bookId(id), name); // 已做路径校验
Resource body = new InputStreamResource(Files.newInputStream(file));
return ResponseEntity.ok()
.contentLength(Files.size(file))
.header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.body(body);
}
contentLength 要显式给,否则响应会退化成 chunked 传输,客户端拿不到进度,也无法在下载前预判大小。
14.1.5 文件名安全:路径穿越与后缀白名单
getOriginalFilename() 来自客户端,任何字符都不可信。直接拼进路径就会踩到路径穿越:
// 攻击者传文件名 "../../../../etc/passwd" 或 "..%2f..%2fapp.yml"
Path target = root.resolve(file.getOriginalFilename()); // 危险
正确做法有三层,缺一不可:
- 不信任原始名,自己生成存储名。 用 UUID/内容哈希加白名单后缀,原始名只作为展示字段存库。
- 后缀白名单,不是黑名单。允许
jpg/jpeg/png/webp/pdf,其余一律拒绝。 - 规范化后校验前缀,确保解析出的路径仍在根目录之下。
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "webp", "pdf");
public String safeKey(Long bookId, String originalName) {
String cleaned = StringUtils.cleanPath(originalName == null ? "" : originalName);
if (cleaned.contains("..")) {
throw new IllegalArgumentException("Path traversal detected");
}
int dot = cleaned.lastIndexOf('.');
String ext = dot < 0 ? "" : cleaned.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!ALLOWED.contains(ext)) {
throw new IllegalArgumentException("Unsupported extension: " + ext);
}
return "books/" + bookId + "/" + UUID.randomUUID() + "." + ext;
}
后缀白名单只防「可执行后缀」,它不能证明内容真的合规。一个改名为 .png 的脚本依然是脚本。因此下载时必须配合 14.1.6 的响应头策略,并且对图片类做真正的魔数校验或交给下游的图片处理服务。
14.1.6 下载响应头:中文文件名与类型嗅探
下载响应有三个头要处理对:Content-Disposition、Content-Type、X-Content-Type-Options。
中文文件名的编码。 Content-Disposition 头是 ASCII 的,中文直接放进去会乱码或触发容器的非法字符拒绝。标准做法是同时给一个 ASCII 回退名和一个 RFC 5987 的 filename*:
String raw = "算法导论-第三版.pdf";
String asciiFallback = "attachment.pdf";
String encoded = URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("+", "%20");
String disposition = "attachment; filename=\"" + asciiFallback
+ "\"; filename*=UTF-8''" + encoded;
filename* 用 UTF-8'' 前缀加百分号编码,现代浏览器优先取它,老客户端回退到 filename。注意 URLEncoder 会把空格编成 +,而 header 场景需要 %20,所以要替换。
Content-Type 不能信客户端。 上传时记录的 getContentType() 是客户端声明的,可能被伪造。下载时要么用服务端存储时确定并校验过的类型,要么用 Files.probeContentType 探测,最稳妥的是对「用户可上传的内容」一律以 application/octet-stream 下发,强制走下载而非内联渲染。
X-Content-Type-Options: nosniff 必须加。 它的作用是禁止浏览器对 Content-Type 做嗅探。没有它,一个被声明为 text/plain 但内容是 HTML 的附件,可能被浏览器当页面渲染,从而触发存储型 XSS。加上它,浏览器就只认你声明的类型:
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
.header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.header("X-Content-Type-Options", "nosniff")
.contentLength(Files.size(file))
.body(new InputStreamResource(Files.newInputStream(file)));
如果确实要让图片内联预览,就把 attachment 换成 inline,并确保该类型是你允许的、且响应头里类型正确——绝不要用 inline 下发可执行的 HTML/SVG。
14.1.7 为什么文件不该进数据库 BLOB
「把文件存进数据库 BLOB 字段」看起来最省事:和业务数据在一起、天然有事务、备份一次就够。但生产上几乎都后悔。原因集中在几点:
| 维度 | 存 BLOB | 存文件系统 / 对象存储 |
|---|---|---|
| 单表体积 | 迅速膨胀,几 TB 级 | 数据库只存元数据与 key |
| 备份/恢复 | 备份文件巨大,恢复以小时计 | 数据库轻量,文件另备 |
| 复制/主从 | 二进制日志被大对象撑爆,延迟高 | 不参与复制 |
| 连接占用 | 读写大对象长时间占连接与事务 | 不占数据库连接 |
| 缓存/CDN | 无法直接缓存 | 可直接挂 CDN、支持 Range |
| 内存 | 大对象易触发 TOAST/溢出存储、GC 压力 | 与应用内存解耦 |
| 扩容 | 加只读副本成本高 | 对象存储近乎无限扩容 |
以 PostgreSQL 为例,大字段会走 TOAST 溢出到独立的 TOAST 表,读时可能触发解压与额外 IO;以 MySQL 为例,max_allowed_packet 与 innodb_log_file_size 都会成为硬约束。结论:数据库只存「文件元数据 + 存储键」,字节流交给文件系统或对象存储。 元数据表大致长这样:
CREATE TABLE book_attachment (
id BIGINT PRIMARY KEY,
book_id BIGINT NOT NULL,
storage_key VARCHAR(512) NOT NULL, -- 对象键或相对路径,不是文件内容
file_name VARCHAR(255) NOT NULL, -- 展示用原始名
content_type VARCHAR(128),
size_bytes BIGINT NOT NULL,
sha256 CHAR(64),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
storage_key 与 file_name 分开存:前者是不可变的定位符,后者是可变的展示名。sha256 用于去重与完整性校验。
14.1.8 常见坑
坑一:max-file-size 调了、max-request-size 没调。 单文件能过,多文件请求照样被拒。两者要成对调整。
坑二:用 getBytes() 读上传文件。 并发一上来就 OOM。始终用 getInputStream() 流式处理。
坑三:直接把 getOriginalFilename() 拼进路径。 路径穿越漏洞。生成自己的存储名,后缀走白名单,路径规范化后校验前缀。
坑四:下载时信客户端声明的 Content-Type。 被伪造的类型加上浏览器嗅探,可能变成 XSS。加 X-Content-Type-Options: nosniff,用户内容一律以 application/octet-stream 下发。
坑五:中文文件名直接写进 Content-Disposition。 会乱码或被拒。用 filename*=UTF-8''... 编码,并给 ASCII 回退。
坑六:忽略 max-swallow-size。 大文件超限时客户端拿到的是「连接重置」而非 413,联调时极难定位。
小结
- 上传大小由
spring.servlet.multipart.*决定(4.x 由spring-boot-servlet模块的MultipartProperties承载),max-file-size与max-request-size必须成对调整;容器层还有server.tomcat.max-part-count(4.1 起默认 50)、max-http-form-post-size、max-swallow-size等真实限制,不存在maxSwaggerPostSize这个属性。 resolve-lazily=true让你能在不解析请求体的情况下先鉴权、限流;超限异常用全局@ExceptionHandler处理,容器提前截断的情况只能靠容器层兜底。- 读写都要流式:
getInputStream()+Files.copy,绝不用getBytes()把大文件读进堆。 - 文件名三层防护:自生成存储名、后缀白名单、规范化后校验前缀。
- 下载响应要处理中文文件名编码(
filename*)、强制application/octet-stream,并加X-Content-Type-Options: nosniff。 - 文件字节流不进数据库 BLOB,库里只存元数据与
storage_key。
本地磁盘能跑通,但它不共享、不可横向扩展、备份也麻烦。下一节把落盘换成对象存储。
阅读导航:上一节:13.3 分布式调度与锁 · 下一节:14.2 对象存储集成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。