文件上传限制1024MB,磁盘被撑爆:Spring Boot Multipart 配置踩坑与四道防线

40 阅读9分钟

老炮踩坑录 · F04 · 翻车现场系列

基于「企业融合评估平台」真实源码,复盘一个上传限制配置引发的磁盘爆满事故

关键词:max-file-size 1024MB · MultipartConfig 临时目录 · 全局异常未启用 · 磁盘爆满

👋 欢迎阅读

v04.jpg

🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:文件上传大小限制

前言

我离职后大概半年的时间,有一天下午运维的老周给我打电话,语气不太好:"你们那个申报系统,服务器磁盘又满了,上周我刚清理过。"

我第一反应是日志没切分,让他去看看日志目录下文件大小。

过了没几分钟他又回电话过来:"不是日志,是应用根目录,一堆 .tmp 文件,加起来快 200G了。"

我愣了一下。临时文件怎么会跑到应用根目录去?

打开项目源码翻了翻配置,看到这两行:

# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 1024MB
      max-request-size: 1024MB

单个文件 1GB,整个请求也 1GB。

再往下翻,看到这个:

// MultipartConfig.java
@Configuration
public class MultipartConfig {
    @Bean
    MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        String location = System.getProperty("user.dir");
        File tmpFile = new File(location);
        if (!tmpFile.exists()) {
            tmpFile.mkdirs();
        }
        factory.setLocation(location);
        return factory.createMultipartConfig();
    }
}

临时文件目录指向 user.dir——也就是应用启动时的工作目录。

那一刻我大概知道发生什么了。

先把上传代码过一遍

这个项目是企业申报系统,企业要上传营业执照、财务报告、诊断报告这些附件。文件最终存到华为云 OBS,中间会在本地落一次临时文件。

上传入口在 FileController:

// FileController.java:501
@PostMapping("/huawei/upload")
public Result uploadToHuaweiyun(@RequestParam("file") MultipartFile[] files,
                                @Param("enterpriseid") String enterpriseid,
                                @RequestParam(value="rapplyid", required = false) String rapplyid,
                                @Param("type") Integer type) {
    try {
        if (files.length == SysContants.SIZE_ZERO) {
            return new Result().fail(ResultEnum.FAIL_NOT_ATTACHMENT);
        }
        // 校验文件名长度
        for (MultipartFile mf : files) {
            if (mf.getSize() == SysContants.SIZE_ZERO) {
                return new Result().fail(ResultEnum.FAIL_EMPTY_FILE);
            }
            String originalFilename = mf.getOriginalFilename();
            if (originalFilename.length() > 64) {
                fns.append(";").append(originalFilename);
            }
        }
        // ...
        String ret = huaweiyunFileServiceImpl.uploadFileType(files, type, enterpriseid, rapplyid, endPoint, ak, sk, bucketName);

进到 uploadFileType,核心就两步:先 transferTo 落本地,再调 uploadFile 分片传华为云。

// HuaweiyunFileServiceImpl.java:204
for (int i = 0; i < files.length; i++) {
    File file1 = new File(path + "/" + UUID.randomUUID() + files[i].getOriginalFilename());
    try {
        files[i].transferTo(file1);           // 落本地临时文件
        this.uploadFile(file1, endPoint, ak, sk, bucketName);  // 分片传华为云
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        return null;
    } finally {
        if (file1 != null) {
            file1.delete();                  // 删临时文件
        }
    }
}

uploadFile 里用的是华为云的分片上传,每片 5MB:

// HuaweiyunFileServiceImpl.java:125
long partSize = 5 * 1024 * 1024l;
long partCount = file.length() % partSize == 0 ? file.length() / partSize : file.length() / partSize + 1;
if (partCount > 10000) {
    throw new RuntimeException("所有部分不能超过10000个");
}

算一下:10000 片 × 5MB = 50000MB,差不多 48.8GB。理论上单个文件能传到 48GB。

看起来没毛病,那么问题出在哪呢?

问题出在哪?

缺陷一:1024MB 这个数字是拍脑袋来的

营业执照是 PDF,几 MB。财务报告是 Excel 扫描件,几十 MB 顶天。诊断报告是系统生成的 PDF,也就几 MB。

整个业务里没有任何一个场景需要传 1GB 的文件。

那 1024MB 怎么来的?我猜是当年有人上传一个大一点的附件被 Spring 默认的 1MB 限制拦住了,报错 MaxUploadSizeExceededException,然后改配置的时候直接拉满到 1024MB。

这一改,等于把"上传限制"这道门拆了。

配置写1024MB,不是技术决策,是情绪决策——怕报错,直接拉满。

缺陷二:临时文件落在应用根目录

MultipartConfig 里这行:

String location = System.getProperty("user.dir");
factory.setLocation(location);

user.dir 是 JVM 启动时的工作目录,在这个项目里就是应用的根目录。

正常情况下,Spring 的 MultipartFile 会根据文件大小决定存在内存还是临时目录,超过阈值就落磁盘。请求结束后这些临时文件会被自动清理。

但有两个情况会导致临时文件残留:

  1. 进程异常终止:比如上传到一半 OOM 了,或者被 kill -9,临时文件来不及删。
  2. transferTo 之后的异常:虽然 finally 里有 file1.delete(),但如果 delete() 失败(Windows 下文件被占用),或者 catch 里 return null 之前又抛了异常,临时文件就留下了。

我没亲眼见过生产上的残留情况,但老周说有 200G 的 .tmp 文件,说明确实在发生。

缺陷三:全局异常处理被注释掉了

// SysControllerAdvice.java
// @RestControllerAdvice
public class SysControllerAdvice {
​
//    @ExceptionHandler(SystemException.class)
//    @ResponseBody
//    public Result<String> myerroe(Exception e) {
//        return new Result<String>().fail(e.getMessage());
//    }
​
}

整个类的注解和方法全被注释了。等于没有全局异常处理。

如果有人传一个超过 1024MB 的文件,Spring 会抛 MaxUploadSizeExceededException。这个异常没有被任何地方捕获,直接返回 500 给前端。

前端拿到 500,用户看到"系统异常",不知道是文件太大的问题。运维也不知道为什么突然一堆 500 错误。

缺陷四:业务层面没有文件大小校验

FileController 里只做了两件事:

if (mf.getSize() == SysContants.SIZE_ZERO) {
    return new Result().fail(ResultEnum.FAIL_EMPTY_FILE);  // 不能是空文件
}
if (originalFilename.length() > 64) {
    fns.append(";").append(originalFilename);              // 文件名不能超过64字符
}

校验了空文件,校验了文件名长度,唯独没校验文件大小。

再看 ApplyFileServiceImpl.validateFile:

// ApplyFileServiceImpl.java:185
private String validateIsIndustryInternet(MultipartFile[] files) {
    return null;
}
private String validateBenchmarking(MultipartFile[] files) {
    return null;
}
// ... 所有 validateXxx 方法全是 return null

八个校验方法,八个空实现。

等于业务层完全没有文件大小限制,全靠 Spring 的 max-file-size 兜底。一旦 Spring 那层被绕过或者配大了,就没有第二道防线。

为什么这个坑埋了三年才爆

这个项目从 2020 年上线到我离职,上传一直没出过事。原因很简单:企业传的文件都很小。

营业执照几 MB,财务报告几十 MB,诊断报告几 MB。从来没人传过接近 1GB 的文件。

那 200G 临时文件从哪来的?

我后来问了老周,他说大概是某个企业的财务人员,把一整年的财务凭证扫描成一个 PDF,大概 600 多 MB,上传了好几次都失败(网络超时或者前端报错),每次失败都在应用根目录留下一个 600MB 的临时文件。

传了十几次,磁盘就满了。

这个问题隐蔽在哪:

  1. 平时不传大文件,1024MB 的配置形同虚设,谁也不会注意到。
  2. 临时文件目录在应用根目录,运维监控一般看 /tmp 和日志目录,不会盯着应用根目录。
  3. 没有全局异常处理,上传失败返回 500,前端没有明确提示,用户就反复重试。
  4. finally 里的 delete() 不一定成功,特别是 Windows 环境或者文件句柄没释放的情况。

四个问题叠在一起,平时不出事,一碰上大文件就崩。

正确写法:四道防线

防线一:上传限制按业务实际情况设

不要拍脑袋写 1024MB。先搞清楚业务上最大的文件有多大,再留个余量。

这个项目里,最大的文件是财务报告,算它 100MB 顶天了,那就设 128MB:

spring:
  servlet:
    multipart:
      max-file-size: 128MB
      max-request-size: 128MB
      file-size-threshold: 10MB

file-size-threshold 也别漏了,超过 10MB 的文件才落磁盘,小文件直接走内存,减少磁盘 IO。

防线二:临时文件目录用系统临时目录

不要用 user.dir,用 java.io.tmpdir,或者干脆不配置 location,让 Spring 用默认值:

@Configuration
public class MultipartConfig {
    @Bean
    MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        // 用系统临时目录,不要用 user.dir
        factory.setLocation(System.getProperty("java.io.tmpdir"));
        return factory.createMultipartConfig();
    }
}

或者更简单,直接删掉这个配置类,让 Spring Boot 用默认配置。默认的临时目录就是系统临时目录,请求结束后自动清理。

防线三:全局异常处理要开着

@RestControllerAdvice
public class SysControllerAdvice {
​
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    @ResponseBody
    public Result<String> handleMaxUploadSize(MaxUploadSizeExceededException e) {
        return new Result<String>().fail("文件大小超过限制");
    }
​
    @ExceptionHandler(SystemException.class)
    @ResponseBody
    public Result<String> handleSystem(SystemException e) {
        return new Result<String>().fail(e.getMessage());
    }
}

至少把 MaxUploadSizeExceededException 接住,返回一个明确的提示。用户看到"文件太大"就不会反复重试了。

防线四:业务层校验文件大小

不要把所有希望寄托在 Spring 的配置上。业务层也要校验,而且要按不同上传类型区别对待:

public String validateFile(MultipartFile[] files, Integer type) {
    long maxSize;
    switch (type) {
        case SysContants.UPLOADFILE_TYPE_LICENSEFILEPATH:
            maxSize = 10 * 1024 * 1024;     // 营业执照 10MB
            break;
        case SysContants.UPLOADFILE_TYPE_FINANCEFILEPATH:
            maxSize = 100 * 1024 * 1024;    // 财务报告 100MB
            break;
        default:
            maxSize = 50 * 1024 * 1024;     // 其他 50MB
    }
    for (MultipartFile file : files) {
        if (file.getSize() > maxSize) {
            return "文件大小超过限制,最大允许 " + maxSize / 1024 / 1024 + "MB";
        }
    }
    return null;
}

这样即使 Spring 的配置被改大了,业务层还有第二道防线。

自查清单

自查清单.png

老炮点评

这个事故的本质是 "配置脱离了业务实际"。

1024MB 不是一个经过测算的值,是一个"先设大一点免得报错"的值。这个思路在开发阶段很爽——不用改配置,不用跟产品确认文件大小,直接拉满。

但拉满的代价是把风险转嫁给了运维。磁盘满了、服务挂了,运维半夜爬起来处理,开发已经下班了。

文件上传这个事,限制不是越小越好,也不是越大越好,是要跟业务匹配。营业执照 10MB 够了,财务报告 100MB 够了,诊断报告系统生成的根本不用用户传。搞清楚每个上传口的实际文件大小,再留个 20% 的余量,就够了。

临时文件目录也是一样。Spring 默认用系统临时目录,请求结束自动清理,挺好的。非要改成 user.dir,等于自己给自己挖坑。

还有全局异常处理和业务层校验,这两个是底线。配置可能被改、网络可能超时、用户可能传错文件,但只要异常能被正确捕获、业务层有兜底,就不会出大事。

"设大一点"是懒人思维,"算清楚"才是工程师该干的事。


下期预告:《番外篇:多模块工程没 install,跑起来的代码是上周的》

主线项目的坑先缓一缓。下期继续翻base-archives的旧账——改了common包的代码,web模块跑起来行为没有任何变化,排查半天才想起来自己根本没install。

Maven reactor、SNAPSHOT缓存、IDEA工作区解析,三套规则混在一起,下期讲清楚怎么确认自己运行的到底是哪个版本的代码。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次点赞互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。