这两年教育行业都在搞线上化,录播课、微课、直播回放基本成了每个学校的标配。但真正做过教育类网页应用的人都知道,最坑的往往不是课程设计和播放器,而是最不起眼的“上传视频”。一门课程的视频动不动就是几百MB甚至几个GB,用传统的multipart表单整体提交,网络稍微抖一下就直接失败,用户骂完前端骂后端。Java后端要支持视频文件的分块上传,本质上不是为了炫技,而是被真实场景逼出来的刚需。
这篇文章我按自己在实际项目里的落地经验,把分块上传从设计思路、接口定义、Java代码实现到断点续传、秒传、合并校验、常见坑位全流程讲一遍。适合正在做在线教育平台、知识付费网站、内部培训系统的Java工程师参考,也适合准备面试时想把这个技术点讲清楚的读者。不管你用的是Spring Boot还是Spring Cloud,核心思路是一致的,把下面这套方案吃透,基本能应对绝大多数教育场景的视频上传需求。
1. 为什么视频上传必须走分块这条路
1.1 教育场景下的大文件上传困境
教育行业的视频文件和普通图片、文档不一样,它天然就是“大文件”。一节45分钟的录播课,就算压缩成720P,少说也要300MB到500MB;如果是高清录制的实操课或者双师课堂回放,单个文件超过1GB很常见。这类文件如果走传统的一次性POST上传,会遇到三个很现实的问题。
第一个问题是HTTP连接超时。浏览器和服务器之间的连接不可能无限期保持,网关、负载均衡器、浏览器本身都有超时限制。一个500MB的文件,在用户上行带宽只有2Mbps的情况下,要传半个多小时,中间只要断一次,前面传的全白费。第二个问题是服务器内存压力,传统的Commons FileUpload和Part.write会把整个文件写入临时目录,但解析multipart时如果配置不当,文件内容被读入JVM堆内存,大文件直接触发OOM,这是线上事故最常见的来源。第三个问题是无法断点续传,用户传了80%断网,重新来一遍,这个体验在教育行业是要被投诉的。
分块上传的思路就是把这个大问题拆成小问题:文件在前端切成若干个小块,每个块单独上传,全部传完后由后端合并成完整文件。任何一个块失败,只需要重传那一个块,而不是整个文件。这个思路在原理上很像断点下载,只不过方向反过来了。
1.2 分块上传的核心收益与适用边界
分块上传解决的核心是三个问题:超时、重传成本和并行效率。切成小块后,单次HTTP请求的耗时被控制在几秒到几十秒内,大幅降低超时概率;某个分块失败后重传成本极低;浏览器对同一域名的并发连接数有限制,但对同一个文件的多个分块,我们可以控制并发数在3到6个之间,充分利用用户带宽。
不过分块上传也并不是银弹,它有一个明显的代价:复杂度上来了。涉及前端切片、分块元数据管理、并发控制、合并校验、临时存储清理。如果文件本身只有几MB,走分块反而多余,直接整包上传更简单。所以你在设计时要有阈值判断逻辑,比如小于100MB走整包上传,大于100MB走分块上传。教育场景的视频基本都超过这个阈值,所以分块是必选项。
另外要注意的是,分块上传只是解决了上传链路的问题,不解决视频处理的问题。教育平台通常还需要在文件合并后进行转码(因为网页端播放MP4兼容性最好,但录屏软件常输出AVI或MOV),生成不同清晰度的版本,再切片成HLS供播放器点播。这一部分可以和上传解耦,但架构上要提前留好接口。
2. 分块上传整体设计与核心细节
2.1 前后端交互流程设计
在动手写代码之前,我强烈建议先把整体流程在纸上画清楚。一个标准的分块上传流程包含三个接口:初始化上传、上传分块、合并文件。有些设计会把查询已上传分块也独立成一个接口,用来支持断点续传,我会在后面单独讲。
初始化上传阶段,前端在用户选中文件后,先向后端发送一个请求,携带文件名、文件大小、MD5、分块大小等信息。后端根据MD5判断是否已经存在相同文件,如果存在就直接返回“秒传”标识,否则生成一个全局唯一的fileId,并把上传任务的元数据记录到数据库。这里生成唯一ID不能太随意,我建议用UUID去掉横线,再拼上时间戳的Base62编码,足够短并且不容易撞。
上传分块阶段,前端按固定大小把文件切片,每传一个分块就带着fileId和chunkIndex作为表单字段,分块内容本身作为文件字段。后端收到后把分块写入临时目录,并更新数据库中的已上传分块信息。这里有个细节:分块索引要从前端传过来,后端不能依赖文件中继信息去推断序号。
合并文件阶段,当前端检测到所有分块都已上传成功后,调用合并接口。后端按chunkIndex从小到大依次读取临时分块文件,写入目标文件。合并完成后计算整个文件的MD5,和初始化时记录的MD5比对,一致就更新任务状态为成功,不一致就标记失败并触发清理。
2.2 前端切片参数怎么定
分块大小直接影响到上传的成败和效率,这个参数不能拍脑袋定。我见过有些项目切2MB一块,一个1GB文件就是512块,并发6个也得很久,而且分块数量太多会导致HTTP请求数量过大,服务端IO压力陡增。也有项目切50MB一块,这又回到了单请求超时的老问题上。
我的经验是分块大小在5MB到20MB之间比较合适,推荐10MB。为什么是这个量级?首先,10MB的块在2Mbps上行带宽下大约耗时40秒,在普通4G网络下20秒左右,不容易触发超时;其次,一个1GB文件拆分后是100块,这个数量级对数据库记录、失败重试、进度统计来说都很可控。如果用户网络环境特别好,比如校园网或企业专线,可以适当调大到20MB,减少请求数。教育场景的教师用户经常在办公网络下上传,稳定性优先,我一般固定取10MB。
前端切片用Blob对象的slice方法,这是HTML5的标准能力,兼容性没有问题。代码大致是这样:
const CHUNK_SIZE = 10 * 1024 * 1024; const file = document.getElementById('videoFile').files[0]; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); let start = 0; for (let index = 0; index < totalChunks; index++) { let end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); chunks.push({ index, chunk }); start = end; }这里要注意一个细节:切完片之后,每个分块的Blob不能一直被强引用,尤其是大文件。如果一次性把所有分块都保存到内存数组里,浏览器会直接被拖垮。正确做法是定义一个当前待上传的分块队列,传完一个丢弃一个。
2.3 服务端存储规划
服务端要存的数据有两类:一类是每个分块的内容,临时存放在磁盘上;另一类是上传任务的元数据,存在数据库里。分块和最终文件的存储位置要规划清楚,不能把分块直接写到最终目录里,否则合并时容易被其他逻辑误读。
我习惯的目录结构是这样:先定义一个根目录,比如/data/upload,下面分三个子目录,temp存放分块,final存放合并后的文件,failed存放合并失败待人工处理的分块。分块文件名直接命名为fileId_chunkIndex,比如6f2a93c1_0、6f2a93c1_1,这样合并时排序非常方便。
数据库表的字段规划也很重要。上传任务表至少包含fileId、fileName、fileSize、md5、chunkSize、totalChunks、uploadedChunks(可以用逗号分隔的字符串存已经上传的分块序号,也可以用JSON数组)、status、createTime、updateTime。如果分块数量很大,用逗号分隔字符串在更新时会反复拼接,性能不理想,这种情况可以单独建一张分块明细表。但教育场景单个文件分块数量基本在100到300个之间,用逗号分隔字符串完全够用,少一张表少一堆事务问题。
3. 实操落地:Java后端关键实现
3.1 初始化上传接口
我用Spring Boot举例,项目结构按controller、service、mapper三层分。初始化上传的Controller接收前端传来的文件名、文件大小、MD5和分块大小,生成fileId并写入数据库。
@PostMapping("/upload/init") public Result initUpload(@RequestBody UploadInitRequest request) { // 1. 先查一遍MD5,判断是否能秒传 UploadTask existing = uploadTaskMapper.findByMd5(request.getMd5()); if (existing != null && existing.getStatus() == 2) { return Result.ok("秒传成功", existing.getFileId()); } // 2. 生成fileId String fileId = generateFileId(); UploadTask task = new UploadTask(); task.setFileId(fileId); task.setFileName(request.getFileName()); task.setFileSize(request.getFileSize()); task.setMd5(request.getMd5()); task.setChunkSize(request.getChunkSize()); task.setTotalChunks( (int) Math.ceil(request.getFileSize() / (double) request.getChunkSize()) ); task.setStatus(0); // 0:初始化 1:上传中 2:完成 3:失败 uploadTaskMapper.insert(task); return Result.ok("初始化成功", fileId); }这里有个判断逻辑很容易被忽视:前端传来的fileName是不安全的,可能包含路径分隔符或者非法字符,后端必须做白名单校验或者重命名。教育平台保存视频时我建议直接以fileId作为存储文件名,原始文件名只存数据库用于展示。
3.2 分块上传接口
分块上传是整个流程中最核心的接口。前端用multipart/form-data提交,携带fileId、chunkIndex和分块文件本身。
@PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam("fileId") String fileId, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("file") MultipartFile chunk) { // 1. 校验任务存在且状态正确 UploadTask task = uploadTaskMapper.findByFileId(fileId); if (task == null || task.getStatus() != 1) { return Result.error("任务不存在或状态异常"); } // 2. 校验chunkIndex范围 if (chunkIndex < 0 || chunkIndex >= task.getTotalChunks()) { return Result.error("分块索引越界"); } // 3. 保存分块到临时目录 Path chunkPath = Paths.get(tempDir, fileId + "_" + chunkIndex); chunk.transferTo(chunkPath.toFile()); // 注意:transferTo内部走的还是IO复制 // 4. 更新已上传分块记录,这里用Redis的Set更合适 // 我用数据库时是取出uploadedChunks字符串,追加后写回 String uploaded = task.getUploadedChunks(); uploaded = uploaded == null || uploaded.isEmpty() ? String.valueOf(chunkIndex) : uploaded + "," + chunkIndex; uploadTaskMapper.updateUploadedChunks(fileId, uploaded); return Result.ok("分块上传成功"); }这条接口看起来简单,实际有几个坑。第一个坑是MultipartFile的transferTo方法,在Servlet环境下它底层是复制而不是移动,分块文件大的话,这个方法会同时占用输入输出流的缓冲,容易造成内存峰值,我建议改用Files.copy(chunk.getInputStream(), chunkPath),显式用InputStream遍历写入,可控性更强。第二个坑是上传分块时的任务状态判断,我上面写的status必须是1,但是初始化后我插进去的status是0,前端传第一个分块时会直接校验失败。这里逻辑要调整:初始化后直接置为1,或者分块上传时只要任务存在且status不等于2都可以接收分块。
第三个坑是并发更新uploadedChunks字符串。前端并发上传多个分块时,如果都走“取出-拼接-写回”这个逻辑,会出现丢失更新。比如chunkIndex为3和4的请求同时到了,都读到同一个旧字符串,分别拼上3和4后各自写回,结果只留下了一个。这个问题有两个解法:单机环境下用synchronized或者ConcurrentHashMap做内存锁,分布式环境下用Redis的Set类型做分块记录,或者用数据库行锁。我实际项目中用的是Redis的sadd命令,每收到一个分块就sadd进一个key为fileId的Set里,合并时用scard或者smembers判断完整性。这个方案天然支持并发,而且合并时还能顺便统计进度。
3.3 合并文件接口
当前端确认所有分块都上传完毕后,会调用合并接口。合并的核心逻辑是按顺序把小文件流拼接到一个大文件里。
@PostMapping("/upload/merge") public Result mergeUpload(@RequestBody MergeRequest request) { String fileId = request.getFileId(); UploadTask task = uploadTaskMapper.findByFileId(fileId); if (task == null) { return Result.error("任务不存在"); } // 1. 校验分块完整性 Set<Integer> uploadedChunks = getUploadedChunksFromRedis(fileId); if (uploadedChunks.size() != task.getTotalChunks()) { return Result.error("分块不完整,已上传 " + uploadedChunks.size() + "/" + task.getTotalChunks()); } // 2. 按顺序合并 Path finalPath = Paths.get(finalDir, fileId + getExtension(task.getFileName())); try (FileChannel out = FileChannel.open(finalPath, CREATE, WRITE)) { for (int i = 0; i < task.getTotalChunks(); i++) { Path chunkPath = Paths.get(tempDir, fileId + "_" + i); try (FileChannel in = FileChannel.open(chunkPath, READ)) { long size = in.size(); long transferred = in.transferTo(0, size, out); if (transferred != size) { return Result.error("合并失败:分块 " + i + " 写入不完整"); } } } } // 3. 计算合并文件MD5,与初始化时的MD5比对 String finalMd5 = md5(finalPath.toFile()); if (!task.getMd5().equalsIgnoreCase(finalMd5)) { task.setStatus(3); uploadTaskMapper.updateStatus(fileId, 3); return Result.error("MD5校验失败,文件可能已损坏"); } // 4. 更新状态,清掉临时分块 task.setStatus(2); uploadTaskMapper.updateStatus(fileId, 2); cleanChunks(fileId, task.getTotalChunks()); return Result.ok("合并成功", finalPath.toFile().getName()); }合并时的FileChannel.transferTo是零拷贝实现,在内核态直接复制数据,性能比传统的ByteBuffer读写高出很多。这里有一个我在生产环境踩过的坑:transferTo在Windows平台上有一个已知限制,单次传输超过2GB时可能出错,虽然教育场景单文件很少超过2GB,但如果平台允许上传超高清长视频,建议在循环里判断transferred的大小,如果返回0则要用另一种方式传输,否则会出现死循环或者文件截断。稳妥的办法是每次transferTo只传实际大小,并加一个计数器防止无限循环。
合并之后的视频校验除了MD5,最好再加一道“文件头校验”。很多教育视频是MP4格式,MP4文件的头部包含ftyp标识,可以通过读取前8个字节判断是不是合法的MP4。MD5能确认文件内容一致,但确认不了文件格式是否可播放,文件头校验能更进一步保证合并结果有效。这一步成本很低,但能提前拦住一批“文件大小没问题但播放器打不开”的投诉。
3.4 服务端并发与线程安全处理
教育平台的视频上传通常集中在某个时间段,比如开学前一周,教师集中上传课件。后端并发处理分块时要特别注意两个层面:一是单文件分块之间的并发顺序,二是多文件上传任务之间的资源隔离。
单文件分块并发,也就是同一个fileId的多个分块同时到达,Redis的Set操作天然支持并发,不会丢数据。但如果用的是数据库存分块记录,建议给uploadTask表加上乐观锁版本号,或者直接用行锁更新。这个锁只锁同一个fileId的行,不会成为全局瓶颈。
多文件之间的资源隔离主要靠文件目录设计,每个fileId的分块都落在同一个临时目录下,文件名以fileId开头,天然隔离。更讲究一点,可以按fileId的前几个字符建多级子目录,比如/temp/6f/2a/fileId_0,避免单个目录下文件数量过多导致文件系统性能下降。我见过一个项目把所有文件都堆在一个目录里,跑了半年后目录里有几十万个小文件,合并时列出目录都卡顿,这个教训值得记。
3.5 前端上传逻辑与并发控制
后端接口就绪后,前端要设计一个可靠的上传队列。核心思路是:先初始化拿fileId,然后把分块数组放进队列,启动N个上传Worker并发消费队列,每个Worker完成后更新进度条。
async function uploadFile(file, fileId, totalChunks) { let uploaded = await getUploadedChunks(fileId); // 断点续传时返回已传列表 let queue = []; for (let i = 0; i < totalChunks; i++) { if (!uploaded.includes(i)) { queue.push(i); } } const CONCURRENCY = 4; let index = 0; async function worker() { while (index < queue.length) { let chunkIndex = queue[index++]; let start = chunkIndex * CHUNK_SIZE; let end = Math.min(start + CHUNK_SIZE, file.size); let blob = file.slice(start, end); let formData = new FormData(); formData.append('fileId', fileId); formData.append('chunkIndex', chunkIndex); formData.append('file', blob, file.name); await axios.post('/upload/chunk', formData); updateProgress(...); } } let workers = Array.from({ length: CONCURRENCY }, () => worker()); await Promise.all(workers); await axios.post('/upload/merge', { fileId }); }并发数取多少要看用户网络。并发太低浪费带宽,太高容易触发路由器或防火墙并发限制。我试过2、4、6、8四个档位,在普通家庭宽带上4最稳,校园网环境下8也没有问题。一个更聪明的做法是运行时根据单块上传耗时动态调节并发数,但这对教育场景有点过度设计,固定4就好。
进度条的计算也值得写清楚。单块进度是整个文件进度的基础,但在实现时要把“已上传分块数”作为主进度,而不是看当前正在传的那一块传了多少。比如100个块,传了37个,整体进度就是37%,正在传的第38块的局部进度可以展示在分块明细里,但主进度条保持按整块推进。这样用户在网速波动时看到的是稳定的阶梯式进度,而不是一下跳一下停,体感好很多。
4. 断点续传、秒传与轮询设计
4.1 断点续传的实现方式
分块上传天然支持断点续传,这是它比整包上传强一个档次的地方。前端在初始化完成之后,可以请求一个查询接口,拿到服务端已经收到的分块索引列表,然后跳过这些分块,只传缺失的部分。
查询已上传分块的逻辑非常简单:
@GetMapping("/upload/progress") public Result getProgress(@RequestParam("fileId") String fileId) { Set<Integer> uploadedChunks = redisService.smembers("upload:chunks:" + fileId); UploadTask task = uploadTaskMapper.findByFileId(fileId); Map<String, Object> data = new HashMap<>(); data.put("uploadedChunks", uploadedChunks); data.put("totalChunks", task.getTotalChunks()); data.put("status", task.getStatus()); return Result.ok(data); }前端拿到uploadedChunks后,在构造队列时直接过滤掉,这样就实现了“接着传”。这里要提醒的是,如果用户刷新了浏览器页面,file对象还在,但内存里的fileId可能已经丢了,所以前端在上传前要把fileId存在localStorage或sessionStorage里,刷新后重新初始化时如果localStorage里有有效的fileId,就不要重新生成,而是直接去查进度。初始化接口要支持传入已有fileId做幂等处理。
4.2 秒传的判定与实现
秒传的核心逻辑是MD5碰撞检测。前端在初始化时把整个文件的MD5发给后端,后端查数据库里有没有相同MD5且状态为2的记录,如果有,说明平台上已经存在一模一样的文件,直接返回秒传成功,不需要再传任何分块。
计算大文件MD5在前端是有成本的,一个1GB文件在前端算MD5可能需要几十秒,这期间界面无响应。建议前端先把文件算好MD5再进入上传流程,并且用一个loading状态提示用户“正在校验文件”。如果觉得前端算MD5太慢,也可以退而求其次只取文件的前后各几个KB算指纹,但碰撞概率高一些,不适合对内容精度要求高的教育平台。我个人的做法是折中:先用文件大小做粗筛,再用文件头尾块做二次校验,最后服务端合并时还会做全量MD5校验,三层保险。
文件大小和MD5双条件查询时,要给数据库加上组合索引,否则上传量大了之后秒传接口会慢。
4.3 后端定时任务补漏
分块上传最头疼的异常是用户上传到一半就关掉了浏览器,留下一些分块占着磁盘空间。服务端需要一个定时清理任务,定期扫描临时目录里超过一定时限没有更新的分块文件,并把对应的数据库记录和Redis key删掉。
清理任务要注意不能误删正在上传的文件。通常方案是分块文件的上次修改时间作为判断条件,比如超过24小时未更新就认为任务已失效。教育场景下一节课上传不太可能拖24小时,这个阈值是安全的。清理时还要先改数据库状态为“已过期”,再删文件,防止清理过程中用户又传了新的分块。定时任务可以用Spring的@Scheduled注解,每天凌晨执行一次,扫描量控制在全量task表的10%以内,避免拖慢正常上传接口。
5. 常见问题与排查技巧实录
5.1 分块丢失,合并时提示“分块不完整”
这个问题我在项目里遇到过好几次,大多是Redis的key过期导致的。上传过程中的Redis key如果没有设置过期时间,Redis的默认淘汰策略在内存紧张时可能把不常用的key清掉,合并时smembers查不到完整的分块列表。解决方法是给上传中的Redis key设置一个合理的TTL,比如此文件应该在上传的时限内,比如24小时;同时,在合并接口的校验逻辑里,如果Redis查不到完整分块,可以回退去临时目录统计chunk文件的实际数量,以文件系统为准,这样即使Redis数据丢失也能精确判断。
5.2 合并后视频损坏或无法播放
合并之后视频损坏的原因通常有三个。第一是分块顺序错乱,用FileChannel合并时排序必须用数值比较而不是字符串比较,否则fileId_10会排在fileId_2前面,字符串排序会让你怀疑人生。第二是前端切片时没有把最后一个块按实际剩余大小截断,我见过有前端代码直接按chunkSize切,最后一块越界导致合并时多出一段0字节。第三是并发上传同一个分块,后端没有做幂等处理导致文件被覆盖。
排查这类问题,我建议在合并接口里加审计日志,记录每个分块的顺序、大小、MD5,合并后把日志和最终MD5放在一起比对,缩小问题范围。这是我调试过几次之后总结出来的高效方法,空想问题原因浪费时间。
5.3 上传高峰期服务器磁盘IO打满
教育平台的教师上传高峰期通常在寒暑假前和开学季,上传接口的性能瓶颈一般不在CPU而在磁盘。合并大文件时连续读写会让磁盘IO飙升,如果分块也被落在同一块磁盘上,IO争抢会更严重。优化手段有四个方向:分块临时目录和合并最终目录分到不同磁盘;合并时限制并行合并任务数量,比如用信号量控制在2个以内;分块落盘时不需要fsync,让操作系统延迟刷盘;大文件可以尝试用内存映射,但要注意留给JVM的内存要够,免得引发GC问题。
5.4 转码与上传的衔接问题
教育平台的视频上传完成后通常要触发转码任务,生成不同清晰度的版本。这里要注意的是,转码服务的触发不要放在合并接口里直接调用,因为合并接口阻塞太久会影响HTTP响应;正确做法是合并成功后往消息队列里发一条转码任务消息,由转码Worker异步拉取处理。同时要给任务表加一个转码状态字段,前端在上传成功后轮询这个状态,显示“上传成功,转码中”“转码完成”等不同阶段。不管是RabbitMQ还是RocketMQ,这个模式比同步调用健壮得多。
写在最后
我做过几个教育类的Java后端项目,分块上传这个功能让我最大的体会是:方案本身不复杂,难的是把各种异常情况想全。文件断了、并发撞了、Redis丢了、磁盘满了、转码等了,每一环都可能出问题,而这些恰恰是面试官最爱问、线上最容易踩的细节。
如果你正在做一个教育平台的视频上传功能,从分块大小、并发数、Redis记录分块、合并校验、定时清理这几步入手,先把主链路打通,再逐步加上秒传、断点续传和转码联动。按照我上面说的这套结构来做,大概率能少走很多弯路。最后分享一个小技巧:上线之前,用浏览器的开发者工具把网络切到Slow 3G,再传一个500MB以上的视频,真实验一遍断网重连、刷新页面续传,比你写多少单元测试都管用。教育场景的用户不会按你预设的完美路径操作,把坏路走通了,就是好系统。