
1. 这不是教科书里的Demo是我在三个真实项目里反复打磨出来的文件上传下载方案“通过SpringMVC实现文件/多文件上传与下载案例”——光看这个标题你可能以为又是一篇复制粘贴的入门教程加个RequestParam(file) MultipartFile file配个CommonsMultipartResolver再写个ResponseEntityResource就完事。但现实根本不是这样。我带过的某高校实验室图像处理Demo、某公司内部文档协同系统、还有某跨平台设备日志分析平台全都在文件上传下载环节踩过坑前端选中200MB的原始DICOM影像后页面卡死用户同时上传5个PDF时后端OOM直接重启下载大文件时浏览器提示“网络错误”而Nginx日志里只有一行499 Client Closed Request更别提中文文件名在Chrome里显示为乱码在Safari里直接变成%E6%96%87%E4%BB%B6.pdf……这些都不是理论问题是凌晨两点被电话叫醒、盯着线程堆栈和Wireshark抓包反复验证的真实现场。核心关键词其实就三个SpringMVC、多文件并发、生产级健壮性。它解决的从来不是“能不能传”而是“传得稳不稳、下得全不全、查得清不清、扩得快不快”。适合谁不是刚学完Controller的新手而是正在接手遗留系统、需要把老式FTP上传模块替换成Web接口的中级开发者是负责交付客户验收、必须保证100MB以上工程图纸零丢失的项目负责人也是要支撑日均5万次上传请求、且不能因单个恶意大文件拖垮整个服务的运维同学。这篇文章不讲XML配置怎么写不列bean标签所有代码都基于Spring Boot 2.7底层仍是SpringMVC每一步都标注了为什么这么选、不这么选会掉进什么坑、线上监控该盯哪个指标。接下来的内容全部来自我把同一套逻辑在三类不同负载场景下重写、压测、调优、复盘的实操记录。2. 方案设计背后的硬逻辑为什么放弃“标准答案”选择这套组合拳2.1 不用StandardServletMultipartResolver因为Tomcat默认吃不消Spring MVC官方文档里推荐的StandardServletMultipartResolver本质是把解析工作甩给Servlet容器Tomcat/Jetty。看似轻量但问题藏在细节里Tomcat 9.0.x默认maxSwallowSize是2MB意思是当客户端上传一个200MB文件却中途断开连接时Tomcat会尝试读取并丢弃剩余数据——这200MB内存就卡在堆里不释放连续几次就能触发Full GC。我们某设备日志平台上线首周监控发现JVM堆内存每小时上涨1.2GB最终定位到就是这个参数。改配置可以但maxSwallowSize-1禁用又带来新风险恶意客户端发个超长header中断连接能直接耗尽线程池。所以我的方案是主动接管解析权用CommonsFileUpload做前置校验在流式读取过程中实时计算MD5、检查文件头Magic Number、拦截超限文件让非法请求在进入Spring DispatcherServlet之前就被拦截。这不是炫技是把防御点前移到最外层。2.2 多文件上传不用ListMultipartFile因为内存泄漏比你想象得更隐蔽很多教程教这么写PostMapping(/upload) public String upload(RequestParam(files) ListMultipartFile files) { ... }看起来干净但MultipartFile的临时文件默认存在Tomcat的work/Catalina/localhost/ROOT/目录下而Spring的清理机制依赖于HttpServletRequest生命周期结束。可一旦前端用fetch上传时没设timeout或网络抖动导致请求挂起这个临时文件就会一直占着磁盘——我们某文档系统曾发现一台服务器/tmp分区被占满98%排查三天才发现是327个未清理的.tmp文件每个2GB。我的解法是强制指定临时存储路径手动清理钩子用DiskFileItemFactory设置setRepository(new File(/data/upload/tmp))并在AfterReturning切面里注册deleteOnExit()同时用ScheduledExecutorService每5分钟扫描/data/upload/tmp下超过30分钟未修改的文件。这不是过度设计是给系统加一道保险丝。2.3 下载不用ResourceHttpRequestHandler因为HTTP Range请求必须自己握在手里ResourceHttpRequestHandler对静态资源友好但对动态生成的文件比如从数据库Blob字段拼接的PDF报告就无能为力。更重要的是它不支持细粒度的Range控制。当用户用迅雷或IDM下载大文件时客户端会发Range: bytes0-1048575请求分片而默认Handler返回200 OK而非206 Partial Content导致断点续传失效。我们的医疗影像系统要求支持PACS设备直连下载必须精确响应Content-Range头。所以我的方案是完全绕过Handler用StreamingResponseBody流式输出读取文件输入流时根据Range头计算起始偏移和长度用response.getOutputStream().write(buffer, 0, len)逐块推送并手动设置Accept-Ranges: bytes、Content-Range、Content-Length。代码量多了30行但换来的是企业级下载体验。2.4 为什么坚持用MultipartFile而不是Part因为生态兼容性决定上线速度Java EE 7引入的Part接口理论上更标准但实际落地全是坑Spring Boot 2.3默认禁用javax.servlet要用Part得额外加spring-boot-starter-tomcat依赖Jetty容器下Part的getSubmittedFileName()在IE11里返回空字符串更致命的是所有现成的文件校验工具如Apache Tika检测文件类型、病毒扫描SDKClamAV Java Binding都只认InputStream或byte[]而MultipartFile.getInputStream()是稳定可靠的。我试过强行切到Part结果在客户现场调试了两天才搞定IE兼容最后还是切回MultipartFile。技术选型不是比谁新而是比谁少踩坑。这套方案已稳定运行23个月零上传失败率业务层统计这就是选择的理由。3. 核心实现细节从请求入口到磁盘落盘的每一处关键控制点3.1 前端上传控件的隐藏陷阱与真实适配方案很多人以为前端只是input typefile multiple但真实世界远比这复杂。我们某跨平台系统需兼容微信内置浏览器、iOS Safari、Android Chrome而它们对input的支持天差地别微信iOS版不支持multiple属性一次只能选1个文件且acceptimage/*会失效必须用accept*再JS过滤Safari 15.4对webkitdirectory支持不稳定用户选中文件夹时可能只返回第一个文件Android UC浏览器上传大文件时onprogress事件不触发无法显示进度条。我的解决方案是三层适配策略基础层用原生input typefile idfileInput禁用multiple靠JS多次触发增强层检测到现代浏览器时动态创建input typefile multiple acceptapplication/pdf,image/*兜底层对微信环境注入WeixinJSBridge监听调用wx.chooseImageAPI获取base64再转为Blob上传。关键代码片段Vue3 Composition APIconst handleFileSelect (event) { const files Array.from(event.target.files); // 微信环境特殊处理 if (isWechat() files.length 0) { const file files[0]; const reader new FileReader(); reader.onload (e) { const base64 e.target.result; // 调用后端预签名接口获取上传URL api.getUploadUrl({ filename: file.name, size: file.size }) .then(res uploadToOSS(base64, res.url)); }; reader.readAsDataURL(file); return; } // 普通浏览器走FormData上传 uploadWithFormData(files); };提示永远不要相信event.target.files.lengthiOS Safari在某些版本下会返回0即使用户选了文件必须用event.target.files.item(0)判空。3.2 后端接收层的四重校验从协议层到业务层的纵深防御Spring MVC的MultipartFile只是个门面真正的校验必须贯穿整个链路。我的校验体系分四层缺一不可第一层HTTP协议层校验Nginx前置在Nginx配置中限制单次请求体大小# /etc/nginx/conf.d/upload.conf server { client_max_body_size 512m; # 必须大于后端设置否则413错误在Nginx就返回了 client_body_timeout 300s; # 防止慢速攻击 }注意client_max_body_size必须严格大于Spring Boot的spring.servlet.multipart.max-request-size否则Nginx会先返回413后端根本收不到请求。第二层Servlet容器层校验Tomcatapplication.yml中配置spring: servlet: multipart: max-file-size: 500MB max-request-size: 500MB location: /data/upload/tmp # 强制指定临时目录这里有个反直觉的点max-file-size和max-request-size必须设为相同值。如果前者是100MB后者是500MB当用户上传5个120MB文件时单个文件超限被拒绝但整个请求体仍会进入容器造成资源浪费。第三层Spring MVC层校验Controller入口PostMapping(/upload) public ResponseEntityUploadResult upload( RequestParam(files) MultipartFile[] files, RequestHeader(value X-Request-ID, required false) String reqId) { // 1. 数量校验防止恶意提交1000个空文件 if (files.length 0 || files.length 20) { throw new IllegalArgumentException(文件数量必须在1-20之间); } // 2. 总大小校验避免内存溢出 long totalSize Arrays.stream(files) .mapToLong(MultipartFile::getSize) .sum(); if (totalSize 500 * 1024 * 1024L) { // 500MB throw new IllegalArgumentException(所有文件总大小不能超过500MB); } // 3. 文件名校验防御路径遍历 for (MultipartFile file : files) { String filename file.getOriginalFilename(); if (filename.contains(..) || filename.startsWith(/)) { throw new SecurityException(文件名非法 filename); } } // 4. 类型校验用Tika检测真实MIME不信任后缀 for (MultipartFile file : files) { try (InputStream is file.getInputStream()) { String mimeType tika.detect(is); if (!ALLOWED_TYPES.contains(mimeType)) { throw new IllegalArgumentException(不支持的文件类型 mimeType); } } } // 执行上传逻辑... }第四层业务逻辑层校验Service检查用户当日上传配额如免费用户限10GB/天对图片文件调用ImageIO.read()验证是否真为有效图片对PDF调用PdfReader检查是否损坏计算文件MD5存入数据库用于去重和完整性校验。注意Tika的detect()方法必须传入InputStream而非byte[]否则无法准确识别加密PDF或损坏文件。我们曾因用file.getBytes()导致127个“假PDF”入库后续全部人工核验。3.3 多文件并发上传的线程安全与性能平衡术多文件上传不是简单循环file.transferTo()。当10个用户同时上传20个文件共200个文件如果每个文件都开独立线程处理瞬间创建200个线程CPU上下文切换开销会拖垮系统。我的方案是两级队列固定线程池第一级Controller层快速接收MultipartFile对象本身不消耗太多资源Controller只做校验和元数据提取文件名、大小、MD5然后将UploadTask对象含文件输入流、目标路径等放入内存队列。第二级Service层异步处理Service public class UploadService { private final ExecutorService uploadPool new ThreadPoolExecutor( 4, // 核心线程数 CPU核心数 8, // 最大线程数 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), // 队列容量100超限触发拒绝策略 new ThreadFactoryBuilder().setNameFormat(upload-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时由调用线程执行 ); public void processUploadTask(UploadTask task) { uploadPool.submit(() - { try { // 1. 创建唯一文件名UUID时间戳 String safeName generateSafeFilename(task.getOriginalName()); Path targetPath Paths.get(/data/upload/files/, safeName); // 2. 流式写入避免内存溢出 try (InputStream is task.getFile().getInputStream(); OutputStream os Files.newOutputStream(targetPath)) { byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); } } // 3. 更新数据库记录 fileRepo.save(FileRecord.builder() .fileName(safeName) .originalName(task.getOriginalName()) .size(task.getFile().getSize()) .md5(task.getMd5()) .uploadTime(LocalDateTime.now()) .build()); } catch (Exception e) { log.error(上传任务失败, e); // 发送告警消息到企业微信机器人 alertService.sendAlert(上传失败 e.getMessage()); } }); } }关键点CallerRunsPolicy确保当队列满时由Web容器线程如Tomcat的http-nio-8080-exec-5直接执行任务避免请求堆积。这比AbortPolicy直接抛异常更友好比DiscardPolicy静默丢弃更可靠。3.4 下载功能的工业级实现支持断点续传、中文名、大文件流式传输下载接口必须同时满足三个矛盾需求快低延迟、稳断点续传、准中文名不乱码。我的实现摒弃了所有“一行代码搞定”的方案采用手动流控GetMapping(/download/{id}) public void downloadFile( PathVariable Long id, HttpServletRequest request, HttpServletResponse response) throws IOException { FileRecord record fileRepo.findById(id).orElseThrow(); Path filePath Paths.get(/data/upload/files/, record.getFileName()); if (!Files.exists(filePath)) { response.sendError(HttpServletResponse.SC_NOT_FOUND, 文件不存在); return; } // 1. 设置响应头关键在Content-Disposition String encodedName URLEncoder.encode(record.getOriginalName(), UTF-8) .replaceAll(\\, %20); // 号要转义为空格 response.setHeader(Content-Disposition, attachment; filename*UTF-8 encodedName); response.setHeader(Content-Transfer-Encoding, binary); // 2. 处理Range请求断点续传 String rangeHeader request.getHeader(Range); long fileLength Files.size(filePath); long start 0, end fileLength - 1; boolean isPartial false; if (rangeHeader ! null rangeHeader.startsWith(bytes)) { isPartial true; String[] ranges rangeHeader.substring(6).split(-); start Long.parseLong(ranges[0].trim()); end ranges.length 1 ? Long.parseLong(ranges[1].trim()) : fileLength - 1; if (end fileLength) end fileLength - 1; } // 3. 设置响应状态码和头 if (isPartial) { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader(Content-Range, String.format(bytes %d-%d/%d, start, end, fileLength)); response.setHeader(Accept-Ranges, bytes); response.setContentLengthLong(end - start 1); } else { response.setContentLengthLong(fileLength); } // 4. 流式传输避免内存溢出 try (RandomAccessFile raf new RandomAccessFile(filePath.toFile(), r); OutputStream os response.getOutputStream()) { raf.seek(start); byte[] buffer new byte[8192]; long remaining end - start 1; while (remaining 0) { int len (int) Math.min(buffer.length, remaining); int read raf.read(buffer, 0, len); if (read -1) break; os.write(buffer, 0, read); remaining - read; } } }实测心得RandomAccessFile比Files.newInputStream()更适合Range场景因为seek()操作更精准URLEncoder.encode()后必须replaceAll(\\, %20)否则Chrome会把解析为空格导致文件名错乱response.setContentLengthLong()必须在os.write()前设置否则Tomcat会自动设为-1触发chunked编码。4. 真实压测与问题排查那些文档里不会写的血泪教训4.1 压测环境搭建与关键指标定义我们用JMeter模拟真实场景不是简单跑100个线程场景1小文件高频500并发每个用户每秒上传1个1MB JPG持续10分钟场景2大文件低频50并发每个用户上传1个300MB视频间隔30秒场景3混合负载200并发其中70%上传10MB PDF30%上传200MB DICOM。监控指标不止看TPS和错误率重点盯三个“隐形杀手”JVM堆外内存Direct Memory-XX:MaxDirectMemorySize1g防止Netty缓冲区爆满Linux文件句柄数ulimit -n 65535否则高并发时出现Too many open files磁盘I/O等待时间iostat -x 1await超过50ms说明磁盘成为瓶颈。压测工具链JMeter Prometheus Grafana Arthas。特别强调Arthas它是定位线上问题的神器比如当上传变慢时用thread -n 5看最耗时线程用watch com.example.UploadController upload returnObj实时观察方法返回值。4.2 典型问题速查表与根因分析问题现象监控线索根本原因解决方案上传5分钟后超时Nginx日志显示499netstat -an | grep :8080 | wc -l显示ESTABLISHED连接数达800Tomcat最大连接数maxConnections200不足连接排队超时调整server.tomcat.max-connections1000accept-count500下载大文件时Chrome提示“网络错误”Wireshark抓包显示TCP RSTss -s显示memory项used接近limitLinux内核net.ipv4.tcp_rmem设置过小大文件传输时缓冲区溢出echo net.ipv4.tcp_rmem 4096 65536 8388608 /etc/sysctl.conf中文文件名在Firefox下载后变成%E6%96%87%E4%BB%B6.pdfcurl -I http://localhost/download/1返回Content-Disposition: attachment; filename%E6%96%87%E4%BB%B6.pdf服务端未使用filename*UTF-8格式Firefox严格遵循RFC 5987改用URLEncoder.encode(name,UTF-8).replaceAll(\\,%20)构造上传100个文件后/data/upload/tmp目录残留37个.tmp文件find /data/upload/tmp -mmin 30 | wc -l返回37MultipartFile的cleanup()未被调用因异常导致finally块未执行在Controller中用try-catch-finally显式调用file.cleanup()多线程上传时数据库报Deadlock found when trying to get lockshow engine innodb status\G显示事务锁等待多个线程同时插入file_record表按id主键顺序竞争锁改用INSERT IGNORE INTO file_record (...) VALUES (...)跳过重复4.3 那些只有踩过才懂的避坑技巧技巧1用Content-MD5头做客户端校验在上传前前端用Web Crypto API计算文件MD5async function calculateMD5(file) { const arrayBuffer await file.arrayBuffer(); const hashBuffer await crypto.subtle.digest(MD5, arrayBuffer); const hashArray Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b b.toString(16).padStart(2, 0)).join(); } // 上传时带上 headers[Content-MD5] await calculateMD5(file);后端收到后对比不一致直接返回400。这比等文件上传完再校验快10倍尤其对大文件。技巧2临时文件命名加用户ID哈希前缀不要用UUID.randomUUID().toString()而用DigestUtils.md5Hex(userId System.currentTimeMillis())。这样当同一用户重复上传同名文件时临时文件不会冲突且便于审计追踪。技巧3下载接口加X-Content-Type-Options: nosniff头防止浏览器MIME嗅探导致XSS尤其当用户上传HTML文件时此头能强制浏览器按Content-Type头解析而非猜测。技巧4用Async处理非核心逻辑文件上传成功后发送邮件通知、生成缩略图、更新搜索索引这些操作必须用Async异步化否则阻塞主线程。但注意Async方法不能在同一个类内调用否则代理失效必须抽取到独立Service。5. 生产环境加固与可观测性建设让上传下载从功能变成能力5.1 文件存储架构演进从本地磁盘到对象存储的平滑迁移最初所有文件都存/data/upload/files/但很快遇到问题磁盘空间告警、备份困难、多节点同步延迟。我们分三步迁移到对象存储以阿里云OSS为例阶段1兼容模式保持原有Controller不变只替换FileService实现// 接口不变 public interface FileStorageService { String upload(InputStream is, String filename, long size); void download(String key, OutputStream os); } // 实现类切换 Bean Profile(prod) public FileStorageService ossStorageService() { return new OssFileStorageService(ossClient, bucketName); }阶段2双写模式新上传同时写本地和OSS旧文件仍从本地读用Scheduled定时任务逐步迁移历史文件。阶段3全量切换确认OSS读写性能达标实测100MB文件上传平均320ms比本地慢15%但可用性提升100%后彻底下线本地存储。关键经验OSS的putObject必须设置ObjectMetadata的setContentLength()否则SDK会自动计算对大文件造成额外内存开销getObject返回的OSSObject必须调用close()否则连接池耗尽。5.2 全链路监控埋点从用户点击到文件落盘的每一步我们给上传下载全流程打点用ELK收集日志关键字段包括trace_id全链路追踪ID用MDC注入steppre_check/stream_write/db_save/oss_uploadduration_ms各步骤耗时file_size文件大小字节statussuccess/failed/timeouterror_code自定义错误码如UPLOAD_001文件超限UPLOAD_002类型不支持在Kibana中建立看板实时监控上传成功率目标≥99.95%平均上传耗时P95 2s for 10MB, 15s for 100MB失败原因TOP5分布当UPLOAD_001错误突增时立即触发告警通常是客户修改了前端maxFileSize限制未同步后端。5.3 安全加固清单不只是防黑客更是防误操作文件名消毒用正则[^a-zA-Z0-9._-]替换所有非安全字符防止../../../etc/passwd路径遍历临时目录权限chmod 700 /data/upload/tmp禁止其他用户读取数据库敏感字段加密file_record.original_name用AES-256加密存储密钥由KMS托管上传频率限制用Redis计数器INCR upload:ip:192.168.1.100EXPIRE设为60秒超限返回429病毒扫描集成上传完成后调用ClamAV REST API扫描curl -X POST http://clamav:3310/scan --data-binary /tmp/file扫描失败则删除文件并告警。最后分享个小技巧在Controller方法上加Timed(value upload.duration, histogram true)Micrometer注解Prometheus会自动暴露http_server_requests_seconds_bucket指标不用写一行代码就能看到上传耗时分布直方图。这比自己埋点省事十倍而且数据更准。我在实际项目中发现真正决定上传下载模块成败的往往不是技术多炫酷而是对边界条件的敬畏——那个被忽略的号转义、那个没设Content-Length的响应、那个没关的OSSObject流。这套方案没有银弹但每一步都踩过坑、验过真现在把它完整交给你。如果你正在为类似问题头疼不妨从Nginx的client_max_body_size开始检查那往往是第一个突破口。