ARTICLE DETAIL

资讯详情

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

Java后端大文件分块上传与断点续传实战:从设计到避坑

Java后端大文件分块上传与断点续传实战:从设计到避坑 在线教育平台的课程上传场景我前前后后做了不下三套方案踩过的坑基本可以写满一页A4纸。Java后端在处理大文件分块与断点续传这件事上最怕的就是胡子眉毛一把抓一上来就堆代码。今天这篇博文我把自己在真实项目中打磨过的一套完整方案拆开来讲从设计思路、数据库表结构、后端核心代码到前端切片、并发控制、进度恢复每一步都尽量讲透为什么这么做而不是只给你一段能跑但不知道为什么的代码。如果你正在做网校、慕课、企业内部培训平台这类业务需要支撑几百MB甚至几个GB的视频课件上传这篇内容应该能让你少走不少弯路。1. 教育行业大文件上传难在哪1.1 常规上传的三个硬伤先把痛点说清楚。教育平台上传的东西和其他行业不一样视频课程是绝对主力。一门课的录播视频1080P全程录制下来30分钟就是1GB起步遇到几个小时的直播回放五六个GB都很正常。用传统的方式也就是前端input typefile直接往服务器丢会碰到三个几乎无解的问题。第一是HTTP连接超时。一个GB级别的文件就算你本地带宽上行有20Mbps传完也要将近7分钟光靠一个HTTP长连接撑那么久中间只要网络抖动一次nginx默认60秒的proxy_read_timeout就把连接掐了。而教育场景里上传视频的人往往是老师或者教务人员他们的网络环境千奇百怪从校园网到家庭宽带都有超时几乎是必然事件。第二是失败后全量重传。这事在真实业务里特别伤士气。老师折腾了半小时传一个2GB的视频传到88%突然断了前端报错他再点一次上传又得从头再来。一次两次还能忍经常这样他真的会打电话来骂人。断点续传的本质诉求不是什么高端技术追求就是让人不用重头再来。第三是服务器内存和临时目录的压力。传统的Servlet接大文件文件会先落入tomcat的工作目录或者被MultipartFile整体读入内存。大文件并发上来的时候Tomcat的maxPostSize和内存都会被瞬间击穿磁盘IO也容易跑满。教育平台虽然不像直播那样时刻高并发但开学季、考试季几千人同时传课件也够喝一壶。1.2 分块上传为什么能解决这些问题分块上传的核心逻辑其实很朴素把大文件在前端切成很多小段一段一段传后端点收齐再拼回完整文件。这一下就把上面三个问题全解了。单次请求只传5MB或者10MB的数据上传时间短几乎不存在连接超时的窗口因为文件被切成了块每个块都是独立上传的服务器按块记录状态下次再传的时候前端先问一下后端哪些块已经有了把没有的补传就行这就是断点续传内存压力也大幅下降每次只处理一个小分块后端落盘在临时目录不占应用内存。所以分块是手段最终目的一是绕开传输超时二是给断点续传和秒传打下基础。要把这两件事做得可靠关键不在于写出上传接口而在于整个链路里对块的状态管理要严谨这也是本文后面花了大量篇幅讲设计细节的原因。2. 整体设计与关键技术选择2.1 前端切片、后端合并的经典架构先说方案选型。大文件上传的落地路线有好几种有直接用MinIO的presigned URL让前端直传对象存储的有走WebSocket/UDP自研传输协议的还有用百度的WebUploader这类成熟组件包一层的。但如果你是在一个常规的Java WebSpring Boot 关系型数据库项目里做团队没有专职运维或者对象存储预算有限最稳妥、最可控的还是经典方案前端负责切片和并发控制后端Java负责接收分块、记录状态、合并文件。这套架构的好处有两个层面。一是逻辑直观每个分块就是一个普通的multipart/form-data请求不依赖任何特殊协议前后端任何语言都能对接二是在现有Java工程里加代码很干净不需要额外引入重型中间件。你完全可以在之后把存储层从本地磁盘切换到MinIO、OSS分块上传的状态管理逻辑基本不用动。2.2 三个必须做对的设计决策第一个决策是文件唯一标识。我推荐用文件MD5作为任务ID前端在切片前先用SparkMD5算出整个文件的MD5后面所有分块请求都带上这个值。用MD5还有个隐藏福利同一个MD5在服务器上如果已经有了完整文件直接返回秒传成功用户瞬间看到上传完成体验非常爽。有的团队嫌大文件算MD5耗时想用UUID我劝你别贪这个省事少了MD5断点续传阶段你就没办法判断这个文件是不是同一个只能靠文件名而文件名重名是家常便饭到时候就等着文件错乱吧。第二个决策是分块大小定多少。我实测下来5MB是一个很均衡的值。2GB文件切400块每一块的请求体足够小网络抖动对单块的影响可接受400个请求对服务器压力也不算大。如果切得太细比如512KB一个块2GB文件就是4096个请求路由器、浏览器连接数、服务器NIO线程都会被拖垮如果切太粗比如50MB一块一旦网络抖动重传成本太高而且nginx的body大小限制也得往上调很多。第三个决策是合并策略。合并动作必须做到乱序安全。很多网上例子是按顺序读取每个分块文件再依次追加写入目标文件这个做法在按顺序上传时没问题。但前端为了效率通常会开4~5个并发通道上传分块分块到达服务器的顺序是不确定的所以合并前必须先做两件事一是校验分块完整性看看是不是所有块都到齐了二是按分块索引排序后再写。我在生产环境里用的是RandomAccessFile按块偏移量直接写入目标文件的方式这样即使分块到达顺序乱合并结果也不会出错。2.3 数据表与接口定义有了上面的原则数据模型就简单了。我这里设计了一张tb_upload_task表记录每一个上传任务核心字段如下字段类型说明idBIGINT主键file_md5VARCHAR(32)文件唯一标识上传任务唯一键file_nameVARCHAR(255)原始文件名file_sizeBIGINT文件总字节数chunk_sizeINT分块大小字节total_chunksINT总分块数uploaded_indexesTEXT已成功上传的分块索引英文逗号分隔statusTINYINT0上传中1合并中2已完成merge_pathVARCHAR(255)合并后的文件存储路径create_timeDATETIME创建时间uploaded_indexes直接存一个1,3,5,7这样的字符串在分块数量只有几百个的时候完全够用。等以后数据量大了再拆成tb_upload_chunk明细表不迟不必过度设计。接口按功能拆分成四个POST /api/upload/check传入fileMd5返回上传任务是否存在、已完成分块索引列表、是否可以直接秒传。POST /api/upload/chunk上传单个分块携带fileMd5、chunkIndex和文件体。POST /api/upload/merge分块全部上传完成后触发合并。GET /api/upload/status查询当前任务进度用于页面刷新后恢复进度。这四个接口配合起来断点续传的交互闭环就形成了用户重新打开页面前端先check拿到已上传分块列表然后跳过这些块去传缺的块。3. 后端JAVA实现核心代码与合并逻辑3.1 工程环境与依赖我用的后端基础是Spring Boot 2.7.x JDK 8这套方案不需要额外依赖特殊组件spring-boot-starter-web就够了如果走数据库记录就用MyBatis-Plus或JdbcTemplate不想引ORM直接用一个内存Map也行但生产环境还是建议落到数据库毕竟应用一重启内存数据就没了。再说说配置文件里几个关键项。spring.servlet.multipart.max-file-size和max-request-size这两个参数默认都是1MB必须调大。因为前端虽然切了块但每个分块请求本身还是一整个multipart请求需要给足请求体大小。一般我会设为20MB因为实际分块5MB加上一些字节数偏差10MB已经够用留一倍余量到20MB是图个稳。上传目录我习惯分成两块/data/fileupload/temp/{md5}/存放分块临时文件/data/fileupload/merge/存放合并后的正式文件。分块临时目录用md5隔离不同文件之间天然不冲突也方便后续整个目录一键清理。3.2 分块上传与检查接口先看检查接口。这个接口做的事很直接根据fileMd5查任务表如果没有记录返回{hasTask: false}如果有记录返回{hasTask: true, uploadedIndexes: [0,1,2]}。这里有一个秒传逻辑可以一起处理如果tb_upload_task里已经有这条记录而且status2说明这个文件已经完整传过了直接返回秒传标记前端连分块都不用发。分块上传接口是我这个方案里的核心。代码本身不长关键是做好两件事第一chunkIndex从MultipartFile里拿到后要落盘到以md5命名的临时目录下文件名规范为{md5}_{chunkIndex}.part第二落盘成功后要更新数据库里这个任务的uploaded_indexes字段追加当前索引。PostMapping(/chunk) public Result uploadChunk(RequestParam(fileMd5) String fileMd5, RequestParam(chunkIndex) int chunkIndex, RequestParam(file) MultipartFile file) throws IOException { // 1. 创建该文件的临时分块目录 Path chunkDir Paths.get(tempRoot, fileMd5); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } // 2. 分块文件命名: md5_索引.part Path chunkFile chunkDir.resolve(fileMd5 _ chunkIndex .part); // 3. 写入临时目录覆盖已存在同名分块 try (InputStream in file.getInputStream()) { Files.copy(in, chunkFile, StandardCopyOption.REPLACE_EXISTING); } // 4. 更新任务表中的已上传索引 uploadTaskService.markChunkUploaded(fileMd5, chunkIndex); return Result.success(); }是不是意外地简单真实生产代码里你还可以加上分块校验比如前端传chunkSize和chunkMd5后端比对一下防止传输过程中这个分块本身损坏。不过视频类文件对个别比特丢失容忍度较高大多数项目不做这层校验我选择在合并后统一校验总大小性价比更高。3.3 合并接口与完整性校验合并是后端最容易出bug的地方。很多初学的人会把合并写成拿第一个分块文件的流把后面的文件流一个个append进去这么做在分块按顺序上传时没问题但前面说了有了并发上传就存在乱序可能。我的做法是用RandomAccessFile。先把目标文件以rw模式打开然后循环遍历分块索引根据chunkIndex * chunkSize计算出每个块在目标文件里的写入偏移位置再用seek定位到那个位置后写入分块数据。这样无论哪个块先写入最终文件每个字节的位置都是确定的合并结果一定正确。合并前必须做一遍完整性检查把任务里记录的uploaded_indexes解析出来理论上应该等于0,1,2,...,totalChunks-1的完整集合。前端虽然会保证所有块传完才调merge但身处分布式或者并发场景时还是要在后端再校验一次不能信任前端传参。PostMapping(/merge) public Result merge(RequestParam(fileMd5) String fileMd5, RequestParam(fileName) String fileName, RequestParam(totalChunks) int totalChunks) throws IOException { UploadTask task uploadTaskService.getByMd5(fileMd5); if (task null || task.getUploadedCount() ! totalChunks) { return Result.error(分块未完整无法合并); } Path chunkDir Paths.get(tempRoot, fileMd5); // 用随机访问写保证乱序分块也能正确合并 String safeName System.currentTimeMillis() _ fileName; Path targetFile Paths.get(mergeRoot, safeName); try (RandomAccessFile raf new RandomAccessFile(targetFile.toFile(), rw)) { for (int i 0; i totalChunks; i) { Path part chunkDir.resolve(fileMd5 _ i .part); byte[] data Files.readAllBytes(part); raf.seek((long) i * task.getChunkSize()); raf.write(data); } } // 合并后校验最终文件大小 if (Files.size(targetFile) ! task.getFileSize()) { Files.deleteIfExists(targetFile); return Result.error(合并结果异常文件大小不一致); } // 更新任务状态为已合并清理临时分块目录 uploadTaskService.markMerged(fileMd5, targetFile.toString()); deleteDirectory(chunkDir); return Result.success(targetFile.toString()); }这段代码里的expectedSize校验是个很容易被忽略但必须写的环节。文件下完以后服务器上目标文件的大小应该和前端第一次check时传过来的fileSize完全一致。我这个方案里任务表在check之后会被创建并记录fileSize所以合并时可以直接拿库里的值做比对。如果在分段合并时发现某个块文件不存在我建议直接把当前任务标记为异常日志里输出具体缺失的块索引。这种情况几乎都是前端并发导致的一致性bug你直接返回一个合并失败会让前端很难排查不如把缺失的块索引也一起返回。3.4 分块文件清理策略合并完成之后临时分块目录必须清理否则垃圾数据会越积越多。但光在合并时清理还不够我遇到过用户传了一半就关掉浏览器的情况这些半截子任务的临时目录会一直留在磁盘上。所以需要配合一个定时任务定期扫描临时目录把最后修改时间超过24小时的分块目录清掉。Scheduled(fixedDelay 3600000) public void cleanExpiredChunks() { Path tempRootPath Paths.get(tempRoot); try (StreamPath dirs Files.list(tempRootPath)) { dirs.forEach(dir - { try { long lastModified Files.getLastModifiedTime(dir).toMillis(); if (System.currentTimeMillis() - lastModified 24 * 3600 * 1000L) { deleteDirectory(dir); } } catch (IOException e) { log.error(清理过期分块目录失败: {}, dir, e); } }); } catch (IOException e) { log.error(扫描临时目录失败, e); } }这个清理任务部署的时候注意要和线上的上传任务并发做隔离不要用一台共用服务器去跑定时任务。我第一版就是直接在应用内嵌了Scheduled结果测试环境同时跑上传和清理把正在上传的一半文件给删掉了自那以后我都是单独部署一台清理任务节点或者用xxl-job这类分布式任务调度。4. 前端切片与断点续传交互4.1 前端文件切片与MD5计算前端这一侧首先要解决的就是切片和MD5计算。切片用浏览器自带的File.slice()方法没有任何兼容性问题。MD5计算我选spark-md5这个库它的好处是可以边读边算不用把整个文件读进内存。注意大文件算MD5还是要一些时间的我这边的实测数据2GB本地SSD大约要15到25秒期间页面会卡顿所以必须用一个隐藏的Worker线程去算不能在主线程里干这件事。计算MD5时顺便就把分块大小确定下来。我推荐所有前端配置都从后端拉取比如后端提供一个/api/upload/config接口返回chunkSize前端不用硬编码。这样以后想把分块从5MB调整到10MB改后端一个配置就行前端不用发版。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function calcFileMd5(file) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); const totalChunks Math.ceil(file.size / CHUNK_SIZE); let currentChunk 0; reader.onerror reject; reader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; function loadNext() { const start currentChunk * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }4.2 断点续传的核心交互先check再补传断点续传的交互模式我用文字描述一遍你跟着走一遍流程就清楚了。用户选择了一个文件前端先把文件切成块然后调用POST /api/upload/check把MD5发给后端。后端拿到MD5去查任务表返回的结果有三种情况任务不存在表示第一次传这个文件后端创建一个新任务前端从第0块开始全部上传。任务存在且status0表示这个文件曾经传过但没传完后端返回已上传的分块索引列表前端把这些索引从待上传队列里剔除只补传缺失的块。任务存在且status2表示文件已经在服务器上了直接提示秒传成功前端一个请求都不用发。这个check接口是整个断点续传的心脏。用户上传中途刷新页面、浏览器崩溃、断网重新打开页面再选同一个文件前端通过MD5识别出是同一个文件然后无缝从断点处继续。4.3 并发控制与进度展示前端上传分块时我建议并发数控制在3到5个别一次性把所有分块请求全部发出去。我实测过5个并发对我来说是性能和稳定性的甜点。再多服务器端连接数和磁盘随机写压力都会上来再少上传速度跑不满。进度计算也有两种层面。一是分块级别的进度每个分块上传时axios的onUploadProgress能拿到当前块的进度二是整体进度用已完成分块数 / 总分块数来算转成百分比这种方式简单可靠前端展示足够用了。页面刷新后想恢复之前的进度调用一次GET /api/upload/status把已上传分块数拉回来进度条回显用户体感非常自然。这里提一个我在真实环境里踩过的坑有些前端同学喜欢把大文件按顺序一个一个传等前一个成功再传下一个这种做法在弱网环境里极其吃亏。比如第5块网络慢导致超时后面的块全部排队等待整个任务卡在第5块。所以前端并发一定不能是单线程串行至少3个并发才能保证整体吞吐。4.4 合并完成后的回显与下载合并完成后前端拿到后端返回的文件访问路径需要做两件事。第一是把课件的元数据入库比如课程ID、章节ID、文件路径关联起来这个走业务接口就行第二是回显视频地址方便老师即时预览。预览时注意如果你用video标签直接播放刚合并完的大文件需要后端支持HTTP Range请求否则浏览器从零开始下载播放体验会很差。Spring Boot的ResourceHttpRequestHandler默认支持Range但有时候你手写的文件流接口会把Content-Length和Accept-Ranges头搞丢导致进度条拖动失效这是另一个故事了这里先埋个伏笔。5. 实战排错与避坑记录5.1 Nginx和网关层上传大小限制这是我被问得最多的问题。明明后端max-file-size已经调到20MB了结果前端传分块还是报413。原因几乎都是nginx那层没改。nginx默认的client_max_body_size是1MB你前端传一个5MB的分块nginx在接收完请求体之前不会把请求转发给后端所以直接就在网关层把请求拦住了返回413 Request Entity Too Large。修改方法很简单在nginx的server或者location块里加上client_max_body_size 20m;但注意一个细节这个值必须大于你前端单个分块的大小加上请求头的余量。如果分块是5MB就不要只设成5m因为multipart请求还有一堆表单字段和HTTP头建议设成20m甚至30m至少是分块大小的3到4倍。你要是同时跑了多个系统改完nginx记得nginx -s reload不是在编辑完配置后就自动生效。如果你的项目前面还挂了Spring Cloud Gateway或者Kong这类网关也要检查它们的请求体限制。我记得Spring Cloud Gateway默认没有字节大小限制但如果你开了spring.codec.max-in-memory-size之类的参数也要一并调大。5.2 并发上传导致的分块丢失这个坑是我当时排查最久的一个。症状是前端明明把400个分块全部上传成功了调用merge时后端却报某个分块文件不存在。后来翻日志才发现前端确实发了400个请求但网络层或者后端框架层面有两个请求被丢弃了而前端毫不知情。解决方案分两层。在传输层前端每次发起分块上传时axios配置timeout超时并做失败重试重试最多3次。在业务层后端merge前必须做完整性校验发现缺失的块就直接返回失败和缺失块索引前端拿到索引后只补传缺失的块而不是整文件重传。这两层配合基本能保证分块一个不丢。另外uploaded_indexes字段的更新要用数据库事务或者加锁不然多个并发请求同时更新这个字段会出现后写覆盖前写的问题导致已上传分块索引列表不完整。我的做法是在更新时用SQL做字符串拼接UPDATE tb_upload_task SET uploaded_indexes CONCAT(uploaded_indexes, , , #{chunkIndex}) WHERE file_md5 #{fileMd5} AND uploaded_indexes NOT LIKE CONCAT(%, #{chunkIndex}, %);当然更稳的方式是拆成明细表用INSERT ON DUPLICATE KEY UPDATE去幂等写入再通过SELECT COUNT(*) FROM tb_upload_chunk WHERE task_id ...来获取已上传块数。数据量小的时候字符串方案够用但生产环境我还是建议拆表。5.3 合并时文件大小对不上文件能合并完但合并完的尺寸和原始文件不一致这个问题一般出现在两种情况下。第一种是chunkSize用错了。前端切片的CHUNK_SIZE和后端计算偏移用的chunkSize不统一比如前端传的是5 * 1024 * 1024后端常量写成了5 * 1000 * 1000那从第2块开始所有块的写入偏移就全是乱的。合并出来文件大小不对视频还会花屏。所以我在设计时chunkSize不是前端自己定的而是后端check接口作为配置返回给前端前端直接用避免两端不一致。第二种是最后一个分块的大小不是标准的5MB而是剩余的所有字节。这部分在计算偏移时必须用min(chunkSize, fileSize - i * chunkSize)来确定每个块的写入长度不能固定按5MB去seek。上面合并代码里我直接用Files.readAllBytes(part)读取完整块数据再写长度天然就是对的但是如果你用固定缓冲区分块读就会踩这个坑。5.4 清理任务和上传任务打架前面提到的定时清理任务如果直接用Scheduled放在应用里很容易出现一种竞态一个文件刚传了一部分最后修改时间是昨天刚好被清理任务扫到整个临时目录被删了后面上传的分块全部404。避坑的方法有三个按推荐程度排序第一个把清理任务的扫描时间阈值调到48小时以上给慢速网络留足余量第二个清理前先检查任务表里这个md5对应的任务状态如果是上传中状态跳过不清理第三个清理任务标记为删除操作时再确认一下该临时目录下所有分块文件的最后修改时间都超过了阈值避免半截子任务被误删。5.5 文件MD5计算耗时与UI卡顿不夸张地讲在纯前端主线程计算2GB文件的MD5Chrome直接弹出页面无响应提示都是可能的。我今天给你的建议就一条用Web Worker去算MD5把计算放到后台线程主线程只负责更新进度条。如果不想引入Worker还有一个折中做法只对文件的开头256KB、中间256KB、结尾256KB做采样MD5虽然不能保证全局唯一但对于避免重复上传已经足够用。这个方案我后来在某些弱网设备上作为降级策略在使用效果还不错。不过要说明白采样MD5只能用来做秒传判断不能用做断点续传的同一文件识别否则还是有极小概率识别错。我的一点实操体会这套方案从我最早在网校系统上线跑到现在稳定处理过的视频课件加起来几个TB是有的。分块上传本身不是新东西但真正把它做扎实核心不在算法有多精妙而在状态管理够不够严谨。你能让用户在断网之后重选同一个文件接着传他能感觉到平台是用心的这比任何炫酷的进度条动画都重要。如果你后续想把方案升级可以从两个方向入手一是把合并后的文件搬到MinIO或者OSS上实现对象存储和本地磁盘的解耦二是做全局秒传多个用户上传相同课件时后端直接复用已有文件省下大量存储和带宽成本。这两个方向我都已经在实际项目中验证过等有空了再单独写一篇聊聊。
返回列表