老炮踩坑录 · F04 · 翻车现场系列
基于「企业融合评估平台」真实源码,复盘一个上传限制配置引发的磁盘爆满事故
关键词:max-file-size 1024MB · MultipartConfig 临时目录 · 全局异常未启用 · 磁盘爆满
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:文件上传大小限制
前言
我离职后大概半年的时间,有一天下午运维的老周给我打电话,语气不太好:"你们那个申报系统,服务器磁盘又满了,上周我刚清理过。"
我第一反应是日志没切分,让他去看看日志目录下文件大小。
过了没几分钟他又回电话过来:"不是日志,是应用根目录,一堆 .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 会根据文件大小决定存在内存还是临时目录,超过阈值就落磁盘。请求结束后这些临时文件会被自动清理。
但有两个情况会导致临时文件残留:
- 进程异常终止:比如上传到一半 OOM 了,或者被
kill -9,临时文件来不及删。 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 的临时文件。
传了十几次,磁盘就满了。
这个问题隐蔽在哪:
- 平时不传大文件,1024MB 的配置形同虚设,谁也不会注意到。
- 临时文件目录在应用根目录,运维监控一般看
/tmp和日志目录,不会盯着应用根目录。 - 没有全局异常处理,上传失败返回 500,前端没有明确提示,用户就反复重试。
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 的配置被改大了,业务层还有第二道防线。
自查清单
老炮点评
这个事故的本质是 "配置脱离了业务实际"。
1024MB 不是一个经过测算的值,是一个"先设大一点免得报错"的值。这个思路在开发阶段很爽——不用改配置,不用跟产品确认文件大小,直接拉满。
但拉满的代价是把风险转嫁给了运维。磁盘满了、服务挂了,运维半夜爬起来处理,开发已经下班了。
文件上传这个事,限制不是越小越好,也不是越大越好,是要跟业务匹配。营业执照 10MB 够了,财务报告 100MB 够了,诊断报告系统生成的根本不用用户传。搞清楚每个上传口的实际文件大小,再留个 20% 的余量,就够了。
临时文件目录也是一样。Spring 默认用系统临时目录,请求结束自动清理,挺好的。非要改成 user.dir,等于自己给自己挖坑。
还有全局异常处理和业务层校验,这两个是底线。配置可能被改、网络可能超时、用户可能传错文件,但只要异常能被正确捕获、业务层有兜底,就不会出大事。
"设大一点"是懒人思维,"算清楚"才是工程师该干的事。
下期预告:《番外篇:多模块工程没 install,跑起来的代码是上周的》
主线项目的坑先缓一缓。下期继续翻base-archives的旧账——改了common包的代码,web模块跑起来行为没有任何变化,排查半天才想起来自己根本没install。
Maven reactor、SNAPSHOT缓存、IDEA工作区解析,三套规则混在一起,下期讲清楚怎么确认自己运行的到底是哪个版本的代码。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
你的每一次点赞互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。