老炮踩坑录 · F04 · 翻车现场系列
基于「企业融合评估平台」真实源码,复盘一个上传限制配置引发的磁盘爆满事故
关键词:max-file-size 1024MB · MultipartConfig 临时目录 · 全局异常未启用 · 磁盘爆满
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:文件上传大小限制
文章目录
- 前言
- 先把上传代码过一遍
- 问题出在哪?
- 缺陷一:1024MB 这个数字是拍脑袋来的
- 缺陷二:临时文件落在应用根目录
- 缺陷三:全局异常处理被注释掉了
- 缺陷四:业务层面没有文件大小校验
- 为什么这个坑埋了三年才爆
- 正确写法:四道防线
- 防线一:上传限制按业务实际情况设
- 防线二:临时文件目录用系统临时目录
- 防线三:全局异常处理要开着
- 防线四:业务层校验文件大小
- 自查清单
- 老炮点评
前言
我离职后大概半年的时间,有一天下午运维的老周给我打电话,语气不太好:“你们那个申报系统,服务器磁盘又满了,上周我刚清理过。”
我第一反应是日志没切分,让他去看看日志目录下文件大小。
过了没几分钟他又回电话过来:“不是日志,是应用根目录,一堆.tmp文件,加起来快 200G了。”
我愣了一下。临时文件怎么会跑到应用根目录去?
打开项目源码翻了翻配置,看到这两行:
# application.ymlspring:servlet:multipart:max-file-size:1024MBmax-request-size:1024MB单个文件 1GB,整个请求也 1GB。
再往下翻,看到这个:
// MultipartConfig.java@ConfigurationpublicclassMultipartConfig{@BeanMultipartConfigElementmultipartConfigElement(){MultipartConfigFactoryfactory=newMultipartConfigFactory();Stringlocation=System.getProperty("user.dir");FiletmpFile=newFile(location);if(!tmpFile.exists()){tmpFile.mkdirs();}factory.setLocation(location);returnfactory.createMultipartConfig();}}临时文件目录指向user.dir——也就是应用启动时的工作目录。
那一刻我大概知道发生什么了。
先把上传代码过一遍
这个项目是企业申报系统,企业要上传营业执照、财务报告、诊断报告这些附件。文件最终存到华为云 OBS,中间会在本地落一次临时文件。
上传入口在FileController:
// FileController.java:501@PostMapping("/huawei/upload")publicResultuploadToHuaweiyun(@RequestParam("file")MultipartFile[]files,@Param("enterpriseid")Stringenterpriseid,@RequestParam(value="rapplyid",required=false)Stringrapplyid,@Param("type")Integertype){try{if(files.length==SysContants.SIZE_ZERO){returnnewResult().fail(ResultEnum.FAIL_NOT_ATTACHMENT);}// 校验文件名长度for(MultipartFilemf:files){if(mf.getSize()==SysContants.SIZE_ZERO){returnnewResult().fail(ResultEnum.FAIL_EMPTY_FILE);}StringoriginalFilename=mf.getOriginalFilename();if(originalFilename.length()>64){fns.append(";").append(originalFilename);}}// ...Stringret=huaweiyunFileServiceImpl.uploadFileType(files,type,enterpriseid,rapplyid,endPoint,ak,sk,bucketName);进到uploadFileType,核心就两步:先transferTo落本地,再调uploadFile分片传华为云。
// HuaweiyunFileServiceImpl.java:204for(inti=0;i<files.length;i++){Filefile1=newFile(path+"/"+UUID.randomUUID()+files[i].getOriginalFilename());try{files[i].transferTo(file1);// 落本地临时文件this.uploadFile(file1,endPoint,ak,sk,bucketName);// 分片传华为云}catch(Exceptione){log.error(e.getMessage(),e);returnnull;}finally{if(file1!=null){file1.delete();// 删临时文件}}}uploadFile里用的是华为云的分片上传,每片 5MB:
// HuaweiyunFileServiceImpl.java:125longpartSize=5*1024*1024l;longpartCount=file.length()%partSize==0?file.length()/partSize:file.length()/partSize+1;if(partCount>10000){thrownewRuntimeException("所有部分不能超过10000个");}算一下:10000 片 × 5MB = 50000MB,差不多 48.8GB。理论上单个文件能传到 48GB。
看起来没毛病,那么问题出在哪呢?
问题出在哪?
缺陷一:1024MB 这个数字是拍脑袋来的
营业执照是 PDF,几 MB。财务报告是 Excel 扫描件,几十 MB 顶天。诊断报告是系统生成的 PDF,也就几 MB。
整个业务里没有任何一个场景需要传 1GB 的文件。
那 1024MB 怎么来的?我猜是当年有人上传一个大一点的附件被 Spring 默认的 1MB 限制拦住了,报错MaxUploadSizeExceededException,然后改配置的时候直接拉满到 1024MB。
这一改,等于把"上传限制"这道门拆了。
配置写1024MB,不是技术决策,是情绪决策——怕报错,直接拉满。
缺陷二:临时文件落在应用根目录
MultipartConfig里这行:
Stringlocation=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// @RestControllerAdvicepublicclassSysControllerAdvice{// @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){returnnewResult().fail(ResultEnum.FAIL_EMPTY_FILE);// 不能是空文件}if(originalFilename.length()>64){fns.append(";").append(originalFilename);// 文件名不能超过64字符}校验了空文件,校验了文件名长度,唯独没校验文件大小。
再看ApplyFileServiceImpl.validateFile:
// ApplyFileServiceImpl.java:185privateStringvalidateIsIndustryInternet(MultipartFile[]files){returnnull;}privateStringvalidateBenchmarking(MultipartFile[]files){returnnull;}// ... 所有 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:128MBmax-request-size:128MBfile-size-threshold:10MBfile-size-threshold也别漏了,超过 10MB 的文件才落磁盘,小文件直接走内存,减少磁盘 IO。
防线二:临时文件目录用系统临时目录
不要用user.dir,用java.io.tmpdir,或者干脆不配置location,让 Spring 用默认值:
@ConfigurationpublicclassMultipartConfig{@BeanMultipartConfigElementmultipartConfigElement(){MultipartConfigFactoryfactory=newMultipartConfigFactory();// 用系统临时目录,不要用 user.dirfactory.setLocation(System.getProperty("java.io.tmpdir"));returnfactory.createMultipartConfig();}}或者更简单,直接删掉这个配置类,让 Spring Boot 用默认配置。默认的临时目录就是系统临时目录,请求结束后自动清理。
防线三:全局异常处理要开着
@RestControllerAdvicepublicclassSysControllerAdvice{@ExceptionHandler(MaxUploadSizeExceededException.class)@ResponseBodypublicResult<String>handleMaxUploadSize(MaxUploadSizeExceededExceptione){returnnewResult<String>().fail("文件大小超过限制");}@ExceptionHandler(SystemException.class)@ResponseBodypublicResult<String>handleSystem(SystemExceptione){returnnewResult<String>().fail(e.getMessage());}}至少把MaxUploadSizeExceededException接住,返回一个明确的提示。用户看到"文件太大"就不会反复重试了。
防线四:业务层校验文件大小
不要把所有希望寄托在 Spring 的配置上。业务层也要校验,而且要按不同上传类型区别对待:
publicStringvalidateFile(MultipartFile[]files,Integertype){longmaxSize;switch(type){caseSysContants.UPLOADFILE_TYPE_LICENSEFILEPATH:maxSize=10*1024*1024;// 营业执照 10MBbreak;caseSysContants.UPLOADFILE_TYPE_FINANCEFILEPATH:maxSize=100*1024*1024;// 财务报告 100MBbreak;default:maxSize=50*1024*1024;// 其他 50MB}for(MultipartFilefile:files){if(file.getSize()>maxSize){return"文件大小超过限制,最大允许 "+maxSize/1024/1024+"MB";}}returnnull;}这样即使 Spring 的配置被改大了,业务层还有第二道防线。
有个细节我没完全确认:Spring Boot 默认的临时目录是 java.io.tmpdir,在 Windows 和 Linux 上行为是不是完全一致?我本地验证的是 Linux 环境,Windows 上没逐个版本试过,有踩过坑的兄弟评论区指点一下。
自查清单
自查打卡:打开你项目的 application.yml,搜 max-file-size,打在评论区——我看看谁配的是 1024MB。
老炮点评
这个事故的本质是“配置脱离了业务实际”。
1024MB 不是一个经过测算的值,是一个"先设大一点免得报错"的值。这个思路在开发阶段很爽——不用改配置,不用跟产品确认文件大小,直接拉满。
但拉满的代价是把风险转嫁给了运维。磁盘满了、服务挂了,运维半夜爬起来处理,开发已经下班了。
文件上传这个事,限制不是越小越好,也不是越大越好,是要跟业务匹配。营业执照 10MB 够了,财务报告 100MB 够了,诊断报告系统生成的根本不用用户传。搞清楚每个上传口的实际文件大小,再留个 20% 的余量,就够了。
临时文件目录也是一样。Spring 默认用系统临时目录,请求结束自动清理,挺好的。非要改成user.dir,等于自己给自己挖坑。
还有全局异常处理和业务层校验,这两个是底线。配置可能被改、网络可能超时、用户可能传错文件,但只要异常能被正确捕获、业务层有兜底,就不会出大事。
"设大一点"是懒人思维,"算清楚"才是工程师该干的事。
最后问一句:你们项目的上传限制是怎么定的?按业务算出来的,还是拍脑袋定了个整数?评论区聊聊,我看看有多少是“先设大一点”的。
下期预告:《番外篇:多模块工程没 install,跑起来的代码是上周的》
主线项目的坑先缓一缓。下期继续翻base-archives的旧账——改了common包的代码,web模块跑起来行为没有任何变化,排查半天才想起来自己根本没install。
Maven reactor、SNAPSHOT缓存、IDEA工作区解析,三套规则混在一起,下期讲清楚怎么确认自己运行的到底是哪个版本的代码。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
你的每一次点赞互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。