拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 的所有请求都会先进入 DispatcherServlet,doDispatch 方法的核心流程简化后是这个样子:

// 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> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.4</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency>

版本上有两个坑提醒一下。第一,commons-fileupload 1.4 是目前比较稳的版本,1.5 虽然修复了部分安全问题,但对老 Spring 工程的兼容性表现一般,不是必须追新。第二,如果你用的是 Spring Boot,Boot 默认启用的是 Servlet 3.0 的 StandardServletMultipartResolver,不走 commons-fileupload。想用本文方案,要么在配置里排除 MultipartAutoConfiguration,要么确保自定义解析器的 bean 名为 multipartResolver,并且把 Boot 的 multipart 开关关掉。Spring Boot 下配置略有差异,但核心代码完全一样。

3.2 multipartResolver 参数配置详解

在 SpringMVC 的 XML 配置里,解析器是这样定义的:

<bean id="multipartResolver" class="com.example.upload.ProgressMultipartResolver"> <!-- 单次上传最大字节数,这里按 10GB 举例 --> <property name="maxUploadSize" value="10737418240"/> <!-- 超过该字节数的文件落临时盘,小于该值的内存缓存 --> <property name="maxInMemorySize" value="1048576"/> <property name="defaultEncoding" value="UTF-8"/> <!-- 必须保持 false,保证请求一进来就开始解析 --> <property name="resolveLazily" value="false"/> </bean>

参数含义不复杂,但有两个值得展开。第一个是 maxInMemorySize,它决定小文件在内存里缓存、大文件写临时文件。传 1MB 的文件,1MB 以内全部在内存,不会触发临时文件逻辑,ProgressListener 依然会回调,因为解析器读 InputStream 的过程不变。第二个是 resolveLazily,这个参数必须保持 false。如果设成 true,multipart 解析会被延后到 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 class="com.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。如果你每次回调都 setAttribute,Tomcat 的 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 怎么选

进度展示层有轮询、SSE(Server-Sent Events)、WebSocket 三种常见选择。很多教程一上来就上 WebSocket,我觉得是过度设计。上传进度这种场景,数据量极小、频率不高(500ms 一次已经够流畅),轮询完全够用,而且实现和维护成本最低。

方式实时性实现成本适用场景
轮询有 0.5~1s 延迟最低进度条这种低频小数据量场景,推荐
SSE近实时,服务端单向推送中等需要服务端主动推送且无老客户端
WebSocket近实时,双向较高不只要看进度,还要做暂停、断点续传等双向交互

如果一个项目已经用了 WebSocket 基础设施,用它推进度也顺理成章;但如果只是为了进度条单独拉一套 WebSocket,轮询的投入产出比明显更高。另外,轮询天然对任意客户端友好,老浏览器、无前端刷新的场景都能用。

5.2 一个开箱即用的轮询前端代码

前端用原生 JavaScript + fetch 就够了,不需要引入任何库。上传用 XMLHttpRequest 是为了兼容老环境,轮询用 fetch 是纯 GET,问题不大。

<input type="file" id="fileInput"> <button onclick="upload()">开始上传</button> <progress id="progressBar" max="100" value="0"></progress> <span id="statusText">未开始</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_STARTED(0):前端还没有发起上传,或轮询过早到达
  • STATUS_UPLOADING(1):服务端正在接收请求体,ProgressListener 正在回调
  • STATUS_SAVING(2):请求体接收完毕,Controller 正在保存文件或做后续处理
  • STATUS_DONE(3):保存成功
  • STATUS_FAILED(4):保存失败或请求体超过限制

为什么要单独区分 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 针对大文件上传的容器参数

代码写完后,容器参数不调,大文件上传照样跑不通。先看 Tomcat,connector 配置里有两个参数要关注:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" 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 多标签页/多用户进度互相覆盖

如果同一个浏览器开了两个标签页同时上传文件,两个上传请求共用同一个 Session,Session 里只有一个 ProgressRecord 键,后发起的上传会把前面的记录覆盖掉。前面的上传进度就跳变了。

解决思路是给每次上传分配一个 uploadId。前端在上传前先调用一个初始化接口,或者由上传页生成一个 UUID 放进表单,服务端解析时用Map<String, ProgressRecord>存在 Session 里,键是 uploadId。轮询时带上 uploadId,从 Map 里取对应记录。这个改造不复杂,但涉及接口协议调整,适合确实有多文件并发上传需求的系统。如果业务上允许,更简单的方案是直接限制"同一会话同时只能有一个上传任务",后端发现已有未完成记录时直接拒绝新上传,前端提示用户等待。

7.4 集群部署下的 Session 策略

系统做了负载均衡,多个节点之间 Session 如果不共享,上传请求打到节点 A,进度轮询请求打到节点 B,进度照样读不到。常规解决方案是配置 Session 共享(比如基于 Redis 的 Session 管理),但这里有个隐藏的性能地雷:ProgressRecord 存在 Session 里,每次字段更新如果是写 Session 存储,Redis 就会被高频写入打满。

我的建议是,在集群环境下不要把进度对象放在分布式 Session 里,而是放在节点本地内存缓存中。上传请求在哪个节点解析完,进度数据就在哪个节点的内存里;前端轮询时带上 uploadId,如果请求被负载均衡转发到了其他节点,由网关做"粘滞会话"(sticky session)保证同一个上传任务的请求都打到同一个节点。大部分网关都支持按 Session 或按请求参数做粘滞,这是比分布式 Session 更合理的解耦方式。具体实现时,用一个 ConcurrentHashMap<uploadId, ProgressRecord> 代替 Session 存储,进度接口先从本地缓存查,查不到再回退 Session,两套逻辑兼容。

不过要提醒一句:这些都属于"先跑通再加固"的进阶优化。如果你只是做一个内部小系统的上传进度,单机 + Session 方案完全够用;等真的出现集群需求,再按这一节的方法改造也不迟。

8. 一个从零起步的最小实践建议

最后说点我个人踩坑总结出来的起步路径。第一次做这个功能时,别急着上集群方案、也别急着做断点续传。我建议按三步走:第一步,先把第 4 节的 ProgressMultipartResolver 和 ProgressRecord 跑通,用浏览器开发者工具看 /progress 接口能否在文件传输过程中实时返回变化的 percent;第二步,接入第 5 节的前端轮询代码,把状态机字段加上,让"传输完成但服务端在处理"这个阶段显示清楚;第三步,再根据真实业务升级到多上传并发、集群 Session 或者 WebSocket 推送。

这个顺序能让你用最小代价验证方案是否成立,也避免一开始就把复杂度拉满导致排查困难。进度监控这件事,核心从来不是技术多炫,而是让用户在任何时刻都知道"系统还在动、还要等多久"。把这件小事做好了,用户的耐心和信任度会明显不一样。

返回列表