在国企OA系统里,视频文件上传一直是个绕不开的烦心事。审批流程要挂视频附件、培训资料要上传录像、项目例会要留痕存档,动辄几百兆的视频文件扔给普通上传接口,传一半断了、超时了、服务器崩了,都是家常便饭。去年我接手一个JavaWeb项目,需求很明确:让用户能以接近“秒传”的速度把大视频提交到OA系统,同时全程能看到上传进度,断网了还能续传。这篇文章就把那套基于JavaWeb的分片秒传与进度监控方案完整拆给你看,从设计思路、核心代码、前后端联调到排坑记录,一次讲透。
1. 方案选型与整体设计思路
1.1 为什么不能用普通上传直接搞定视频文件
很多人第一反应是“直接甩个multipart请求不就完了?”,在OA系统里这么干,基本是给自己埋雷。国企OA通常部署在内网,服务器配置不算豪华,带宽峰值看着有百兆,实际多用户同时访问时,单文件上传速度能跌到1~2MB/s。一个500MB的视频,普通上传要传七八分钟,期间只要有一个请求超时、浏览器标签被误关、网络出现一次抖动,整个文件就报废,用户就得从头再来。
还有一个隐藏问题:Tomcat默认对multipart请求体大小有限制,直接传大文件很容易触发MaxUploadSizeExceededException。就算你调大了max-file-size,也会让服务器的IO线程长时间被一个请求占住,其他用户连普通的OA表单提交都会跟着变卡。所以我当时给业务方的第一句话就是:大文件上传必须分片,没有第二条路。
分片上传的核心逻辑是把一个大文件切成若干个独立小块,逐块上传。每块都是独立的HTTP请求,失败只重试这一块;服务器再按顺序把所有分片合并成完整文件。再加上秒传机制——先用文件内容算出唯一指纹,如果服务器已经有这个文件,直接告诉前端“不用传了,我已经有了”,这才是真正意义上的“秒传”。
1.2 技术栈与总体架构
我们这套方案整体还是走JavaWeb传统路线,没有引入太激进的东西。具体选型是这样:
| 组件 | 选型 | 说明 |
|---|---|---|
| 后端框架 | SpringBoot 2.x + MyBatis-Plus | 开发接口快,维护性好 |
| 数据库 | MySQL 8.0 | 存文件元数据和分片记录 |
| 缓存 | Redis 5.x | 存实时进度、临时状态 |
| 前端 | 原生JS + axios + SparkMD5 | 兼容性优先,OA系统很多用户还在用老内核浏览器 |
| 文件存储 | 本地磁盘 + Nginx静态映射 | 内网环境没必要上OSS,省成本 |
整体流程是这样的:前端拿到视频文件后,先计算整个文件的MD5值;带着这个MD5去后端查一下“有没有入库”,有就直接秒传成功;没有则按固定大小切片,分组并发上传;后端每收到一片就记录分片信息,全部收齐后触发合并;合并过程中前端轮询进度接口,把进度条从0走到100%。
为什么进度用Redis而不是纯前端统计?因为要考虑断点续传。用户传了一半断网,重新打开页面后,前端需要知道哪些片传过了、哪些没传过,这些信息必须存在后端。Redis的SET、INCR都能原子操作,天然适合记录分片状态。文件元数据和分片映射关系还是落MySQL,保证持久化,Redis挂了可以从数据库恢复。
2. 核心细节解析与实操要点
2.1 切片大小与并发数的确定
切片大小不是拍脑袋定的,我总结出一个经验区间:2MB到8MB之间最稳。切片太小,比如1MB一片,HTTP请求的往返开销和数据库写入次数会翻好几倍,上传500MB文件要发500次请求,光握手和响应就耗费大量时间;切片太大,比如50MB一片,就失去了分片的意义,一片失败重传的成本太高。
切片数的计算公式很简单:总片数 = Math.ceil(fileSize / chunkSize)。如果视频是1GB,切片定8MB,就是128片;如果网络差、用户多,我建议切成4MB,256片对后端来说也能扛住。我们实际生产环境用的就是8MB,内网环境下单片上传耗时基本在1秒以内,整体体验很好。
并发数同样需要控制。浏览器对同一域名的并发连接数有限制(HTTP/1.1下通常6个),但你不能真的把6个并发全打满,因为后端Tomcat的线程池要伺候整个OA系统。我一般把前端并发数压到3,后端接口超时时间放宽到120秒。算一笔账:如果同时有10个人在传视频,每人3个并发,共30个请求在跑,Tomcat默认200线程池,剩余线程足够支撑OA其他接口,不会把系统拖死。
2.2 文件唯一标识与秒传逻辑
秒传的关键是文件唯一标识,我直接用的全文件MD5。这里必须强调:不能只比对文件名,同一部培训视频可能在十个科室里叫十个名字,内容完全一样;也不能只看文件大小,大小一样内容可能完全不同。MD5碰撞概率在业务场景里基本可以忽略,用来做秒传的判断指纹完全够用。
计算整个文件MD5的耗时和文件大小有关,1GB视频在普通办公电脑上可能要算10到20秒。这个时间不能浪费,前端需要一边算MD5一边给用户展示“正在识别文件指纹”,让用户觉得系统在做正事,而不是卡死了。计算完成后,调用秒传查询接口:
GET /file/exists?md5={md5}后端返回三种状态:fileExists=true表示服务器已有相同文件,前端直接结束;chunkExists=true表示该文件的历史分片存在但未合并,需要从缺失的分片开始续传;都为空则从第一片开始传。这个接口相当重要,我把判断逻辑放在最前面,如果每次都傻乎乎地传完整分片,就谈不上秒传了。
2.3 进度监控的前后端交互方案
进度监控最容易踩的坑是“前端自己数,后端不关心”。用户刷新页面后,前端的内存计数归零,进度条回退,体验很差。我采用的方案是后端记录 + 前端轮询,两端数据对齐。
后端记录分片数用两个维度:一个是MySQL里的分片表,记录每片的上传状态;另一个是Redis里的计数器,专门给进度接口调用的。每次分片上传成功,先写数据库,然后对Redis的key做INCR。进度接口返回当前已上传片数和总片数,前端计算出百分比。
如果不想每上传一片就轮询一次,可以等本片上传完成后立即更新本地进度,同时开一个每秒一次的定时器去后端同步“真实进度”,两个进度之间做校准。这种方式能保证大文件长时间上传时,就算前端事件丢失也能从后端捞回来。至于WebSocket推送,在内网OA环境里不强求,轮询已经足够轻量。
3. 实操过程与核心环节实现
3.1 前端实现:文件切片与秒传查询
前端这块我用的是原生JS手写切片逻辑,没有用第三方大文件上传组件。不是否定组件,而是OA系统环境复杂,老浏览器兼容性问题很多,自研代码反而更容易控制边界。
核心代码框架如下:
// 文件选择后 const file = document.getElementById('videoFile').files[0]; const chunkSize = 8 * 1024 * 1024; // 8MB一片 const chunkCount = Math.ceil(file.size / chunkSize); // 1. 计算整个文件MD5 const fileMd5 = await computeFileMd5(file); // 基于spark-md5封装,建议放到Web Worker里 // 2. 秒传查询 const { data } = await axios.get('/file/exists', { params: { md5: fileMd5 } }); if (data.fileExists) { showProgress(100); return; } // 3. 从缺失分片位置开始上传 let uploadedChunks = data.uploadedChunks || []; let current = 0; // 4. 并发上传分片 const concurrency = 3; async function uploadWorker() { while (current < chunkCount) { const index = current++; if (uploadedChunks.includes(index)) continue; const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('file', chunk); formData.append('md5', fileMd5); formData.append('chunkIndex', index); formData.append('chunks', chunkCount); formData.append('fileName', file.name); try { await axios.post('/file/uploadChunk', formData); updateProgress(); } catch (e) { retryPush(index); // 进入重试队列 } } }注意file.slice是Blob对象,不需要加载整个文件到内存,所以不用担心用户打开1GB视频占用掉2GB内存。计算MD5时我用FileReader分块读取文件,每块大小1MB,读取完一块继续下一块,SParkMD5支持增量追加,这个方法在IE上也能跑通。
3.2 后端实现:分片接收与合并
后端的核心接口就这么几个:秒传查询、分片上传、进度查询、合并。分片上传的Controller方法:
@PostMapping("/uploadChunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("md5") String md5, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("chunks") Integer chunks) throws IOException { // 1. 防止重复提交:先判断分片是否已存在 FileChunk entity = fileChunkMapper.selectByMd5AndIndex(md5, chunkIndex); if (entity != null && entity.getStatus() == 1) { return Result.ok("分片已存在,跳过"); } // 2. 保存分片到临时目录 File dir = new File(fileStoragePath + File.separator + md5); if (!dir.exists()) dir.mkdirs(); File chunkFile = new File(dir, chunkIndex + ".part"); file.transferTo(chunkFile); // 3. 记录分片信息 fileChunkMapper.insert(md5, chunkIndex, file.getSize(), 1); return Result.ok(); }这里有个细节:有些前端框架会把分片序号从0开始或从1开始,后端必须和前端严格对齐,否则合并出来的视频会缺块或者顺序错乱。我统一约定从0开始,数据库里chunk_index字段存的就是0、1、2...,合并时按这个字段升序排列。
合并接口的写法:
@PostMapping("/merge") public Result merge(@RequestParam("md5") String md5, @RequestParam("chunks") Integer chunks, @RequestParam("fileName") String fileName) { // 校验分片完整性 Integer count = fileChunkMapper.countFinishedByMd5(md5); if (count == null || count != chunks) { return Result.error("分片未全部上传"); } File dir = new File(fileStoragePath + File.separator + md5); File finalFile = new File(fileStoragePath + File.separator + md5 + getExt(fileName)); try (FileOutputStream fos = new FileOutputStream(finalFile)) { for (int i = 0; i < chunks; i++) { File part = new File(dir, i + ".part"); Files.copy(part.toPath(), fos); } } // 校验合并文件大小与原始文件一致 if (finalFile.length() != expectedSize) { return Result.error("合并失败,文件大小不匹配"); } // 写入文件表,并清理临时分片 fileInfoMapper.insert(new FileInfo(md5, fileName, finalFile.getPath(), finalFile.length(), 1)); deleteQuietly(dir); return Result.ok(); }合并时使用Files.copy(part.toPath(), fos)时,流不会自动关闭分片文件,但好在每个分片都不大。正确姿势是用try-with-resources包裹每个分片的输入流,或者干脆用RandomAccessFile按偏移量写入,我这里为了代码直白起见就用顺序写。对于100片以内的视频,顺序写不会有性能问题。
3.3 进度监控接口与前端进度条
进度接口我直接复用分片表的数据,不额外存“进度百分比”这种容易失效的字段:
GET /file/progress?md5={md5}返回JSON结构:
{ "totalChunks": 128, "uploadedChunks": 67, "uploadedSize": 603979776, "totalSize": 1073741824, "percent": 56.2 }后端实现上,先查Redis的布隆过滤器或者存储的uploadedCount,如果Redis里没有,就查MySQL的分片表count。为什么有Redis还要查MySQL?因为Redis可能在服务器重启后丢数据,必须有一个持久化兜底。Redis的key设置成upload:progress:{md5},每次分片上传成功后在Service层执行redisTemplate.opsForValue().increment(key),并设置24小时过期。
前端进度条在每片上传成功后调用一次这个接口,而不是单纯本地累计。为了避免请求太频繁,我加了一个节流:每200毫秒最多刷新一次进度条。这样即使用户切到后台,等切回来时进度条也能通过轮询追上真实状态。
3.4 断点续传与失败重试
断点续传本质上不是花哨功能,而是把“已上传分片”作为基础数据服务。用户刷新页面后,前端重新计算MD5,不放心就调秒传查询接口,后端返回uploadedChunks列表。然后前端把所有已上传的索引存进Set里,上传前先判断,这个索引如果已经被Set记录了,直接跳过。
失败重试单独说一下。网络不稳定时,可能某一片传了99%断掉,axios会抛异常。我的策略是:单片的失败次数不超过3次,超过3次就暂停整个任务,提示用户“网络异常,请点击重试”。重试时不需要重新计算MD5,直接复用之前的值,把未上传的分片继续传完就行。这个设计避免了一直在网络边缘疯狂试探。
4. 常见问题与排查技巧实录
4.1 大文件MD5计算时间过长,界面卡死
这个问题在我们上线第一周就遇到了。有用户传一个2.4GB的执法记录仪视频,前端用的是普通FileReader同步计算MD5,结果浏览器标签页直接无响应,用户骂娘,我也很无奈。
解决办法有两个:一是把MD5计算塞进Web Worker线程,计算过程完全不占用UI线程,用户可以看到进度条还在动;二是做一个降级策略——如果文件超过2GB,就只计算前128MB的MD5作为秒传标识,命中率稍微低一点,但至少不会让浏览器崩掉。我最后两个都做了,效果好得多。
4.2 分片合并后视频文件无法播放或损坏
这个坑非常隐蔽。当时我发现部分视频合并后打开提示“文件已损坏”,排查一圈终于找到原因:前端切片时的end计算用了Math.min(start + chunkSize, file.size),代码看着没毛病,但在一个边界值上,最后一篇切片如果大小为0,也会被上传。后端合并时多合并了一个0字节文件,导致视频数据错位。
修复方法是前端保证end - start > 0才上传,后端合并前也校验一下每个分片文件的大小>0。另外还要注意分片文件的磁盘写入是否完整,MultipartFile.transferTo()结束后,不要立刻就对分片做校验,最好重新打开文件检查长度是否和请求中的file.getSize()一致,确保没有写一半的情况。
4.3 多人同时上传同一个文件,秒传与合并冲突
场景是这样的:某天下午,信息科三个人同时上传同一份培训视频,这个视频之前没入库,三个人都算出了相同的MD5,都没秒传成功,于是三批分片同时写进了同一个临时目录。合并时每个人都在做“把分片合成文件”,结果A合并成功后又删了临时目录,B的合并请求进来发现分片不够,直接报错。
解决方案是给file_info表的MD5字段加唯一索引,合并时使用“先查后插”的原子操作。具体来说,合并接口里先执行一次SELECT ... WHERE md5=... AND status=1,如果有记录,直接删除当前临时分片并返回秒传成功;如果没有,再执行INSERT,但这里要捕获唯一键冲突的异常,捕获后同样删除临时分片返回秒传成功。这样多个并发请求只有一个能真正合并,其余都自动“撞车”成秒传。
4.4 上传过程中服务器临时目录被清空或磁盘不足
我在一台测试服务器上遇到过临时目录所在的磁盘只有20GB,一个视频就占了8GB分片,用户还没合并,另一个任务又占8GB,磁盘直接满了,整个OA系统其他功能都开始报错。后来我做了两件事:
第一,前端在开始上传前,调用后端接口查询磁盘剩余空间,若剩余空间不足当前文件大小的1.5倍,直接拒绝上传并提示用户联系管理员清理。第二,加了一个定时任务,每六小时扫描一次临时目录,删除创建时间超过24小时且没有合并记录的分片文件。这个时间窗口要大于最慢用户的上传时间,避免误删正在上传的文件。
4.5 浏览器兼容性:老版IE内核无法用FormData或fetch
国企OA环境最头疼的是浏览器。有的科室电脑还停留在Windows 7 + IE11,旧版内核不支持fetch和Promise,甚至连Blob.slice的写法都有差异。我在代码里做了兼容判断:检测到浏览器不支持File.prototype.slice时,自动降级为普通上传,同时提示用户安装新版Chrome内核浏览器。普通上传接口依然保留,虽然体验差点,但功能能用,不至于让用户完全没法提交视频。
5. 一些亲测有效的优化与建议
5.1 服务端限流与线程池隔离
分片上传接口如果完全放开并发,Tomcat线程池很容易被打满。我在接口入口加了基于Semaphore的限流,信号量设成50,同时只允许50个分片上传请求进入处理逻辑,其余请求排队等待。这样即使全公司都在传视频,最多也就占用50个线程,OA系统其他接口依然响应正常。
实际调参建议:如果你们OA并发用户数在200以内,信号量建议设30~50;超过500人的国企集团,建议设80,同时配合Nginx的limit_req模块做IO层限流。这个值不是越大越好,因为分片上传消耗的主要是磁盘IO,线程太多反而导致分片写入时互相抢锁。
5.2 与OA流程表单的业务集成细节
视频传完不等于流程走完,还得和OA的业务表单关联。我的做法是在合并成功后,后端返回一个fileId和fileUrl,OA表单提交时把这两个字段埋进隐藏域,等表单走审批流程时,通过fileId关联到具体附件。
这里要注意一个安全问题:视频文件可能涉及内部信息,OA系统必须做权限校验。上传接口和合并接口我全部加了登录拦截器,并校验当前用户是否有对应模块的附件上传权限,不能因为做了进度监控就放松鉴权。同时所有视频文件的访问URL都需要带临时签名,防止普通用户猜测路径直接下载。
5.3 其他值得一试的小技巧
我用Redis做进度计数时,发现每次分片上传都调用一次increment其实有点浪费网络。后来直接把计数更新放在分片表写入的同一个事务里,在数据库更新成功后,再异步发送一条Redis消息,进度接口兜底查库。这样Redis挂了也能保证进度查询走数据库,不会出现用户看到进度条卡在90%不动。
日志也别忽视。每个分片上传成功的日志我打了md5 + chunkIndex + size三个字段,排查问题时按MD5搜日志,第一次上传是哪儿断了、哪个分片没传完,一目了然。
最后再提醒一句:上线前一定要用带宽限速工具模拟弱网环境,把上传速度限到200KB/s,测一下分片重传和进度校准。别问为什么,等你被生产环境的“网络抖动”教育过就明白了。这套方案我落地之后,用户满意度确实上来了,至少不再有人半夜打电话说视频传不上去。希望这些经验能帮到你,有细节问题可以在评论区继续聊。