《Spring Boot 实战》14.1 上传下载与大文件

围绕借阅服务的封面上传,讲清 MultipartFile 与 spring.servlet.multipart.* 配置项、Tomcat 容器级的 max-part-count 等真实限制、延迟解析与异常处理位置、流式读写避免 getBytes() 撑爆堆、路径穿越与后缀白名单防护、中文文件名下载编码,以及为什么文件不该进数据库 BLOB。

本节目标:把借阅服务的「上传图书封面、下载电子附件」做成能上生产的上传下载接口——配好 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-size2MB只作用于 application/x-www-form-urlencoded 表单解析,不影响 multipart
server.tomcat.max-part-count50(4.0 时为 10)单个请求允许的 multipart 分段数量
server.tomcat.max-part-header-size8KB每个分段头部的上限
server.tomcat.max-swallow-size2MB请求被拒后,容器愿意继续「吞掉」的剩余字节数

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 之前就把整个请求解析完——包括把所有分段落盘到临时目录。这意味着两件事:

  1. 超限异常发生在解析阶段,此时还没有 Controller 方法可返回,全局 @ExceptionHandler 才是正确的兜底位置。
  2. 解析会消耗磁盘与时间,即使用户传错了参数,代价也已经付出。

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());   // 危险

正确做法有三层,缺一不可:

  1. 不信任原始名,自己生成存储名。 用 UUID/内容哈希加白名单后缀,原始名只作为展示字段存库。
  2. 后缀白名单,不是黑名单。允许 jpg/jpeg/png/webp/pdf,其余一律拒绝。
  3. 规范化后校验前缀,确保解析出的路径仍在根目录之下。
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 对象存储集成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计