1. 大文件上传进度监控的痛点与解决方案
在Web应用开发中,处理大文件上传是个老生常谈却又常做常新的需求。我经历过一个医疗影像管理系统项目,需要上传平均300MB以上的DICOM文件,最初采用传统表单提交方式,用户经常抱怨:"传了半小时,最后告诉我失败了?"这种糟糕体验促使我们研究进度监控方案。
SpringMVC的拦截器机制恰好能完美解决这个问题。不同于过滤器(Filter)处理的是Servlet请求,拦截器(Interceptor)工作在更上层的Spring MVC框架中,能获取到DispatcherServlet分发后的控制器上下文信息。这让我们可以在文件上传过程中插入监控逻辑,而不会污染业务代码。
关键认知:拦截器 vs 过滤器
- 过滤器:Servlet规范定义,处理原始HTTP请求/响应
- 拦截器:Spring MVC特有,可访问HandlerMethod等Spring上下文
- 执行顺序:Filter -> DispatcherServlet -> Interceptor -> Controller
2. 核心实现架构设计
2.1 整体流程设计
实现进度监控需要解决三个技术点:
- 前端分片上传与进度事件监听
- 服务端接收分片时的进度计算
- 前后端进度信息的实时同步
我们采用的技术组合:
- 前端:FineUploader(支持分片和进度回调)
- 传输层:Commons FileUpload + Servlet 3.0 API
- 监控层:自定义ProgressInterceptor
- 数据同步:Redis发布订阅
java复制// 架构伪代码示意
class ProgressInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(...) {
// 初始化进度上下文
}
@Override
public void afterCompletion(...) {
// 清理进度数据
}
}
class UploadController {
@PostMapping("/upload")
public void chunkUpload(
@RequestParam("file") MultipartFile chunk,
HttpSession session) {
// 处理分片并更新进度
}
}
2.2 进度计算数学模型
假设一个500MB文件,分片大小为5MB,则:
- 总分片数 = ceil(500 / 5) = 100片
- 每片进度贡献 = 1 / 100 = 1%
- 已上传进度 = min(100%, 当前片数 * 1% + 当前片内进度)
这个模型需要在拦截器中实时维护:
java复制class UploadProgress {
private long totalSize;
private long uploaded;
private int currentChunk;
private int totalChunks;
public double getPercent() {
double chunkProgress = (currentChunk - 1) * 100.0 / totalChunks;
double currentChunkProgress = uploaded * 100.0 / (totalSize / totalChunks);
return Math.min(100, chunkProgress + currentChunkProgress);
}
}
3. 拦截器深度实现
3.1 拦截器注册与配置
在Spring Boot中配置拦截器:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(progressInterceptor())
.addPathPatterns("/upload/**")
.excludePathPatterns("/static/**");
}
@Bean
public ProgressInterceptor progressInterceptor() {
return new ProgressInterceptor();
}
}
3.2 进度上下文管理
关键设计点:
- 使用ThreadLocal保证线程安全
- 基于sessionId隔离不同用户的上传进度
- 采用LRU缓存防止内存泄漏
java复制public class ProgressInterceptor extends HandlerInterceptorAdapter {
private static final int MAX_CACHE_SIZE = 1000;
private static final Map<String, UploadProgress> progressCache =
Collections.synchronizedMap(
new LinkedHashMap<String, UploadProgress>(16, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_CACHE_SIZE;
}
});
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
if (handler instanceof HandlerMethod) {
String sessionId = request.getSession().getId();
String progressKey = sessionId + "_" +
request.getParameter("flowIdentifier");
if (!progressCache.containsKey(progressKey)) {
progressCache.put(progressKey, new UploadProgress(
Long.parseLong(request.getParameter("flowTotalSize")),
Integer.parseInt(request.getParameter("flowTotalChunks"))
));
}
request.setAttribute("progressKey", progressKey);
}
return true;
}
}
3.3 进度更新策略
在控制器中更新进度:
java复制@PostMapping("/upload")
@ResponseBody
public ResponseEntity<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("flowChunkNumber") int chunkNumber,
HttpServletRequest request) {
String progressKey = (String) request.getAttribute("progressKey");
UploadProgress progress = progressCache.get(progressKey);
progress.setCurrentChunk(chunkNumber);
progress.addUploaded(file.getSize());
// 存储分片数据...
return ResponseEntity.ok().build();
}
4. 前端实时展示方案
4.1 进度查询接口
java复制@GetMapping("/progress")
@ResponseBody
public ProgressDTO getProgress(
@RequestParam String flowIdentifier,
HttpSession session) {
String key = session.getId() + "_" + flowIdentifier;
UploadProgress progress = progressCache.get(key);
return new ProgressDTO(
progress.getPercent(),
progress.getUploaded(),
progress.getTotalSize()
);
}
4.2 前端轮询优化
不建议使用简单setInterval,应采用智能轮询策略:
javascript复制let retryCount = 0;
const MAX_RETRY = 5;
const BASE_DELAY = 1000;
function checkProgress() {
fetch(`/progress?flowIdentifier=${flowId}`)
.then(res => res.json())
.then(data => {
retryCount = 0;
updateProgressBar(data.percent);
if (data.percent < 100) {
// 动态调整轮询间隔
const nextDelay = Math.min(
5000,
BASE_DELAY + data.percent * 40
);
setTimeout(checkProgress, nextDelay);
}
})
.catch(err => {
if (++retryCount < MAX_RETRY) {
setTimeout(checkProgress, BASE_DELAY * retryCount);
}
});
}
5. 生产环境优化实践
5.1 性能优化方案
-
内存优化:
- 使用WeakReference包装进度对象
- 定期清理过期进度(30分钟未更新)
java复制@Scheduled(fixedRate = 30 * 60 * 1000) public void cleanStaleProgress() { progressCache.entrySet().removeIf(entry -> System.currentTimeMillis() - entry.getValue().getLastUpdated() > 30 * 60 * 1000); } -
集群支持:
- 采用Redis存储进度信息
- 使用PUB/SUB机制同步节点间进度
java复制// Redis进度存取示例 public void updateProgress(String key, UploadProgress progress) { redisTemplate.opsForValue().set( "upload:progress:" + key, progress, 2, TimeUnit.HOURS); redisTemplate.convertAndSend("progress.update", key); }
5.2 异常处理机制
必须处理的边界情况:
- 分片序号不连续
- 分片大小异常
- 网络中断恢复
- 服务端重启
建议实现的校验逻辑:
java复制public void validateChunk(UploadProgress progress,
int chunkNumber,
long chunkSize) {
if (chunkNumber <= 0 || chunkNumber > progress.getTotalChunks()) {
throw new InvalidChunkException("Invalid chunk number");
}
long expectedSize = progress.getTotalSize() / progress.getTotalChunks();
if (chunkNumber < progress.getTotalChunks() &&
chunkSize != expectedSize) {
throw new InvalidChunkException("Unexpected chunk size");
}
}
6. 实测对比与调优建议
6.1 不同分片大小的影响
我们针对1GB文件进行的测试数据:
| 分片大小 | 上传总耗时 | 内存占用 | 进度精度 |
|---|---|---|---|
| 1MB | 142s | 高 | ±1% |
| 5MB | 138s | 中 | ±5% |
| 10MB | 135s | 低 | ±10% |
建议选择:
- 内网环境:5-10MB分片
- 公网环境:1-2MB分片
6.2 常见问题排查
-
进度卡在99%
- 检查最后一个分片是否特殊处理
- 验证文件合并逻辑
-
内存持续增长
- 确认LRU缓存生效
- 检查会话过期监听器
-
跨域问题
java复制@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/upload/**") .allowedOrigins("*") .allowedMethods("POST", "GET") .allowCredentials(true); } }
7. 扩展应用场景
这套方案经过改造还可用于:
- 视频转码进度监控
- 大数据导出任务跟踪
- 分布式计算任务状态同步
比如在视频处理场景中,可以扩展进度对象:
java复制class VideoProcessProgress extends UploadProgress {
private TranscodeStage stage; // UPLOAD/TRANSCODE/SAVE
private double transcodePercent;
@Override
public double getPercent() {
switch (stage) {
case UPLOAD: return super.getPercent() * 0.3;
case TRANSCODE: return 30 + transcodePercent * 0.6;
case SAVE: return 90 + super.getPercent() * 0.1;
}
}
}
在实现这类需求时,建议将进度监控模块设计为可插拔组件,通过抽象ProgressTracker接口支持不同业务场景:
java复制public interface ProgressTracker {
void update(String taskId, ProgressUpdate update);
ProgressInfo get(String taskId);
void remove(String taskId);
}