ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringMVC上传进度实时监控:拦截器与监听器全解析

2026/10/2 3:47:49 拓冰建站 浏览量
SpringMVC上传进度实时监控:拦截器与监听器全解析 大附件上传的进度条大概是 Web 项目里最容易被先能传再说带过的功能。用户选了一个几个 GB 的文件点上上传浏览器上只剩一只小转圈。传了多久不知道传到百分之多少不知道万一中途断了还得从头再来。群里天天有人问SpringMVC 怎么看上传进度而 HandlerInterceptor拦截器经常被点名。这篇就把这个方案彻底讲透SpringMVC 的拦截器到底拦了什么、哪些阶段它根本拦不到以及真正让大附件上传进度做到实时监控的三个核心组件该怎么配合。这套做法的适用范围很明确SpringMVC 项目Spring 4/5 传统工程都适用Spring Boot 只要把解析器切回 CommonsMultipartResolver 也可以需要给大附件上传加服务端进度反馈的场景。无论你是还在维护老项目的守城人还是想把新项目一次性做对的架构控这篇都值得往下看。我会先讲原理再给可直接抄的代码最后把我们实测踩过的坑一次说完。1. 先搞清楚大附件上传到底难在哪1.1 用户端看到的黑盒与服务端手忙脚乱先还原一个真实场景。用户往文档系统里传一个 4GB 的项目压缩包点击上传后页面只有一个转圈。他不是工程师不知道 4GB 要传多久也不知道是不是已经卡死。等了两分钟他关掉页面重试服务端却已经收了一半——这就是典型的黑盒上传。对服务端来说大附件上传也远不是接收个文件这么简单。请求体大、网络波动频繁、超时风险高、磁盘 IO 压力大任何一个环节出问题用户感知到的就是传不上去。如果没有进度反馈开发者排查问题的唯一手段是翻日志用户排查问题的唯一手段是骂产品。所以大附件上传的第一步不是优化传输速度而是先把进度这个信息从服务端传到用户眼前。1.2 前端自带的进度监听为什么不够用很多人第一反应是浏览器不是自带 xhr.upload.onprogress 吗为什么要绕一大圈去服务端拿进度浏览器自带的 onprogress 确实能拿到当前请求的上传字节数但它有三个硬伤。第一它只能反映浏览器发出去了多少字节没法反映服务端收到了多少、处理到了哪一步。网络传输完成不等于服务端处理完成上传完还要落盘、做校验、生成缩略图、通知其他模块这一段时间用户看到的进度永远停在 100% 但页面还在转体验一样糟糕。第二多文件、断点续传、服务端聚合处理这类场景前端 onprogress 只能一个一个文件地报无法给出一个聚合后的整体进度。第三兼容性虽然如今不是大问题但在内部控制类项目里总有一批老浏览器和老客户端的存量用户前端方案对他们无效。服务端进度方案则不同客户端只要能发一个 GET 请求就能拿到进度。这也是很多老项目坚持做服务端进度的根本原因。1.3 主流方案对比nginx、WebSocket、轮询为什么最终选了拦截器监听器服务端做进度的方案并不少我按原理、优点、缺点整理过一张对比表直接贴出来方案原理优点缺点nginx upload_progress_module在 nginx 层面统计请求体流量不改业务代码性能高依赖 nginx且看不到应用层的保存、校验等处理阶段前端 xhr.upload.onprogress浏览器统计发送字节零服务端成本只覆盖传输阶段看不到服务端处理WebSocket / SSE 主动推送服务端主动推进度实时性最好增加连接管理成本老环境兼容性一般请求轮询本篇方案上传时把进度写进 Session监控接口轮询读取实现简单覆盖完整处理周期任意客户端可用有 1 秒左右的延迟需处理 Session 并发我也见过用 nginx lua 脚本拦截上传请求、手动统计请求体大小的玩法那是 nginx 层面的另一套思路适合入口统一且不想动 Java 代码的场景。但大多数业务系统还是要感知文件保存完没、后续处理跑完没这些只有应用层知道。所以我最终选的是CommonsMultipartResolver 解析时通过 ProgressListener 记录进度 拦截器管控进度查询接口 前端轮询的组合。其中拦截器负责监控接口的横切逻辑ProgressListener 才是真正读取上传字节数的数据源。2. SpringMVC 处理上传请求的完整链路2.1 一次上传请求在 DispatcherServlet 里的三段旅程想理解拦截器能干什么必须先理清 SpringMVC 处理一次上传请求的完整时序。传统 SpringMVC 的所有请求都会先进入 DispatcherServletdoDispatch 方法的核心流程简化后是这个样子// DispatcherServlet.doDispatch 核心流程简化 protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HttpServletRequest processedRequest request; HandlerExecutionChain mappedHandler null; // 第一阶段Multipart 解析 processedRequest checkMultipart(request); // 第二阶段根据请求路径找到 Handler 和拦截器链 mappedHandler getHandler(processedRequest); // 第三阶段执行拦截器 preHandle然后才调用 Controller if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // 调用 Controller 方法 mv ha.handle(processedRequest, response, mappedHandler.getHandler()); // 执行拦截器 postHandle 与 afterCompletion mappedHandler.applyPostHandle(processedRequest, response, mv); }注意 checkMultipart 在最前面它在找 Handler、执行任何拦截器之前就已经把 multipart 请求解析完了。这个顺序是整个方案的命门很多人就是在这里栽了跟头。2.2 拦截器的盲区multipart 解析发生在拦截器之前正因为 checkMultipart 在最前面所以一个注册在 /upload 路径上的 HandlerInterceptor它的 preHandle 是在文件已经解析完成、请求体已经读完后才触发的。换句话说如果你以为在拦截器里读输入流就能拿到上传进度那注定白忙一场——你拿到的是一个已经被 DispatcherServlet 消费完的请求体。这不是拦截器不行而是它天生不在这个位置。SpringMVC 的 HandlerInterceptor 拦截的是从确定 Handler 到执行 Controller 前后这段流程它擅长做登录校验、权限控制、请求日志、响应头设置这类横切逻辑但 multipart 解析这个环节在它前面它看不见。那标题里说的通过拦截器实现进度监控到底怎么落地答案是把拦截器用在正确的位置拦截进度查询接口 /progress。上传请求本身由自定义 MultipartResolver 内部挂载的 ProgressListener 记录进度前端循环 GET /progress拦截器负责校验会话、禁止缓存、控制访问频率。这样各司其职才是一条能跑通的完整链路。2.3 ProgressListener 才是进度数据的源头真正能逐字节看到上传进度的是 Apache Commons FileUpload 提供的 ProgressListener。CommonsMultipartResolver 底层就是 Commons FileUpload 的 ServletFileUpload它在解析请求时会把 InputStream 一段一段地读出来每读一段就会回调一次 ProgressListener。回调方法长这样public interface ProgressListener { void update(long pBytesRead, long pContentLength, int pItems); }三个参数分别是已经读到的字节数、整个请求体的总长度、目前已解析的表单项数量。这个回调发生在请求体读取过程中正好对应上传的传输阶段。我们把它拿到的数据写进当前用户的 HttpSession前端轮询读取就形成了一个完整的进度闭环。这里有一个很关键的技巧ProgressListener 的回调发生在任意一个普通方法内部它拿不到 HttpServletRequest但我们可以通过 Spring 的 RequestContextHolder 在当前线程里取到请求对象。DispatcherServlet 在处理请求时会把请求上下文绑定到当前线程所以解析器内部完全能拿到 Session。这个技巧是我见过很多教程没讲透的后面代码里我会直接给出。3. 环境准备与基础配置3.1 依赖引入与版本坑传统 SpringMVC 工程用的是 CommonsMultipartResolver它依赖两个库pom 里加进去dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.4/version /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency版本上有两个坑提醒一下。第一commons-fileupload 1.4 是目前比较稳的版本1.5 虽然修复了部分安全问题但对老 Spring 工程的兼容性表现一般不是必须追新。第二如果你用的是 Spring BootBoot 默认启用的是 Servlet 3.0 的 StandardServletMultipartResolver不走 commons-fileupload。想用本文方案要么在配置里排除 MultipartAutoConfiguration要么确保自定义解析器的 bean 名为 multipartResolver并且把 Boot 的 multipart 开关关掉。Spring Boot 下配置略有差异但核心代码完全一样。3.2 multipartResolver 参数配置详解在 SpringMVC 的 XML 配置里解析器是这样定义的bean idmultipartResolver classcom.example.upload.ProgressMultipartResolver !-- 单次上传最大字节数这里按 10GB 举例 -- property namemaxUploadSize value10737418240/ !-- 超过该字节数的文件落临时盘小于该值的内存缓存 -- property namemaxInMemorySize value1048576/ property namedefaultEncoding valueUTF-8/ !-- 必须保持 false保证请求一进来就开始解析 -- property nameresolveLazily valuefalse/ /bean参数含义不复杂但有两个值得展开。第一个是 maxInMemorySize它决定小文件在内存里缓存、大文件写临时文件。传 1MB 的文件1MB 以内全部在内存不会触发临时文件逻辑ProgressListener 依然会回调因为解析器读 InputStream 的过程不变。第二个是 resolveLazily这个参数必须保持 false。如果设成 truemultipart 解析会被延后到 Controller 真正读取文件时才执行那样进度数据出现的时间点就完全对不上了。实测中曾有人为了提升性能把这个参数打开结果进度条从上传开始到结束全是 0。3.3 拦截器注册与路径规划用 Java 配置注册拦截器实现 WebMvcConfigurer 即可Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UploadProgressInterceptor()) .addPathPatterns(/progress); } }用 XML 配置则是mvc:interceptors mvc:interceptor mvc:mapping path/progress/ bean classcom.example.upload.UploadProgressInterceptor/ /mvc:interceptor /mvc:interceptors路径规划只有一个原则拦截器只匹配 /progress 这一条轻量查询路径不要去匹配 /upload。原因前面已经说过匹配 /upload 也拦不到解析过程还白增加一次请求开销。如果系统里还有静态资源记得给资源路径加上排除规则别让拦截器把 css、js 也拦了。4. 三件套核心代码进度对象、解析器、拦截器4.1 ProgressRecord放在 Session 里的进度对象先写进度数据模型。这个类会被上传线程写入、被轮询线程读取天然存在并发访问所以内部字段我用 volatile 保证可见性percent 计算做成线程安全的方法。package com.example.upload; import java.io.Serializable; public class ProgressRecord implements Serializable { public static final int STATUS_NOT_STARTED 0; public static final int STATUS_UPLOADING 1; public static final int STATUS_SAVING 2; public static final int STATUS_DONE 3; public static final int STATUS_FAILED 4; private volatile long bytesRead; private volatile long contentLength -1; private volatile int items; private volatile int status STATUS_NOT_STARTED; private volatile long startTime System.currentTimeMillis(); private volatile long lastUpdateTime; private volatile long lastBytesRead; private volatile long speed; // 每秒字节数 private volatile String message; public synchronized void update(long bytesRead, long contentLength, int items) { this.bytesRead bytesRead; if (contentLength 0) { this.contentLength contentLength; } this.items items; this.status STATUS_UPLOADING; long now System.currentTimeMillis(); if (lastUpdateTime 0) { long deltaTime now - lastUpdateTime; long deltaBytes bytesRead - lastBytesRead; if (deltaTime 0) { this.speed deltaBytes * 1000 / deltaTime; } } this.lastUpdateTime now; this.lastBytesRead bytesRead; } public synchronized int getPercent() { if (contentLength 0) { return 0; } long percent bytesRead * 100 / contentLength; return (int) Math.min(percent, 100); } public long getRemainingSeconds() { if (speed 0 contentLength 0 bytesRead contentLength) { return (contentLength - bytesRead) / speed; } return -1; } // getter / setter 按需生成 public long getBytesRead() { return bytesRead; } public long getContentLength() { return contentLength; } public int getItems() { return items; } public int getStatus() { return status; } public void setStatus(int status) { this.status status; } public long getSpeed() { return speed; } public String getMessage() { return message; } public void setMessage(String message) { this.message message; } }这里有个容易被忽略的细节percent 计算用的是 long 乘法bytesRead * 100 在文件特别大时可能溢出 int所以先用 long 算再强转 int。这个 bug 在传 2GB 以上文件时必现很多人排查半天才发现是 int 溢出。4.2 自定义 MultipartResolver把监听器挂到解析器上核心解析器继承 CommonsMultipartResolver重写两个方法。第一个是 resolveMultipart在整个解析开始前先从请求里拿到 Session放入一个全新的 ProgressRecord第二个是 prepareFileUpload把 ProgressListener 挂到 FileUpload 实例上。package com.example.upload; import org.apache.commons.fileupload.FileUpload; import org.apache.commons.fileupload.ProgressListener; import org.apache.commons.fileupload.FileUploadException; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartException; import org.springframework.web.multipart.commons.CommonsMultipartResolver; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; public class ProgressMultipartResolver extends CommonsMultipartResolver { public static final String PROGRESS_SESSION_KEY UPLOAD_PROGRESS_RECORD; Override public MultipartParsingResult resolveMultipart(HttpServletRequest request) throws MultipartException { // 确保 Session 存在并放入全新的进度对象 HttpSession session request.getSession(true); ProgressRecord record new ProgressRecord(); session.setAttribute(PROGRESS_SESSION_KEY, record); return super.resolveMultipart(request); } Override protected FileUpload prepareFileUpload(String encoding) throws FileUploadException { FileUpload fileUpload super.prepareFileUpload(encoding); fileUpload.setProgressListener(new ProgressListener() { Override public void update(long pBytesRead, long pContentLength, int pItems) { ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs null) { return; } HttpSession session attrs.getRequest().getSession(false); if (session null) { return; } ProgressRecord record (ProgressRecord) session.getAttribute(PROGRESS_SESSION_KEY); if (record ! null) { record.update(pBytesRead, pContentLength, pItems); } } }); return fileUpload; } }两个关键点说明白第一resolveMultipart 里我调用 request.getSession(true)强制创建 Session。因为用户第一次上传时可能没有可用的会话而 ProgressListener 回调时拿不到创建新会话这种操作只能 getSession(false)所以必须在解析前就把 Session 准备好。第二进度记录对象在解析开始时放入 Session之后监听器每次回调都是直接修改这个对象的字段而不是重新 setAttribute。如果你每次回调都 setAttributeTomcat 的 Session 实现会触发通知和可能的序列化逻辑大文件下性能会急剧劣化。4.3 进度查询接口与拦截器联动进度查询接口本身很简单从 Session 里把 ProgressRecord 拿出来返回 JSON。如果 Session 里没有记录就返回一个表示未开始的空对象让前端好判断package com.example.upload.controller; import com.example.upload.ProgressRecord; import com.example.upload.ProgressMultipartResolver; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; import javax.servlet.http.HttpSession; Controller public class UploadProgressController { GetMapping(/progress) ResponseBody public ProgressRecord progress(HttpSession session) { ProgressRecord record (ProgressRecord) session.getAttribute(ProgressMultipartResolver.PROGRESS_SESSION_KEY); if (record null) { return new ProgressRecord(); } return record; } }但这里有一个非常容易踩的坑轮询请求是 GET且路径固定浏览器和中间代理都可能对响应做缓存。如果响应被缓存前端拿到的永远是第一次查询的进度进度条自然一动不动。所以拦截器在这里的职责就是给 /progress 的响应打上禁止缓存的头package com.example.upload.interceptor; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class UploadProgressInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 禁止浏览器/代理缓存进度响应 response.setHeader(Cache-Control, no-store, no-cache, must-revalidate); response.setHeader(Pragma, no-cache); response.setDateHeader(Expires, 0); return true; } }别小看这个拦截器。我们在生产环境就遇到过进度条卡在第一次查询结果的情况排查了整整半天最后抓包发现是浏览器缓存了 GET /progress 的响应。加上这三个响应头之后问题立刻消失。这就是拦截器在大附件上传进度方案里的实际价值它不产生进度数据但保证进度数据能被前端实时拿到。4.4 上传 Controller收尾与清理上传接口接收 MultipartFile保存文件并在成功后清理 Session 里的记录。这里要处理两个情况文件超限时的异常提示以及返回 JSON 给前端判断结果。package com.example.upload.controller; import com.example.upload.ProgressMultipartResolver; import com.example.upload.ProgressRecord; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpSession; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; Controller public class UploadController { PostMapping(/upload) ResponseBody public String upload(RequestParam(file) MultipartFile file, HttpSession session) { ProgressRecord record (ProgressRecord) session.getAttribute(ProgressMultipartResolver.PROGRESS_SESSION_KEY); if (record ! null) { record.setStatus(ProgressRecord.STATUS_SAVING); } try (InputStream in file.getInputStream()) { File dest new File(/data/uploads/ System.currentTimeMillis() _ file.getOriginalFilename()); try (FileOutputStream out new FileOutputStream(dest)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } if (record ! null) { record.setStatus(ProgressRecord.STATUS_DONE); } return {\success\:true}; } catch (Exception e) { if (record ! null) { record.setStatus(ProgressRecord.STATUS_FAILED); record.setMessage(e.getMessage()); } return {\success\:false,\message\:\ e.getMessage() \}; } finally { session.removeAttribute(ProgressMultipartResolver.PROGRESS_SESSION_KEY); } } }注意 finally 里的清理逻辑。不管成功还是失败都要把 Session 里的进度记录清掉否则下一次上传时前端会在新上传开始前先看到上一次残留的进度数据。上传过程中把状态置为 SAVING是为了告诉前端传输已完成服务端正在处理这一步对用户体验至关重要后面第 5 节会重点讲。5. 前端实时展示轮询与进度条实现5.1 轮询、SSE、WebSocket 怎么选进度展示层有轮询、SSEServer-Sent Events、WebSocket 三种常见选择。很多教程一上来就上 WebSocket我觉得是过度设计。上传进度这种场景数据量极小、频率不高500ms 一次已经够流畅轮询完全够用而且实现和维护成本最低。方式实时性实现成本适用场景轮询有 0.5~1s 延迟最低进度条这种低频小数据量场景推荐SSE近实时服务端单向推送中等需要服务端主动推送且无老客户端WebSocket近实时双向较高不只要看进度还要做暂停、断点续传等双向交互如果一个项目已经用了 WebSocket 基础设施用它推进度也顺理成章但如果只是为了进度条单独拉一套 WebSocket轮询的投入产出比明显更高。另外轮询天然对任意客户端友好老浏览器、无前端刷新的场景都能用。5.2 一个开箱即用的轮询前端代码前端用原生 JavaScript fetch 就够了不需要引入任何库。上传用 XMLHttpRequest 是为了兼容老环境轮询用 fetch 是纯 GET问题不大。input typefile idfileInput button onclickupload()开始上传/button progress idprogressBar max100 value0/progress span idstatusText未开始/span script var pollTimer null; function upload() { var file document.getElementById(fileInput).files[0]; if (!file) { alert(请先选择文件); return; } var formData new FormData(); formData.append(file, file); var xhr new XMLHttpRequest(); xhr.open(POST, /upload); xhr.send(formData); xhr.onload function() { stopPolling(); var result JSON.parse(xhr.responseText); if (result.success) { document.getElementById(statusText).innerText 上传成功; } else { document.getElementById(statusText).innerText 上传失败 (result.message || 未知错误); } }; xhr.onerror function() { stopPolling(); document.getElementById(statusText).innerText 网络异常; }; startPolling(); } function startPolling() { pollTimer setInterval(fetchProgress, 500); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer null; } } function fetchProgress() { fetch(/progress) .then(function(resp) { return resp.json(); }) .then(function(data) { var bar document.getElementById(progressBar); var text document.getElementById(statusText); if (data.status 3) { bar.value 100; text.innerText 服务端处理完成; stopPolling(); return; } if (data.status 4) { text.innerText 上传失败 (data.message || ); stopPolling(); return; } if (data.status 2) { bar.value 100; text.innerText 传输完成服务端保存中...; return; } var percent data.percent || 0; bar.value percent; if (data.contentLength 0) { var readMB (data.bytesRead / 1024 / 1024).toFixed(1); var totalMB (data.contentLength / 1024 / 1024).toFixed(1); var speedKB (data.speed / 1024).toFixed(1); var remain data.remainingSeconds 0 ? data.remainingSeconds 秒 : 未知; text.innerText readMB MB / totalMB MB speedKB KB/s剩余 remain; } }) .catch(function() { // 轮询请求失败先不处理等下一次 }); } /script这个前端代码里有个细节值得说轮询请求失败时不立即停止而是等下一次轮询。因为上传过程中服务端可能短暂繁忙一次 GET 失败不代表上传失败。只有上传请求本身 onerror 了才真正判定网络异常。5.3 阶段状态机传输中、服务端处理中、完成、失败进度不能只有一个百分比推荐在 ProgressRecord 里维护一个状态机。整个上传的生命周期分五个阶段STATUS_NOT_STARTED0前端还没有发起上传或轮询过早到达STATUS_UPLOADING1服务端正在接收请求体ProgressListener 正在回调STATUS_SAVING2请求体接收完毕Controller 正在保存文件或做后续处理STATUS_DONE3保存成功STATUS_FAILED4保存失败或请求体超过限制为什么要单独区分 UPLOADING 和 SAVING因为大文件的传输阶段和信息落盘阶段是分开的传输到 100% 后服务端还得花时间写磁盘。如果没有 SAVING 状态用户会看到进度条到 100% 后页面还一直转又变成新的黑盒。加上状态字段后前端可以明确显示传输完成服务端保存中用户就明白不是卡死了。这个状态机是我认为整个方案里最容易被忽略、但也最提升体验的部分。6. 生产环境下的并发与容器配置6.1 Session 并发读写安全与更新频率控制上传请求的线程在写 ProgressRecord轮询请求的线程在读同一个 ProgressRecord这是典型的多线程读写共享对象。Tomcat 的 HttpSession 内部不是线程安全的直接用 session.getAttribute 和 setAttribute 并发操作会有隐患。我建议的规避方式有两条。第一减少 setAttribute 的频率。如第 4 节所说解析开始时把 ProgressRecord 放入 Session之后监听器只修改 POJO 字段不再触发 Session 的写入通知。这样轮询读到的永远是最新值且不产生大量 Session 序列化操作。第二对共享字段做好可见性保障。我在 ProgressRecord 里用 volatile 修饰所有字段update 和 getPercent 方法用 synchronized 保证读改写操作原子性。如果不做这一步极端情况下轮询可能读到写了一半的数据percent 出现跳变。更新频率上ProgressListener 的回调可能会非常密集尤其是在高速内网里每秒能回调几十上百次。逐次去同步计算没有问题但如果你把回调里做了重活比如写日志、刷 Redis性能就会崩。实测下来监听器回调只做一次内存字段更新开销极小不需要额外节流。但如果你的环境 Session 是放在 Redis 的那就必须把记录对象改成本地缓存否则每次回调都写 Redis上传 2GB 文件 Redis 会被打爆。这点在 7.4 节再展开。6.2 Tomcat/Nginx 针对大文件上传的容器参数代码写完后容器参数不调大文件上传照样跑不通。先看 Tomcatconnector 配置里有两个参数要关注Connector port8080 protocolHTTP/1.1 connectionTimeout20000 maxSwallowSize-1 maxPostSize-1/maxSwallowSize 是 Tomcat 在业务代码读取完请求体后还愿意多读多少字节来保持连接的上限默认 2MB。大文件上传时如果请求体没被完整消费Tomcat 会尝试继续读剩余部分。文件很大的话这个额度过小会导致 Tomcat 直接返回 400 或连接异常。对纯文件上传的场景直接设成 -1 表示不限制。maxPostSize 是表单 POST 体大小的限制对 multipart 请求的原始 body 影响不大但为了避免从表单参数等其他路径踩坑可以一并放开。如果前面还有 nginx 做反向代理还要调 nginx 的两个参数client_max_body_size 10g; proxy_read_timeout 300s; proxy_send_timeout 300s;client_max_body_size 不调超过默认 1MB 的请求直接 413。proxy_read_timeout 不调上传超过 60 秒没响应nginx 就把连接断了进度条传到一半直接失败。至于具体值按你业务里最大的目标文件放大一点留余量就好。6.3 临时文件清理与异常兜底CommonsMultipartResolver 默认把大文件先写到临时目录文件处理完由 FileCleaningTracker 异步删除。但如果进程被 kill、文件校验失败、或者幂等逻辑没走到清理点临时文件会一直留在磁盘上。大文件上传量的项目一天下来可能堆积几十 GB 垃圾文件。兜底措施有三层。第一层在 Controller 的 finally 里确保记录清理同时删除临时文件——其实只要 SpringMVC 解析器正常释放 MultipartFile临时文件会自动清理关键是别让异常路径跳过这条链。第二层用ControllerAdvice统一捕获 MaxUploadSizeExceededException返回明确的 JSON 而不是一堆堆栈信息。第三层写一个定时任务定期清理临时目录里超过 24 小时未访问的文件。我个人更推荐第三层因为纯粹靠代码保障总有漏网之鱼定时清理是最粗暴也最有效的手段。7. 实测踩坑实录与排查清单7.1 进度条一直 0% 的四个原因进度全程 0% 是最常见的故障我们在不同项目里反复遇到过原因基本就那么几个。第一个原因是 Session 没有建立。这通常发生在上传请求是会话内第一个请求的场景比如用户直接在浏览器地址栏输入上传页 URL 然后立刻选文件提交。解决方案就是第 4 节的 resolveMultipart 里强制 getSession(true)。第二个原因是客户端没带 JSESSIONID。有些内部系统用 HTTP 客户端或自己封的请求库上传文件默认不保存 Cookie导致每次请求都新建 Session。上传线程写进的是 Session A轮询线程读的是 Session B进度自然永远为空。排查方法是打开浏览器开发者工具看上传请求的 Cookie 里有没有 JSESSIONID以及轮询请求的 Cookie 是否一致。第三个原因是 resolveLazily 被设成了 true。前面说过延迟解析会拖到 Controller 真正读文件时才执行解析逻辑进度数据完全变形。排查时直接翻配置。第四个原因是监听了错误的对象。如果项目里同时存在多个 MultipartResolver 定义SpringMVC 只会认名字为 multipartResolver 的那个其他都是摆设。看启动日志里到底注册了哪个解析器是最快的确认方式。7.2 进度到 100% 但请求一直挂着这个现象不是 bug而是缺少 SAVING 状态导致的前端误判。请求体接收完毕ProgressListener 报出 100%但 Controller 还在循环写磁盘而这部分没有更新任何进度字段前端就以为卡死了。解决方案就是第 5.3 节的状态机。在 Controller 接收到文件后立刻把 status 置为 SAVING前端看到 SAVING 就显示传输完成服务端保存中并把进度条停留在 100%。保存完毕后再置为 DONE。这一步改动量极小但对用户体验的提升是决定性的。7.3 多标签页/多用户进度互相覆盖如果同一个浏览器开了两个标签页同时上传文件两个上传请求共用同一个 SessionSession 里只有一个 ProgressRecord 键后发起的上传会把前面的记录覆盖掉。前面的上传进度就跳变了。解决思路是给每次上传分配一个 uploadId。前端在上传前先调用一个初始化接口或者由上传页生成一个 UUID 放进表单服务端解析时用MapString, ProgressRecord存在 Session 里键是 uploadId。轮询时带上 uploadId从 Map 里取对应记录。这个改造不复杂但涉及接口协议调整适合确实有多文件并发上传需求的系统。如果业务上允许更简单的方案是直接限制同一会话同时只能有一个上传任务后端发现已有未完成记录时直接拒绝新上传前端提示用户等待。7.4 集群部署下的 Session 策略系统做了负载均衡多个节点之间 Session 如果不共享上传请求打到节点 A进度轮询请求打到节点 B进度照样读不到。常规解决方案是配置 Session 共享比如基于 Redis 的 Session 管理但这里有个隐藏的性能地雷ProgressRecord 存在 Session 里每次字段更新如果是写 Session 存储Redis 就会被高频写入打满。我的建议是在集群环境下不要把进度对象放在分布式 Session 里而是放在节点本地内存缓存中。上传请求在哪个节点解析完进度数据就在哪个节点的内存里前端轮询时带上 uploadId如果请求被负载均衡转发到了其他节点由网关做粘滞会话sticky session保证同一个上传任务的请求都打到同一个节点。大部分网关都支持按 Session 或按请求参数做粘滞这是比分布式 Session 更合理的解耦方式。具体实现时用一个 ConcurrentHashMapuploadId, ProgressRecord 代替 Session 存储进度接口先从本地缓存查查不到再回退 Session两套逻辑兼容。不过要提醒一句这些都属于先跑通再加固的进阶优化。如果你只是做一个内部小系统的上传进度单机 Session 方案完全够用等真的出现集群需求再按这一节的方法改造也不迟。8. 一个从零起步的最小实践建议最后说点我个人踩坑总结出来的起步路径。第一次做这个功能时别急着上集群方案、也别急着做断点续传。我建议按三步走第一步先把第 4 节的 ProgressMultipartResolver 和 ProgressRecord 跑通用浏览器开发者工具看 /progress 接口能否在文件传输过程中实时返回变化的 percent第二步接入第 5 节的前端轮询代码把状态机字段加上让传输完成但服务端在处理这个阶段显示清楚第三步再根据真实业务升级到多上传并发、集群 Session 或者 WebSocket 推送。这个顺序能让你用最小代价验证方案是否成立也避免一开始就把复杂度拉满导致排查困难。进度监控这件事核心从来不是技术多炫而是让用户在任何时刻都知道系统还在动、还要等多久。把这件小事做好了用户的耐心和信任度会明显不一样。